diff --git a/internal/cli/db.go b/internal/cli/db.go index 85ba2ad..056f61c 100644 --- a/internal/cli/db.go +++ b/internal/cli/db.go @@ -17,8 +17,10 @@ import ( // Postgres database + role/grant verbs on the shared engine. create/user/grant/ // drop/gc mirror the up-saga provision flow (lock → overlay → provisioner → // ledger → event) via internal/orchestrate; list is a lock-free ledger read. -// snapshot/restore (+ snapshot ls) graduate the spec-15 data-lifecycle verbs; -// reset/pull stay v2 stubs. +// snapshot/restore (+ snapshot ls) plus reset/pull graduate the full spec-15 +// data-lifecycle surface (reset = drop + re-provision an empty tenant; pull = +// fetch-by-name from the LOCAL snapshot store + restore — the remote/team shared +// store is deferred to spec 21). func newDbCmd(g *GlobalOpts) *cobra.Command { cmd := &cobra.Command{ Use: "db", @@ -34,9 +36,8 @@ func newDbCmd(g *GlobalOpts) *cobra.Command { // spec-15 data-lifecycle verbs. newDbSnapshotCmd(g), newDbRestoreCmd(g), - // remaining spec-15 verbs (reset/pull) reserved as stubs. - stub("reset", "Drop and re-provision a project's database", "v2 (spec 15)"), - stub("pull", "Pull a database snapshot from a shared store", "v2 (spec 15)"), + newDbResetCmd(g), + newDbPullCmd(g), ) return cmd } @@ -173,6 +174,107 @@ func newDbRestoreCmd(g *GlobalOpts) *cobra.Command { return cmd } +// newDbResetCmd wires `db reset` (spec 15): DROP and re-provision a project's +// tenant database on the shared Postgres to a clean, empty tenant. Destructive — +// it refuses without --yes; --force overrides a still-connected database +// (terminating its live backends). The drop + re-provision DDL runs under the +// flock; the never-recreate-a-stateful-shared-service guard holds (only the +// tenant DATABASE is dropped, never the shared container). +func newDbResetCmd(g *GlobalOpts) *cobra.Command { + var project, instance string + var yes, force bool + cmd := &cobra.Command{ + Use: "reset", + Short: "Drop and re-provision a project's tenant database to empty (destructive)", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if g.JSON && !yes { + return fmt.Errorf("refusing to reset without --yes for --json/non-interactive use") + } + d, closeFn, err := buildUpDeps(cmd) + if err != nil { + return err + } + defer closeFn() + proj := project + if proj == "" { + proj = defaultProject(d) + } + if !yes { + if !confirm(cmd, fmt.Sprintf("This DROPS and recreates %q's tenant database EMPTY (all data destroyed). Type 'yes' to continue: ", proj)) { + fmt.Fprintln(cmd.OutOrStdout(), "aborted") + return nil + } + } + res, err := orchestrate.Reset(cmd.Context(), d, orchestrate.ResetOptions{ + Project: project, Instance: instance, Force: force, + }) + if err != nil { + return err + } + if g.JSON { + return writeJSON(cmd, res) + } + if !g.Quiet { + fmt.Fprintf(cmd.OutOrStdout(), "reset %s: dropped and recreated empty database %q owned by %q\n", res.Project, res.Database, res.Role) + } + return nil + }, + } + cmd.Flags().StringVar(&project, "project", "", "owner project (default: the workspace's single/first project)") + cmd.Flags().StringVar(&instance, "instance", "", "shared postgres instance (default: the first postgres instance)") + cmd.Flags().BoolVar(&yes, "yes", false, "skip the confirmation prompt (required for --json)") + cmd.Flags().BoolVar(&force, "force", false, "terminate live connections and reset a still-connected database") + return cmd +} + +// newDbPullCmd wires `db pull ` (spec 15): fetch a named snapshot from the +// LOCAL snapshot store and apply it into the project's tenant, seeding it from a +// real dataset. In this scope the store is local ($DEVSTACK_HOME/snapshots), so a +// pull is a fetch-by-name + restore that refuses to clobber a non-empty tenant +// (run `db reset` first to re-seed). The REMOTE/team "shared store" (S3/HTTP fetch +// + the mandatory sanitize transform) is DEFERRED to spec 21. +func newDbPullCmd(g *GlobalOpts) *cobra.Command { + var project, database, instance string + cmd := &cobra.Command{ + Use: "pull ", + Short: "Seed a project's tenant from a named snapshot in the local store", + Long: "Fetch a named snapshot from the local snapshot store ($DEVSTACK_HOME/snapshots) " + + "and apply it into the project's tenant database, seeding a fresh tenant. " + + "Refuses to overwrite a non-empty tenant (run `db reset` first to re-seed). " + + "The remote/team shared store (fetch-by-URL + sanitize) is deferred to spec 21.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + d, closeFn, err := buildUpDeps(cmd) + if err != nil { + return err + } + defer closeFn() + dumper := defaultPgDumper() + if err := dumper.Preflight(cmd.Context()); err != nil { + return err + } + meta, err := orchestrate.Pull(cmd.Context(), d, dumper, orchestrate.PullOptions{ + Project: project, Database: database, Instance: instance, Name: args[0], + }) + if err != nil { + return err + } + if g.JSON { + return writeJSON(cmd, meta) + } + if !g.Quiet { + fmt.Fprintf(cmd.OutOrStdout(), "pulled snapshot %q into %s (digest %s)\n", meta.Name, meta.Database, shortDigest(meta.Digest)) + } + return nil + }, + } + cmd.Flags().StringVar(&project, "project", "", "owner project (default: the workspace's single/first project)") + cmd.Flags().StringVar(&database, "db", "", "physical tenant database (default: the project's own database)") + cmd.Flags().StringVar(&instance, "instance", "", "shared postgres instance (default: the first postgres instance)") + return cmd +} + // shortDigest is a display helper: the first 12 hex chars of a sha256, or "-". func shortDigest(d string) string { if len(d) >= 12 { diff --git a/internal/cli/db_reset_pull_test.go b/internal/cli/db_reset_pull_test.go new file mode 100644 index 0000000..93c0657 --- /dev/null +++ b/internal/cli/db_reset_pull_test.go @@ -0,0 +1,53 @@ +package cli + +import ( + "strings" + "testing" +) + +func TestDbResetPullRegistered(t *testing.T) { + root := NewRootCmd(Options{}) + for _, path := range [][]string{{"db", "reset"}, {"db", "pull"}} { + c, _, err := root.Find(path) + if err != nil || c.RunE == nil { + t.Fatalf("db %v not registered as a real command: %v", path, err) + } + } +} + +func TestDbResetFlags(t *testing.T) { + root := NewRootCmd(Options{}) + reset, _, err := root.Find([]string{"db", "reset"}) + if err != nil { + t.Fatal(err) + } + for _, f := range []string{"project", "instance", "yes", "force"} { + if reset.Flags().Lookup(f) == nil { + t.Errorf("db reset missing --%s", f) + } + } + pull, _, err := root.Find([]string{"db", "pull"}) + if err != nil { + t.Fatal(err) + } + for _, f := range []string{"project", "db", "instance"} { + if pull.Flags().Lookup(f) == nil { + t.Errorf("db pull missing --%s", f) + } + } +} + +// TestDbResetJSONRequiresYes asserts the destructive-verb non-interactive guard: +// `db reset --json` without --yes errors BEFORE touching any workspace/Docker +// state (mirrors the `workspace destroy --json` guard). +func TestDbResetJSONRequiresYes(t *testing.T) { + t.Chdir(t.TempDir()) + var out strings.Builder + root := NewRootCmd(Options{}) + root.SetArgs([]string{"db", "reset", "--json"}) + root.SetOut(&out) + root.SetErr(&out) + if err := root.Execute(); err == nil { + t.Fatal("db reset --json without --yes must error") + } +} diff --git a/internal/orchestrate/reset.go b/internal/orchestrate/reset.go new file mode 100644 index 0000000..266b947 --- /dev/null +++ b/internal/orchestrate/reset.go @@ -0,0 +1,130 @@ +package orchestrate + +import ( + "context" + "fmt" + "strings" + + "github.com/open-source-cloud/devstack/internal/generate" + "github.com/open-source-cloud/devstack/internal/lock" + "github.com/open-source-cloud/devstack/internal/provision" +) + +// This file is the imperative side of spec 15's `db reset`: DROP + re-provision a +// project's per-project tenant database on the SHARED Postgres, reusing the exact +// provision-phase host-reachability path (engineTarget → ledger port + loopback +// overlay via `compose up`) so the pgx admin connection reaches the warm server +// WITHOUT publishing a permanent host port. +// +// Unlike snapshot/restore (whose long-running dump/restore PROCESS runs OUTSIDE +// the flock), a reset is a handful of quick DDL statements, so the terminate → +// DROP DATABASE → re-provision SQL runs INSIDE the flock (spec 15: "the drop/ +// recreate SQL happen inside the flock"), exactly like the provision phase. The +// never-recreate-a-stateful-shared-service guard still holds: we drop the tenant +// DATABASE only, never the shared container/volume, and terminate only THIS +// tenant's backends (never other tenants' live connections). + +// ResetOptions selects the tenant to drop + re-provision. +type ResetOptions struct { + Project string // owner project (default: the workspace's single/first project) + Database string // physical tenant db (default: the project's own db) + Instance string // shared Postgres instance (default: the first postgres instance) + Force bool // terminate + proceed even if the tenant still has live connections +} + +// ResetResult is the outcome of a reset (the recreated empty tenant). +type ResetResult struct { + Project string `json:"project"` + Instance string `json:"instance"` + Database string `json:"database"` + Role string `json:"role"` +} + +// liveBackendsSQL detects any backend (other than our own) attached to the tenant +// database — the guard that makes reset refuse a still-connected DB unless --force. +const liveBackendsSQL = `SELECT 1 FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()` + +// terminateBackendsSQL terminates only THIS tenant's backends (never other +// tenants' — the shared-service isolation guard, spec 15). +const terminateBackendsSQL = `SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()` + +// Reset drops a project's tenant database and re-runs the idempotent provisioner +// to recreate an empty tenant (role kept/recreated, database recreated fresh). The +// terminate → DROP → re-provision DDL runs inside the flock; the loopback overlay +// is applied outside it (engineTarget self-locks the port allocation). +func Reset(ctx context.Context, d UpDeps, opt ResetOptions) (ResetResult, error) { + proj, dbName, inst, err := resolveTenant(d, opt.Project, opt.Database, opt.Instance) + if err != nil { + return ResetResult{}, err + } + + // Apply the host-reachability overlay (ledger port + `compose up`), same path + // the provision phase uses. This runs outside the flock (FreeHostPort self-locks). + target, err := engineTarget(ctx, d, "postgres", inst) + if err != nil { + return ResetResult{}, err + } + connect := d.PgConnect + if connect == nil { + connect = defaultPgConnect + } + user := target.AdminEnv["user"] + pass := target.AdminEnv["password"] + dsn := provision.DSN(target.Host, target.Port, user, pass, user) + + var result ResetResult + if err := lock.WithLock(ctx, d.LockPath, func() error { + conn, closeConn, err := connect(ctx, dsn) + if err != nil { + return fmt.Errorf("connect to shared %s on %s:%d: %w", inst, target.Host, target.Port, err) + } + defer func() { _ = closeConn() }() + + // Refuse a still-connected tenant unless --force (data-loss guard, spec 15). + if !opt.Force { + live, err := conn.Exists(ctx, liveBackendsSQL, dbName) + if err != nil { + return fmt.Errorf("check live connections on %q: %w", dbName, err) + } + if live { + return fmt.Errorf("database %q still has active connections; pass --force to terminate them and reset anyway", dbName) + } + } + + // Terminate only this tenant's backends, then drop the database. DROP + // DATABASE cannot run in a transaction and fails with attached sessions — + // so terminate first (spec 15). + if err := conn.Exec(ctx, terminateBackendsSQL, dbName); err != nil { + return fmt.Errorf("terminate sessions on %q: %w", dbName, err) + } + if err := conn.Exec(ctx, `DROP DATABASE IF EXISTS `+quotePgIdent(dbName)); err != nil { + return fmt.Errorf("drop database %q: %w", dbName, err) + } + + // Re-provision an empty tenant via the SAME existence-guarded, idempotent + // SQL the provision phase uses (role kept in sync / recreated, fresh db, + // predictable dev cred == project name). + creds, err := provision.Postgres{}.EnsureProject(ctx, conn, proj, proj) + if err != nil { + return fmt.Errorf("re-provision %s on %s: %w", proj, inst, err) + } + + if err := d.DB.RecordProvisioned(proj, "role", creds.Role); err != nil { + return err + } + if err := d.DB.RecordProvisioned(proj, "database", creds.Database); err != nil { + return err + } + d.DB.LogEvent("db.reset", proj, fmt.Sprintf("dropped+recreated %s on %s", creds.Database, generate.SharedAlias(inst))) + result = ResetResult{Project: proj, Instance: inst, Database: creds.Database, Role: creds.Role} + return nil + }); err != nil { + return ResetResult{}, err + } + return result, nil +} + +// quotePgIdent double-quotes a Postgres identifier, doubling embedded quotes. +// Local to orchestrate so DROP DATABASE renders a safe identifier (the tenant db +// is already hyphen-sanitized by pgTenantDB, but quoting is the belt-and-braces). +func quotePgIdent(s string) string { return `"` + strings.ReplaceAll(s, `"`, `""`) + `"` } diff --git a/internal/orchestrate/reset_test.go b/internal/orchestrate/reset_test.go new file mode 100644 index 0000000..623dcba --- /dev/null +++ b/internal/orchestrate/reset_test.go @@ -0,0 +1,212 @@ +package orchestrate + +import ( + "context" + "encoding/json" + "slices" + "strings" + "testing" + + dbpkg "github.com/open-source-cloud/devstack/internal/db" + "github.com/open-source-cloud/devstack/internal/provision" + "github.com/open-source-cloud/devstack/internal/state" +) + +// resetConn is a fake provision.Conn for the reset path: it records every Exec +// (so a test can assert the terminate/DROP/CREATE DDL ran) and answers the +// live-connection probe from a configurable flag. All other Exists queries (the +// EnsureProject role/db existence guards) return false so re-provision takes the +// CREATE path. +type resetConn struct { + execs []string + connected bool // pg_stat_activity live-backend probe result +} + +func (c *resetConn) Exec(_ context.Context, sql string, _ ...any) error { + c.execs = append(c.execs, sql) + return nil +} + +func (c *resetConn) Exists(_ context.Context, sql string, _ ...any) (bool, error) { + if strings.Contains(sql, "pg_stat_activity") { + return c.connected, nil + } + return false, nil +} + +func (c *resetConn) joined() string { return strings.Join(c.execs, " | ") } + +// resetConnector hands out a single shared resetConn so the test inspects the SQL. +type resetConnector struct{ conn *resetConn } + +func (rc *resetConnector) connect(_ context.Context, _ string) (provision.Conn, func() error, error) { + return rc.conn, func() error { return nil }, nil +} + +func TestResetDropsAndReprovisions(t *testing.T) { + d, fr, ledger := upFixture(t) + rc := &resetConnector{conn: &resetConn{}} + d.PgConnect = rc.connect + + res, err := Reset(context.Background(), d, ResetOptions{Project: "app"}) + if err != nil { + t.Fatalf("Reset: %v", err) + } + if res.Database != "app" || res.Role != "app" || res.Project != "app" { + t.Errorf("unexpected reset result: %+v", res) + } + + // The destructive DDL ran in order: terminate → DROP DATABASE → re-provision. + j := rc.conn.joined() + for _, want := range []string{"pg_terminate_backend", "DROP DATABASE IF EXISTS", "CREATE ROLE", "CREATE DATABASE"} { + if !strings.Contains(j, want) { + t.Errorf("reset DDL missing %q: %s", want, j) + } + } + if ti := strings.Index(j, "pg_terminate_backend"); ti < 0 || ti > strings.Index(j, "DROP DATABASE") { + t.Errorf("terminate must precede DROP DATABASE: %s", j) + } + if di := strings.Index(j, "DROP DATABASE"); di < 0 || di > strings.Index(j, "CREATE DATABASE") { + t.Errorf("DROP DATABASE must precede the re-provision CREATE DATABASE: %s", j) + } + + // The ledger re-recorded the role + database ownership rows. + rows, _ := ledger.ProvisionedFor("app") + var kinds []string + for _, r := range rows { + kinds = append(kinds, r.Kind+":"+r.Name) + } + if !slices.Contains(kinds, "role:app") || !slices.Contains(kinds, "database:app") { + t.Errorf("provisioned rows = %v, want role:app + database:app", kinds) + } + + // The reset was event-logged. + if n := countEvents(t, ledger, "db.reset"); n == 0 { + t.Error("db.reset event not logged") + } + + // The loopback overlay was applied via compose up on the shared stack (same + // host-reachability path as the provision phase). + if !fr.saw("-p "+"devstack-shared", "compose.provision.yaml") { + t.Errorf("reset did not apply the provision overlay via compose up: %v", fr.cmds) + } +} + +func TestResetRefusesStillConnectedWithoutForce(t *testing.T) { + d, _, _ := upFixture(t) + rc := &resetConnector{conn: &resetConn{connected: true}} + d.PgConnect = rc.connect + + _, err := Reset(context.Background(), d, ResetOptions{Project: "app"}) + if err == nil { + t.Fatal("Reset should refuse a still-connected database without --force") + } + if !strings.Contains(err.Error(), "active connections") { + t.Errorf("unexpected error: %v", err) + } + // No destructive DDL may have run. + if strings.Contains(rc.conn.joined(), "DROP DATABASE") { + t.Errorf("DROP DATABASE ran despite the still-connected refusal: %s", rc.conn.joined()) + } + + // With --force it terminates + proceeds. + rc2 := &resetConnector{conn: &resetConn{connected: true}} + d.PgConnect = rc2.connect + if _, err := Reset(context.Background(), d, ResetOptions{Project: "app", Force: true}); err != nil { + t.Fatalf("Reset --force: %v", err) + } + if !strings.Contains(rc2.conn.joined(), "DROP DATABASE") { + t.Errorf("Reset --force did not DROP DATABASE: %s", rc2.conn.joined()) + } +} + +func TestResetJSONShape(t *testing.T) { + d, _, _ := upFixture(t) + rc := &resetConnector{conn: &resetConn{}} + d.PgConnect = rc.connect + + res, err := Reset(context.Background(), d, ResetOptions{Project: "app"}) + if err != nil { + t.Fatalf("Reset: %v", err) + } + b, err := json.Marshal(res) + if err != nil { + t.Fatal(err) + } + var got map[string]any + if err := json.Unmarshal(b, &got); err != nil { + t.Fatal(err) + } + for _, key := range []string{"project", "instance", "database", "role"} { + if _, ok := got[key]; !ok { + t.Errorf("reset json missing key %q: %s", key, b) + } + } +} + +func TestPullRestoresNamedSnapshot(t *testing.T) { + newSnapEnv(t) + d, _, ledger := upFixture(t) + dr := &dumpRunner{} + dumper := dbpkg.PgDumper{Runner: dr, LookPath: func(string) (string, error) { return "/usr/bin/x", nil }} + + // Capture a snapshot to seed the local store. + if _, err := Snapshot(context.Background(), d, dumper, SnapshotOptions{Project: "app", Name: "seed"}); err != nil { + t.Fatalf("Snapshot: %v", err) + } + + // Pull it into a fresh (empty) tenant. + dr.tables = 0 + meta, err := Pull(context.Background(), d, dumper, PullOptions{Project: "app", Name: "seed"}) + if err != nil { + t.Fatalf("Pull: %v", err) + } + if meta.Name != "seed" || meta.Database != "app" || meta.Digest == "" { + t.Errorf("unexpected pull meta: %+v", meta) + } + pgr := dr.sawTool("pg_restore") + if pgr == nil || !dr.restored { + t.Fatalf("pg_restore never ran on pull: %v", dr.cmds) + } + rjoined := strings.Join(pgr, " ") + for _, want := range []string{"-d app", "--clean", "--if-exists"} { + if !strings.Contains(rjoined, want) { + t.Errorf("pull pg_restore argv missing %q: %s", want, rjoined) + } + } + + // The pull was event-logged distinctly from a restore. + if n := countEvents(t, ledger, "db.pull"); n == 0 { + t.Error("db.pull event not logged") + } +} + +// countEvents counts event_log rows of a given kind (direct query — the ledger +// exposes no event-read API in this milestone). +func countEvents(t *testing.T, db *state.DB, kind string) int { + t.Helper() + var n int + if err := db.QueryRow(`SELECT COUNT(*) FROM event_log WHERE kind=?`, kind).Scan(&n); err != nil { + t.Fatalf("count events %q: %v", kind, err) + } + return n +} + +func TestPullRefusesNonEmptyTenant(t *testing.T) { + newSnapEnv(t) + d, _, _ := upFixture(t) + dr := &dumpRunner{} + dumper := dbpkg.PgDumper{Runner: dr, LookPath: func(string) (string, error) { return "/usr/bin/x", nil }} + + if _, err := Snapshot(context.Background(), d, dumper, SnapshotOptions{Project: "app", Name: "seed"}); err != nil { + t.Fatalf("Snapshot: %v", err) + } + // A non-empty tenant → pull refuses (it seeds fresh tenants, never clobbers). + dr.tables = 5 + if _, err := Pull(context.Background(), d, dumper, PullOptions{Project: "app", Name: "seed"}); err == nil { + t.Fatal("Pull should refuse to overwrite a non-empty tenant") + } + if dr.restored { + t.Error("pg_restore ran despite the non-empty refusal") + } +} diff --git a/internal/orchestrate/snapshot.go b/internal/orchestrate/snapshot.go index 81f8159..96ec718 100644 --- a/internal/orchestrate/snapshot.go +++ b/internal/orchestrate/snapshot.go @@ -52,6 +52,18 @@ type RestoreOptions struct { Force bool // replay over a non-empty tenant (destructive) } +// PullOptions selects the tenant + named snapshot to seed from the LOCAL snapshot +// store. `db pull` is the seed-from-a-dump verb (spec 15): in this scope it is a +// fetch-by-name from the local store ($DEVSTACK_HOME/snapshots) + a restore into a +// fresh tenant. The remote/team "shared store" backend (S3/HTTP fetch + the +// mandatory sanitize transform) is DEFERRED to spec 21 — see Pull's doc. +type PullOptions struct { + Project string + Database string + Instance string + Name string // required: the snapshot label to pull + apply +} + // SnapshotMeta is the on-disk + ledger record of one captured dump. It is written // as a sidecar JSON next to the dump and surfaced verbatim by `db snapshot ls`. type SnapshotMeta struct { @@ -180,6 +192,27 @@ func Snapshot(ctx context.Context, d UpDeps, dumper db.Dumper, opt SnapshotOptio // a non-empty tenant unless opt.Force (data-loss guard, spec 15). The pg_restore // PROCESS runs outside the flock; the event row write is locked. func Restore(ctx context.Context, d UpDeps, dumper db.Dumper, opt RestoreOptions) (SnapshotMeta, error) { + return replaySnapshot(ctx, d, dumper, opt, "db.restore") +} + +// Pull fetches a named snapshot from the LOCAL store and applies it into the +// project's tenant, seeding it from a real dataset (spec 15 `db pull`). In this +// scope the store IS local ($DEVSTACK_HOME/snapshots), so a pull is exactly a +// fetch-by-name + restore into a fresh tenant (it reuses the same pg_restore path +// + loopback overlay), refusing a non-empty tenant so it never clobbers live data +// (`db reset` first to re-seed). The REMOTE/team "shared store" — an S3/HTTP fetch +// plus the mandatory pre-store sanitize transform — is DEFERRED to spec 21. +func Pull(ctx context.Context, d UpDeps, dumper db.Dumper, opt PullOptions) (SnapshotMeta, error) { + return replaySnapshot(ctx, d, dumper, RestoreOptions{ + Project: opt.Project, Database: opt.Database, Instance: opt.Instance, Name: opt.Name, + }, "db.pull") +} + +// replaySnapshot is the shared body of Restore + Pull: resolve the tenant, verify +// the stored dump's integrity, guard against clobbering a non-empty tenant, then +// replay the dump OUTSIDE the flock and log `event` (locked). event distinguishes +// a `db.restore` (roll back) from a `db.pull` (seed) in the event log. +func replaySnapshot(ctx context.Context, d UpDeps, dumper db.Dumper, opt RestoreOptions, event string) (SnapshotMeta, error) { if opt.Name == "" { return SnapshotMeta{}, fmt.Errorf("a snapshot name is required") } @@ -225,7 +258,7 @@ func Restore(ctx context.Context, d UpDeps, dumper db.Dumper, opt RestoreOptions } if err := lock.WithLock(ctx, d.LockPath, func() error { - d.DB.LogEvent("db.restore", proj, fmt.Sprintf("%s into %s (%s, digest %s)", opt.Name, dbName, generate.SharedAlias(inst), digest)) + d.DB.LogEvent(event, proj, fmt.Sprintf("%s into %s (%s, digest %s)", opt.Name, dbName, generate.SharedAlias(inst), digest)) return nil }); err != nil { return SnapshotMeta{}, err