Skip to content

Commit b7419b6

Browse files
feat(db): snapshot and restore the shared Postgres (spec 15) (#100)
Graduate the spec-15 data-lifecycle verbs `db snapshot [name]`, `db snapshot ls`, and `db restore <name>` (thin v2 scope: Postgres-only, per-project tenant). - New internal/db seam: a Dumper interface + PgDumper that shells pg_dump/pg_restore/psql behind an injectable Runner (release binary stays CGO-free; password rides PGPASSWORD in env, never the argv). Preflight maps a missing client tool to a one-line remediation. - internal/orchestrate/snapshot.go reuses the exact provision host-reachability pattern (engineTarget → FreeHostPort + writeProvisionOverlay + compose up on the shared stack) to reach the warm Postgres over a ledger-allocated 127.0.0.1 host port WITHOUT publishing a permanent one. The dump/restore process runs OUTSIDE the flock; only the ledger row + event write are locked. - Dumps + a sidecar land under $DEVSTACK_HOME/snapshots/<workspace>/<name>.dump (store.SnapshotsPath); each snapshot is a provisioned(kind=snapshot) ledger row + a db.snapshot/db.restore event (free-text kind → no migration). - restore refuses a non-empty tenant without --force, confirms (or --yes) for destructive replay, re-hashes the dump for integrity, and honors the --project/--db/--instance selectors. --json contract throughout. Tests: snapshot→restore round-trip via a mock runner (asserts pg_dump/pg_restore argv + PGPASSWORD-in-env + the host-port overlay allocation + the ledger row), restore-refusal without --force, the --json shape, ls, and the PgDumper argv + Preflight remediation. No real Postgres. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 27f7cc7 commit b7419b6

7 files changed

Lines changed: 1038 additions & 5 deletions

File tree

‎internal/cli/db.go‎

Lines changed: 152 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import (
66

77
"github.com/spf13/cobra"
88

9+
"github.com/open-source-cloud/devstack/internal/db"
10+
"github.com/open-source-cloud/devstack/internal/docker"
911
"github.com/open-source-cloud/devstack/internal/orchestrate"
1012
"github.com/open-source-cloud/devstack/internal/resource"
1113
"github.com/open-source-cloud/devstack/internal/state"
@@ -14,8 +16,9 @@ import (
1416
// newDbCmd wires the `devstack db` group (spec 29 §databases): tenant-scoped
1517
// Postgres database + role/grant verbs on the shared engine. create/user/grant/
1618
// drop/gc mirror the up-saga provision flow (lock → overlay → provisioner →
17-
// ledger → event) via internal/orchestrate; list is a lock-free ledger read. This
18-
// graduates the reserved `db` stub. snapshot/restore/reset/pull stay v2 stubs.
19+
// ledger → event) via internal/orchestrate; list is a lock-free ledger read.
20+
// snapshot/restore (+ snapshot ls) graduate the spec-15 data-lifecycle verbs;
21+
// reset/pull stay v2 stubs.
1922
func newDbCmd(g *GlobalOpts) *cobra.Command {
2023
cmd := &cobra.Command{
2124
Use: "db",
@@ -28,15 +31,159 @@ func newDbCmd(g *GlobalOpts) *cobra.Command {
2831
newDbListCmd(g),
2932
newDbDropCmd(g),
3033
newDbGcCmd(g),
31-
// v2 data-lifecycle verbs (spec 15) reserved as stubs.
32-
stub("snapshot", "Snapshot a project's database", "v2 (spec 15)"),
33-
stub("restore", "Restore a project's database from a snapshot", "v2 (spec 15)"),
34+
// spec-15 data-lifecycle verbs.
35+
newDbSnapshotCmd(g),
36+
newDbRestoreCmd(g),
37+
// remaining spec-15 verbs (reset/pull) reserved as stubs.
3438
stub("reset", "Drop and re-provision a project's database", "v2 (spec 15)"),
3539
stub("pull", "Pull a database snapshot from a shared store", "v2 (spec 15)"),
3640
)
3741
return cmd
3842
}
3943

44+
// defaultPgDumper is the real pg_dump/pg_restore/psql client, shelled behind the
45+
// docker exec runner (the release binary stays CGO-free — the tools are external).
46+
func defaultPgDumper() db.Dumper { return db.PgDumper{Runner: docker.ExecRunner{}} }
47+
48+
// newDbSnapshotCmd wires `db snapshot [name]` (capture) with the `ls` subcommand
49+
// (list). A snapshot dumps ONLY the project's tenant database on the shared
50+
// Postgres to ~/.devstack/snapshots/<workspace>/ and records a ledger row (spec 15).
51+
func newDbSnapshotCmd(g *GlobalOpts) *cobra.Command {
52+
var project, database, instance string
53+
cmd := &cobra.Command{
54+
Use: "snapshot [name]",
55+
Short: "Capture a project's tenant database to the snapshot store",
56+
Args: cobra.MaximumNArgs(1),
57+
RunE: func(cmd *cobra.Command, args []string) error {
58+
d, closeFn, err := buildUpDeps(cmd)
59+
if err != nil {
60+
return err
61+
}
62+
defer closeFn()
63+
dumper := defaultPgDumper()
64+
if err := dumper.Preflight(cmd.Context()); err != nil {
65+
return err
66+
}
67+
var name string
68+
if len(args) == 1 {
69+
name = args[0]
70+
}
71+
meta, err := orchestrate.Snapshot(cmd.Context(), d, dumper, orchestrate.SnapshotOptions{
72+
Project: project, Database: database, Instance: instance, Name: name,
73+
})
74+
if err != nil {
75+
return err
76+
}
77+
if g.JSON {
78+
return writeJSON(cmd, meta)
79+
}
80+
if !g.Quiet {
81+
fmt.Fprintf(cmd.OutOrStdout(), "captured snapshot %q of %s (%d bytes)\n%s\n", meta.Name, meta.Database, meta.Size, meta.Path)
82+
}
83+
return nil
84+
},
85+
}
86+
cmd.Flags().StringVar(&project, "project", "", "owner project (default: the workspace's single/first project)")
87+
cmd.Flags().StringVar(&database, "db", "", "physical tenant database (default: the project's own database)")
88+
cmd.Flags().StringVar(&instance, "instance", "", "shared postgres instance (default: the first postgres instance)")
89+
cmd.AddCommand(newDbSnapshotLsCmd(g))
90+
return cmd
91+
}
92+
93+
func newDbSnapshotLsCmd(g *GlobalOpts) *cobra.Command {
94+
var project string
95+
cmd := &cobra.Command{
96+
Use: "ls",
97+
Short: "List captured snapshots (lock-free)",
98+
Args: cobra.NoArgs,
99+
RunE: func(cmd *cobra.Command, _ []string) error {
100+
d, closeFn, err := buildUpDeps(cmd)
101+
if err != nil {
102+
return err
103+
}
104+
defer closeFn()
105+
snaps, err := orchestrate.ListSnapshots(d, project)
106+
if err != nil {
107+
return err
108+
}
109+
if g.JSON {
110+
return writeJSON(cmd, map[string]any{"snapshots": snaps})
111+
}
112+
w := cmd.OutOrStdout()
113+
if len(snaps) == 0 {
114+
fmt.Fprintln(w, "no snapshots")
115+
return nil
116+
}
117+
for _, s := range snaps {
118+
fmt.Fprintf(w, "%-24s %-12s %10d %s %s\n", s.Name, s.Database, s.Size, shortDigest(s.Digest), s.CreatedAt)
119+
}
120+
return nil
121+
},
122+
}
123+
cmd.Flags().StringVar(&project, "project", "", "only this project's snapshots")
124+
return cmd
125+
}
126+
127+
func newDbRestoreCmd(g *GlobalOpts) *cobra.Command {
128+
var project, database, instance string
129+
var force, yes bool
130+
cmd := &cobra.Command{
131+
Use: "restore <name>",
132+
Short: "Restore a project's tenant database from a snapshot (destructive)",
133+
Args: cobra.ExactArgs(1),
134+
RunE: func(cmd *cobra.Command, args []string) error {
135+
if g.JSON && !yes {
136+
return fmt.Errorf("refusing to restore without --yes for --json/non-interactive use")
137+
}
138+
d, closeFn, err := buildUpDeps(cmd)
139+
if err != nil {
140+
return err
141+
}
142+
defer closeFn()
143+
dumper := defaultPgDumper()
144+
if err := dumper.Preflight(cmd.Context()); err != nil {
145+
return err
146+
}
147+
if !yes {
148+
if !confirm(cmd, fmt.Sprintf("This REPLACES the tenant database from snapshot %q (current data destroyed). Type 'yes' to continue: ", args[0])) {
149+
fmt.Fprintln(cmd.OutOrStdout(), "aborted")
150+
return nil
151+
}
152+
}
153+
meta, err := orchestrate.Restore(cmd.Context(), d, dumper, orchestrate.RestoreOptions{
154+
Project: project, Database: database, Instance: instance, Name: args[0], Force: force,
155+
})
156+
if err != nil {
157+
return err
158+
}
159+
if g.JSON {
160+
return writeJSON(cmd, meta)
161+
}
162+
if !g.Quiet {
163+
fmt.Fprintf(cmd.OutOrStdout(), "restored %s from snapshot %q (digest %s)\n", meta.Database, meta.Name, shortDigest(meta.Digest))
164+
}
165+
return nil
166+
},
167+
}
168+
cmd.Flags().StringVar(&project, "project", "", "owner project (default: the workspace's single/first project)")
169+
cmd.Flags().StringVar(&database, "db", "", "physical tenant database (default: the project's own database)")
170+
cmd.Flags().StringVar(&instance, "instance", "", "shared postgres instance (default: the first postgres instance)")
171+
cmd.Flags().BoolVar(&force, "force", false, "replay over a non-empty database (overwrite existing data)")
172+
cmd.Flags().BoolVar(&yes, "yes", false, "skip the confirmation prompt (required for --json)")
173+
return cmd
174+
}
175+
176+
// shortDigest is a display helper: the first 12 hex chars of a sha256, or "-".
177+
func shortDigest(d string) string {
178+
if len(d) >= 12 {
179+
return d[:12]
180+
}
181+
if d == "" {
182+
return "-"
183+
}
184+
return d
185+
}
186+
40187
// sanitizePg maps a name to a safe Postgres identifier (hyphens → underscores),
41188
// matching provision.pgIdent so ledger + physical names line up.
42189
func sanitizePg(s string) string { return strings.ReplaceAll(s, "-", "_") }

‎internal/cli/db_snapshot_test.go‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package cli
2+
3+
import "testing"
4+
5+
func TestDbSnapshotCommandsRegistered(t *testing.T) {
6+
root := NewRootCmd(Options{})
7+
for _, path := range [][]string{
8+
{"db", "snapshot"}, {"db", "snapshot", "ls"}, {"db", "restore"},
9+
} {
10+
c, _, err := root.Find(path)
11+
if err != nil || c.RunE == nil {
12+
t.Fatalf("db %v not registered as a real command: %v", path, err)
13+
}
14+
}
15+
}
16+
17+
func TestDbSnapshotFlags(t *testing.T) {
18+
root := NewRootCmd(Options{})
19+
snap, _, err := root.Find([]string{"db", "snapshot"})
20+
if err != nil {
21+
t.Fatal(err)
22+
}
23+
for _, f := range []string{"project", "db", "instance"} {
24+
if snap.Flags().Lookup(f) == nil {
25+
t.Errorf("db snapshot missing --%s", f)
26+
}
27+
}
28+
restore, _, err := root.Find([]string{"db", "restore"})
29+
if err != nil {
30+
t.Fatal(err)
31+
}
32+
for _, f := range []string{"project", "db", "instance", "force", "yes"} {
33+
if restore.Flags().Lookup(f) == nil {
34+
t.Errorf("db restore missing --%s", f)
35+
}
36+
}
37+
}
38+
39+
func TestShortDigest(t *testing.T) {
40+
for _, tc := range []struct{ in, want string }{
41+
{"", "-"},
42+
{"abc", "abc"},
43+
{"0123456789abcdef", "0123456789ab"},
44+
} {
45+
if got := shortDigest(tc.in); got != tc.want {
46+
t.Errorf("shortDigest(%q) = %q, want %q", tc.in, got, tc.want)
47+
}
48+
}
49+
}

‎internal/db/pg.go‎

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
// Package db is the data-lifecycle seam for the shared engines (spec 15): it
2+
// captures and replays a single project's tenant namespace on the warm shared
3+
// Postgres via the engine's own external client tooling (pg_dump / pg_restore /
4+
// psql). The tools are shelled behind the Dumper interface — exactly the
5+
// docker/git wrapping discipline — so the release binary stays a pure-Go,
6+
// CGO-free static binary and every risky external tool gets an internal/ seam
7+
// plus a mock. Only the pg dumper exists in this milestone; redis/minio dumpers
8+
// slot in behind the same interface (Full scope).
9+
//
10+
// The dumper never touches the shared container via the SDK: Compose owns
11+
// containers, the tools run as HOST binaries against a ledger-allocated,
12+
// 127.0.0.1-only host-port overlay (the same reachability path the provision
13+
// phase uses). The password is passed via PGPASSWORD in the process env, never
14+
// on the argv (so it does not leak into `ps`) — the same secret-handling posture
15+
// as the rest of devstack (§7.5).
16+
package db
17+
18+
import (
19+
"context"
20+
"errors"
21+
"fmt"
22+
"os/exec"
23+
"strconv"
24+
"strings"
25+
)
26+
27+
// Runner shells an external command. Its shape matches docker.Runner so the real
28+
// docker.ExecRunner satisfies it directly, and tests inject a recording fake.
29+
type Runner interface {
30+
Run(ctx context.Context, env []string, dir, name string, args ...string) error
31+
Output(ctx context.Context, env []string, dir, name string, args ...string) ([]byte, error)
32+
}
33+
34+
// ConnInfo is a host-reachable admin endpoint for one tenant database. Host/Port
35+
// come from the ledger-allocated 127.0.0.1 overlay; User/Password are the shared
36+
// instance's admin credentials; Database is the per-project tenant db.
37+
type ConnInfo struct {
38+
Host string
39+
Port int
40+
User string
41+
Password string
42+
Database string
43+
}
44+
45+
// Dumper captures and replays a single database behind an external client tool.
46+
// Snapshot writes a content-addressable dump to outPath; Restore replays inPath
47+
// into the (recreated/clean) database; IsEmpty reports whether the tenant has any
48+
// user tables (the restore-over-non-empty guard); Preflight checks the tool is
49+
// present and version-compatible.
50+
type Dumper interface {
51+
Preflight(ctx context.Context) error
52+
Snapshot(ctx context.Context, conn ConnInfo, outPath string) error
53+
Restore(ctx context.Context, conn ConnInfo, inPath string) error
54+
IsEmpty(ctx context.Context, conn ConnInfo) (bool, error)
55+
}
56+
57+
// ErrToolMissing is returned by Preflight when the required client binary is not
58+
// on PATH. It carries a one-line remediation (ARCHITECTURE §7.6).
59+
type ErrToolMissing struct {
60+
Tool string
61+
Remediation string
62+
}
63+
64+
func (e *ErrToolMissing) Error() string {
65+
return fmt.Sprintf("%s not found: %s", e.Tool, e.Remediation)
66+
}
67+
68+
// PgDumper shells pg_dump / pg_restore / psql. The client major must be ≥ the
69+
// server major to restore reliably (spec 15); this milestone stores no version
70+
// gate but the seam is here. Runner is injectable (nil → the real exec runner is
71+
// supplied by the caller); LookPath is injectable so Preflight is unit-testable.
72+
type PgDumper struct {
73+
Runner Runner
74+
LookPath func(string) (string, error) // nil → exec.LookPath
75+
}
76+
77+
// pgClientTools are the external binaries the pg dumper needs on PATH.
78+
var pgClientTools = []string{"pg_dump", "pg_restore", "psql"}
79+
80+
// Preflight verifies the PostgreSQL client tools are installed. Absence degrades
81+
// the db verbs only (never blocks up), consistent with the mkcert/cloudflared
82+
// external-binary posture (DECISIONS D11/D12).
83+
func (p PgDumper) Preflight(_ context.Context) error {
84+
look := p.LookPath
85+
if look == nil {
86+
look = exec.LookPath
87+
}
88+
for _, tool := range pgClientTools {
89+
if _, err := look(tool); err != nil {
90+
return &ErrToolMissing{
91+
Tool: tool,
92+
Remediation: "install the PostgreSQL client tools (e.g. `apt install postgresql-client`, `brew install libpq`, or `dnf install postgresql`) so `" + tool + "` is on PATH",
93+
}
94+
}
95+
}
96+
return nil
97+
}
98+
99+
// connFlags builds the shared -h/-p/-U/-d connection flags. The password is NOT
100+
// here — it rides PGPASSWORD in the env (pgEnv).
101+
func connFlags(c ConnInfo) []string {
102+
return []string{"-h", c.Host, "-p", strconv.Itoa(c.Port), "-U", c.User, "-d", c.Database}
103+
}
104+
105+
// pgEnv passes the password out-of-band so it never lands on the argv.
106+
func pgEnv(c ConnInfo) []string { return []string{"PGPASSWORD=" + c.Password} }
107+
108+
// Snapshot dumps the tenant database to outPath in the custom (compressed,
109+
// selectively-restorable) format, owner-stripped so it replays into a
110+
// freshly-recreated role. pg_dump opens a REPEATABLE READ snapshot, so it is
111+
// consistent against a busy tenant WITHOUT stopping the shared server (spec 15).
112+
func (p PgDumper) Snapshot(ctx context.Context, conn ConnInfo, outPath string) error {
113+
args := append(connFlags(conn), "--format=custom", "--no-owner", "--no-privileges", "--file", outPath)
114+
if err := p.Runner.Run(ctx, pgEnv(conn), "", "pg_dump", args...); err != nil {
115+
return fmt.Errorf("pg_dump %s: %w", conn.Database, err)
116+
}
117+
return nil
118+
}
119+
120+
// Restore replays inPath into the tenant database, dropping conflicting objects
121+
// first (--clean --if-exists) and ignoring dump ownership (--no-owner). The
122+
// caller must have terminated live backends + recreated a clean database (the
123+
// tenant scope) before calling; the drop/recreate SQL is provisioning's guarded
124+
// pgx path (spec 15). --exit-on-error so a partial restore fails loudly.
125+
func (p PgDumper) Restore(ctx context.Context, conn ConnInfo, inPath string) error {
126+
args := append(connFlags(conn), "--clean", "--if-exists", "--no-owner", "--no-privileges", "--exit-on-error", inPath)
127+
if err := p.Runner.Run(ctx, pgEnv(conn), "", "pg_restore", args...); err != nil {
128+
return fmt.Errorf("pg_restore %s: %w", conn.Database, err)
129+
}
130+
return nil
131+
}
132+
133+
// emptyCountSQL counts user tables (excluding the system schemas) so a restore
134+
// can refuse to clobber a tenant that already has data unless --force.
135+
const emptyCountSQL = `SELECT count(*) FROM information_schema.tables WHERE table_schema NOT IN ('pg_catalog','information_schema')`
136+
137+
// IsEmpty reports whether the tenant database has no user tables.
138+
func (p PgDumper) IsEmpty(ctx context.Context, conn ConnInfo) (bool, error) {
139+
args := append(connFlags(conn), "-tAX", "-c", emptyCountSQL)
140+
out, err := p.Runner.Output(ctx, pgEnv(conn), "", "psql", args...)
141+
if err != nil {
142+
return false, fmt.Errorf("psql count tables in %s: %w", conn.Database, err)
143+
}
144+
n, perr := strconv.Atoi(strings.TrimSpace(string(out)))
145+
if perr != nil {
146+
return false, fmt.Errorf("parse table count %q: %w", strings.TrimSpace(string(out)), perr)
147+
}
148+
return n == 0, nil
149+
}
150+
151+
// IsToolMissing reports whether err is (or wraps) an ErrToolMissing.
152+
func IsToolMissing(err error) bool {
153+
var e *ErrToolMissing
154+
return errors.As(err, &e)
155+
}

0 commit comments

Comments
 (0)