Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 152 additions & 5 deletions internal/cli/db.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (

"github.com/spf13/cobra"

"github.com/open-source-cloud/devstack/internal/db"
"github.com/open-source-cloud/devstack/internal/docker"
"github.com/open-source-cloud/devstack/internal/orchestrate"
"github.com/open-source-cloud/devstack/internal/resource"
"github.com/open-source-cloud/devstack/internal/state"
Expand All @@ -14,8 +16,9 @@ import (
// newDbCmd wires the `devstack db` group (spec 29 §databases): tenant-scoped
// 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. This
// graduates the reserved `db` stub. snapshot/restore/reset/pull stay v2 stubs.
// 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.
func newDbCmd(g *GlobalOpts) *cobra.Command {
cmd := &cobra.Command{
Use: "db",
Expand All @@ -28,15 +31,159 @@ func newDbCmd(g *GlobalOpts) *cobra.Command {
newDbListCmd(g),
newDbDropCmd(g),
newDbGcCmd(g),
// v2 data-lifecycle verbs (spec 15) reserved as stubs.
stub("snapshot", "Snapshot a project's database", "v2 (spec 15)"),
stub("restore", "Restore a project's database from a snapshot", "v2 (spec 15)"),
// 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)"),
)
return cmd
}

// defaultPgDumper is the real pg_dump/pg_restore/psql client, shelled behind the
// docker exec runner (the release binary stays CGO-free — the tools are external).
func defaultPgDumper() db.Dumper { return db.PgDumper{Runner: docker.ExecRunner{}} }

// newDbSnapshotCmd wires `db snapshot [name]` (capture) with the `ls` subcommand
// (list). A snapshot dumps ONLY the project's tenant database on the shared
// Postgres to ~/.devstack/snapshots/<workspace>/ and records a ledger row (spec 15).
func newDbSnapshotCmd(g *GlobalOpts) *cobra.Command {
var project, database, instance string
cmd := &cobra.Command{
Use: "snapshot [name]",
Short: "Capture a project's tenant database to the snapshot store",
Args: cobra.MaximumNArgs(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
}
var name string
if len(args) == 1 {
name = args[0]
}
meta, err := orchestrate.Snapshot(cmd.Context(), d, dumper, orchestrate.SnapshotOptions{
Project: project, Database: database, Instance: instance, Name: name,
})
if err != nil {
return err
}
if g.JSON {
return writeJSON(cmd, meta)
}
if !g.Quiet {
fmt.Fprintf(cmd.OutOrStdout(), "captured snapshot %q of %s (%d bytes)\n%s\n", meta.Name, meta.Database, meta.Size, meta.Path)
}
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)")
cmd.AddCommand(newDbSnapshotLsCmd(g))
return cmd
}

func newDbSnapshotLsCmd(g *GlobalOpts) *cobra.Command {
var project string
cmd := &cobra.Command{
Use: "ls",
Short: "List captured snapshots (lock-free)",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
d, closeFn, err := buildUpDeps(cmd)
if err != nil {
return err
}
defer closeFn()
snaps, err := orchestrate.ListSnapshots(d, project)
if err != nil {
return err
}
if g.JSON {
return writeJSON(cmd, map[string]any{"snapshots": snaps})
}
w := cmd.OutOrStdout()
if len(snaps) == 0 {
fmt.Fprintln(w, "no snapshots")
return nil
}
for _, s := range snaps {
fmt.Fprintf(w, "%-24s %-12s %10d %s %s\n", s.Name, s.Database, s.Size, shortDigest(s.Digest), s.CreatedAt)
}
return nil
},
}
cmd.Flags().StringVar(&project, "project", "", "only this project's snapshots")
return cmd
}

func newDbRestoreCmd(g *GlobalOpts) *cobra.Command {
var project, database, instance string
var force, yes bool
cmd := &cobra.Command{
Use: "restore <name>",
Short: "Restore a project's tenant database from a snapshot (destructive)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if g.JSON && !yes {
return fmt.Errorf("refusing to restore without --yes for --json/non-interactive use")
}
d, closeFn, err := buildUpDeps(cmd)
if err != nil {
return err
}
defer closeFn()
dumper := defaultPgDumper()
if err := dumper.Preflight(cmd.Context()); err != nil {
return err
}
if !yes {
if !confirm(cmd, fmt.Sprintf("This REPLACES the tenant database from snapshot %q (current data destroyed). Type 'yes' to continue: ", args[0])) {
fmt.Fprintln(cmd.OutOrStdout(), "aborted")
return nil
}
}
meta, err := orchestrate.Restore(cmd.Context(), d, dumper, orchestrate.RestoreOptions{
Project: project, Database: database, Instance: instance, Name: args[0], Force: force,
})
if err != nil {
return err
}
if g.JSON {
return writeJSON(cmd, meta)
}
if !g.Quiet {
fmt.Fprintf(cmd.OutOrStdout(), "restored %s from snapshot %q (digest %s)\n", meta.Database, meta.Name, 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)")
cmd.Flags().BoolVar(&force, "force", false, "replay over a non-empty database (overwrite existing data)")
cmd.Flags().BoolVar(&yes, "yes", false, "skip the confirmation prompt (required for --json)")
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 {
return d[:12]
}
if d == "" {
return "-"
}
return d
}

// sanitizePg maps a name to a safe Postgres identifier (hyphens → underscores),
// matching provision.pgIdent so ledger + physical names line up.
func sanitizePg(s string) string { return strings.ReplaceAll(s, "-", "_") }
Expand Down
49 changes: 49 additions & 0 deletions internal/cli/db_snapshot_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package cli

import "testing"

func TestDbSnapshotCommandsRegistered(t *testing.T) {
root := NewRootCmd(Options{})
for _, path := range [][]string{
{"db", "snapshot"}, {"db", "snapshot", "ls"}, {"db", "restore"},
} {
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 TestDbSnapshotFlags(t *testing.T) {
root := NewRootCmd(Options{})
snap, _, err := root.Find([]string{"db", "snapshot"})
if err != nil {
t.Fatal(err)
}
for _, f := range []string{"project", "db", "instance"} {
if snap.Flags().Lookup(f) == nil {
t.Errorf("db snapshot missing --%s", f)
}
}
restore, _, err := root.Find([]string{"db", "restore"})
if err != nil {
t.Fatal(err)
}
for _, f := range []string{"project", "db", "instance", "force", "yes"} {
if restore.Flags().Lookup(f) == nil {
t.Errorf("db restore missing --%s", f)
}
}
}

func TestShortDigest(t *testing.T) {
for _, tc := range []struct{ in, want string }{
{"", "-"},
{"abc", "abc"},
{"0123456789abcdef", "0123456789ab"},
} {
if got := shortDigest(tc.in); got != tc.want {
t.Errorf("shortDigest(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
155 changes: 155 additions & 0 deletions internal/db/pg.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
// Package db is the data-lifecycle seam for the shared engines (spec 15): it
// captures and replays a single project's tenant namespace on the warm shared
// Postgres via the engine's own external client tooling (pg_dump / pg_restore /
// psql). The tools are shelled behind the Dumper interface — exactly the
// docker/git wrapping discipline — so the release binary stays a pure-Go,
// CGO-free static binary and every risky external tool gets an internal/ seam
// plus a mock. Only the pg dumper exists in this milestone; redis/minio dumpers
// slot in behind the same interface (Full scope).
//
// The dumper never touches the shared container via the SDK: Compose owns
// containers, the tools run as HOST binaries against a ledger-allocated,
// 127.0.0.1-only host-port overlay (the same reachability path the provision
// phase uses). The password is passed via PGPASSWORD in the process env, never
// on the argv (so it does not leak into `ps`) — the same secret-handling posture
// as the rest of devstack (§7.5).
package db

import (
"context"
"errors"
"fmt"
"os/exec"
"strconv"
"strings"
)

// Runner shells an external command. Its shape matches docker.Runner so the real
// docker.ExecRunner satisfies it directly, and tests inject a recording fake.
type Runner interface {
Run(ctx context.Context, env []string, dir, name string, args ...string) error
Output(ctx context.Context, env []string, dir, name string, args ...string) ([]byte, error)
}

// ConnInfo is a host-reachable admin endpoint for one tenant database. Host/Port
// come from the ledger-allocated 127.0.0.1 overlay; User/Password are the shared
// instance's admin credentials; Database is the per-project tenant db.
type ConnInfo struct {
Host string
Port int
User string
Password string
Database string
}

// Dumper captures and replays a single database behind an external client tool.
// Snapshot writes a content-addressable dump to outPath; Restore replays inPath
// into the (recreated/clean) database; IsEmpty reports whether the tenant has any
// user tables (the restore-over-non-empty guard); Preflight checks the tool is
// present and version-compatible.
type Dumper interface {
Preflight(ctx context.Context) error
Snapshot(ctx context.Context, conn ConnInfo, outPath string) error
Restore(ctx context.Context, conn ConnInfo, inPath string) error
IsEmpty(ctx context.Context, conn ConnInfo) (bool, error)
}

// ErrToolMissing is returned by Preflight when the required client binary is not
// on PATH. It carries a one-line remediation (ARCHITECTURE §7.6).
type ErrToolMissing struct {
Tool string
Remediation string
}

func (e *ErrToolMissing) Error() string {
return fmt.Sprintf("%s not found: %s", e.Tool, e.Remediation)
}

// PgDumper shells pg_dump / pg_restore / psql. The client major must be ≥ the
// server major to restore reliably (spec 15); this milestone stores no version
// gate but the seam is here. Runner is injectable (nil → the real exec runner is
// supplied by the caller); LookPath is injectable so Preflight is unit-testable.
type PgDumper struct {
Runner Runner
LookPath func(string) (string, error) // nil → exec.LookPath
}

// pgClientTools are the external binaries the pg dumper needs on PATH.
var pgClientTools = []string{"pg_dump", "pg_restore", "psql"}

// Preflight verifies the PostgreSQL client tools are installed. Absence degrades
// the db verbs only (never blocks up), consistent with the mkcert/cloudflared
// external-binary posture (DECISIONS D11/D12).
func (p PgDumper) Preflight(_ context.Context) error {
look := p.LookPath
if look == nil {
look = exec.LookPath
}
for _, tool := range pgClientTools {
if _, err := look(tool); err != nil {
return &ErrToolMissing{
Tool: tool,
Remediation: "install the PostgreSQL client tools (e.g. `apt install postgresql-client`, `brew install libpq`, or `dnf install postgresql`) so `" + tool + "` is on PATH",
}
}
}
return nil
}

// connFlags builds the shared -h/-p/-U/-d connection flags. The password is NOT
// here — it rides PGPASSWORD in the env (pgEnv).
func connFlags(c ConnInfo) []string {
return []string{"-h", c.Host, "-p", strconv.Itoa(c.Port), "-U", c.User, "-d", c.Database}
}

// pgEnv passes the password out-of-band so it never lands on the argv.
func pgEnv(c ConnInfo) []string { return []string{"PGPASSWORD=" + c.Password} }

// Snapshot dumps the tenant database to outPath in the custom (compressed,
// selectively-restorable) format, owner-stripped so it replays into a
// freshly-recreated role. pg_dump opens a REPEATABLE READ snapshot, so it is
// consistent against a busy tenant WITHOUT stopping the shared server (spec 15).
func (p PgDumper) Snapshot(ctx context.Context, conn ConnInfo, outPath string) error {
args := append(connFlags(conn), "--format=custom", "--no-owner", "--no-privileges", "--file", outPath)
if err := p.Runner.Run(ctx, pgEnv(conn), "", "pg_dump", args...); err != nil {
return fmt.Errorf("pg_dump %s: %w", conn.Database, err)
}
return nil
}

// Restore replays inPath into the tenant database, dropping conflicting objects
// first (--clean --if-exists) and ignoring dump ownership (--no-owner). The
// caller must have terminated live backends + recreated a clean database (the
// tenant scope) before calling; the drop/recreate SQL is provisioning's guarded
// pgx path (spec 15). --exit-on-error so a partial restore fails loudly.
func (p PgDumper) Restore(ctx context.Context, conn ConnInfo, inPath string) error {
args := append(connFlags(conn), "--clean", "--if-exists", "--no-owner", "--no-privileges", "--exit-on-error", inPath)
if err := p.Runner.Run(ctx, pgEnv(conn), "", "pg_restore", args...); err != nil {
return fmt.Errorf("pg_restore %s: %w", conn.Database, err)
}
return nil
}

// emptyCountSQL counts user tables (excluding the system schemas) so a restore
// can refuse to clobber a tenant that already has data unless --force.
const emptyCountSQL = `SELECT count(*) FROM information_schema.tables WHERE table_schema NOT IN ('pg_catalog','information_schema')`

// IsEmpty reports whether the tenant database has no user tables.
func (p PgDumper) IsEmpty(ctx context.Context, conn ConnInfo) (bool, error) {
args := append(connFlags(conn), "-tAX", "-c", emptyCountSQL)
out, err := p.Runner.Output(ctx, pgEnv(conn), "", "psql", args...)
if err != nil {
return false, fmt.Errorf("psql count tables in %s: %w", conn.Database, err)
}
n, perr := strconv.Atoi(strings.TrimSpace(string(out)))
if perr != nil {
return false, fmt.Errorf("parse table count %q: %w", strings.TrimSpace(string(out)), perr)
}
return n == 0, nil
}

// IsToolMissing reports whether err is (or wraps) an ErrToolMissing.
func IsToolMissing(err error) bool {
var e *ErrToolMissing
return errors.As(err, &e)
}
Loading
Loading