carlos / platform Public

carlos db push/pull: copy an instance's SQLite database out or in through the console

`carlos db pull` copies a hibernating, activator-managed instance's
replicated database to the member's machine; `carlos db push` replaces it
with a local file, after a backup and a confirmation; `status` and
`backups` read what those leave behind. A member needs a login only: the
CLI asks the console, the console writes a request onto the instance
record (an additive `db` field, provision.DBTransfer, with guarded CAS
transitions beside the move ones) and stages bytes in the control bucket,
and the box that owns the route bridges to the data bucket.

Box side (internal/host/dbpass.go, armed and run beside the moves
worker): a pull restores the replica without the lease into scratch,
CheckDBs it and stages it; a push holds the route at status
"maintenance", fences and quiesces the child, checkpoints, backs up the
replica under backups/<id>.db (newest five kept), deletes the replica
prefix, replicates the upload into it under a fresh lease, purges the
warm cache so the next wake restores the pushed file, then releases the
route. Every step is idempotent and resumes from the record's Step; a
failure after the prefix delete keeps the request in flight rather than
releasing a route over an empty replica. The delete-then-replicate
sequence is proven against the vendored litestream in
internal/lease/replace_test.go.

Console doors (internal/console/apiinstancedb.go) are closed to every
service role: the payload is a whole customer database. The class rail
is factored out of moveGate and shared. A move refuses while a transfer
is in flight and vice versa. docs/site/cli.md gains `### carlos db`.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Tony Wood pushed by tonywood@keymail.dev b014d57d9fa4b2023d444aa3f6720f3a1f347b57 parent 88abfae
19 files changed, +4343 −20
  • auth.go +40 −0
  • db_cli.go +682 −0
  • db_cli_test.go +339 −0
  • docs/site/cli.md +49 −0
  • internal/console/apiinstancedb.go +362 −0
  • internal/console/apiinstancedb_test.go +410 −0
  • internal/console/apiinstancemove.go +53 −18
  • internal/console/authcli.go +14 −0
  • internal/console/svcroutes_test.go +13 −2
  • internal/host/agent.go +5 −0
  • internal/host/dbpass.go +647 −0
  • internal/host/dbpass_test.go +767 −0
  • internal/lease/check.go +13 −0
  • internal/lease/replace_test.go +169 −0
  • internal/provision/dbstage.go +219 −0
  • internal/provision/dbtransfer.go +242 −0
  • internal/provision/dbtransfer_test.go +299 −0
  • internal/provision/provision.go +15 −0
  • main.go +5 −0

CI passed — run details

diff --git a/auth.go b/auth.go
index 3e282cf12..3c1aaf628 100644
--- a/auth.go
+++ b/auth.go
@@ -535,6 +535,46 @@ func (c *consoleClient) doMultipartMethod(ctx context.Context, method, path stri
return res.StatusCode, body, nil
}
+// doStream runs one request whose body is a STREAM in one direction or the
+// other — `carlos db push` sending a database file up, `carlos db pull`
+// reading one down — and hands the response back unread, so the caller
+// copies the body straight to disk (or the request body straight from it)
+// without either side ever holding the file whole. contentLength is the
+// request body's size when known (-1 otherwise); headers are the caller's
+// extras on top of the bearer.
+//
+// Same client shape as doMultipart, for the same reason: no absolute
+// Timeout (the caller's context is the budget — a 256 MiB transfer over a
+// slow link is minutes, and the JSON client's 30s would abort it live),
+// and no redirects. A non-2xx status is NOT an error here either: the
+// caller reads the body through apiErrorFromBody, which knows the
+// {"error"} shape. The caller closes the response body.
+func (c *consoleClient) doStream(ctx context.Context, method, path string, body io.Reader, contentLength int64, headers map[string]string) (*http.Response, error) {
+ req, err := http.NewRequestWithContext(ctx, method, c.base+path, body)
+ if err != nil {
+ return nil, err
+ }
+ if body != nil && contentLength >= 0 {
+ req.ContentLength = contentLength
+ }
+ for k, v := range headers {
+ req.Header.Set(k, v)
+ }
+ if c.token != "" {
+ req.Header.Set("Authorization", "Bearer "+c.token)
+ }
+ client := &http.Client{Timeout: shipHTTPClientTimeout, CheckRedirect: neverFollow}
+ res, err := client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ if merr := consoleMoved(c.base, res.StatusCode, res.Header.Get("Location")); merr != nil {
+ res.Body.Close()
+ return nil, offerConsoleMoved(merr)
+ }
+ return res, nil
+}
+
// neverFollow is the one redirect policy every console client uses.
func neverFollow(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
diff --git a/db_cli.go b/db_cli.go
new file mode 100644
index 000000000..4fce345db
--- /dev/null
+++ b/db_cli.go
@@ -0,0 +1,682 @@
+// db_cli.go is `carlos db`: copy an instance's SQLite database out of the
+// platform (pull) or a replacement into it (push), for a member with no box
+// and no bucket access. Two more verbs read what the first two leave
+// behind: status (the last transfer on the record) and backups (what a
+// push saved before it replaced anything).
+//
+// The shape is the console-mediated one every carlos verb has: the CLI asks
+// the console, the console writes a request onto the instance's record and
+// bumps the serial, and the box that owns the route does the work within
+// seconds — a pull restores the replica and stages a snapshot in the control
+// bucket for the console to serve; a push takes the staged upload, backs up
+// the current replica, replaces it, and purges the box's warm cache so the
+// next wake restores the pushed file (internal/host/dbpass.go has the
+// ordering and the crash-point argument). This side polls the record until
+// it is done or failed, then downloads or reports.
+//
+// Scope, v1: hibernating, activator-managed exec instances only — the class
+// whose database the platform itself manages. A pull reflects the REPLICA,
+// which for a sleeping instance is byte-final and for a running one is at
+// most the sync interval behind. A push is downtime: the route is held out
+// of service from the moment the box picks the request up until the swap is
+// complete, and it needs confirmation — --yes, or the host typed at a
+// terminal.
+package main
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+ "errors"
+ "flag"
+ "fmt"
+ "io"
+ "log"
+ "net/http"
+ "net/url"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "time"
+
+ _ "modernc.org/sqlite"
+)
+
+// dbTransferMaxBytes mirrors the console's cap (provision.DBTransferMaxBytes):
+// checked here first so an oversized file costs a stat, not an upload.
+const dbTransferMaxBytes int64 = 256 << 20
+
+// dbSQLiteContentType is the media type the push door demands and the
+// download doors serve.
+const dbSQLiteContentType = "application/vnd.sqlite3"
+
+// dbConfirmHeader carries the host the push is confirmed for; the door
+// refuses a push whose header does not name the instance.
+const dbConfirmHeader = "X-Carlos-Confirm-Host"
+
+const dbUsage = `usage: carlos db <verb> --app <a> [--host <h>] [flags]
+
+ pull [--out <path> | <path>] [--backup <id>|latest] [--force]
+ copy the instance's database to this machine (default ./<host>.db)
+ push <path> [--yes]
+ replace the instance's database with a local SQLite file — the
+ instance is held out of service for the swap, the current
+ database is backed up first, and the swap needs confirmation
+ status the last transfer requested for the instance
+ backups the backups earlier pushes saved, newest first
+
+Every verb takes --app, and --host when the app has more than one instance;
+the usual --account / --account-id / --console / --destination apply.
+Hibernating, platform-managed instances only, up to 256 MiB.
+
+`
+
+func runDB(args []string) {
+ if len(args) == 0 {
+ fmt.Fprint(os.Stderr, dbUsage)
+ os.Exit(2)
+ }
+ switch args[0] {
+ case "pull":
+ runDBPull(args[1:])
+ case "push":
+ runDBPush(args[1:])
+ case "status":
+ runDBStatus(args[1:])
+ case "backups":
+ runDBBackups(args[1:])
+ case "-h", "--help", "help":
+ fmt.Fprint(stdout, dbUsage)
+ default:
+ log.Fatalf("carlos db: unknown verb %q\n%s", args[0], dbUsage)
+ }
+}
+
+// requireDBAPISession resolves signed-in mode, refusing direct mode by
+// name: a transfer is a console write the fleet listens for, and an
+// operator with the bucket already has the box.
+func requireDBAPISession(consoleFlag string) *apiSession {
+ sess, err := resolveMode(consoleFlag)
+ if err != nil {
+ log.Fatal(err)
+ }
+ if sess == nil {
+ log.Fatal("carlos db works through a console, and this shell is in direct bucket mode\n" +
+ "(CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR is set). A transfer is a member verb with no\n" +
+ "direct-bucket equivalent — the request is a console write the box listens for. Unset the\n" +
+ "store env and run: carlos auth login --console <url>.")
+ }
+ return sess
+}
+
+// dbTarget is what every verb resolves first: the session, the account,
+// the app and the one host the verb acts on.
+type dbTarget struct {
+ sess *apiSession
+ acct apiAcct
+ app string
+ host string
+}
+
+// dbFlags registers the flags every verb shares and returns the resolver.
+func dbFlags(fs *flag.FlagSet, verb string) func() dbTarget {
+ app := appFlag(fs, "app the instance belongs to")
+ host := fs.String("host", "", "the instance (omit when the app has exactly one)")
+ acctRaw, acctIDRaw, _ := accountFlags(fs)
+ consoleFlag := consoleOption(fs, "console to work through (default: the only logged-in one)")
+ return func() dbTarget {
+ sess := requireDBAPISession(*consoleFlag)
+ printUsing(stdout, sess)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
+ name := resolvedApp(sess, acct, strings.TrimSpace(*app), dbUsage)
+ h, err := resolveDBHost(context.Background(), sess, acct, name, strings.TrimSpace(*host))
+ if err != nil {
+ log.Fatalf("carlos db %s: %v", verb, err)
+ }
+ return dbTarget{sess: sess, acct: acct, app: name, host: h}
+ }
+}
+
+// resolveDBHost is --host, or the app's only instance when it has exactly
+// one. Anything else is refused with the hosts the app has: a transfer
+// names one database, and guessing which would be the wrong kind of help.
+func resolveDBHost(ctx context.Context, sess *apiSession, acct apiAcct, app, host string) (string, error) {
+ if host != "" {
+ return host, nil
+ }
+ recs, truncated, _, err := sess.instanceList(ctx, acct, app, "", "")
+ if err != nil {
+ return "", fmt.Errorf("list instances to pick a host: %w", err)
+ }
+ if len(recs) == 1 && !truncated {
+ return recs[0].Host, nil
+ }
+ if len(recs) == 0 {
+ return "", fmt.Errorf("%s has no instances — nothing to transfer", app)
+ }
+ hosts := make([]string, 0, len(recs))
+ for _, r := range recs {
+ hosts = append(hosts, r.Host)
+ }
+ sort.Strings(hosts)
+ return "", fmt.Errorf("--host is required: %s has %d instances: %s", app, len(recs), strings.Join(hosts, ", "))
+}
+
+// ---- the console client half ------------------------------------------------
+
+// apiDBView is the flat view every db door answers with
+// (internal/console/apiinstancedb.go's dbView).
+type apiDBView struct {
+ State string `json:"state"`
+ ID string `json:"id"`
+ Op string `json:"op"`
+ Step string `json:"step"`
+ Error string `json:"error"`
+ Bytes int64 `json:"bytes"`
+ Backup string `json:"backup"`
+ RequestedAt int64 `json:"requested_at"`
+ UpdatedAt int64 `json:"updated_at"`
+}
+
+type apiDBBackup struct {
+ ID string `json:"id"`
+ Bytes int64 `json:"bytes"`
+ CreatedAt time.Time `json:"created_at"`
+ Request string `json:"request"`
+}
+
+func dbPath(a apiAcct, app, host, tail string) string {
+ return apiPath(a, app, "/instances/"+url.PathEscape(host)+"/db"+tail)
+}
+
+func decodeDBView(status int, body []byte, want int) (apiDBView, error) {
+ if status != want {
+ return apiDBView{}, apiErrorFromBody(status, body)
+ }
+ var v apiDBView
+ if err := json.Unmarshal(body, &v); err != nil {
+ return apiDBView{}, fmt.Errorf("db: unreadable response: %w", err)
+ }
+ return v, nil
+}
+
+func (s *apiSession) dbStatus(ctx context.Context, a apiAcct, app, host string) (apiDBView, error) {
+ status, body, err := s.getJSON(ctx, dbPath(a, app, host, ""))
+ if err != nil {
+ return apiDBView{}, err
+ }
+ return decodeDBView(status, body, http.StatusOK)
+}
+
+func (s *apiSession) dbPullRequest(ctx context.Context, a apiAcct, app, host string) (apiDBView, error) {
+ status, body, err := s.postJSON(ctx, dbPath(a, app, host, "/pull"), map[string]string{})
+ if err != nil {
+ return apiDBView{}, err
+ }
+ return decodeDBView(status, body, http.StatusAccepted)
+}
+
+func (s *apiSession) dbClear(ctx context.Context, a apiAcct, app, host string) error {
+ status, body, err := s.deleteJSON(ctx, dbPath(a, app, host, ""))
+ if err != nil {
+ return err
+ }
+ if status != http.StatusOK {
+ return apiErrorFromBody(status, body)
+ }
+ return nil
+}
+
+func (s *apiSession) dbBackups(ctx context.Context, a apiAcct, app, host string) ([]apiDBBackup, error) {
+ status, body, err := s.getJSON(ctx, dbPath(a, app, host, "/backups"))
+ if err != nil {
+ return nil, err
+ }
+ if status != http.StatusOK {
+ return nil, apiErrorFromBody(status, body)
+ }
+ var res struct {
+ Backups []apiDBBackup `json:"backups"`
+ }
+ if err := json.Unmarshal(body, &res); err != nil {
+ return nil, fmt.Errorf("db backups: unreadable response: %w", err)
+ }
+ return res.Backups, nil
+}
+
+// dbPush streams the file up with the confirmation header. 202 is the
+// only success: the console has staged the bytes and written the request.
+func (s *apiSession) dbPush(ctx context.Context, a apiAcct, app, host string, body io.Reader, size int64) (apiDBView, error) {
+ res, err := s.c.doStream(ctx, http.MethodPut, dbPath(a, app, host, "/push"), body, size, map[string]string{
+ "Content-Type": dbSQLiteContentType,
+ dbConfirmHeader: host,
+ })
+ if err != nil {
+ return apiDBView{}, err
+ }
+ defer res.Body.Close()
+ raw, err := io.ReadAll(io.LimitReader(res.Body, 1<<20))
+ if err != nil {
+ return apiDBView{}, err
+ }
+ return decodeDBView(res.StatusCode, raw, http.StatusAccepted)
+}
+
+// dbDownload opens a streamed download of one staged database. The caller
+// closes the body; a non-200 is turned into the console's own sentence.
+func (s *apiSession) dbDownload(ctx context.Context, path string) (io.ReadCloser, int64, error) {
+ res, err := s.c.doStream(ctx, http.MethodGet, path, nil, -1, nil)
+ if err != nil {
+ return nil, 0, err
+ }
+ if res.StatusCode != http.StatusOK {
+ defer res.Body.Close()
+ raw, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20))
+ return nil, 0, apiErrorFromBody(res.StatusCode, raw)
+ }
+ return res.Body, res.ContentLength, nil
+}
+
+// ---- the local half ---------------------------------------------------------
+
+// dbPollInterval and dbPollTimeout bound the wait for the box: seconds
+// between polls (the console's instance budget is 60 a minute, shared with
+// everything else this token does), and ten minutes in all — a restore, a
+// backup and a replicate of a 256 MiB database on a busy box.
+const (
+ dbPollInterval = 2 * time.Second
+ dbPollTimeout = 10 * time.Minute
+)
+
+// pollDB polls the status door until transfer id is done or failed. A
+// failure is returned as an error carrying the box's sentence; a timeout
+// names the verb that keeps watching. progress gets one dot per poll so a
+// terminal shows the wait is alive.
+func pollDB(ctx context.Context, sess *apiSession, t dbTarget, id string, interval, timeout time.Duration, progress io.Writer) (apiDBView, error) {
+ deadline := time.Now().Add(timeout)
+ for {
+ v, err := sess.dbStatus(ctx, t.acct, t.app, t.host)
+ if err != nil {
+ return v, err
+ }
+ if v.ID != id {
+ return v, fmt.Errorf("the transfer was superseded on the console (now %s %s)", v.Op, v.State)
+ }
+ switch v.State {
+ case "done":
+ return v, nil
+ case "failed":
+ return v, fmt.Errorf("the box could not complete the %s: %s", v.Op, v.Error)
+ }
+ if time.Now().After(deadline) {
+ return v, fmt.Errorf("still %s after %s — the box has not finished; carlos db status --app %s --host %s keeps watching",
+ v.State, timeout.Round(time.Second), t.app, t.host)
+ }
+ if progress != nil {
+ fmt.Fprint(progress, ".")
+ }
+ select {
+ case <-ctx.Done():
+ return v, ctx.Err()
+ case <-time.After(interval):
+ }
+ }
+}
+
+// writeDownload copies r to path through a temp file in the same
+// directory and a rename, so a torn download never sits at the final name.
+// An existing file is refused unless force is set: a pull that silently
+// overwrote yesterday's copy is the one accident this verb must not have.
+func writeDownload(path string, r io.Reader, force bool) (int64, error) {
+ if _, err := os.Stat(path); err == nil && !force {
+ return 0, fmt.Errorf("%s already exists — pass --force to replace it, or --out <path>", path)
+ } else if err != nil && !errors.Is(err, os.ErrNotExist) {
+ return 0, err
+ }
+ dir := filepath.Dir(path)
+ tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".*.part")
+ if err != nil {
+ return 0, err
+ }
+ tmpName := tmp.Name()
+ n, err := io.Copy(tmp, r)
+ if cerr := tmp.Close(); err == nil {
+ err = cerr
+ }
+ if err != nil {
+ _ = os.Remove(tmpName)
+ return 0, err
+ }
+ if err := os.Chmod(tmpName, 0o600); err != nil {
+ _ = os.Remove(tmpName)
+ return 0, err
+ }
+ if err := os.Rename(tmpName, path); err != nil {
+ _ = os.Remove(tmpName)
+ return 0, err
+ }
+ return n, nil
+}
+
+// prepareDBUpload proves the local file is a sound SQLite database and
+// writes a self-contained copy of it to a temp file: VACUUM INTO folds any
+// -wal content into one file, which is what the wire carries and the box
+// checks. The caller removes the returned path.
+func prepareDBUpload(path string) (tmpPath string, size int64, err error) {
+ fi, err := os.Stat(path)
+ if err != nil {
+ return "", 0, err
+ }
+ if fi.IsDir() {
+ return "", 0, fmt.Errorf("%s is a directory", path)
+ }
+ db, err := sql.Open("sqlite", "file:"+path+"?mode=ro")
+ if err != nil {
+ return "", 0, err
+ }
+ defer db.Close()
+ db.SetMaxOpenConns(1)
+ if _, err := db.Exec("PRAGMA busy_timeout=5000"); err != nil {
+ return "", 0, fmt.Errorf("%s is not a SQLite database: %v", path, err)
+ }
+ var res string
+ if err := db.QueryRow("PRAGMA integrity_check").Scan(&res); err != nil {
+ return "", 0, fmt.Errorf("%s is not a SQLite database: %v", path, err)
+ }
+ if res != "ok" {
+ return "", 0, fmt.Errorf("%s fails integrity_check: %s", path, res)
+ }
+ var tables int
+ if err := db.QueryRow("SELECT count(*) FROM sqlite_master WHERE type='table'").Scan(&tables); err != nil {
+ return "", 0, err
+ }
+ if tables == 0 {
+ return "", 0, fmt.Errorf("%s has no tables — refusing to push an empty database", path)
+ }
+ // VACUUM INTO wants a name that does not exist yet; CreateTemp reserves
+ // one in a directory we can write, then gives it back.
+ tmp, err := os.CreateTemp("", "carlos-db-push-*.db")
+ if err != nil {
+ return "", 0, err
+ }
+ tmpPath = tmp.Name()
+ tmp.Close()
+ _ = os.Remove(tmpPath)
+ if _, err := db.Exec("VACUUM INTO ?", tmpPath); err != nil {
+ _ = os.Remove(tmpPath)
+ return "", 0, fmt.Errorf("could not write a self-contained copy of %s: %v", path, err)
+ }
+ if err := os.Chmod(tmpPath, 0o600); err != nil {
+ _ = os.Remove(tmpPath)
+ return "", 0, err
+ }
+ out, err := os.Stat(tmpPath)
+ if err != nil {
+ _ = os.Remove(tmpPath)
+ return "", 0, err
+ }
+ if out.Size() > dbTransferMaxBytes {
+ _ = os.Remove(tmpPath)
+ return "", 0, fmt.Errorf("%s is %d MiB once packed; transfers are capped at %d MiB", path, out.Size()>>20, dbTransferMaxBytes>>20)
+ }
+ return tmpPath, out.Size(), nil
+}
+
+// confirmDBPush is the confirmation: --yes, or the host typed back at a
+// terminal. A non-terminal without --yes is refused — a script that
+// replaces a database says so in its own text.
+func confirmDBPush(host string, yes, interactive bool, out io.Writer) error {
+ if yes {
+ return nil
+ }
+ if !interactive {
+ return errors.New("a push replaces the instance's whole database and needs confirmation: pass --yes, or run it at a terminal and type the host")
+ }
+ warnf(out, "this replaces the database of %s. The instance is held out of service for the swap, and the current database is backed up first.\n", host)
+ typed := promptLine(out, "type the host to confirm: ")
+ if typed != host {
+ return fmt.Errorf("confirmation did not match %s — nothing was pushed", host)
+ }
+ return nil
+}
+
+// dbSize renders a byte count with the DB column's own formatter
+// (humanBytes, cli.go), so `carlos db` and `carlos instances list` agree on
+// what a megabyte looks like.
+func dbSize(n int64) string { return humanBytes(&n) }
+
+// ---- the verbs --------------------------------------------------------------
+
+const dbPullUsage = `usage: carlos db pull --app <a> [--host <h>] [--out <path> | <path>]
+ [--backup <id>|latest] [--force]
+
+Copies the instance's replicated database to this machine — by default to
+./<host>.db. The copy is the replica: byte-final for a sleeping instance, at
+most the sync interval behind a running one. --backup fetches one of the
+backups an earlier push saved instead ("latest" for the newest). An existing
+file is never overwritten without --force.
+
+`
+
+func runDBPull(args []string) {
+ fs := flag.NewFlagSet("db pull", flag.ExitOnError)
+ out := fs.String("out", "", "where to write the file (default ./<host>.db)")
+ backup := fs.String("backup", "", "download this backup instead of a fresh pull (an id from carlos db backups, or latest)")
+ force := fs.Bool("force", false, "replace an existing file at the destination")
+ resolve := dbFlags(fs, "pull")
+ fs.Usage = flagUsage(fs, dbPullUsage)
+ fs.Parse(args)
+ if rest := fs.Args(); len(rest) == 1 && *out == "" {
+ *out = rest[0]
+ } else if len(rest) > 0 {
+ log.Fatalf("carlos db pull: unexpected arguments %v\n%s", rest, dbPullUsage)
+ }
+ t := resolve()
+ ctx := context.Background()
+ dest := *out
+ if dest == "" {
+ dest = t.host + ".db"
+ }
+ if !*force {
+ if _, err := os.Stat(dest); err == nil {
+ log.Fatalf("carlos db pull: %s already exists — pass --force to replace it, or --out <path>", dest)
+ }
+ }
+
+ var body io.ReadCloser
+ var err error
+ if *backup != "" {
+ id := *backup
+ if id == "latest" {
+ list, err := t.sess.dbBackups(ctx, t.acct, t.app, t.host)
+ if err != nil {
+ log.Fatalf("carlos db pull: %v", err)
+ }
+ if len(list) == 0 {
+ log.Fatalf("carlos db pull: %s has no backups yet — a push makes one", t.host)
+ }
+ id = list[0].ID
+ }
+ fmt.Fprintf(stdout, "downloading backup %s of %s\n", id, t.host)
+ body, _, err = t.sess.dbDownload(ctx, dbPath(t.acct, t.app, t.host, "/backups/"+url.PathEscape(id)))
+ if err != nil {
+ log.Fatalf("carlos db pull: %v", err)
+ }
+ } else {
+ v, err := t.sess.dbPullRequest(ctx, t.acct, t.app, t.host)
+ if err != nil {
+ log.Fatalf("carlos db pull: %v", err)
+ }
+ fmt.Fprintf(stdout, "pull of %s requested (%s) — waiting for the box", t.host, v.ID)
+ v, err = pollDB(ctx, t.sess, t, v.ID, dbPollInterval, dbPollTimeout, stdout)
+ fmt.Fprintln(stdout)
+ if err != nil {
+ log.Fatalf("carlos db pull: %v", err)
+ }
+ body, _, err = t.sess.dbDownload(ctx, dbPath(t.acct, t.app, t.host, "/file?id="+url.QueryEscape(v.ID)))
+ if err != nil {
+ log.Fatalf("carlos db pull: %v", err)
+ }
+ }
+ defer body.Close()
+ n, err := writeDownload(dest, body, *force)
+ if err != nil {
+ log.Fatalf("carlos db pull: %v", err)
+ }
+ if *backup == "" {
+ // The staged snapshot has served its purpose. Best-effort: a
+ // leftover object is storage the next pull clears anyway.
+ if err := t.sess.dbClear(ctx, t.acct, t.app, t.host); err != nil {
+ mutedf(stdout, " (could not clear the staged snapshot on the console: %v)\n", err)
+ }
+ }
+ fmt.Fprintf(stdout, "%s %s → %s (%s)\n", colorFor(stdout).ok("pulled"), t.host, dest, dbSize(n))
+}
+
+const dbPushUsage = `usage: carlos db push --app <a> [--host <h>] <path> [--yes]
+
+Replaces the instance's database with the SQLite file at <path>. The file is
+checked and packed into a self-contained copy here, uploaded through the
+console, and the instance's box then holds the route out of service, backs up
+the current database, replaces the replica with the upload, and drops its
+local copy so the next wake restores the pushed file. The backup id is
+printed at the end; carlos db pull --backup <id> fetches it.
+
+A push needs confirmation: --yes, or the host typed at a terminal.
+
+`
+
+func runDBPush(args []string) {
+ fs := flag.NewFlagSet("db push", flag.ExitOnError)
+ yes := fs.Bool("yes", false, "confirm the replacement without a prompt")
+ resolve := dbFlags(fs, "push")
+ fs.Usage = flagUsage(fs, dbPushUsage)
+ fs.Parse(args)
+ rest := fs.Args()
+ if len(rest) != 1 {
+ log.Fatalf("carlos db push: name exactly one file to push\n%s", dbPushUsage)
+ }
+ src := rest[0]
+ // The local checks come BEFORE the console is asked anything: a file
+ // that is not a database costs nothing on the wire.
+ tmp, size, err := prepareDBUpload(src)
+ if err != nil {
+ log.Fatalf("carlos db push: %v", err)
+ }
+ defer os.Remove(tmp)
+ t := resolve()
+ if err := confirmDBPush(t.host, *yes, interactiveTerminal(), stdout); err != nil {
+ log.Fatalf("carlos db push: %v", err)
+ }
+ f, err := os.Open(tmp)
+ if err != nil {
+ log.Fatalf("carlos db push: %v", err)
+ }
+ ctx := context.Background()
+ fmt.Fprintf(stdout, "uploading %s (%s) to %s\n", src, dbSize(size), t.host)
+ v, err := t.sess.dbPush(ctx, t.acct, t.app, t.host, f, size)
+ f.Close()
+ if err != nil {
+ log.Fatalf("carlos db push: %v", err)
+ }
+ fmt.Fprintf(stdout, "push requested (%s) — the box is swapping the database", v.ID)
+ v, err = pollDB(ctx, t.sess, t, v.ID, dbPollInterval, dbPollTimeout, stdout)
+ fmt.Fprintln(stdout)
+ if err != nil {
+ if v.Backup != "" {
+ mutedf(stdout, " the backup taken before the attempt is %s: carlos db pull --app %s --host %s --backup %s\n", v.Backup, t.app, t.host, v.Backup)
+ }
+ log.Fatalf("carlos db push: %v", err)
+ }
+ fmt.Fprintf(stdout, "%s %s — the next wake of %s serves the pushed database\n", colorFor(stdout).ok("pushed"), src, t.host)
+ if v.Backup != "" {
+ fmt.Fprintf(stdout, " backup of the previous database: %s\n", v.Backup)
+ mutedf(stdout, " restore it with: carlos db pull --app %s --host %s --backup %s\n", t.app, t.host, v.Backup)
+ } else {
+ mutedf(stdout, " the instance had no replicated database before this push, so there is no backup\n")
+ }
+}
+
+const dbStatusUsage = `usage: carlos db status --app <a> [--host <h>]
+
+Prints the last transfer requested for the instance: its verb, state, step,
+size, backup id and error, if any.
+
+`
+
+func runDBStatus(args []string) {
+ fs := flag.NewFlagSet("db status", flag.ExitOnError)
+ resolve := dbFlags(fs, "status")
+ fs.Usage = flagUsage(fs, dbStatusUsage)
+ fs.Parse(args)
+ t := resolve()
+ v, err := t.sess.dbStatus(context.Background(), t.acct, t.app, t.host)
+ if err != nil {
+ log.Fatalf("carlos db status: %v", err)
+ }
+ fmt.Fprint(stdout, formatDBStatus(t.host, v))
+}
+
+func formatDBStatus(host string, v apiDBView) string {
+ if v.State == "none" {
+ return fmt.Sprintf("%s: no database transfer on record\n", host)
+ }
+ var b strings.Builder
+ fmt.Fprintf(&b, "%s: %s %s (%s)\n", host, v.Op, v.State, v.ID)
+ if v.Step != "" {
+ fmt.Fprintf(&b, " step: %s\n", v.Step)
+ }
+ if v.Bytes > 0 {
+ fmt.Fprintf(&b, " size: %s\n", dbSize(v.Bytes))
+ }
+ if v.Backup != "" {
+ fmt.Fprintf(&b, " backup: %s\n", v.Backup)
+ }
+ if v.Error != "" {
+ fmt.Fprintf(&b, " error: %s\n", v.Error)
+ }
+ if v.RequestedAt > 0 {
+ fmt.Fprintf(&b, " requested: %s\n", time.Unix(v.RequestedAt, 0).UTC().Format(time.RFC3339))
+ }
+ if v.UpdatedAt > 0 {
+ fmt.Fprintf(&b, " updated: %s\n", time.Unix(v.UpdatedAt, 0).UTC().Format(time.RFC3339))
+ }
+ return b.String()
+}
+
+const dbBackupsUsage = `usage: carlos db backups --app <a> [--host <h>]
+
+Lists the backups earlier pushes saved for the instance, newest first. The
+box keeps the newest five. carlos db pull --backup <id> downloads one.
+
+`
+
+func runDBBackups(args []string) {
+ fs := flag.NewFlagSet("db backups", flag.ExitOnError)
+ resolve := dbFlags(fs, "backups")
+ fs.Usage = flagUsage(fs, dbBackupsUsage)
+ fs.Parse(args)
+ t := resolve()
+ list, err := t.sess.dbBackups(context.Background(), t.acct, t.app, t.host)
+ if err != nil {
+ log.Fatalf("carlos db backups: %v", err)
+ }
+ fmt.Fprint(stdout, formatDBBackups(t.host, list))
+}
+
+func formatDBBackups(host string, list []apiDBBackup) string {
+ if len(list) == 0 {
+ return fmt.Sprintf("%s: no backups yet — a push makes one\n", host)
+ }
+ var b strings.Builder
+ fmt.Fprintf(&b, "%-26s %10s %s\n", "BACKUP", "SIZE", "TAKEN")
+ for _, bk := range list {
+ when := "-"
+ if !bk.CreatedAt.IsZero() {
+ when = bk.CreatedAt.UTC().Format(time.RFC3339)
+ }
+ fmt.Fprintf(&b, "%-26s %10s %s\n", bk.ID, dbSize(bk.Bytes), when)
+ }
+ return b.String()
+}
diff --git a/db_cli_test.go b/db_cli_test.go
new file mode 100644
index 000000000..2036a98f3
--- /dev/null
+++ b/db_cli_test.go
@@ -0,0 +1,339 @@
+package main
+
+import (
+ "bytes"
+ "context"
+ "database/sql"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+// dbTestServer is a console that answers the instances list and the db
+// doors from a script: the list body, a queue of status views, and a
+// record of what the push door received.
+type dbTestServer struct {
+ instances string
+ statuses []apiDBView
+ pushed []byte
+ pushHdr http.Header
+ cleared int
+ pullCalls int
+}
+
+func (d *dbTestServer) handler(t *testing.T) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/instances"):
+ w.Header().Set("Content-Type", "application/json")
+ io.WriteString(w, d.instances)
+ case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/db"):
+ v := d.statuses[0]
+ if len(d.statuses) > 1 {
+ d.statuses = d.statuses[1:]
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(v)
+ case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/db/pull"):
+ d.pullCalls++
+ w.WriteHeader(http.StatusAccepted)
+ json.NewEncoder(w).Encode(apiDBView{State: "requested", ID: "0123456789abcdef", Op: "pull"})
+ case r.Method == http.MethodPut && strings.HasSuffix(r.URL.Path, "/db/push"):
+ d.pushHdr = r.Header.Clone()
+ body, _ := io.ReadAll(r.Body)
+ d.pushed = body
+ w.WriteHeader(http.StatusAccepted)
+ json.NewEncoder(w).Encode(apiDBView{State: "requested", ID: "fedcba9876543210", Op: "push", Bytes: int64(len(body))})
+ case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/db/file"):
+ w.Header().Set("Content-Type", dbSQLiteContentType)
+ w.Write([]byte("snapshot-bytes"))
+ case r.Method == http.MethodDelete && strings.HasSuffix(r.URL.Path, "/db"):
+ d.cleared++
+ json.NewEncoder(w).Encode(apiDBView{State: "none"})
+ default:
+ t.Errorf("unexpected request %s %s", r.Method, r.URL)
+ w.WriteHeader(http.StatusTeapot)
+ }
+ })
+}
+
+// --host may be omitted only when the app has exactly one instance;
+// otherwise the refusal names the hosts.
+func TestDBResolveHost(t *testing.T) {
+ cases := []struct {
+ name, list, host, want, wantErr string
+ }{
+ {"given", `{"instances":[]}`, "a.go.example.com", "a.go.example.com", ""},
+ {"one", `{"instances":[{"host":"only.go.example.com"}]}`, "", "only.go.example.com", ""},
+ {"none", `{"instances":[]}`, "", "", "no instances"},
+ {"many", `{"instances":[{"host":"b.go.example.com"},{"host":"a.go.example.com"}]}`, "", "", "--host is required: app has 2 instances: a.go.example.com, b.go.example.com"},
+ {"truncated", `{"instances":[{"host":"x.go.example.com"}],"truncated":true}`, "", "", "--host is required"},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ d := &dbTestServer{instances: c.list}
+ ts := httptest.NewServer(d.handler(t))
+ defer ts.Close()
+ got, err := resolveDBHost(context.Background(), newTestSession(ts.URL), apiAcct{Seg: "bab"}, "app", c.host)
+ if c.wantErr != "" {
+ if err == nil || !strings.Contains(err.Error(), c.wantErr) {
+ t.Fatalf("err = %v, want %q", err, c.wantErr)
+ }
+ return
+ }
+ if err != nil || got != c.want {
+ t.Fatalf("host = %q, %v; want %q", got, err, c.want)
+ }
+ })
+ }
+}
+
+// An existing file is refused without --force; the write goes through a
+// temp file and a rename, so nothing partial ever sits at the final name.
+func TestDBWriteDownloadRefusesOverwrite(t *testing.T) {
+ dir := t.TempDir()
+ dest := filepath.Join(dir, "a.db")
+ n, err := writeDownload(dest, strings.NewReader("first"), false)
+ if err != nil || n != 5 {
+ t.Fatalf("first write: %d %v", n, err)
+ }
+ if _, err := writeDownload(dest, strings.NewReader("second"), false); err == nil || !strings.Contains(err.Error(), "--force") {
+ t.Fatalf("overwrite without --force = %v, want a refusal naming --force", err)
+ }
+ if got, _ := os.ReadFile(dest); string(got) != "first" {
+ t.Fatalf("the refused write touched the file: %q", got)
+ }
+ if _, err := writeDownload(dest, strings.NewReader("second"), true); err != nil {
+ t.Fatal(err)
+ }
+ if got, _ := os.ReadFile(dest); string(got) != "second" {
+ t.Fatalf("after --force: %q", got)
+ }
+ ents, _ := os.ReadDir(dir)
+ if len(ents) != 1 {
+ t.Fatalf("temp files left behind: %v", ents)
+ }
+ // A torn download never lands.
+ if _, err := writeDownload(filepath.Join(dir, "torn.db"), &failingReader{}, false); err == nil {
+ t.Fatal("a failed copy reported success")
+ }
+ if _, err := os.Stat(filepath.Join(dir, "torn.db")); !os.IsNotExist(err) {
+ t.Fatal("a torn download sits at the final name")
+ }
+ ents, _ = os.ReadDir(dir)
+ if len(ents) != 1 {
+ t.Fatalf("temp files left behind after a torn download: %v", ents)
+ }
+}
+
+type failingReader struct{}
+
+func (failingReader) Read([]byte) (int, error) { return 0, io.ErrUnexpectedEOF }
+
+// The confirmation: --yes passes silently, a non-terminal without it is
+// refused, and at a terminal the host must be typed back exactly.
+func TestDBConfirmPush(t *testing.T) {
+ var out bytes.Buffer
+ if err := confirmDBPush("a.go.example.com", true, false, &out); err != nil || out.Len() != 0 {
+ t.Fatalf("--yes: %v (printed %q)", err, out.String())
+ }
+ if err := confirmDBPush("a.go.example.com", false, false, &out); err == nil || !strings.Contains(err.Error(), "--yes") {
+ t.Fatalf("non-interactive without --yes = %v, want a refusal naming --yes", err)
+ }
+ prev := chooserIn
+ t.Cleanup(func() { chooserIn = prev })
+ chooserIn = strings.NewReader("a.go.example.com\n")
+ out.Reset()
+ if err := confirmDBPush("a.go.example.com", false, true, &out); err != nil {
+ t.Fatalf("typed host: %v", err)
+ }
+ if !strings.Contains(out.String(), "replaces the database of a.go.example.com") {
+ t.Fatalf("the prompt did not say what it replaces: %q", out.String())
+ }
+ chooserIn = strings.NewReader("b.go.example.com\n")
+ if err := confirmDBPush("a.go.example.com", false, true, &out); err == nil || !strings.Contains(err.Error(), "nothing was pushed") {
+ t.Fatalf("wrong host typed = %v, want a refusal", err)
+ }
+ chooserIn = strings.NewReader("")
+ if err := confirmDBPush("a.go.example.com", false, true, &out); err == nil {
+ t.Fatal("EOF at the prompt confirmed a push")
+ }
+}
+
+// prepareDBUpload proves the file and packs it whole: rows written to the
+// WAL of the source are in the single-file copy; a non-database is refused.
+func TestDBPrepareUpload(t *testing.T) {
+ src := filepath.Join(t.TempDir(), "src.db")
+ db, err := sql.Open("sqlite", src)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := db.Exec(`PRAGMA journal_mode=WAL`); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := db.Exec(`CREATE TABLE t (id INTEGER PRIMARY KEY, body TEXT)`); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := db.Exec(`INSERT INTO t (body) VALUES ('in-the-wal')`); err != nil {
+ t.Fatal(err)
+ }
+ // Left open: the WAL is not checkpointed into the main file.
+ defer db.Close()
+ tmp, size, err := prepareDBUpload(src)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer os.Remove(tmp)
+ if size <= 0 {
+ t.Fatalf("size = %d", size)
+ }
+ if _, err := os.Stat(tmp + "-wal"); !os.IsNotExist(err) {
+ t.Fatal("the packed copy has a WAL beside it")
+ }
+ packed, err := sql.Open("sqlite", "file:"+tmp+"?mode=ro")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer packed.Close()
+ var body string
+ if err := packed.QueryRow(`SELECT body FROM t`).Scan(&body); err != nil || body != "in-the-wal" {
+ t.Fatalf("packed copy: %q %v — the WAL's rows were not folded in", body, err)
+ }
+
+ junk := filepath.Join(t.TempDir(), "junk.db")
+ if err := os.WriteFile(junk, []byte("this is not a database, however you look at it, no"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if _, _, err := prepareDBUpload(junk); err == nil {
+ t.Fatal("a non-database was accepted")
+ }
+ empty := filepath.Join(t.TempDir(), "empty.db")
+ edb, _ := sql.Open("sqlite", empty)
+ edb.Exec(`PRAGMA user_version=1`)
+ edb.Close()
+ if _, _, err := prepareDBUpload(empty); err == nil || !strings.Contains(err.Error(), "no tables") {
+ t.Fatalf("an empty database = %v, want the no-tables refusal", err)
+ }
+ if _, _, err := prepareDBUpload(t.TempDir()); err == nil {
+ t.Fatal("a directory was accepted")
+ }
+}
+
+// The push wire shape: PUT, the SQLite content type, the confirmation
+// header naming the host, the bytes streamed whole, a 202 view back.
+func TestDBPushSendsConfirmHeaderAndBody(t *testing.T) {
+ d := &dbTestServer{}
+ ts := httptest.NewServer(d.handler(t))
+ defer ts.Close()
+ body := bytes.Repeat([]byte("x"), 4096)
+ v, err := newTestSession(ts.URL).dbPush(context.Background(), apiAcct{Seg: "bab"}, "app", "a.go.example.com", bytes.NewReader(body), int64(len(body)))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if v.State != "requested" || v.Op != "push" || v.Bytes != 4096 {
+ t.Fatalf("view = %+v", v)
+ }
+ if !bytes.Equal(d.pushed, body) {
+ t.Fatalf("server got %d bytes", len(d.pushed))
+ }
+ if d.pushHdr.Get(dbConfirmHeader) != "a.go.example.com" || d.pushHdr.Get("Content-Type") != dbSQLiteContentType || d.pushHdr.Get("Authorization") != "Bearer cli-token" {
+ t.Fatalf("push headers: %v", d.pushHdr)
+ }
+ if d.pushHdr.Get("Content-Length") != "4096" {
+ t.Fatalf("Content-Length = %q, want the file's size (a chunked upload is what the door's cap cannot see coming)", d.pushHdr.Get("Content-Length"))
+ }
+ // A refusal comes back as the console's own sentence.
+ refuse := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusConflict)
+ io.WriteString(w, `{"error":"a db pull is still in flight"}`)
+ }))
+ defer refuse.Close()
+ if _, err := newTestSession(refuse.URL).dbPush(context.Background(), apiAcct{Seg: "bab"}, "app", "a.go.example.com", bytes.NewReader(body), int64(len(body))); err == nil || err.Error() != "a db pull is still in flight" {
+ t.Fatalf("refusal = %v", err)
+ }
+}
+
+// pollDB waits through requested, returns on done, and turns failed and a
+// superseding transfer into errors.
+func TestDBPoll(t *testing.T) {
+ d := &dbTestServer{statuses: []apiDBView{
+ {State: "requested", ID: "0123456789abcdef", Op: "pull"},
+ {State: "requested", ID: "0123456789abcdef", Op: "pull"},
+ {State: "done", ID: "0123456789abcdef", Op: "pull", Bytes: 14},
+ }}
+ ts := httptest.NewServer(d.handler(t))
+ defer ts.Close()
+ sess := newTestSession(ts.URL)
+ tgt := dbTarget{sess: sess, acct: apiAcct{Seg: "bab"}, app: "app", host: "a.go.example.com"}
+ var progress bytes.Buffer
+ v, err := pollDB(context.Background(), sess, tgt, "0123456789abcdef", time.Millisecond, time.Second, &progress)
+ if err != nil || v.State != "done" || v.Bytes != 14 {
+ t.Fatalf("poll = %+v %v", v, err)
+ }
+ if progress.String() != ".." {
+ t.Fatalf("progress = %q, want one dot per wait", progress.String())
+ }
+ d.statuses = []apiDBView{{State: "failed", ID: "0123456789abcdef", Op: "pull", Error: "the instance has no replicated database yet"}}
+ if _, err := pollDB(context.Background(), sess, tgt, "0123456789abcdef", time.Millisecond, time.Second, nil); err == nil || !strings.Contains(err.Error(), "no replicated database") {
+ t.Fatalf("failed poll = %v", err)
+ }
+ d.statuses = []apiDBView{{State: "requested", ID: "ffffffffffffffff", Op: "push"}}
+ if _, err := pollDB(context.Background(), sess, tgt, "0123456789abcdef", time.Millisecond, time.Second, nil); err == nil || !strings.Contains(err.Error(), "superseded") {
+ t.Fatalf("superseded poll = %v", err)
+ }
+ d.statuses = []apiDBView{{State: "requested", ID: "0123456789abcdef", Op: "pull"}}
+ if _, err := pollDB(context.Background(), sess, tgt, "0123456789abcdef", time.Millisecond, 5*time.Millisecond, nil); err == nil || !strings.Contains(err.Error(), "carlos db status") {
+ t.Fatalf("timed-out poll = %v, want the status verb named", err)
+ }
+}
+
+// The download stream: 200 hands the body back for the caller to copy; a
+// non-200 is the console's sentence.
+func TestDBDownload(t *testing.T) {
+ d := &dbTestServer{}
+ ts := httptest.NewServer(d.handler(t))
+ defer ts.Close()
+ sess := newTestSession(ts.URL)
+ body, n, err := sess.dbDownload(context.Background(), dbPath(apiAcct{Seg: "bab"}, "app", "a.go.example.com", "/file?id=0123456789abcdef"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer body.Close()
+ got, _ := io.ReadAll(body)
+ if string(got) != "snapshot-bytes" || n != 14 {
+ t.Fatalf("download = %q (%d)", got, n)
+ }
+ refuse := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusConflict)
+ io.WriteString(w, `{"error":"the pull is still in flight"}`)
+ }))
+ defer refuse.Close()
+ if _, _, err := newTestSession(refuse.URL).dbDownload(context.Background(), "/x"); err == nil || err.Error() != "the pull is still in flight" {
+ t.Fatalf("refusal = %v", err)
+ }
+}
+
+func TestDBFormatting(t *testing.T) {
+ if got := formatDBStatus("a.go.example.com", apiDBView{State: "none"}); !strings.Contains(got, "no database transfer") {
+ t.Fatalf("none = %q", got)
+ }
+ got := formatDBStatus("a.go.example.com", apiDBView{State: "failed", Op: "push", ID: "fedcba9876543210", Step: "backed-up", Backup: "20260929-120000-0badf00d", Error: "boom", RequestedAt: 1_800_000_000})
+ for _, want := range []string{"push failed (fedcba9876543210)", "step: backed-up", "backup: 20260929-120000-0badf00d", "error: boom", "requested: 2027-01-15"} {
+ if !strings.Contains(got, want) {
+ t.Fatalf("status missing %q:\n%s", want, got)
+ }
+ }
+ if got := formatDBBackups("a.go.example.com", nil); !strings.Contains(got, "no backups yet") {
+ t.Fatalf("empty backups = %q", got)
+ }
+ got = formatDBBackups("a.go.example.com", []apiDBBackup{{ID: "20260929-120000-0badf00d", Bytes: 2 << 20, CreatedAt: time.Date(2026, 9, 29, 12, 0, 0, 0, time.UTC)}})
+ if !strings.Contains(got, "20260929-120000-0badf00d") || !strings.Contains(got, "2.0MB") || !strings.Contains(got, "2026-09-29T12:00:00Z") {
+ t.Fatalf("backups = %q", got)
+ }
+}
diff --git a/docs/site/cli.md b/docs/site/cli.md
index c173c7929..c700396ff 100644
--- a/docs/site/cli.md
+++ b/docs/site/cli.md
@@ -660,6 +660,55 @@ it lists that box's own registry routes, backing and owning unit included.
Off the box, [carlos routes](/docs/cli#carlos-routes) answers the same
question through the console.
+### carlos db
+
+Copies an instance's SQLite database to your machine, or replaces it with a
+file from your machine — with a login alone, no box and no bucket access.
+
+```sh
+carlos db pull --app hello
+carlos db pull --app hello --host hello.bab.oncarlos.com --out ./hello-today.db
+carlos db push --app hello ./hello-fixed.db
+carlos db status --app hello
+carlos db backups --app hello
+```
+
+`--host` may be left out when the app has exactly one instance; with more,
+the command refuses and lists them. It works on hibernating, platform-managed
+instances only — the class whose database the platform itself keeps
+replicated — and never on an addr-backed, static, pooled or fleet-placed
+route. A transfer is capped at 256 MiB in either direction.
+
+**pull** asks the console for a snapshot; the instance's box restores the
+replicated copy, checks that it opens, and stages it for the console to
+serve, and the command downloads it to `./<host>.db` (or `--out <path>`, or a
+bare path argument). An existing file is never overwritten without
+`--force`. The snapshot is the *replica*: byte-final for a sleeping
+instance, at most the replication interval behind a running one. The
+instance is never woken or stopped for a pull.
+
+**push** is downtime, deliberately. The file is checked here first
+(`integrity_check`, then packed with `VACUUM INTO` so any write-ahead log is
+folded in), uploaded through the console, and the instance's box then holds
+the route out of service, stops the process if it is running, backs up the
+current database, replaces the replica with the upload, and drops its local
+copy — so the next request that wakes the instance restores exactly the
+pushed file. It needs confirmation: `--yes`, or the host typed back at a
+terminal. A script without `--yes` is refused rather than prompted.
+
+Every push keeps a backup of the database it replaced. The command prints
+the backup id at the end, `carlos db backups` lists them (the newest five are
+kept), and `carlos db pull --backup <id>` — or `--backup latest` — downloads
+one, which is also how a push is undone: pull the backup, push it back. A
+push of an instance that had no replicated database yet has nothing to back
+up and says so.
+
+A push is refused while a residence move is in flight, and a move is refused
+while a transfer is; a replica instance can be pulled but not pushed (push to
+the home it mirrors). `carlos db status` shows the last transfer on the
+record — verb, state, step, size, backup id, and the box's reason if it
+failed.
+
### carlos sidecars
A sidecar is a member that nothing can dial: a process the platform runs for
diff --git a/internal/console/apiinstancedb.go b/internal/console/apiinstancedb.go
new file mode 100644
index 000000000..cabb970f8
--- /dev/null
+++ b/internal/console/apiinstancedb.go
@@ -0,0 +1,362 @@
+package console
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "log"
+ "net/http"
+ "os"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/carlosframework/platform/internal/provision"
+)
+
+// The database transfer doors (`carlos db pull` / `carlos db push`): a
+// member with no box and no bucket access copies an instance's SQLite
+// database out, or a replacement in. The console writes REQUESTS onto the
+// record and stages bytes in the control bucket; the box that owns the
+// route does everything that touches data (internal/host's db worker) and
+// writes the outcome back. Seven verbs under one prefix:
+//
+// GET .../instances/{host}/db the flat state view
+// POST .../instances/{host}/db/pull request a snapshot
+// PUT .../instances/{host}/db/push upload a replacement
+// GET .../instances/{host}/db/file?id=<id> download a DONE pull
+// GET .../instances/{host}/db/backups list pre-push backups
+// GET .../instances/{host}/db/backups/{id} download one
+// DELETE .../instances/{host}/db clear a done/failed op
+//
+// Every door is registered with the ZERO role set: the payload is a whole
+// customer database, and a credential the app itself holds must not be
+// able to walk that out of (or into) the platform. A person's membership
+// is the only authority here, which is the same cut the door-key and
+// instance-token doors take.
+
+// dbView is the flat answer every db door speaks: "none" is a real state
+// so a poller needs no 404 branch; timestamps are unix seconds like the
+// move view's.
+type dbView struct {
+ State string `json:"state"`
+ ID string `json:"id,omitempty"`
+ Op string `json:"op,omitempty"`
+ Step string `json:"step,omitempty"`
+ Error string `json:"error,omitempty"`
+ Bytes int64 `json:"bytes,omitempty"`
+ Backup string `json:"backup,omitempty"`
+ RequestedAt int64 `json:"requested_at,omitempty"`
+ UpdatedAt int64 `json:"updated_at,omitempty"`
+}
+
+func dbViewOf(rec *provision.Record) dbView {
+ if rec == nil || rec.DB == nil {
+ return dbView{State: "none"}
+ }
+ d := rec.DB
+ return dbView{State: d.State, ID: d.ID, Op: d.Op, Step: d.Step, Error: d.Error, Bytes: d.Bytes,
+ Backup: d.Backup, RequestedAt: d.RequestedAt.Unix(), UpdatedAt: d.UpdatedAt.Unix()}
+}
+
+// dbGate is the family's front half: the instance gate (principal, budget,
+// bucket), the byte-identical 404, the class rail (execInstanceGate), and
+// the stage key builder — which re-checks the host shape, so no key is ever
+// built for a host the gate did not pass.
+func (s *Server) dbGate(w http.ResponseWriter, r *http.Request) (principal string, acct *Account, app, host string, rec *provision.Record, stage provision.DBStage, ok bool) {
+ principal, acct, app, host, rec, ok = s.execInstanceGate(w, r, "db push/pull")
+ if !ok {
+ return "", nil, "", "", nil, provision.DBStage{}, false
+ }
+ stage, err := provision.NewDBStage(acct.Sqid, app, host)
+ if err != nil {
+ http.NotFound(w, r)
+ return "", nil, "", "", nil, provision.DBStage{}, false
+ }
+ return principal, acct, app, host, rec, stage, true
+}
+
+// dbSQLiteContentType is the media type a push body carries and a download
+// is served as — IANA's registered type for SQLite 3 files.
+const dbSQLiteContentType = "application/vnd.sqlite3"
+
+// dbConfirmHeader is the push door's typed confirmation: the CLI sends the
+// host it is about to replace, and the door refuses a request whose header
+// does not name {host}. A replacement is a whole database gone; the
+// confirmation is the member's, restated by the client, not inferred.
+const dbConfirmHeader = "X-Carlos-Confirm-Host"
+
+func (s *Server) handleAPIInstanceDBStatus(w http.ResponseWriter, r *http.Request) {
+ _, _, _, _, rec, _, ok := s.dbGate(w, r)
+ if !ok {
+ return
+ }
+ writeJSON(w, http.StatusOK, dbViewOf(rec))
+}
+
+// dbTransition applies one guarded transition and maps its outcomes the
+// way moveTransition does: ErrDBConflict → 409 with the transition's own
+// sentence, success → the flat view + a serial bump.
+func (s *Server) dbTransition(w http.ResponseWriter, r *http.Request, acct *Account, app, host string, mut func(*provision.Record) error, okStatus int) (*provision.Record, bool) {
+ ctx := r.Context()
+ rec, err := provision.SetRecordDB(ctx, s.cfg.Releases, acct.Sqid, app, host, mut)
+ if errors.Is(err, provision.ErrDBConflict) {
+ jsonError(w, http.StatusConflict, strings.TrimPrefix(err.Error(), provision.ErrDBConflict.Error()+": "))
+ return nil, false
+ }
+ if err != nil {
+ log.Printf("console: api instance db %s/%s/%s: %v", acct.Sqid, app, host, err)
+ jsonError(w, http.StatusBadGateway, "could not write the request — try again")
+ return nil, false
+ }
+ s.bumpSerial(ctx)
+ writeJSON(w, okStatus, dbViewOf(rec))
+ return rec, true
+}
+
+// clearPriorStage deletes the staged objects of a transfer that is done or
+// failed, before a new request replaces it on the record. An in-flight one
+// is left alone: the transition will refuse the new request anyway, and
+// its objects are the box's.
+func (s *Server) clearPriorStage(r *http.Request, rec *provision.Record, stage provision.DBStage) error {
+ if rec.DB == nil || rec.DBInFlight() {
+ return nil
+ }
+ return stage.DeleteRequest(r.Context(), s.cfg.Releases, rec.DB.ID)
+}
+
+func (s *Server) handleAPIInstanceDBPull(w http.ResponseWriter, r *http.Request) {
+ principal, acct, app, host, rec, stage, ok := s.dbGate(w, r)
+ if !ok {
+ return
+ }
+ if err := s.clearPriorStage(r, rec, stage); err != nil {
+ log.Printf("console: api instance db pull %s/%s/%s: clear stage: %v", acct.Sqid, app, host, err)
+ jsonError(w, http.StatusBadGateway, "could not clear the previous transfer — try again")
+ return
+ }
+ id := provision.NewDBTransferID()
+ if _, ok := s.dbTransition(w, r, acct, app, host, provision.DBRequest(provision.DBPull, id, time.Now()), http.StatusAccepted); !ok {
+ return
+ }
+ s.audit(acct, app, principal, "db-pull", "requested a database pull of "+host+" ("+id+") (cli)")
+}
+
+// handleAPIInstanceDBPush stages the upload FIRST and writes the request
+// second: a request whose bytes are not yet there would send the box after
+// nothing. If the CAS loses, the staged object is deleted again.
+func (s *Server) handleAPIInstanceDBPush(w http.ResponseWriter, r *http.Request) {
+ principal, acct, app, host, rec, stage, ok := s.dbGate(w, r)
+ if !ok {
+ return
+ }
+ // Everything that can be refused WITHOUT reading the body is refused
+ // before it: a 256 MiB upload for a 409 is a waste on both ends.
+ if rec.Role != "" {
+ jsonError(w, http.StatusBadRequest, "a "+rec.Role+" holds a one-way mirror of the home instance — push to the home instead")
+ return
+ }
+ if rec.Move != nil {
+ jsonError(w, http.StatusConflict, "a move to "+s.homeRegionOut(rec.Move.To)+" is "+rec.Move.State+" — wait for it to finish or abort it first")
+ return
+ }
+ if rec.DBInFlight() {
+ jsonError(w, http.StatusConflict, "a db "+rec.DB.Op+" is still in flight — wait for it to finish")
+ return
+ }
+ if r.Header.Get(dbConfirmHeader) != host {
+ jsonError(w, http.StatusBadRequest, "confirm the replacement by sending "+dbConfirmHeader+": "+host)
+ return
+ }
+ ct, _, _ := strings.Cut(r.Header.Get("Content-Type"), ";")
+ if strings.TrimSpace(strings.ToLower(ct)) != dbSQLiteContentType {
+ jsonError(w, http.StatusUnsupportedMediaType, "send the database file as the body with Content-Type: "+dbSQLiteContentType)
+ return
+ }
+ // The same concurrency bound and read deadline `carlos ship` runs
+ // under (handleAPIShip): a slow upload holds a goroutine and a growing
+ // buffer, and the shared semaphore is what keeps a handful of them from
+ // being a resource-exhaustion path.
+ select {
+ case s.shipSem <- struct{}{}:
+ defer func() { <-s.shipSem }()
+ default:
+ retryAfter(w, 2*time.Second)
+ jsonError(w, http.StatusTooManyRequests, "another upload is in progress — try again shortly")
+ return
+ }
+ rc := http.NewResponseController(w)
+ if err := rc.SetReadDeadline(time.Now().Add(10 * time.Minute)); err != nil && !errors.Is(err, http.ErrNotSupported) {
+ log.Printf("console: api db push set read deadline: %v", err)
+ }
+ defer func() {
+ if err := rc.SetReadDeadline(time.Time{}); err != nil && !errors.Is(err, http.ErrNotSupported) {
+ log.Printf("console: api db push clear read deadline: %v", err)
+ }
+ }()
+ body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, provision.DBTransferMaxBytes))
+ if err != nil {
+ var tooLarge *http.MaxBytesError
+ if errors.As(err, &tooLarge) {
+ jsonError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("upload too large — transfers are capped at %d MiB", provision.DBTransferMaxBytes>>20))
+ return
+ }
+ jsonError(w, http.StatusBadRequest, "could not read the upload")
+ return
+ }
+ if !provision.LooksLikeSQLite(body) {
+ jsonError(w, http.StatusBadRequest, "the upload is not a SQLite database file (no SQLite header)")
+ return
+ }
+ if err := s.clearPriorStage(r, rec, stage); err != nil {
+ log.Printf("console: api instance db push %s/%s/%s: clear stage: %v", acct.Sqid, app, host, err)
+ jsonError(w, http.StatusBadGateway, "could not clear the previous transfer — try again")
+ return
+ }
+ id := provision.NewDBTransferID()
+ ctx := r.Context()
+ if _, err := s.cfg.Releases.Put(ctx, stage.PushKey(id), body, ""); err != nil {
+ log.Printf("console: api instance db push %s/%s/%s: stage: %v", acct.Sqid, app, host, err)
+ jsonError(w, http.StatusBadGateway, "could not stage the upload — try again")
+ return
+ }
+ size := int64(len(body))
+ mut := func(rec *provision.Record) error {
+ if err := provision.DBRequest(provision.DBPush, id, time.Now())(rec); err != nil {
+ return err
+ }
+ rec.DB.Bytes = size
+ return nil
+ }
+ if _, ok := s.dbTransition(w, r, acct, app, host, mut, http.StatusAccepted); !ok {
+ // The request did not land; the bytes must not linger for a later
+ // request to find.
+ if err := s.cfg.Releases.Delete(ctx, stage.PushKey(id)); err != nil {
+ log.Printf("console: api instance db push %s/%s/%s: unstage after a refused request: %v", acct.Sqid, app, host, err)
+ }
+ return
+ }
+ s.audit(acct, app, principal, "db-push", fmt.Sprintf("requested a database push to %s (%s, %d bytes) (cli)", host, id, size))
+}
+
+// serveDBFile writes one staged database: the type, the length, no caching
+// (a snapshot is a moment, and a backup is a customer's data), and a
+// filename a browser or curl -O would keep.
+func serveDBFile(w http.ResponseWriter, name string, body []byte) {
+ w.Header().Set("Content-Type", dbSQLiteContentType)
+ w.Header().Set("Content-Length", strconv.Itoa(len(body)))
+ w.Header().Set("Cache-Control", "no-store")
+ w.Header().Set("Content-Disposition", `attachment; filename="`+name+`"`)
+ w.WriteHeader(http.StatusOK)
+ if _, err := w.Write(body); err != nil {
+ log.Printf("console: api db file %s: write: %v", name, err)
+ }
+}
+
+// handleAPIInstanceDBFile streams a DONE pull's snapshot. The id is the
+// caller's proof they mean THIS pull: a stale link to a superseded one is a
+// 404, and a pull that has not finished is a 409 naming where it is.
+func (s *Server) handleAPIInstanceDBFile(w http.ResponseWriter, r *http.Request) {
+ _, acct, app, host, rec, stage, ok := s.dbGate(w, r)
+ if !ok {
+ return
+ }
+ id := r.URL.Query().Get("id")
+ if !provision.ValidDBTransferID(id) || rec.DB == nil || rec.DB.ID != id || rec.DB.Op != provision.DBPull {
+ jsonError(w, http.StatusNotFound, "no pull with that id on this instance")
+ return
+ }
+ switch rec.DB.State {
+ case provision.DBDone:
+ case provision.DBFailed:
+ jsonError(w, http.StatusConflict, "the pull failed: "+rec.DB.Error)
+ return
+ default:
+ jsonError(w, http.StatusConflict, "the pull is still in flight — poll the status door until it is done")
+ return
+ }
+ body, _, err := s.cfg.Releases.Get(r.Context(), stage.PullKey(id))
+ if errors.Is(err, os.ErrNotExist) {
+ jsonError(w, http.StatusNotFound, "the snapshot is no longer staged — request the pull again")
+ return
+ }
+ if err != nil {
+ log.Printf("console: api instance db file %s/%s/%s: %v", acct.Sqid, app, host, err)
+ jsonError(w, http.StatusBadGateway, "could not read the snapshot — try again")
+ return
+ }
+ serveDBFile(w, host+".db", body)
+}
+
+func (s *Server) handleAPIInstanceDBBackups(w http.ResponseWriter, r *http.Request) {
+ _, acct, app, host, _, stage, ok := s.dbGate(w, r)
+ if !ok {
+ return
+ }
+ list, err := stage.ListBackups(r.Context(), s.cfg.Releases)
+ if err != nil {
+ log.Printf("console: api instance db backups %s/%s/%s: %v", acct.Sqid, app, host, err)
+ jsonError(w, http.StatusBadGateway, "could not list the backups — try again")
+ return
+ }
+ if list == nil {
+ list = []provision.DBBackup{}
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"backups": list})
+}
+
+func (s *Server) handleAPIInstanceDBBackup(w http.ResponseWriter, r *http.Request) {
+ _, acct, app, host, _, stage, ok := s.dbGate(w, r)
+ if !ok {
+ return
+ }
+ id := r.PathValue("id")
+ if !provision.ValidDBBackupID(id) {
+ jsonError(w, http.StatusNotFound, "no backup with that id on this instance")
+ return
+ }
+ body, _, err := s.cfg.Releases.Get(r.Context(), stage.BackupKey(id))
+ if errors.Is(err, os.ErrNotExist) {
+ jsonError(w, http.StatusNotFound, "no backup with that id on this instance")
+ return
+ }
+ if err != nil {
+ log.Printf("console: api instance db backup %s/%s/%s: %v", acct.Sqid, app, host, err)
+ jsonError(w, http.StatusBadGateway, "could not read the backup — try again")
+ return
+ }
+ serveDBFile(w, host+"-"+id+".db", body)
+}
+
+// handleAPIInstanceDBClear drops a done or failed transfer from the record
+// and its staged objects from the bucket. Backups are never touched: they
+// are what makes a push undoable, and a cleanup verb must not be the thing
+// that removes the undo.
+func (s *Server) handleAPIInstanceDBClear(w http.ResponseWriter, r *http.Request) {
+ principal, acct, app, host, rec, stage, ok := s.dbGate(w, r)
+ if !ok {
+ return
+ }
+ if rec.DB == nil {
+ writeJSON(w, http.StatusOK, dbViewOf(nil))
+ return
+ }
+ id := rec.DB.ID
+ ctx := r.Context()
+ if _, err := provision.SetRecordDB(ctx, s.cfg.Releases, acct.Sqid, app, host, provision.DBClear()); err != nil {
+ if errors.Is(err, provision.ErrDBConflict) {
+ jsonError(w, http.StatusConflict, strings.TrimPrefix(err.Error(), provision.ErrDBConflict.Error()+": "))
+ return
+ }
+ log.Printf("console: api instance db clear %s/%s/%s: %v", acct.Sqid, app, host, err)
+ jsonError(w, http.StatusBadGateway, "could not clear the transfer — try again")
+ return
+ }
+ if err := stage.DeleteRequest(ctx, s.cfg.Releases, id); err != nil {
+ // The record is clear; a leftover object is storage, and the next
+ // request's clearPriorStage cannot reach it any more, so say so.
+ log.Printf("console: api instance db clear %s/%s/%s: delete staged %s: %v", acct.Sqid, app, host, id, err)
+ }
+ s.audit(acct, app, principal, "db-clear", "cleared database transfer "+id+" on "+host+" (cli)")
+ writeJSON(w, http.StatusOK, dbViewOf(nil))
+}
diff --git a/internal/console/apiinstancedb_test.go b/internal/console/apiinstancedb_test.go
new file mode 100644
index 000000000..f12f19d5e
--- /dev/null
+++ b/internal/console/apiinstancedb_test.go
@@ -0,0 +1,410 @@
+package console
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/svcrole"
+)
+
+// rawBytes is rawReq for a body that is not JSON: any method, a content
+// type, extra headers, raw bytes; the response's status, headers and body.
+func rawBytes(t *testing.T, method, u, bearer, contentType string, headers map[string]string, body []byte) (int, http.Header, []byte) {
+ t.Helper()
+ var rdr io.Reader
+ if body != nil {
+ rdr = bytes.NewReader(body)
+ }
+ req, err := http.NewRequest(method, u, rdr)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if contentType != "" {
+ req.Header.Set("Content-Type", contentType)
+ }
+ for k, v := range headers {
+ req.Header.Set(k, v)
+ }
+ if bearer != "" {
+ req.Header.Set("Authorization", "Bearer "+bearer)
+ }
+ res, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer res.Body.Close()
+ out, err := io.ReadAll(res.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return res.StatusCode, res.Header, out
+}
+
+// sqliteBytes is a body that passes the door's header check: the magic,
+// padded to a page.
+func sqliteBytes(n int) []byte {
+ b := make([]byte, n)
+ copy(b, provision.SQLiteMagic)
+ return b
+}
+
+func dbFixture(t *testing.T, code string) (*consoleFixture, string, string) {
+ t.Helper()
+ fx, token := instanceFixture(t, code)
+ m := titoManifest()
+ m.Regions = nil
+ m.Pool = ""
+ enableInstances(t, fx, "titogo", m)
+ u := instancesURL(fx, "bab", "titogo")
+ if code, _, out := rawReq(t, http.MethodPost, u, token, `{"host":"acme.go.tito.io","region":"ie"}`); code != http.StatusCreated {
+ t.Fatalf("create = %d %s", code, out)
+ }
+ return fx, token, u
+}
+
+// The verbs end to end against the record: request, poll, the box's half
+// written straight to the store, download, clear — and every refusal the
+// state machine answers with.
+func TestAPIInstanceDBVerbs(t *testing.T) {
+ fx, token, u := dbFixture(t, "code-inst-db")
+ ctx := context.Background()
+ dbURL := u + "/acme.go.tito.io/db"
+ stage, err := provision.NewDBStage("bab", "titogo", "acme.go.tito.io")
+ if err != nil {
+ t.Fatal(err)
+ }
+ var view struct {
+ State string `json:"state"`
+ ID string `json:"id"`
+ Op string `json:"op"`
+ Bytes int64 `json:"bytes"`
+ Backup string `json:"backup"`
+ Error string `json:"error"`
+ }
+ decode := func(out string) {
+ t.Helper()
+ view = struct {
+ State string `json:"state"`
+ ID string `json:"id"`
+ Op string `json:"op"`
+ Bytes int64 `json:"bytes"`
+ Backup string `json:"backup"`
+ Error string `json:"error"`
+ }{}
+ if err := json.Unmarshal([]byte(out), &view); err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ // Quiet: 200 none, never a 404.
+ code, _, out := rawReq(t, http.MethodGet, dbURL, token, "")
+ if code != http.StatusOK || !strings.Contains(out, `"state":"none"`) {
+ t.Fatalf("db status with nothing = %d %s", code, out)
+ }
+ // A download with no pull is a 404, a clear a no-op.
+ if code, _, _ := rawReq(t, http.MethodGet, dbURL+"/file?id=0123456789abcdef", token, ""); code != http.StatusNotFound {
+ t.Fatalf("file with no pull = %d, want 404", code)
+ }
+ if code, _, out := rawReq(t, http.MethodDelete, dbURL, token, ""); code != http.StatusOK || !strings.Contains(out, `"state":"none"`) {
+ t.Fatalf("clear with nothing = %d %s", code, out)
+ }
+
+ // Pull: 202 with the flat view and an id.
+ code, _, out = rawReq(t, http.MethodPost, dbURL+"/pull", token, "")
+ if code != http.StatusAccepted {
+ t.Fatalf("pull = %d %s", code, out)
+ }
+ decode(out)
+ if view.State != provision.DBRequested || view.Op != provision.DBPull || !provision.ValidDBTransferID(view.ID) {
+ t.Fatalf("after pull request: %+v", view)
+ }
+ pullID := view.ID
+ // The record carries it verbatim; the poll door mirrors it.
+ if code, _, out := rawReq(t, http.MethodGet, u+"/acme.go.tito.io", token, ""); code != http.StatusOK || !strings.Contains(out, `"db":{"id":"`+pullID+`"`) {
+ t.Fatalf("record after request: %d %s", code, out)
+ }
+ if code, _, out := rawReq(t, http.MethodGet, dbURL, token, ""); code != http.StatusOK || !strings.Contains(out, pullID) {
+ t.Fatalf("status mid-pull = %d %s", code, out)
+ }
+ // In flight: a second pull, a push, a clear and a download all refuse.
+ if code, _, out := rawReq(t, http.MethodPost, dbURL+"/pull", token, ""); code != http.StatusConflict {
+ t.Fatalf("second pull = %d %s, want 409", code, out)
+ }
+ if code, _, out := rawBytes(t, http.MethodPut, dbURL+"/push", token, dbSQLiteContentType, map[string]string{dbConfirmHeader: "acme.go.tito.io"}, sqliteBytes(4096)); code != http.StatusConflict {
+ t.Fatalf("push during a pull = %d %s, want 409", code, out)
+ }
+ if code, _, _ := rawReq(t, http.MethodDelete, dbURL, token, ""); code != http.StatusConflict {
+ t.Fatalf("clear during a pull = %d, want 409", code)
+ }
+ if code, _, out := rawReq(t, http.MethodGet, dbURL+"/file?id="+pullID, token, ""); code != http.StatusConflict || !strings.Contains(out, "in flight") {
+ t.Fatalf("file before done = %d %s, want 409", code, out)
+ }
+ // A move during a pull: 409 from the transition itself. The armed
+ // gate needs a target that publishes a staging bucket.
+ if _, err := fx.rel.Put(ctx, "control/edges/uk-box.json", []byte(`{"v":1,"box":"uk-box","eip":"1.2.3.4","placement":"uk","data_bucket":"data-uk","data_bucket_region":"eu-west-2","hosts":{}}`), ""); err != nil {
+ t.Fatal(err)
+ }
+ if code, _, out := rawReq(t, http.MethodPost, u+"/acme.go.tito.io/move", token, `{"region":"uk"}`); code != http.StatusConflict || !strings.Contains(out, "db pull") {
+ t.Fatalf("move during a pull = %d %s, want 409 naming the pull", code, out)
+ }
+
+ // The box's half: snapshot staged, record done.
+ snapshot := sqliteBytes(8192)
+ if _, err := fx.rel.Put(ctx, stage.PullKey(pullID), snapshot, ""); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := provision.SetRecordDB(ctx, fx.rel, "bab", "titogo", "acme.go.tito.io", provision.DBComplete(pullID, int64(len(snapshot)))); err != nil {
+ t.Fatal(err)
+ }
+ code, _, out = rawReq(t, http.MethodGet, dbURL, token, "")
+ decode(out)
+ if code != http.StatusOK || view.State != provision.DBDone || view.Bytes != int64(len(snapshot)) {
+ t.Fatalf("status after done = %d %+v", code, view)
+ }
+ // Download: the bytes, the type, the length, no caching. A wrong id
+ // is a 404.
+ code, hdr, body := rawBytes(t, http.MethodGet, dbURL+"/file?id="+pullID, token, "", nil, nil)
+ if code != http.StatusOK || !bytes.Equal(body, snapshot) {
+ t.Fatalf("file = %d (%d bytes), want 200 with the snapshot", code, len(body))
+ }
+ if hdr.Get("Content-Type") != dbSQLiteContentType || hdr.Get("Content-Length") != "8192" || hdr.Get("Cache-Control") != "no-store" {
+ t.Fatalf("file headers: %v", hdr)
+ }
+ if code, _, _ := rawReq(t, http.MethodGet, dbURL+"/file?id=ffffffffffffffff", token, ""); code != http.StatusNotFound {
+ t.Fatalf("file with a stale id = %d, want 404", code)
+ }
+ if code, _, _ := rawReq(t, http.MethodGet, dbURL+"/file?id=../../x", token, ""); code != http.StatusNotFound {
+ t.Fatalf("file with a malformed id = %d, want 404", code)
+ }
+ // Clear: the field goes, the staged object goes.
+ if code, _, out := rawReq(t, http.MethodDelete, dbURL, token, ""); code != http.StatusOK || !strings.Contains(out, `"state":"none"`) {
+ t.Fatalf("clear = %d %s", code, out)
+ }
+ if keys, _ := fx.rel.List(ctx, stage.Prefix()); len(keys) != 0 {
+ t.Fatalf("staged objects survived the clear: %v", keys)
+ }
+
+ // Push. The refusals that need no body first.
+ if code, _, out := rawBytes(t, http.MethodPut, dbURL+"/push", token, dbSQLiteContentType, nil, sqliteBytes(4096)); code != http.StatusBadRequest || !strings.Contains(string(out), dbConfirmHeader) {
+ t.Fatalf("push without the confirm header = %d %s, want 400 naming it", code, out)
+ }
+ if code, _, _ := rawBytes(t, http.MethodPut, dbURL+"/push", token, dbSQLiteContentType, map[string]string{dbConfirmHeader: "other.go.tito.io"}, sqliteBytes(4096)); code != http.StatusBadRequest {
+ t.Fatalf("push confirming the wrong host = %d, want 400", code)
+ }
+ if code, _, _ := rawBytes(t, http.MethodPut, dbURL+"/push", token, "application/octet-stream", map[string]string{dbConfirmHeader: "acme.go.tito.io"}, sqliteBytes(4096)); code != http.StatusUnsupportedMediaType {
+ t.Fatalf("push with the wrong content type = %d, want 415", code)
+ }
+ confirm := map[string]string{dbConfirmHeader: "acme.go.tito.io"}
+ if code, _, out := rawBytes(t, http.MethodPut, dbURL+"/push", token, dbSQLiteContentType, confirm, []byte("not a database")); code != http.StatusBadRequest || !strings.Contains(string(out), "SQLite") {
+ t.Fatalf("push of a non-database = %d %s, want 400", code, out)
+ }
+ if code, _, _ := rawBytes(t, http.MethodPut, dbURL+"/push", token, dbSQLiteContentType, confirm, sqliteBytes(100)); code != http.StatusBadRequest {
+ t.Fatalf("push of a too-short file = %d, want 400", code)
+ }
+ // The cap: 413, and nothing staged.
+ old := provision.DBTransferMaxBytes
+ provision.DBTransferMaxBytes = 2048
+ if code, _, out := rawBytes(t, http.MethodPut, dbURL+"/push", token, dbSQLiteContentType, confirm, sqliteBytes(4096)); code != http.StatusRequestEntityTooLarge {
+ t.Fatalf("push over the cap = %d %s, want 413", code, out)
+ }
+ provision.DBTransferMaxBytes = old
+ if keys, _ := fx.rel.List(ctx, stage.Prefix()); len(keys) != 0 {
+ t.Fatalf("a refused push staged something: %v", keys)
+ }
+ // A good push: 202, the object staged FIRST, the record carrying the
+ // size.
+ upload := sqliteBytes(4096)
+ code, _, body = rawBytes(t, http.MethodPut, dbURL+"/push", token, dbSQLiteContentType, confirm, upload)
+ if code != http.StatusAccepted {
+ t.Fatalf("push = %d %s", code, body)
+ }
+ decode(string(body))
+ if view.State != provision.DBRequested || view.Op != provision.DBPush || view.Bytes != 4096 {
+ t.Fatalf("after push request: %+v", view)
+ }
+ pushID := view.ID
+ if staged, _, err := fx.rel.Get(ctx, stage.PushKey(pushID)); err != nil || !bytes.Equal(staged, upload) {
+ t.Fatalf("push.db not staged: %v", err)
+ }
+ // A move now is refused at the door, before its own transition.
+ if code, _, out := rawReq(t, http.MethodPost, u+"/acme.go.tito.io/move", token, `{"region":"uk"}`); code != http.StatusConflict {
+ t.Fatalf("move during a push = %d %s, want 409", code, out)
+ }
+ // The box fails it: the error is the view's, and a fresh push replaces
+ // it — deleting the failed push's object on the way.
+ if _, err := provision.SetRecordDB(ctx, fx.rel, "bab", "titogo", "acme.go.tito.io", provision.DBFail(pushID, "the uploaded file is not a usable SQLite database: no tables")); err != nil {
+ t.Fatal(err)
+ }
+ code, _, out = rawReq(t, http.MethodGet, dbURL, token, "")
+ decode(out)
+ if view.State != provision.DBFailed || !strings.Contains(view.Error, "no tables") {
+ t.Fatalf("status after failure = %+v", view)
+ }
+ code, _, body = rawBytes(t, http.MethodPut, dbURL+"/push", token, dbSQLiteContentType, confirm, upload)
+ if code != http.StatusAccepted {
+ t.Fatalf("push after a failure = %d %s", code, body)
+ }
+ decode(string(body))
+ if _, _, err := fx.rel.Get(ctx, stage.PushKey(pushID)); err == nil {
+ t.Fatal("the failed push's object survived the next request")
+ }
+ if _, _, err := fx.rel.Get(ctx, stage.PushKey(view.ID)); err != nil {
+ t.Fatal("the new push's object is not staged")
+ }
+ // The serial was bumped by the successful writes.
+ if _, _, err := fx.rel.Get(ctx, "control/serial"); err != nil {
+ t.Fatalf("control/serial not written: %v", err)
+ }
+
+ // Backups: listed newest first with their sidecars, streamed by id,
+ // never touched by a clear.
+ bid1 := provision.NewDBBackupID(time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
+ bid2 := provision.NewDBBackupID(time.Date(2026, 9, 2, 0, 0, 0, 0, time.UTC))
+ for i, bid := range []string{bid1, bid2} {
+ if _, err := fx.rel.Put(ctx, stage.BackupKey(bid), sqliteBytes(1024*(i+1)), ""); err != nil {
+ t.Fatal(err)
+ }
+ if err := stage.PutBackupMeta(ctx, fx.rel, provision.DBBackup{ID: bid, Bytes: int64(1024 * (i + 1)), CreatedAt: time.Now(), Request: pushID}); err != nil {
+ t.Fatal(err)
+ }
+ }
+ code, _, out = rawReq(t, http.MethodGet, dbURL+"/backups", token, "")
+ if code != http.StatusOK {
+ t.Fatalf("backups = %d %s", code, out)
+ }
+ var bl struct {
+ Backups []provision.DBBackup `json:"backups"`
+ }
+ if err := json.Unmarshal([]byte(out), &bl); err != nil {
+ t.Fatal(err)
+ }
+ if len(bl.Backups) != 2 || bl.Backups[0].ID != bid2 || bl.Backups[0].Bytes != 2048 || bl.Backups[1].ID != bid1 {
+ t.Fatalf("backups = %+v, want newest first with sizes", bl.Backups)
+ }
+ code, hdr, body = rawBytes(t, http.MethodGet, dbURL+"/backups/"+bid1, token, "", nil, nil)
+ if code != http.StatusOK || len(body) != 1024 || hdr.Get("Content-Type") != dbSQLiteContentType {
+ t.Fatalf("backup download = %d (%d bytes) %v", code, len(body), hdr)
+ }
+ if code, _, _ := rawReq(t, http.MethodGet, dbURL+"/backups/20260901-000000-00000000", token, ""); code != http.StatusNotFound {
+ t.Fatalf("unknown backup = %d, want 404", code)
+ }
+ if code, _, _ := rawReq(t, http.MethodGet, dbURL+"/backups/..%2f..%2fpush.db", token, ""); code != http.StatusNotFound {
+ t.Fatalf("malformed backup id = %d, want 404", code)
+ }
+ // Even after the push finishes and is cleared, the backups stay.
+ if _, err := provision.SetRecordDB(ctx, fx.rel, "bab", "titogo", "acme.go.tito.io", provision.DBComplete(view.ID, 0)); err != nil {
+ t.Fatal(err)
+ }
+ if code, _, _ := rawReq(t, http.MethodDelete, dbURL, token, ""); code != http.StatusOK {
+ t.Fatalf("clear after push = %d", code)
+ }
+ if keys, _ := fx.rel.List(ctx, stage.BackupsPrefix()); len(keys) != 4 {
+ t.Fatalf("backups after clear: %v", keys)
+ }
+ // An empty backups list is a list, not null.
+ if code, _, out := rawReq(t, http.MethodGet, u+"/acme.go.tito.io/db/backups", token, ""); code != http.StatusOK || !strings.Contains(out, `"backups":[`) {
+ t.Fatalf("backups shape = %d %s", code, out)
+ }
+
+ // 404 discipline: an unknown host, on every verb, is the byte-identical
+ // not-found.
+ want := canonicalNotFound(t)
+ for _, c := range []struct{ method, tail string }{
+ {http.MethodGet, "/db"}, {http.MethodPost, "/db/pull"}, {http.MethodGet, "/db/file?id=" + pullID},
+ {http.MethodGet, "/db/backups"}, {http.MethodGet, "/db/backups/" + bid1}, {http.MethodDelete, "/db"},
+ } {
+ code, h, body := rawReq(t, c.method, u+"/ghost.go.tito.io"+c.tail, token, "")
+ assertNotFound(t, c.method+" "+c.tail+" on an unknown host", want, code, h, body)
+ }
+ code, h, body := rawBytes(t, http.MethodPut, u+"/ghost.go.tito.io/db/push", token, dbSQLiteContentType, map[string]string{dbConfirmHeader: "ghost.go.tito.io"}, sqliteBytes(4096))
+ assertNotFound(t, "PUT /db/push on an unknown host", want, code, h, string(body))
+}
+
+// The class rails, and the replica asymmetry: a replica may be pulled but
+// never pushed.
+func TestAPIInstanceDBClassRails(t *testing.T) {
+ fx, token, u := dbFixture(t, "code-inst-db-rails")
+ mk := func(body string) string {
+ t.Helper()
+ code, _, out := rawReq(t, http.MethodPost, u, token, body)
+ if code != http.StatusCreated {
+ t.Fatalf("create %s = %d %s", body, code, out)
+ }
+ var rec provision.Record
+ if err := json.Unmarshal([]byte(out), &rec); err != nil {
+ t.Fatal(err)
+ }
+ return rec.Host
+ }
+ for _, tc := range []struct {
+ name, body, wantIn string
+ }{
+ {"static", `{"host":"st.go.tito.io","kind":"static"}`, "kind static"},
+ {"addr-backed", `{"host":"ad.go.tito.io","addrs":["10.0.0.1:80"],"kind":"service"}`, "kind service"},
+ } {
+ host := mk(tc.body)
+ code, _, out := rawReq(t, http.MethodPost, u+"/"+host+"/db/pull", token, "")
+ if code != http.StatusBadRequest || !strings.Contains(out, tc.wantIn) || !strings.Contains(out, "database") {
+ t.Fatalf("pull %s = %d %s, want 400 in db terms mentioning %q", tc.name, code, out, tc.wantIn)
+ }
+ }
+ host := mk(`{"host":"fl.go.tito.io"}`)
+ if err := provision.SetRecordRegion(context.Background(), fx.rel, "bab", "titogo", host, "fleet/pi"); err != nil {
+ t.Fatal(err)
+ }
+ if code, _, out := rawReq(t, http.MethodGet, u+"/"+host+"/db", token, ""); code != http.StatusBadRequest || !strings.Contains(out, "fleet") {
+ t.Fatalf("db on a fleet-placed instance = %d %s", code, out)
+ }
+ pooled := provision.Record{Schema: 1, Host: "pooled.go.tito.io", Slug: "pooled", App: "titogo",
+ Account: "bab", Pool: "dedicated-x", Channel: "stable", CreatedAt: time.Now().UTC()}
+ if err := provision.CreateRecord(context.Background(), fx.rel, pooled); err != nil {
+ t.Fatal(err)
+ }
+ if code, _, out := rawReq(t, http.MethodPost, u+"/pooled.go.tito.io/db/pull", token, ""); code != http.StatusBadRequest || !strings.Contains(out, "pool") {
+ t.Fatalf("pull on a pooled instance = %d %s", code, out)
+ }
+ rep := provision.Record{Schema: 1, Host: "rep.go.tito.io", Slug: "rep", App: "titogo",
+ Account: "bab", Region: "ie", Role: "replica", Channel: "stable", CreatedAt: time.Now().UTC()}
+ if err := provision.CreateRecord(context.Background(), fx.rel, rep); err != nil {
+ t.Fatal(err)
+ }
+ if code, _, out := rawBytes(t, http.MethodPut, u+"/rep.go.tito.io/db/push", token, dbSQLiteContentType, map[string]string{dbConfirmHeader: "rep.go.tito.io"}, sqliteBytes(4096)); code != http.StatusBadRequest || !strings.Contains(string(out), "replica") {
+ t.Fatalf("push to a replica = %d %s, want 400 naming the replica", code, out)
+ }
+ if code, _, out := rawReq(t, http.MethodPost, u+"/rep.go.tito.io/db/pull", token, ""); code != http.StatusAccepted {
+ t.Fatalf("pull of a replica = %d %s, want 202", code, out)
+ }
+ // The move door's own sentences are untouched by the shared rail.
+ if code, _, out := rawReq(t, http.MethodPost, u+"/st.go.tito.io/move", token, `{"region":"uk"}`); code != http.StatusBadRequest || !strings.Contains(out, "only exec-backed instances move") {
+ t.Fatalf("move refusal changed: %d %s", code, out)
+ }
+}
+
+// No service credential reaches any db door — not the instance role that
+// creates and deletes instances, not admin.
+func TestAPIInstanceDBClosedToServiceCredentials(t *testing.T) {
+ fx, _, u := dbFixture(t, "code-inst-db-svc")
+ for _, role := range []svcrole.Role{svcrole.Instance, svcrole.Admin} {
+ tok := serviceToken(t, fx.st, OpsAccountID, "svc-"+string(role), "titogo", role)
+ for _, c := range []struct{ method, tail string }{
+ {http.MethodGet, "/db"}, {http.MethodPost, "/db/pull"}, {http.MethodGet, "/db/file?id=0123456789abcdef"},
+ {http.MethodGet, "/db/backups"}, {http.MethodDelete, "/db"},
+ } {
+ code, _, out := rawReq(t, c.method, u+"/acme.go.tito.io"+c.tail, tok, "")
+ if code != http.StatusForbidden || !strings.Contains(out, "needs a person") {
+ t.Fatalf("%s %s with role %s = %d %s, want the closed-door 403", c.method, c.tail, role, code, out)
+ }
+ }
+ code, _, out := rawBytes(t, http.MethodPut, u+"/acme.go.tito.io/db/push", tok, dbSQLiteContentType, map[string]string{dbConfirmHeader: "acme.go.tito.io"}, sqliteBytes(4096))
+ if code != http.StatusForbidden || !strings.Contains(string(out), "needs a person") {
+ t.Fatalf("PUT /db/push with role %s = %d %s, want the closed-door 403", role, code, out)
+ }
+ }
+}
diff --git a/internal/console/apiinstancemove.go b/internal/console/apiinstancemove.go
index 0204869e8..468866b35 100644
--- a/internal/console/apiinstancemove.go
+++ b/internal/console/apiinstancemove.go
@@ -28,49 +28,84 @@ import (
// byte-identical 404 for a host that names no record, and the class rail —
// only an exec-backed instance route's state is the platform's to move
// (spec §Out of scope), and fleet placements keep their deliberate
-// state-does-not-follow semantics.
+// state-does-not-follow semantics. The rail itself is execInstanceGate,
+// shared with the database transfer doors (apiinstancedb.go), which hold
+// the same class for the same reason: the activator is what manages the
+// data these verbs act on.
func (s *Server) moveGate(w http.ResponseWriter, r *http.Request) (acct *Account, app, host string, rec *provision.Record, ok bool) {
- _, acct, app, gok := s.instanceGate(w, r)
+ _, acct, app, host, rec, ok = s.execInstanceGate(w, r, "move")
+ return acct, app, host, rec, ok
+}
+
+// execInstanceGate is the rail both families share. verb names the family
+// in the refusals ("move" or "db push/pull"), so a member reading one is
+// told what was refused and why in that verb's own terms. It returns the
+// principal too, for the doors that audit.
+func (s *Server) execInstanceGate(w http.ResponseWriter, r *http.Request, verb string) (principal string, acct *Account, app, host string, rec *provision.Record, ok bool) {
+ principal, acct, app, gok := s.instanceGate(w, r)
if !gok {
- return nil, "", "", nil, false
+ return "", nil, "", "", nil, false
}
host = r.PathValue("host")
if !provision.ValidHostShape(host) {
http.NotFound(w, r)
- return nil, "", "", nil, false
+ return "", nil, "", "", nil, false
}
ctx := r.Context()
rec, err := provision.GetRecord(ctx, s.cfg.Releases, acct.Sqid, app, host)
if errors.Is(err, os.ErrNotExist) {
http.NotFound(w, r)
- return nil, "", "", nil, false
+ return "", nil, "", "", nil, false
}
if err != nil {
- log.Printf("console: api instance move %s/%s/%s: %v", acct.Sqid, app, host, err)
+ log.Printf("console: api instance %s %s/%s/%s: %v", verb, acct.Sqid, app, host, err)
jsonError(w, http.StatusBadGateway, "could not read the instance")
- return nil, "", "", nil, false
+ return "", nil, "", "", nil, false
}
+ if msg := execInstanceRefusal(*rec, verb); msg != "" {
+ jsonError(w, http.StatusBadRequest, msg)
+ return "", nil, "", "", nil, false
+ }
+ return principal, acct, app, host, rec, true
+}
+
+// execInstanceRefusal is the class rail as a sentence, or "" when the
+// record is an activator-managed exec instance. The move sentences are the
+// ones the move doors have always answered with; the db ones say what a
+// database transfer cannot reach and why.
+func execInstanceRefusal(rec provision.Record, verb string) string {
+ move := verb == "move"
if k := rec.Kind; k != "" && k != "instance" {
- jsonError(w, http.StatusBadRequest, "only exec-backed instances move — this route is kind "+k)
- return nil, "", "", nil, false
+ if move {
+ return "only exec-backed instances move — this route is kind " + k
+ }
+ return "only exec-backed instances carry a platform-managed database — this route is kind " + k
}
if rec.Addr != "" || len(rec.Addrs) > 0 {
- jsonError(w, http.StatusBadRequest, "an addr-backed route's process is not platform-managed — nothing to move")
- return nil, "", "", nil, false
+ if move {
+ return "an addr-backed route's process is not platform-managed — nothing to move"
+ }
+ return "an addr-backed route's process is not platform-managed — there is no database for db push/pull to reach"
}
if _, fleet := registry.FleetPlacement(rec.Region); fleet {
- jsonError(w, http.StatusBadRequest, "fleet-placed instances keep state-does-not-follow semantics — move is for platform regions")
- return nil, "", "", nil, false
+ if move {
+ return "fleet-placed instances keep state-does-not-follow semantics — move is for platform regions"
+ }
+ return "fleet-placed instances keep their data on the fleet's own box — db push/pull is for platform regions"
}
if rec.Pool != "" {
- jsonError(w, http.StatusBadRequest, "pooled instances do not move in v1 — their data lives on the pool's own box")
- return nil, "", "", nil, false
+ if move {
+ return "pooled instances do not move in v1 — their data lives on the pool's own box"
+ }
+ return "pooled instances keep their data on the pool's own box — db push/pull does not reach it in v1"
}
if !rec.HibernateOrDefault() {
- jsonError(w, http.StatusBadRequest, "a non-hibernating instance runs outside the activator — nothing manages its data, nothing can move it")
- return nil, "", "", nil, false
+ if move {
+ return "a non-hibernating instance runs outside the activator — nothing manages its data, nothing can move it"
+ }
+ return "a non-hibernating instance runs outside the activator — nothing manages its database, nothing can push or pull it"
}
- return acct, app, host, rec, true
+ return ""
}
// moveView is the flat answer every move door speaks — titogo's consumer
diff --git a/internal/console/authcli.go b/internal/console/authcli.go
index 9fdd2e11c..725403e51 100644
--- a/internal/console/authcli.go
+++ b/internal/console/authcli.go
@@ -626,6 +626,20 @@ func (s *Server) registerCLIRoutes() {
s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/move", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceMoveRequest)
s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/move/cut", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceMoveCut)
s.cliRoute("DELETE /api/cli/apps/{account}/{app}/instances/{host}/move", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceMoveAbort)
+ // The database transfer doors (`carlos db pull/push`, apiinstancedb.go).
+ // The zero Set, deliberately: the payload is a whole customer database
+ // — every row the instance holds, out through the pull door or replaced
+ // through the push one — and a credential the app itself holds must not
+ // be able to walk it out of (or into) the platform. A person's
+ // membership is the only authority here, the instance-token cut taken
+ // over data rather than over credentials.
+ s.cliRoute("GET /api/cli/apps/{account}/{app}/instances/{host}/db", svcrole.Set{}, s.handleAPIInstanceDBStatus)
+ s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/db/pull", svcrole.Set{}, s.handleAPIInstanceDBPull)
+ s.cliRoute("PUT /api/cli/apps/{account}/{app}/instances/{host}/db/push", svcrole.Set{}, s.handleAPIInstanceDBPush)
+ s.cliRoute("GET /api/cli/apps/{account}/{app}/instances/{host}/db/file", svcrole.Set{}, s.handleAPIInstanceDBFile)
+ s.cliRoute("GET /api/cli/apps/{account}/{app}/instances/{host}/db/backups", svcrole.Set{}, s.handleAPIInstanceDBBackups)
+ s.cliRoute("GET /api/cli/apps/{account}/{app}/instances/{host}/db/backups/{id}", svcrole.Set{}, s.handleAPIInstanceDBBackup)
+ s.cliRoute("DELETE /api/cli/apps/{account}/{app}/instances/{host}/db", svcrole.Set{}, s.handleAPIInstanceDBClear)
s.cliRoute("DELETE /api/cli/apps/{account}/{app}/instances/{host}", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceDelete)
// The zero Set, deliberately: a credential may never mint a credential,
// including itself. This is the escalation cut (spec §3). It takes no
diff --git a/internal/console/svcroutes_test.go b/internal/console/svcroutes_test.go
index ced377726..042e5a700 100644
--- a/internal/console/svcroutes_test.go
+++ b/internal/console/svcroutes_test.go
@@ -168,6 +168,17 @@ var closedDoors = []string{
// whose rules it never met. Shut for the same reason the manifest door
// is — an app may not rewrite the bounds it runs inside.
"POST /api/cli/apps/{account}/{app}/instances/{host}/channel",
+ // The database transfer doors carry a whole customer database in each
+ // direction. Shut to every role for the instance-token reason taken
+ // over data: a credential the app holds may run the app, not walk its
+ // tenants' rows out of the platform or replace them.
+ "GET /api/cli/apps/{account}/{app}/instances/{host}/db",
+ "POST /api/cli/apps/{account}/{app}/instances/{host}/db/pull",
+ "PUT /api/cli/apps/{account}/{app}/instances/{host}/db/push",
+ "GET /api/cli/apps/{account}/{app}/instances/{host}/db/file",
+ "GET /api/cli/apps/{account}/{app}/instances/{host}/db/backups",
+ "GET /api/cli/apps/{account}/{app}/instances/{host}/db/backups/{id}",
+ "DELETE /api/cli/apps/{account}/{app}/instances/{host}/db",
// The build probe is what `carlos deploy`'s watch TRUSTS in place of the
// X-Carlos-Version header — the header being the thing that reports a
// release before any process has restarted. A credential that could
@@ -800,8 +811,8 @@ func TestEveryClosedDoorRefusesAServiceCredential(t *testing.T) {
}
seen++
}
- if seen != len(closedDoors) || seen != 34 {
- t.Errorf("walked %d closed doors, want all %d (and 34 today: instance-token, three door-key doors, the routes door, instances-manifest, sidecars-manifest, the route-channel move, six fleets, four services, two email credentials, email pause, the build probe, the live tenancy read, five system doors, two deployment-wide antivirus, and four store-entitlement doors)",
+ if seen != len(closedDoors) || seen != 41 {
+ t.Errorf("walked %d closed doors, want all %d (and 41 today: instance-token, three door-key doors, the routes door, instances-manifest, sidecars-manifest, the route-channel move, seven database transfer doors, six fleets, four services, two email credentials, email pause, the build probe, the live tenancy read, five system doors, two deployment-wide antivirus, and four store-entitlement doors)",
seen, len(closedDoors))
}
}
diff --git a/internal/host/agent.go b/internal/host/agent.go
index 255b399d9..b8ba2233f 100644
--- a/internal/host/agent.go
+++ b/internal/host/agent.go
@@ -357,6 +357,9 @@ func RunAgent(args []string) {
eb.Mirror = m
}
armMovesPass(*cfg, dd, reg, svc)
+ // The database transfer worker (`carlos db pull/push`, dbpass.go):
+ // same coordinates, same quiesce answer, same best-effort arming.
+ armDBPass(*cfg, dd, reg, svc)
// The startup sweep (design §6.7): a leftover instance unit from a
// previous agent process, one this box's registry no longer holds
// a live wake for, is stopped before this process starts spawning
@@ -1795,6 +1798,7 @@ func fastConvergeLoop(reg *registry.Registry, svc *activator.Service, sd *schedu
}
telemetryPass(ctx, store, reg)
runMovesPass(ctx, store)
+ runDBPass(ctx, store)
// Folded once per pass and handed in, never re-read inside the
// loop: this leg runs on every serial bump (a seconds cadence),
// and the fold is a LoadBoxes plus a List plus one Get per edges
@@ -2352,6 +2356,7 @@ func instancesLoop(reg *registry.Registry) {
}
telemetryPass(ctx, store, reg)
runMovesPass(ctx, store)
+ runDBPass(ctx, store)
deploymentRoutes := edgesfile.DeploymentRoutes(ctx, store, time.Now())
if err := domains.ReconcileAliases(ctx, store, reg, time.Now, deploymentRoutes, &aliasClaimCache); err != nil {
log.Printf("agent: instances alias pass: %v", err)
diff --git a/internal/host/dbpass.go b/internal/host/dbpass.go
new file mode 100644
index 000000000..a8fb08ca4
--- /dev/null
+++ b/internal/host/dbpass.go
@@ -0,0 +1,647 @@
+package host
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io/fs"
+ "log"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "github.com/carlosframework/platform/internal/activator"
+ "github.com/carlosframework/platform/internal/lease"
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// The database transfer worker (`carlos db pull` / `carlos db push`):
+// everything a transfer does to DATA happens here, on the box that owns the
+// route. The console only writes requests onto the record and stages bytes
+// in the control bucket, because the control bucket is the one store it can
+// reach; this box reaches both it and the data (hibernate) bucket, so it is
+// the bridge between the member's upload and the instance's replica.
+//
+// One pass = one round per requested transfer this box owns, on the same
+// trigger as the reconcile and move passes (serial bumps land in seconds).
+// Restart-safe because every step derives from the record and the buckets.
+//
+// PULL is a read: restore the host's replica (WITHOUT the lease — a reader
+// costs the holder nothing, lease.RestoreCopy) into scratch, prove it opens,
+// put it in the control bucket, mark done. It never touches the route. The
+// snapshot is the REPLICA, which for a sleeping instance is byte-final and
+// for a running one is at most the sync interval behind.
+//
+// PUSH is a swap, and its ordering is the never-two-writers argument again,
+// with the local warm cache as the thing that must not win:
+//
+// route Status "maintenance" (edge stops serving; the tick and due-wake
+// gates refuse a non-ready row — and the word
+// PERSISTS in the registry across restarts)
+// → wake fence (in-process; closes the tick race G12 closes
+// for moves; re-installed every pass)
+// → child quiesced (Quiesced(host) — retries next pass until true)
+// → local checkpoint (the replica carries the tail before it is
+// backed up)
+// → backup (replica → scratch → backups/<id>.db; Step
+// "backed-up" + the id on the record)
+// → replace (push.db → scratch, CheckDB; DELETE the
+// replica prefix; replicate scratch into it
+// under a fresh lease; purge the local DB so
+// the next wake RESTORES; Step "replaced")
+// → finish (delete push.db, prune backups, Status
+// "ready", lift fence, mark done)
+//
+// Every step is idempotent and the record's Step says where to resume. The
+// crash points, one by one:
+//
+// - Before the status write: nothing happened; the next pass starts over.
+// - After the status write, any time before "replaced": the registry
+// holds "maintenance", so no tick, sweep or request can wake the host
+// even though a restart dropped the fence. The next pass re-fences,
+// re-checks Quiesced, and continues from Step.
+// - After the backup is put but before Step records it: a second backup
+// object with a new id is written on resume; the orphan is pruned by
+// the keep-newest rule. Never a lost backup.
+// - After DeletePrefix and before the replicate completes: the replica
+// is EMPTY. This is the one window where "mark failed and release"
+// would be wrong — a wake with the local file still present would use
+// the OLD warm cache and replicate it over an empty prefix (a silent
+// un-push), and a wake with it absent would boot fresh and SEED. So an
+// error here returns without failing the record: status stays
+// maintenance, the request stays in flight, and the next pass re-runs
+// the replace (push.db is still staged; the delete is idempotent; the
+// replicate is the same fresh lineage). Proven against the vendored
+// litestream in internal/lease/replace_test.go.
+// - After the replicate, before the purge: resume re-runs the replace;
+// the local DB is still the old one but unreachable behind the status.
+// - After the purge, before Step "replaced": same, with the checkpoint
+// skipped (no local file).
+// - After "replaced": the data is done. Resume runs finish only — no
+// status write, no fence, because a wake now would restore exactly the
+// pushed database, which is the point.
+//
+// Failure classes: a dbFailure (corrupt upload, no replica, over the cap)
+// marks the transfer failed and releases the route; anything else (an S3
+// blip, a lease held for another second) is returned, logged through the
+// trouble throttle, and retried next pass with the route still held.
+
+// dbMaintenanceStatus is the route status word a push holds the route at.
+// Everything that serves or wakes a hibernating instance gates on Status ==
+// "ready" (edge.Reload, the activator's ServeScheduled and due-wake sweep,
+// the edges publisher), so any other word takes it out of service; this one
+// says why. Reconcile converges a provisioned row through narrow column
+// UPDATEs that never touch Status, so the word survives every pass until
+// this worker writes "ready" back.
+const dbMaintenanceStatus = "maintenance"
+
+// dbScratchDirName is where a transfer materializes files under DataDir:
+// restores in flight, uploads being checked. 0700, swept per transfer.
+const dbScratchDirName = ".db-transfer"
+
+// dbFailure is a transfer outcome the member needs to hear, as opposed to a
+// transient error the next pass should retry.
+type dbFailure struct{ msg string }
+
+func (f *dbFailure) Error() string { return f.msg }
+
+func failDB(format string, a ...any) error { return &dbFailure{msg: fmt.Sprintf(format, a...)} }
+
+// dbWorker is the worker's cross-pass state, RunAgent writes the pointer
+// once; both converge loops read it.
+type dbWorker struct {
+ cfg dbCfg
+ // pass runs one dbPass at a time, for the moves worker's reason: two
+ // concurrent passes could drive one push twice or lift a fence the
+ // other just installed.
+ pass sync.Mutex
+ mu sync.Mutex
+ mirror lease.MirrorStore
+ nudged sync.Map // host → true: the status→quiesce hop was nudged once
+ trouble sync.Map // host → time.Time of the last logged error
+ fenced sync.Map // host → uint64: the push's wake-fence generation
+}
+
+var armedDBWorker atomic.Pointer[dbWorker]
+
+// dbCfg carries what the worker needs, injectable for tests. The route
+// fields are the move worker's, for the same reasons (see moveCfg).
+type dbCfg struct {
+ Region string // this box's placement code, "" included (the home)
+ OwnBucket string // CARLOS_HIBERNATE_BUCKET
+ OwnBucketRegion string // CARLOS_HIBERNATE_REGION
+ OwnPrefix string // CARLOS_HIBERNATE_PREFIX
+ DataDir string // /data
+ // Mirror builds the same-region client DeletePrefix runs through. Built
+ // lazily and memoized: most passes carry no push.
+ Mirror func(ctx context.Context) (lease.MirrorStore, error)
+ Quiesced func(host string) bool
+ Fence func(ctx context.Context, host string, generation uint64) error
+ LiftFence func(host string, generation uint64) bool
+ SetStatus func(host, status string) error
+ OwnsRoute func(host string) bool
+ Route func(host string) (*registry.Route, error)
+ Checkpoint func(ctx context.Context, dbPath string) error
+ // Restore restores bucket/prefix into out WITHOUT the lease; false
+ // means the prefix holds no replica.
+ Restore func(ctx context.Context, out, bucket, prefix, region string) (bool, error)
+ // Replicate replicates dbPath into bucket/prefix under a fresh lease,
+ // waits for the remote position to catch up, and releases.
+ Replicate func(ctx context.Context, dbPath, bucket, prefix, region string) error
+ Now func() time.Time
+}
+
+// armDBPass wires the worker with what only RunAgent has in hand. Called
+// once, before svc.Start, beside armMovesPass.
+func armDBPass(hib activator.Config, dataDir string, reg *registry.Registry, svc *activator.Service) {
+ cfg := dbCfg{
+ Region: strings.TrimSpace(os.Getenv("CARLOS_REGION")),
+ OwnBucket: hib.Bucket,
+ OwnBucketRegion: hib.Region,
+ OwnPrefix: hib.Prefix,
+ DataDir: dataDir,
+ Mirror: func(ctx context.Context) (lease.MirrorStore, error) {
+ return lease.NewS3Mirror(ctx, hib.Region, hib.Region)
+ },
+ Quiesced: svc.Quiesced,
+ Fence: func(ctx context.Context, host string, generation uint64) error {
+ fctx, cancel := context.WithTimeout(ctx, 2*time.Second)
+ defer cancel()
+ if _, err := svc.FenceAndStop(fctx, host, generation); err != nil && !errors.Is(err, activator.ErrStopUnproven) {
+ return err
+ }
+ return nil
+ },
+ LiftFence: svc.LiftFence,
+ SetStatus: reg.SetStatus,
+ OwnsRoute: func(host string) bool {
+ rt, err := reg.Get(host)
+ return err == nil && rt != nil && rt.Provisioned
+ },
+ Route: reg.Get,
+ Checkpoint: func(ctx context.Context, dbPath string) error {
+ host := strings.TrimSuffix(filepath.Base(dbPath), ".db")
+ return replicateAndRelease(ctx, dbPath, hib.Bucket, activator.HibernatePrefix(hib.Prefix, host), hib.Region)
+ },
+ Restore: lease.RestoreCopy,
+ Replicate: replicateAndRelease,
+ Now: time.Now,
+ }
+ armedDBWorker.Store(&dbWorker{cfg: cfg})
+}
+
+// replicateAndRelease is one full replication of dbPath into bucket/prefix:
+// lease, sync until the remote position matches, close (final sync + lease
+// release). The move worker's checkpoint and the push's replace are the
+// same call over different files.
+func replicateAndRelease(ctx context.Context, dbPath, bucket, prefix, region string) error {
+ rep, err := lease.ReplicateWithLease(ctx, dbPath, bucket, prefix, region)
+ if err != nil {
+ return err
+ }
+ if err := rep.SyncAndWait(ctx); err != nil {
+ _ = rep.Close(ctx)
+ return err
+ }
+ return rep.Close(ctx)
+}
+
+// runDBPass is the loops' call: inert until armed.
+func runDBPass(ctx context.Context, s release.Store) {
+ w := armedDBWorker.Load()
+ if w == nil || s == nil {
+ return
+ }
+ if err := dbPass(ctx, s, w); err != nil {
+ log.Printf("agent: db pass: %v", err)
+ }
+}
+
+func (w *dbWorker) mirrorStore(ctx context.Context) (lease.MirrorStore, error) {
+ w.mu.Lock()
+ m := w.mirror
+ w.mu.Unlock()
+ if m != nil {
+ return m, nil
+ }
+ if w.cfg.Mirror == nil {
+ return nil, errors.New("db: no mirror client wired")
+ }
+ m, err := w.cfg.Mirror(ctx)
+ if err != nil {
+ return nil, err
+ }
+ w.mu.Lock()
+ w.mirror = m
+ w.mu.Unlock()
+ return m, nil
+}
+
+// dbPass runs one round over every requested transfer this box owns.
+func dbPass(ctx context.Context, s release.Store, w *dbWorker) error {
+ w.pass.Lock()
+ defer w.pass.Unlock()
+ cfg := w.cfg
+ recs, err := provision.ListAllRecords(ctx, s)
+ if err != nil {
+ return fmt.Errorf("db: list records: %w", err)
+ }
+ for _, rec := range recs {
+ if !rec.DBInFlight() || rec.Region != cfg.Region {
+ continue
+ }
+ // A move in flight owns the host (the transitions refuse the
+ // overlap; this is the belt to that brace — records have other
+ // writers).
+ if rec.Move != nil {
+ continue
+ }
+ // The class rails the console's dbGate also holds: only an
+ // activator-managed exec instance's database is this worker's to
+ // read or replace.
+ if rec.Pool != "" || !rec.HibernateOrDefault() || (rec.Kind != "" && rec.Kind != "instance") {
+ continue
+ }
+ if cfg.OwnsRoute != nil && !cfg.OwnsRoute(rec.Host) {
+ continue
+ }
+ if err := runDBRound(ctx, s, w, rec); err != nil {
+ w.logTrouble(rec.Host, err)
+ continue
+ }
+ w.trouble.Delete(rec.Host)
+ }
+ w.releaseStale(recs)
+ return nil
+}
+
+// releaseStale puts back any route this process is still holding for a
+// push that is no longer in flight — the record was cleared or rewritten
+// by another writer between two passes, so no round will ever reach
+// finishPush for it. The move worker's liftFences, for the same reason:
+// a fence with nothing behind it must not outlive the thing it guarded.
+func (w *dbWorker) releaseStale(recs []provision.Record) {
+ inFlight := map[string]bool{}
+ for _, rec := range recs {
+ if rec.DBInFlight() && rec.DB.Op == provision.DBPush {
+ inFlight[rec.Host] = true
+ }
+ }
+ w.fenced.Range(func(k, v any) bool {
+ host, gen := k.(string), v.(uint64)
+ if inFlight[host] {
+ return true
+ }
+ w.releaseRoute(host, gen)
+ log.Printf("db: %s: push no longer on the record — route released", host)
+ return true
+ })
+}
+
+// dbFenceGeneration is the push fence's generation: the request time, so
+// every pass of one push installs the same fence and a later push of the
+// same host fences above an earlier one.
+func dbFenceGeneration(d *provision.DBTransfer) uint64 {
+ if n := d.RequestedAt.UnixNano(); n > 0 {
+ return uint64(n)
+ }
+ return 1
+}
+
+// scratchPath is where one transfer's file lives while the box works on
+// it. The directory is created 0700 on first use: a restored database is
+// a whole tenant's data, and DataDir is world-readable on some boxes.
+func (w *dbWorker) scratchPath(host, id, what string) (string, error) {
+ dir := filepath.Join(w.cfg.DataDir, dbScratchDirName)
+ if err := os.MkdirAll(dir, 0o700); err != nil {
+ return "", err
+ }
+ return filepath.Join(dir, host+"-"+id+"-"+what+".db"), nil
+}
+
+// sweepScratch removes a scratch database and every sidecar litestream or
+// the driver may have left beside it. Called on every path out.
+func sweepScratch(path string) {
+ if path == "" {
+ return
+ }
+ activator.PurgeLocalDBArtifacts(path, true)
+}
+
+// routeDBPath is the row's own DBPath when the registry is wired, else the
+// legacy flat guess — the move worker's rule (isolated-exec design §9).
+func (w *dbWorker) routeDBPath(host string) string {
+ dbPath := filepath.Join(w.cfg.DataDir, host+".db")
+ if w.cfg.Route != nil {
+ if rt, err := w.cfg.Route(host); err == nil && rt != nil && rt.DBPath != "" {
+ dbPath = rt.DBPath
+ }
+ }
+ return dbPath
+}
+
+func runDBRound(ctx context.Context, s release.Store, w *dbWorker, rec provision.Record) error {
+ stage, err := provision.NewDBStage(rec.Account, rec.App, rec.Host)
+ if err != nil {
+ return err
+ }
+ switch rec.DB.Op {
+ case provision.DBPull:
+ err = w.runPull(ctx, s, rec, stage)
+ case provision.DBPush:
+ err = w.runPush(ctx, s, rec, stage)
+ default:
+ err = failDB("unknown transfer op %q", rec.DB.Op)
+ }
+ var f *dbFailure
+ if errors.As(err, &f) {
+ if _, cerr := provision.SetRecordDB(ctx, s, rec.Account, rec.App, rec.Host, provision.DBFail(rec.DB.ID, f.msg)); cerr != nil && !errors.Is(cerr, provision.ErrDBConflict) {
+ return cerr
+ }
+ nudgeSerial(ctx, s)
+ log.Printf("db: %s: %s %s failed: %s", rec.Host, rec.DB.Op, rec.DB.ID, f.msg)
+ return nil
+ }
+ return err
+}
+
+// runPull: replica → scratch → CheckDB → pull.db → done.
+func (w *dbWorker) runPull(ctx context.Context, s release.Store, rec provision.Record, stage provision.DBStage) error {
+ cfg := w.cfg
+ scratch, err := w.scratchPath(rec.Host, rec.DB.ID, "pull")
+ if err != nil {
+ return err
+ }
+ sweepScratch(scratch) // litestream refuses to restore over an existing file
+ defer sweepScratch(scratch)
+ prefix := activator.HibernatePrefix(cfg.OwnPrefix, rec.Host)
+ restored, err := cfg.Restore(ctx, scratch, cfg.OwnBucket, prefix, cfg.OwnBucketRegion)
+ if err != nil {
+ return fmt.Errorf("restore replica: %w", err)
+ }
+ if !restored {
+ return failDB("the instance has no replicated database yet")
+ }
+ if err := lease.CheckDB(scratch); err != nil {
+ return failDB("the replicated database does not open cleanly: %v", err)
+ }
+ body, err := readCapped(scratch)
+ if err != nil {
+ return err
+ }
+ // A retry after a crash between the put and the mark: the object is
+ // there under a create-only put, so clear it first. Idempotent.
+ if err := s.Delete(ctx, stage.PullKey(rec.DB.ID)); err != nil {
+ return fmt.Errorf("clear staged pull: %w", err)
+ }
+ if _, err := s.Put(ctx, stage.PullKey(rec.DB.ID), body, ""); err != nil {
+ return fmt.Errorf("stage pull: %w", err)
+ }
+ if _, err := provision.SetRecordDB(ctx, s, rec.Account, rec.App, rec.Host, provision.DBComplete(rec.DB.ID, int64(len(body)))); err != nil && !errors.Is(err, provision.ErrDBConflict) {
+ return err
+ }
+ nudgeSerial(ctx, s)
+ log.Printf("db: %s: pull %s staged (%d bytes)", rec.Host, rec.DB.ID, len(body))
+ return nil
+}
+
+// readCapped reads a scratch database whole, refusing one over the cap: the
+// control bucket store takes a byte slice, and the console serves the
+// object from memory, so the cap is a memory bound on both ends.
+func readCapped(path string) ([]byte, error) {
+ fi, err := os.Stat(path)
+ if err != nil {
+ return nil, err
+ }
+ if fi.Size() > provision.DBTransferMaxBytes {
+ return nil, failDB("the database is %d MiB; transfers are capped at %d MiB", fi.Size()>>20, provision.DBTransferMaxBytes>>20)
+ }
+ return os.ReadFile(path)
+}
+
+// runPush is the swap described in the file header. Step is the resume
+// point; each arm falls through to the next.
+func (w *dbWorker) runPush(ctx context.Context, s release.Store, rec provision.Record, stage provision.DBStage) error {
+ cfg := w.cfg
+ d := rec.DB
+ if d.Step == provision.DBStepReplaced {
+ // The data is done. Finish only — see the header's last crash
+ // point: a wake now restores the pushed database, so nothing is
+ // left to hold the route for.
+ return w.finishPush(ctx, s, rec, stage)
+ }
+ // Step 1+2: out of service, fenced, quiesced.
+ if err := cfg.SetStatus(rec.Host, dbMaintenanceStatus); err != nil {
+ return fmt.Errorf("set route status: %w", err)
+ }
+ gen := dbFenceGeneration(d)
+ if cfg.Fence != nil {
+ if err := cfg.Fence(ctx, rec.Host, gen); err != nil {
+ return fmt.Errorf("fence against wakes: %w", err)
+ }
+ w.fenced.Store(rec.Host, gen)
+ }
+ if !cfg.Quiesced(rec.Host) {
+ if _, done := w.nudged.LoadOrStore(rec.Host, true); !done {
+ nudgeSerial(ctx, s)
+ }
+ return nil // the retire is in flight; the nudged pass re-enters here
+ }
+ // The layout-migration fence, exactly as the move cut holds it: only a
+ // definite ErrNotExist counts as "no journal".
+ if _, err := os.Stat(provision.LayoutJournalPath(cfg.DataDir, rec.Host)); !errors.Is(err, fs.ErrNotExist) {
+ log.Printf("db: %s: layout migration in flight; leaving the push for the next pass", rec.Host)
+ return nil
+ }
+ dbPath := w.routeDBPath(rec.Host)
+ prefix := activator.HibernatePrefix(cfg.OwnPrefix, rec.Host)
+
+ // A failure from here on that is the member's to hear releases the
+ // route on the way out. A transient one keeps it held and retries.
+ release := func(err error) error {
+ var f *dbFailure
+ if errors.As(err, &f) {
+ w.releaseRoute(rec.Host, gen)
+ }
+ return err
+ }
+
+ if d.Step == "" {
+ // Step 3: the replica must carry the tail before it is backed up.
+ if cfg.Checkpoint != nil {
+ if _, statErr := os.Stat(dbPath); statErr == nil {
+ if err := cfg.Checkpoint(ctx, dbPath); err != nil {
+ return fmt.Errorf("final checkpoint: %w", err)
+ }
+ }
+ }
+ // Step 4: the backup.
+ backupID, err := w.backup(ctx, s, rec, stage, prefix)
+ if err != nil {
+ return release(err)
+ }
+ if _, err := provision.SetRecordDB(ctx, s, rec.Account, rec.App, rec.Host, provision.DBBackedUp(d.ID, backupID)); err != nil {
+ if errors.Is(err, provision.ErrDBConflict) {
+ return nil // someone else advanced or cleared it; re-read next pass
+ }
+ return err
+ }
+ d.Step, d.Backup = provision.DBStepBackedUp, backupID
+ if backupID == "" {
+ log.Printf("db: %s: push %s: nothing to back up (no replica yet)", rec.Host, d.ID)
+ } else {
+ log.Printf("db: %s: push %s: backup %s stored", rec.Host, d.ID, backupID)
+ }
+ }
+ if d.Step == provision.DBStepBackedUp {
+ // Step 5: the replace.
+ if err := w.replace(ctx, s, rec, stage, prefix, dbPath); err != nil {
+ return release(err)
+ }
+ if _, err := provision.SetRecordDB(ctx, s, rec.Account, rec.App, rec.Host, provision.DBReplaced(d.ID)); err != nil {
+ if errors.Is(err, provision.ErrDBConflict) {
+ return nil
+ }
+ return err
+ }
+ log.Printf("db: %s: push %s: replica replaced, warm cache purged", rec.Host, d.ID)
+ }
+ // Step 6.
+ return w.finishPush(ctx, s, rec, stage)
+}
+
+// backup restores the replica to scratch and stores it under a fresh backup
+// id. "" with a nil error means there was no replica to back up.
+func (w *dbWorker) backup(ctx context.Context, s release.Store, rec provision.Record, stage provision.DBStage, prefix string) (string, error) {
+ cfg := w.cfg
+ scratch, err := w.scratchPath(rec.Host, rec.DB.ID, "backup")
+ if err != nil {
+ return "", err
+ }
+ sweepScratch(scratch)
+ defer sweepScratch(scratch)
+ restored, err := cfg.Restore(ctx, scratch, cfg.OwnBucket, prefix, cfg.OwnBucketRegion)
+ if err != nil {
+ return "", fmt.Errorf("restore replica for backup: %w", err)
+ }
+ if !restored {
+ return "", nil
+ }
+ if err := lease.CheckDB(scratch); err != nil {
+ // A replica that does not open is still the only copy there is:
+ // refusing the push keeps it, and the member can pull it to look.
+ return "", failDB("the current replicated database does not open cleanly (%v) — refusing to replace it; pull it first", err)
+ }
+ body, err := readCapped(scratch)
+ if err != nil {
+ var f *dbFailure
+ if errors.As(err, &f) {
+ // The cap is what makes the backup restorable through the
+ // console; a push that could not be undone is refused.
+ return "", failDB("the current database cannot be backed up: %s — refusing to replace it without a backup", f.msg)
+ }
+ return "", err
+ }
+ id := provision.NewDBBackupID(cfg.Now())
+ if _, err := s.Put(ctx, stage.BackupKey(id), body, ""); err != nil {
+ return "", fmt.Errorf("store backup: %w", err)
+ }
+ if err := stage.PutBackupMeta(ctx, s, provision.DBBackup{ID: id, Bytes: int64(len(body)), CreatedAt: cfg.Now().UTC(), Request: rec.DB.ID}); err != nil {
+ return "", fmt.Errorf("store backup meta: %w", err)
+ }
+ return id, nil
+}
+
+// replace is step 5: push.db → scratch → CheckDB → DeletePrefix →
+// replicate → purge. An error after the delete is returned as-is (never a
+// dbFailure) so the caller keeps the request in flight and retries.
+func (w *dbWorker) replace(ctx context.Context, s release.Store, rec provision.Record, stage provision.DBStage, prefix, dbPath string) error {
+ cfg := w.cfg
+ body, _, err := s.Get(ctx, stage.PushKey(rec.DB.ID))
+ if errors.Is(err, os.ErrNotExist) {
+ return failDB("the uploaded database is no longer staged — push it again")
+ }
+ if err != nil {
+ return fmt.Errorf("read staged push: %w", err)
+ }
+ scratch, err := w.scratchPath(rec.Host, rec.DB.ID, "push")
+ if err != nil {
+ return err
+ }
+ sweepScratch(scratch)
+ defer sweepScratch(scratch)
+ if err := os.WriteFile(scratch, body, 0o600); err != nil {
+ return err
+ }
+ if err := lease.CheckDB(scratch); err != nil {
+ return failDB("the uploaded file is not a usable SQLite database: %v", err)
+ }
+ m, err := w.mirrorStore(ctx)
+ if err != nil {
+ return err
+ }
+ // The point of no return for this round: from here an error keeps the
+ // request in flight (see the header). The prefix must be a directory
+ // boundary for the listing, as MirrorPrefix normalizes it.
+ if err := lease.DeletePrefix(ctx, m, cfg.OwnBucket, strings.TrimSuffix(prefix, "/")+"/"); err != nil {
+ return fmt.Errorf("delete replica prefix: %w", err)
+ }
+ if err := cfg.Replicate(ctx, scratch, cfg.OwnBucket, prefix, cfg.OwnBucketRegion); err != nil {
+ return fmt.Errorf("replicate pushed database (replica prefix is empty until this succeeds — retrying): %w", err)
+ }
+ // The warm-cache purge: the next wake finds no local file and RESTORES
+ // from the replica, through the ordinary CheckDB + ownership path.
+ activator.PurgeLocalDBArtifacts(dbPath, true)
+ return nil
+}
+
+// finishPush is step 6: staging gone, backups pruned, route back, fence
+// lifted, record done.
+func (w *dbWorker) finishPush(ctx context.Context, s release.Store, rec provision.Record, stage provision.DBStage) error {
+ if err := stage.DeleteRequest(ctx, s, rec.DB.ID); err != nil {
+ return fmt.Errorf("delete staged push: %w", err)
+ }
+ if pruned, err := stage.PruneBackups(ctx, s, provision.DBBackupsKept); err != nil {
+ return fmt.Errorf("prune backups: %w", err)
+ } else if len(pruned) > 0 {
+ log.Printf("db: %s: pruned %d old backup(s): %v", rec.Host, len(pruned), pruned)
+ }
+ w.releaseRoute(rec.Host, dbFenceGeneration(rec.DB))
+ if _, err := provision.SetRecordDB(ctx, s, rec.Account, rec.App, rec.Host, provision.DBComplete(rec.DB.ID, rec.DB.Bytes)); err != nil && !errors.Is(err, provision.ErrDBConflict) {
+ return err
+ }
+ w.nudged.Delete(rec.Host)
+ nudgeSerial(ctx, s)
+ log.Printf("db: %s: push %s complete — the next wake restores the pushed database", rec.Host, rec.DB.ID)
+ return nil
+}
+
+// releaseRoute puts the route back in service and lifts this push's fence.
+// Status first: a fence lifted before the status write would open a window
+// where a tick could wake the host and find it "maintenance" — harmless,
+// but the other order is the one every reader expects.
+func (w *dbWorker) releaseRoute(host string, gen uint64) {
+ if err := w.cfg.SetStatus(host, "ready"); err != nil {
+ log.Printf("db: %s: restore route status: %v", host, err)
+ }
+ if w.cfg.LiftFence != nil {
+ if _, held := w.fenced.LoadAndDelete(host); held {
+ w.cfg.LiftFence(host, gen)
+ }
+ }
+}
+
+// logTrouble rate-limits per-host errors to one line a minute.
+func (w *dbWorker) logTrouble(host string, err error) {
+ now := time.Now()
+ if last, ok := w.trouble.Load(host); ok && now.Sub(last.(time.Time)) < time.Minute {
+ return
+ }
+ w.trouble.Store(host, now)
+ log.Printf("db: %s: %v", host, err)
+}
diff --git a/internal/host/dbpass_test.go b/internal/host/dbpass_test.go
new file mode 100644
index 000000000..708926174
--- /dev/null
+++ b/internal/host/dbpass_test.go
@@ -0,0 +1,767 @@
+package host
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/lease"
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+
+ _ "modernc.org/sqlite"
+)
+
+// dbHarness fakes the two sides the worker bridges: the control bucket is a
+// real DirStore; the data bucket is a map of replica prefix → the SQLite
+// file that a restore of it yields (plus the fake mirror's key listing, so
+// DeletePrefix has something to delete and the test can see it happen).
+type dbHarness struct {
+ s release.Store
+ f *fakeMoveMirror
+ cfg dbCfg
+ replicas map[string]string // prefix → path of the DB a restore yields
+ statuses []string
+ ops []string
+ quiet bool
+ owns bool
+ fences map[string]uint64
+ // replicateErr, when set, is what Replicate answers — the
+ // DeletePrefix-then-crash window.
+ replicateErr error
+ restoreErr error
+}
+
+func newDBHarness(t *testing.T) *dbHarness {
+ t.Helper()
+ h := &dbHarness{s: release.NewDirStore(t.TempDir()), f: newFakeMoveMirror(), quiet: true, owns: true,
+ replicas: map[string]string{}, fences: map[string]uint64{}}
+ dataDir := t.TempDir()
+ h.cfg = dbCfg{
+ Region: "ie",
+ OwnBucket: "data-ie",
+ OwnBucketRegion: "eu-west-1",
+ OwnPrefix: "instances",
+ DataDir: dataDir,
+ Mirror: func(context.Context) (lease.MirrorStore, error) { return &loggingMirror{h: h}, nil },
+ Quiesced: func(string) bool { return h.quiet },
+ Fence: func(_ context.Context, host string, gen uint64) error {
+ h.fences[host] = gen
+ h.ops = append(h.ops, "fence "+host)
+ return nil
+ },
+ LiftFence: func(host string, gen uint64) bool {
+ if h.fences[host] != gen {
+ return false
+ }
+ delete(h.fences, host)
+ h.ops = append(h.ops, "lift "+host)
+ return true
+ },
+ SetStatus: func(host, status string) error {
+ h.statuses = append(h.statuses, host+"="+status)
+ h.ops = append(h.ops, "status "+status)
+ return nil
+ },
+ OwnsRoute: func(string) bool { return h.owns },
+ Checkpoint: func(_ context.Context, dbPath string) error {
+ h.ops = append(h.ops, "checkpoint "+filepath.Base(dbPath))
+ return nil
+ },
+ Restore: func(_ context.Context, out, bucket, prefix, region string) (bool, error) {
+ if h.restoreErr != nil {
+ return false, h.restoreErr
+ }
+ src, ok := h.replicas[prefix]
+ if !ok {
+ return false, nil
+ }
+ body, err := os.ReadFile(src)
+ if err != nil {
+ return false, err
+ }
+ h.ops = append(h.ops, "restore "+prefix)
+ return true, os.WriteFile(out, body, 0o600)
+ },
+ Replicate: func(_ context.Context, dbPath, bucket, prefix, region string) error {
+ if h.replicateErr != nil {
+ return h.replicateErr
+ }
+ // The lineage rule the worker relies on: the prefix must be
+ // EMPTY when the new database is replicated into it.
+ if left, _ := h.f.List(context.Background(), bucket, prefix+"/"); len(left) != 0 {
+ return fmt.Errorf("replicate into a non-empty prefix: %v", left)
+ }
+ body, err := os.ReadFile(dbPath)
+ if err != nil {
+ return err
+ }
+ keep := filepath.Join(h.cfg.DataDir, "replica-"+strings.ReplaceAll(prefix, "/", "_")+".db")
+ if err := os.WriteFile(keep, body, 0o600); err != nil {
+ return err
+ }
+ h.replicas[prefix] = keep
+ h.f.put(bucket, prefix+"/generations/new/snapshot", "pushed")
+ h.ops = append(h.ops, "replicate "+prefix)
+ return nil
+ },
+ Now: time.Now,
+ }
+ return h
+}
+
+func (h *dbHarness) worker() *dbWorker { return &dbWorker{cfg: h.cfg} }
+
+// loggingMirror is the harness's fake data bucket with its deletes folded
+// into the harness op log, so the test can assert the delete's place in the
+// push sequence beside the status, fence and replicate ops.
+type loggingMirror struct{ h *dbHarness }
+
+func (m *loggingMirror) List(ctx context.Context, bucket, prefix string) (map[string]string, error) {
+ return m.h.f.List(ctx, bucket, prefix)
+}
+func (m *loggingMirror) Copy(ctx context.Context, sb, sk, db, dk string) error {
+ return m.h.f.Copy(ctx, sb, sk, db, dk)
+}
+func (m *loggingMirror) Delete(ctx context.Context, bucket, key string) error {
+ m.h.ops = append(m.h.ops, "delete "+bucket+"/"+key)
+ return m.h.f.Delete(ctx, bucket, key)
+}
+
+// seedReplica gives host a replica: a real SQLite file with one table
+// holding `rows` rows, listed in the fake data bucket under the prefix.
+func (h *dbHarness) seedReplica(t *testing.T, host string, rows []string) {
+ t.Helper()
+ prefix := "instances/" + host
+ path := filepath.Join(h.cfg.DataDir, "seed-"+host+".db")
+ makeSQLite(t, path, "t", rows)
+ h.replicas[prefix] = path
+ h.f.put(h.cfg.OwnBucket, prefix+"/generations/old/snapshot", "e1")
+ h.f.put(h.cfg.OwnBucket, prefix+"/lock.json", "e2")
+}
+
+func (h *dbHarness) record(t *testing.T, host string, mut ...func(*provision.Record) error) provision.Record {
+ t.Helper()
+ rec := provision.Record{Schema: 1, Host: host, Slug: "s", App: "app", Account: "bab",
+ Region: "ie", Channel: "stable", CreatedAt: time.Now().UTC()}
+ if err := provision.CreateRecord(context.Background(), h.s, rec); err != nil {
+ t.Fatal(err)
+ }
+ for _, m := range mut {
+ if _, err := provision.SetRecordDB(context.Background(), h.s, "bab", "app", host, m); err != nil {
+ t.Fatal(err)
+ }
+ }
+ return h.read(t, host)
+}
+
+func (h *dbHarness) read(t *testing.T, host string) provision.Record {
+ t.Helper()
+ got, err := provision.GetRecord(context.Background(), h.s, "bab", "app", host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return *got
+}
+
+func (h *dbHarness) stage(t *testing.T, host string) provision.DBStage {
+ t.Helper()
+ st, err := provision.NewDBStage("bab", "app", host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return st
+}
+
+func (h *dbHarness) pass(t *testing.T, w *dbWorker) {
+ t.Helper()
+ if err := dbPass(context.Background(), h.s, w); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func makeSQLite(t *testing.T, path, table string, rows []string) {
+ t.Helper()
+ db, err := sql.Open("sqlite", path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer db.Close()
+ if _, err := db.Exec(`CREATE TABLE ` + table + ` (id INTEGER PRIMARY KEY, body TEXT)`); err != nil {
+ t.Fatal(err)
+ }
+ for _, r := range rows {
+ if _, err := db.Exec(`INSERT INTO `+table+` (body) VALUES (?)`, r); err != nil {
+ t.Fatal(err)
+ }
+ }
+}
+
+func readRows(t *testing.T, path, table string) []string {
+ t.Helper()
+ db, err := sql.Open("sqlite", "file:"+path+"?mode=ro")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer db.Close()
+ rows, err := db.Query(`SELECT body FROM ` + table + ` ORDER BY id`)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer rows.Close()
+ var out []string
+ for rows.Next() {
+ var s string
+ if err := rows.Scan(&s); err != nil {
+ t.Fatal(err)
+ }
+ out = append(out, s)
+ }
+ return out
+}
+
+func scratchFiles(t *testing.T, dataDir string) []string {
+ t.Helper()
+ ents, err := os.ReadDir(filepath.Join(dataDir, dbScratchDirName))
+ if err != nil && !errors.Is(err, os.ErrNotExist) {
+ t.Fatal(err)
+ }
+ var out []string
+ for _, e := range ents {
+ out = append(out, e.Name())
+ }
+ return out
+}
+
+func TestDBPassPull(t *testing.T) {
+ h := newDBHarness(t)
+ host := "a.go.example.com"
+ h.seedReplica(t, host, []string{"one", "two"})
+ id := provision.NewDBTransferID()
+ h.record(t, host, provision.DBRequest(provision.DBPull, id, time.Now()))
+
+ h.pass(t, h.worker())
+ got := h.read(t, host)
+ if got.DB.State != provision.DBDone || got.DB.Bytes == 0 {
+ t.Fatalf("after pull: %+v", got.DB)
+ }
+ raw, _, err := h.s.Get(context.Background(), h.stage(t, host).PullKey(id))
+ if err != nil {
+ t.Fatalf("pull.db not staged: %v", err)
+ }
+ if int64(len(raw)) != got.DB.Bytes {
+ t.Fatalf("bytes %d on the record, %d staged", got.DB.Bytes, len(raw))
+ }
+ out := filepath.Join(t.TempDir(), "pulled.db")
+ if err := os.WriteFile(out, raw, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if rows := readRows(t, out, "t"); fmt.Sprint(rows) != "[one two]" {
+ t.Fatalf("staged pull = %v", rows)
+ }
+ if files := scratchFiles(t, h.cfg.DataDir); len(files) != 0 {
+ t.Fatalf("scratch left behind: %v", files)
+ }
+ // A pull never touches the route.
+ if len(h.statuses) != 0 || len(h.fences) != 0 {
+ t.Fatalf("a pull touched the route: statuses=%v fences=%v", h.statuses, h.fences)
+ }
+ // And the pass is inert for a done record.
+ before := len(h.ops)
+ h.pass(t, h.worker())
+ if len(h.ops) != before {
+ t.Fatal("a done pull ran again")
+ }
+ if _, _, err := h.s.Get(context.Background(), "control/serial"); err != nil {
+ t.Fatal("the serial was not nudged")
+ }
+}
+
+func TestDBPassPullFailures(t *testing.T) {
+ // No replica: failed with the sentence, no object staged.
+ h := newDBHarness(t)
+ host := "a.go.example.com"
+ id := provision.NewDBTransferID()
+ h.record(t, host, provision.DBRequest(provision.DBPull, id, time.Now()))
+ h.pass(t, h.worker())
+ if got := h.read(t, host); got.DB.State != provision.DBFailed || !strings.Contains(got.DB.Error, "no replicated database") {
+ t.Fatalf("pull with no replica: %+v", got.DB)
+ }
+ // A corrupt replica: failed, and the scratch swept.
+ h = newDBHarness(t)
+ junk := filepath.Join(h.cfg.DataDir, "junk.db")
+ if err := os.WriteFile(junk, []byte("not a database at all, not even close, really"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ h.replicas["instances/"+host] = junk
+ h.record(t, host, provision.DBRequest(provision.DBPull, id, time.Now()))
+ h.pass(t, h.worker())
+ if got := h.read(t, host); got.DB.State != provision.DBFailed || !strings.Contains(got.DB.Error, "does not open cleanly") {
+ t.Fatalf("pull of a corrupt replica: %+v", got.DB)
+ }
+ if files := scratchFiles(t, h.cfg.DataDir); len(files) != 0 {
+ t.Fatalf("scratch left behind: %v", files)
+ }
+ // Over the cap: failed naming the cap.
+ h = newDBHarness(t)
+ h.seedReplica(t, host, []string{"one"})
+ old := provision.DBTransferMaxBytes
+ provision.DBTransferMaxBytes = 1024
+ t.Cleanup(func() { provision.DBTransferMaxBytes = old })
+ h.record(t, host, provision.DBRequest(provision.DBPull, id, time.Now()))
+ h.pass(t, h.worker())
+ if got := h.read(t, host); got.DB.State != provision.DBFailed || !strings.Contains(got.DB.Error, "capped") {
+ t.Fatalf("pull over the cap: %+v", got.DB)
+ }
+ // A transient restore error: still requested, retried next pass.
+ provision.DBTransferMaxBytes = old
+ h = newDBHarness(t)
+ h.seedReplica(t, host, []string{"one"})
+ h.restoreErr = errors.New("s3 blip")
+ h.record(t, host, provision.DBRequest(provision.DBPull, id, time.Now()))
+ w := h.worker()
+ h.pass(t, w)
+ if got := h.read(t, host); got.DB.State != provision.DBRequested {
+ t.Fatalf("a transient error settled the pull: %+v", got.DB)
+ }
+ h.restoreErr = nil
+ h.pass(t, w)
+ if got := h.read(t, host); got.DB.State != provision.DBDone {
+ t.Fatalf("the retry did not complete: %+v", got.DB)
+ }
+}
+
+// stagePush uploads a real SQLite file as the push and writes the request,
+// the way the console does.
+func (h *dbHarness) stagePush(t *testing.T, host string, rows []string) string {
+ t.Helper()
+ id := provision.NewDBTransferID()
+ path := filepath.Join(t.TempDir(), "upload.db")
+ makeSQLite(t, path, "pushed", rows)
+ body, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := h.s.Put(context.Background(), h.stage(t, host).PushKey(id), body, ""); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := provision.SetRecordDB(context.Background(), h.s, "bab", "app", host, provision.DBRequest(provision.DBPush, id, time.Now())); err != nil {
+ t.Fatal(err)
+ }
+ return id
+}
+
+func TestDBPassPushHappyPath(t *testing.T) {
+ h := newDBHarness(t)
+ host := "a.go.example.com"
+ h.seedReplica(t, host, []string{"old-1", "old-2"})
+ h.record(t, host)
+ local := filepath.Join(h.cfg.DataDir, host+".db")
+ if err := writeFileForTest(local, "warm cache"); err != nil {
+ t.Fatal(err)
+ }
+ st := h.stage(t, host)
+ // Six older backups, so the prune has something to do.
+ base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
+ for i := 0; i < 6; i++ {
+ bid := provision.NewDBBackupID(base.Add(time.Duration(i) * time.Hour))
+ if _, err := h.s.Put(context.Background(), st.BackupKey(bid), []byte("x"), ""); err != nil {
+ t.Fatal(err)
+ }
+ }
+ id := h.stagePush(t, host, []string{"new-1", "new-2", "new-3"})
+
+ h.pass(t, h.worker())
+ got := h.read(t, host)
+ if got.DB.State != provision.DBDone || got.DB.Step != provision.DBStepReplaced || got.DB.Backup == "" {
+ t.Fatalf("after push: %+v", got.DB)
+ }
+ // The order: status, fence, checkpoint, restore (backup), delete the
+ // old prefix, replicate, status ready, lift.
+ joined := strings.Join(h.ops, "\n")
+ want := []string{"status maintenance", "fence " + host, "checkpoint " + host + ".db", "restore instances/" + host,
+ "delete data-ie/instances/" + host + "/generations/old/snapshot", "replicate instances/" + host, "status ready", "lift " + host}
+ last := -1
+ for _, op := range want {
+ i := strings.Index(joined, op)
+ if i < 0 || i < last {
+ t.Fatalf("op order: %q missing or out of place in\n%s", op, joined)
+ }
+ last = i
+ }
+ // The lease lock was deleted with the prefix (a deleted prefix holds
+ // nothing back).
+ if left, _ := h.f.List(context.Background(), "data-ie", "instances/"+host+"/"); len(left) != 1 {
+ t.Fatalf("old prefix after replace: %v (want only the new snapshot)", left)
+ }
+ // The replica now restores the pushed content.
+ if rows := readRows(t, h.replicas["instances/"+host], "pushed"); fmt.Sprint(rows) != "[new-1 new-2 new-3]" {
+ t.Fatalf("replica after push = %v", rows)
+ }
+ // The backup is the OLD content, with its sidecar.
+ raw, _, err := h.s.Get(context.Background(), st.BackupKey(got.DB.Backup))
+ if err != nil {
+ t.Fatalf("backup not stored: %v", err)
+ }
+ bk := filepath.Join(t.TempDir(), "backup.db")
+ if err := os.WriteFile(bk, raw, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if rows := readRows(t, bk, "t"); fmt.Sprint(rows) != "[old-1 old-2]" {
+ t.Fatalf("backup = %v", rows)
+ }
+ backups, err := st.ListBackups(context.Background(), h.s)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(backups) != provision.DBBackupsKept || backups[0].ID != got.DB.Backup || backups[0].Request != id || backups[0].Bytes != int64(len(raw)) {
+ t.Fatalf("backups after push: %+v", backups)
+ }
+ // The warm cache is gone, the staged push is gone, scratch is swept.
+ if fileExistsForTest(local) {
+ t.Fatal("the local DB survived the push — the next wake would use the old warm cache")
+ }
+ if _, _, err := h.s.Get(context.Background(), st.PushKey(id)); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("push.db after finish: %v", err)
+ }
+ if files := scratchFiles(t, h.cfg.DataDir); len(files) != 0 {
+ t.Fatalf("scratch left behind: %v", files)
+ }
+ if len(h.fences) != 0 {
+ t.Fatalf("fence still held: %v", h.fences)
+ }
+ if h.statuses[len(h.statuses)-1] != host+"=ready" {
+ t.Fatalf("statuses = %v, want ready last", h.statuses)
+ }
+ if _, _, err := h.s.Get(context.Background(), "control/serial"); err != nil {
+ t.Fatal("the serial was not nudged")
+ }
+}
+
+func TestDBPassPushWaitsForQuiesce(t *testing.T) {
+ h := newDBHarness(t)
+ host := "a.go.example.com"
+ h.seedReplica(t, host, []string{"old"})
+ h.record(t, host)
+ h.stagePush(t, host, []string{"new"})
+ h.quiet = false
+ w := h.worker()
+ h.pass(t, w)
+ if fmt.Sprint(h.ops) != "[status maintenance fence "+host+"]" {
+ t.Fatalf("un-quiesced push did more than hold the route: %v", h.ops)
+ }
+ if got := h.read(t, host); got.DB.State != provision.DBRequested || got.DB.Step != "" {
+ t.Fatalf("un-quiesced push advanced: %+v", got.DB)
+ }
+ if _, _, err := h.s.Get(context.Background(), "control/serial"); err != nil {
+ t.Fatal("the status→quiesce hop was not nudged")
+ }
+ // A second un-quiesced pass does not nudge again (one per host).
+ _ = h.s.Delete(context.Background(), "control/serial")
+ h.pass(t, w)
+ if _, _, err := h.s.Get(context.Background(), "control/serial"); err == nil {
+ t.Fatal("a wedged stop turned the loop into a self-triggering cycle")
+ }
+ h.quiet = true
+ h.pass(t, w)
+ if got := h.read(t, host); got.DB.State != provision.DBDone {
+ t.Fatalf("quiesced pass did not finish: %+v", got.DB)
+ }
+}
+
+// Resume from each step: a push that died after backed-up never takes a
+// second backup; one that died after replaced never re-fences.
+func TestDBPassPushResumes(t *testing.T) {
+ h := newDBHarness(t)
+ host := "a.go.example.com"
+ h.seedReplica(t, host, []string{"old"})
+ h.record(t, host)
+ id := h.stagePush(t, host, []string{"new"})
+ if _, err := provision.SetRecordDB(context.Background(), h.s, "bab", "app", host, provision.DBBackedUp(id, "20260101-000000-deadbeef")); err != nil {
+ t.Fatal(err)
+ }
+ h.pass(t, h.worker())
+ got := h.read(t, host)
+ if got.DB.State != provision.DBDone || got.DB.Backup != "20260101-000000-deadbeef" {
+ t.Fatalf("resume from backed-up: %+v", got.DB)
+ }
+ for _, op := range h.ops {
+ if strings.HasPrefix(op, "restore ") {
+ t.Fatalf("a resumed push took a second backup: %v", h.ops)
+ }
+ }
+ if rows := readRows(t, h.replicas["instances/"+host], "pushed"); fmt.Sprint(rows) != "[new]" {
+ t.Fatalf("replica after resumed push = %v", rows)
+ }
+
+ // From replaced: finish only.
+ h = newDBHarness(t)
+ h.seedReplica(t, host, []string{"old"})
+ h.record(t, host)
+ id = h.stagePush(t, host, []string{"new"})
+ for _, m := range []func(*provision.Record) error{provision.DBBackedUp(id, ""), provision.DBReplaced(id)} {
+ if _, err := provision.SetRecordDB(context.Background(), h.s, "bab", "app", host, m); err != nil {
+ t.Fatal(err)
+ }
+ }
+ h.pass(t, h.worker())
+ if got := h.read(t, host); got.DB.State != provision.DBDone {
+ t.Fatalf("resume from replaced: %+v", got.DB)
+ }
+ if fmt.Sprint(h.ops) != "[status ready]" {
+ t.Fatalf("resume from replaced did more than finish: %v", h.ops)
+ }
+ if _, _, err := h.s.Get(context.Background(), h.stage(t, host).PushKey(id)); !errors.Is(err, os.ErrNotExist) {
+ t.Fatal("push.db survived the finish")
+ }
+}
+
+// A corrupt upload fails cleanly: the replica is untouched, the route is
+// back in service, the fence lifted, the backup recorded.
+func TestDBPassPushCorruptUpload(t *testing.T) {
+ h := newDBHarness(t)
+ host := "a.go.example.com"
+ h.seedReplica(t, host, []string{"old"})
+ h.record(t, host)
+ id := provision.NewDBTransferID()
+ junk := make([]byte, 4096)
+ copy(junk, provision.SQLiteMagic) // passes the console's magic check, fails CheckDB
+ if _, err := h.s.Put(context.Background(), h.stage(t, host).PushKey(id), junk, ""); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := provision.SetRecordDB(context.Background(), h.s, "bab", "app", host, provision.DBRequest(provision.DBPush, id, time.Now())); err != nil {
+ t.Fatal(err)
+ }
+ h.pass(t, h.worker())
+ got := h.read(t, host)
+ if got.DB.State != provision.DBFailed || !strings.Contains(got.DB.Error, "not a usable SQLite database") || got.DB.Backup == "" {
+ t.Fatalf("corrupt upload: %+v", got.DB)
+ }
+ for _, op := range h.ops {
+ if strings.HasPrefix(op, "delete ") || strings.HasPrefix(op, "replicate ") {
+ t.Fatalf("the replica was touched by a corrupt upload: %v", h.ops)
+ }
+ }
+ if h.statuses[len(h.statuses)-1] != host+"=ready" || len(h.fences) != 0 {
+ t.Fatalf("route not released: statuses=%v fences=%v", h.statuses, h.fences)
+ }
+ if files := scratchFiles(t, h.cfg.DataDir); len(files) != 0 {
+ t.Fatalf("scratch left behind: %v", files)
+ }
+}
+
+// The DeletePrefix-then-crash window: a replicate that fails leaves the
+// request IN FLIGHT with the route held, and the next pass re-runs the
+// replace to completion. Never failed-and-released over an empty replica.
+func TestDBPassPushHoldsTheRouteAcrossAnEmptyReplica(t *testing.T) {
+ h := newDBHarness(t)
+ host := "a.go.example.com"
+ h.seedReplica(t, host, []string{"old"})
+ h.record(t, host)
+ local := filepath.Join(h.cfg.DataDir, host+".db")
+ if err := writeFileForTest(local, "warm cache"); err != nil {
+ t.Fatal(err)
+ }
+ h.stagePush(t, host, []string{"new"})
+ h.replicateErr = errors.New("s3: connection reset")
+ w := h.worker()
+ h.pass(t, w)
+ got := h.read(t, host)
+ if got.DB.State != provision.DBRequested || got.DB.Step != provision.DBStepBackedUp {
+ t.Fatalf("after a failed replicate: %+v, want still requested at backed-up", got.DB)
+ }
+ if left, _ := h.f.List(context.Background(), "data-ie", "instances/"+host+"/"); len(left) != 0 {
+ t.Fatalf("the prefix was not deleted before the replicate: %v", left)
+ }
+ if h.statuses[len(h.statuses)-1] != host+"=maintenance" || h.fences[host] == 0 {
+ t.Fatalf("route released over an empty replica: statuses=%v fences=%v", h.statuses, h.fences)
+ }
+ if !fileExistsForTest(local) {
+ t.Fatal("the local DB was purged before the replica carried the new lineage")
+ }
+ // The next pass: same step, replicate works, done.
+ h.replicateErr = nil
+ h.pass(t, w)
+ got = h.read(t, host)
+ if got.DB.State != provision.DBDone || got.DB.Step != provision.DBStepReplaced {
+ t.Fatalf("retry after the empty-replica window: %+v", got.DB)
+ }
+ if rows := readRows(t, h.replicas["instances/"+host], "pushed"); fmt.Sprint(rows) != "[new]" {
+ t.Fatalf("replica after retry = %v", rows)
+ }
+ if fileExistsForTest(local) || len(h.fences) != 0 || h.statuses[len(h.statuses)-1] != host+"=ready" {
+ t.Fatalf("after the retry: local=%v fences=%v statuses=%v", fileExistsForTest(local), h.fences, h.statuses)
+ }
+ // Exactly one backup was taken across both passes.
+ backups, _ := h.stage(t, host).ListBackups(context.Background(), h.s)
+ if len(backups) != 1 || backups[0].ID != got.DB.Backup {
+ t.Fatalf("backups after retry: %+v", backups)
+ }
+}
+
+// A push onto an instance with no replica yet: no backup, the replace
+// still runs, Backup stays empty on the record.
+func TestDBPassPushWithoutAReplica(t *testing.T) {
+ h := newDBHarness(t)
+ host := "a.go.example.com"
+ h.record(t, host)
+ h.stagePush(t, host, []string{"new"})
+ h.pass(t, h.worker())
+ got := h.read(t, host)
+ if got.DB.State != provision.DBDone || got.DB.Backup != "" {
+ t.Fatalf("push with no replica: %+v", got.DB)
+ }
+ if rows := readRows(t, h.replicas["instances/"+host], "pushed"); fmt.Sprint(rows) != "[new]" {
+ t.Fatalf("replica after push = %v", rows)
+ }
+ if backups, _ := h.stage(t, host).ListBackups(context.Background(), h.s); len(backups) != 0 {
+ t.Fatalf("a backup of nothing: %+v", backups)
+ }
+}
+
+// A backup that cannot be made (the replica is over the cap) refuses the
+// push rather than replacing without one.
+func TestDBPassPushRefusesWithoutABackup(t *testing.T) {
+ h := newDBHarness(t)
+ host := "a.go.example.com"
+ h.seedReplica(t, host, []string{"old"})
+ h.record(t, host)
+ h.stagePush(t, host, []string{"new"})
+ old := provision.DBTransferMaxBytes
+ provision.DBTransferMaxBytes = 1024
+ t.Cleanup(func() { provision.DBTransferMaxBytes = old })
+ h.pass(t, h.worker())
+ got := h.read(t, host)
+ if got.DB.State != provision.DBFailed || !strings.Contains(got.DB.Error, "without a backup") {
+ t.Fatalf("push over the backup cap: %+v", got.DB)
+ }
+ if left, _ := h.f.List(context.Background(), "data-ie", "instances/"+host+"/"); len(left) != 2 {
+ t.Fatalf("the replica was touched: %v", left)
+ }
+ if h.statuses[len(h.statuses)-1] != host+"=ready" || len(h.fences) != 0 {
+ t.Fatalf("route not released: statuses=%v fences=%v", h.statuses, h.fences)
+ }
+}
+
+// The rails: a move in flight, a pooled or non-hibernating record, a route
+// another box owns — skipped untouched.
+func TestDBPassRailsAndOwnership(t *testing.T) {
+ h := newDBHarness(t)
+ host := "a.go.example.com"
+ h.seedReplica(t, host, []string{"old"})
+ h.record(t, host, provision.DBRequest(provision.DBPull, provision.NewDBTransferID(), time.Now()))
+
+ h.owns = false
+ h.pass(t, h.worker())
+ if len(h.ops) != 0 || h.read(t, host).DB.State != provision.DBRequested {
+ t.Fatalf("a non-owning box acted: %v", h.ops)
+ }
+ h.owns = true
+
+ // A move stamped on the record by another writer: skipped.
+ rec := h.read(t, host)
+ rec.Move = &provision.MoveState{To: "uk", State: provision.MovePreparing}
+ _, etag, _ := h.s.Get(context.Background(), provision.RecordKey("bab", "app", host))
+ if err := provision.UpdateRecord(context.Background(), h.s, rec, etag); err != nil {
+ t.Fatal(err)
+ }
+ h.pass(t, h.worker())
+ if len(h.ops) != 0 {
+ t.Fatalf("a record mid-move was acted on: %v", h.ops)
+ }
+
+ pooled := "p.go.example.com"
+ prec := provision.Record{Schema: 1, Host: pooled, Slug: "p", App: "app", Account: "bab",
+ Region: "ie", Pool: "dedicated-x", Channel: "stable", CreatedAt: time.Now().UTC()}
+ if err := provision.CreateRecord(context.Background(), h.s, prec); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := provision.SetRecordDB(context.Background(), h.s, "bab", "app", pooled, provision.DBRequest(provision.DBPull, provision.NewDBTransferID(), time.Now())); err != nil {
+ t.Fatal(err)
+ }
+ h.pass(t, h.worker())
+ if h.read(t, pooled).DB.State != provision.DBRequested {
+ t.Fatal("a pooled record's pull ran on the platform box")
+ }
+}
+
+// A push checkpoints and purges the row's OWN DBPath when the registry is
+// wired, the move worker's rule.
+func TestDBPassPushUsesRouteDBPath(t *testing.T) {
+ h := newDBHarness(t)
+ host := "a.go.example.com"
+ h.seedReplica(t, host, []string{"old"})
+ h.record(t, host)
+ nested := provision.NestedDBPath(h.cfg.DataDir, host)
+ if err := os.MkdirAll(filepath.Dir(nested), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := writeFileForTest(nested, "stale"); err != nil {
+ t.Fatal(err)
+ }
+ h.cfg.Route = func(string) (*registry.Route, error) { return &registry.Route{Host: host, DBPath: nested}, nil }
+ h.stagePush(t, host, []string{"new"})
+ h.pass(t, h.worker())
+ found := false
+ for _, op := range h.ops {
+ if op == "checkpoint "+filepath.Base(nested) {
+ found = true
+ }
+ }
+ if !found {
+ t.Fatalf("checkpoint did not use the route's DBPath: %v", h.ops)
+ }
+ if fileExistsForTest(nested) {
+ t.Fatal("the route's own DB survived the push")
+ }
+}
+
+func TestDBPassRunsOneAtATime(t *testing.T) {
+ h := newDBHarness(t)
+ w := h.worker()
+ w.pass.Lock()
+ done := make(chan error, 1)
+ go func() { done <- dbPass(context.Background(), h.s, w) }()
+ select {
+ case <-done:
+ t.Fatal("a second pass ran while the first held the worker")
+ case <-time.After(50 * time.Millisecond):
+ }
+ w.pass.Unlock()
+ if err := <-done; err != nil {
+ t.Fatal(err)
+ }
+}
+
+// A route held for a push whose record was rewritten out from under it is
+// released on the next pass rather than stranded at "maintenance".
+func TestDBPassReleasesARouteWhosePushVanished(t *testing.T) {
+ h := newDBHarness(t)
+ host := "a.go.example.com"
+ h.seedReplica(t, host, []string{"old"})
+ h.record(t, host)
+ h.stagePush(t, host, []string{"new"})
+ h.quiet = false
+ w := h.worker()
+ h.pass(t, w) // held: status maintenance, fenced, waiting for quiesce
+ if h.fences[host] == 0 {
+ t.Fatal("the push did not fence")
+ }
+ rec := h.read(t, host)
+ rec.DB = nil
+ _, etag, _ := h.s.Get(context.Background(), provision.RecordKey("bab", "app", host))
+ if err := provision.UpdateRecord(context.Background(), h.s, rec, etag); err != nil {
+ t.Fatal(err)
+ }
+ h.pass(t, w)
+ if len(h.fences) != 0 || h.statuses[len(h.statuses)-1] != host+"=ready" {
+ t.Fatalf("route not released after the record lost its push: fences=%v statuses=%v", h.fences, h.statuses)
+ }
+}
diff --git a/internal/lease/check.go b/internal/lease/check.go
index 8fdac6469..9700c368d 100644
--- a/internal/lease/check.go
+++ b/internal/lease/check.go
@@ -107,6 +107,19 @@ func VerifyReplica(ctx context.Context, bucket, prefix, region, dir string) (uin
return ChangeCounter(out)
}
+// RestoreCopy restores s3://bucket/prefix into out and KEEPS the file — a
+// pull's snapshot, a push's pre-swap backup — without taking the lease.
+// Restore's contract demands the lease because it materializes the LIVE
+// database; VerifyReplica's argument (above) is why a reader needs none:
+// what the lease forbids is a second WRITER, and a copy that is never
+// replicated from cannot shadow anything. The caller owns the copy and its
+// sidecars (out, out-wal, out-shm) and must never replicate from it under
+// the same prefix without the lease. Same false-means-no-replica answer as
+// Restore.
+func RestoreCopy(ctx context.Context, out, bucket, prefix, region string) (bool, error) {
+ return Restore(ctx, out, bucket, prefix, region)
+}
+
// ChangeCounter reads the SQLite file-change counter: bytes 24-27 of the
// database header, big-endian. SQLite increments it on every write transaction
// that modifies the main database file, which makes it the cheapest honest
diff --git a/internal/lease/replace_test.go b/internal/lease/replace_test.go
new file mode 100644
index 000000000..b70b05382
--- /dev/null
+++ b/internal/lease/replace_test.go
@@ -0,0 +1,169 @@
+package lease
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/benbjohnson/litestream"
+ lsfile "github.com/benbjohnson/litestream/file"
+)
+
+// The push worker's replace step (internal/host/dbpass.go, step 5) is:
+// delete the replica prefix, then replicate the pushed file into that same
+// prefix under a fresh lease, so the NEXT wake — which restores only when
+// the local file is absent — materializes exactly the pushed database. This
+// test runs that sequence against the vendored litestream with a
+// file-backed replica client (the same seam the checkpoint tests use), so
+// what it proves is litestream's own behaviour, not a fake's.
+//
+// Two properties, both required by the worker's crash-point argument:
+//
+// - after delete + replicate, a restore yields the pushed content and
+// nothing of the old lineage;
+// - the replace is idempotent — running delete + replicate a second time
+// (what a resumed push does after a crash between the two) still
+// restores the pushed content.
+func TestReplaceReplicaRestoresThePushedDatabase(t *testing.T) {
+ replica := t.TempDir()
+
+ // The old lineage: 50 rows, replicated and released the way an instance
+ // going to sleep leaves its prefix.
+ old := filepath.Join(t.TempDir(), "old.db")
+ seedDB(t, old)
+ replicateOnce(t, old, replica)
+ if rows := restoreRows(t, replica, filepath.Join(t.TempDir(), "check-old.db")); len(rows) != 50 || rows[0] != "row-0" {
+ t.Fatalf("control: the old lineage did not restore: %d rows %v", len(rows), rows)
+ }
+
+ // The pushed database: a different table shape and content, so a
+ // mixed lineage would be unmistakable.
+ pushed := filepath.Join(t.TempDir(), "pushed.db")
+ seedPushed(t, pushed, []string{"alpha", "beta", "gamma"})
+
+ // Step 5 as the worker runs it: delete the prefix, replicate the
+ // scratch file into it under a fresh lease, sync, close.
+ deletePrefixDir(t, replica)
+ replicateOnce(t, pushed, replica)
+ rows := restoreRows(t, replica, filepath.Join(t.TempDir(), "check-new.db"))
+ if fmt.Sprint(rows) != "[alpha beta gamma]" {
+ t.Fatalf("after replace, restore = %v, want exactly the pushed rows", rows)
+ }
+
+ // The resumed-push case: the same sequence again over the already
+ // replaced prefix. Same answer.
+ deletePrefixDir(t, replica)
+ replicateOnce(t, pushed, replica)
+ rows = restoreRows(t, replica, filepath.Join(t.TempDir(), "check-again.db"))
+ if fmt.Sprint(rows) != "[alpha beta gamma]" {
+ t.Fatalf("after a repeated replace, restore = %v, want exactly the pushed rows", rows)
+ }
+
+ // And the pushed file itself is untouched as a database — litestream
+ // switches it to WAL mode, which is what the worker's CheckDB-before
+ // and the later restore both tolerate.
+ if err := CheckDB(pushed); err != nil {
+ t.Fatalf("the pushed scratch file after replication: %v", err)
+ }
+}
+
+// replicateOnce is what the worker's Replicate closure does in production:
+// ReplicateWithLease + SyncAndWait + Close, here through the Held seam so
+// the replica is a directory rather than S3.
+func replicateOnce(t *testing.T, dbPath, replica string) {
+ t.Helper()
+ h := localHeld(t, &lockStore{}, 10e9)
+ h.replicaClient = lsfile.NewReplicaClient(replica)
+ rep, err := h.Replicate(context.Background(), dbPath, 0)
+ if err != nil {
+ t.Fatalf("replicate %s: %v", dbPath, err)
+ }
+ if err := rep.SyncAndWait(context.Background()); err != nil {
+ t.Fatalf("sync %s: %v", dbPath, err)
+ }
+ if err := rep.Close(context.Background()); err != nil {
+ t.Fatalf("close %s: %v", dbPath, err)
+ }
+}
+
+// deletePrefixDir is DeletePrefix for the file-backed replica: every object
+// under the prefix gone, the prefix itself still addressable.
+func deletePrefixDir(t *testing.T, replica string) {
+ t.Helper()
+ if err := os.RemoveAll(replica); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.MkdirAll(replica, 0o700); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func seedPushed(t *testing.T, path string, names []string) {
+ t.Helper()
+ db, err := sql.Open("sqlite", path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer db.Close()
+ if _, err := db.Exec(`CREATE TABLE pushed (id INTEGER PRIMARY KEY, name TEXT)`); err != nil {
+ t.Fatal(err)
+ }
+ for _, n := range names {
+ if _, err := db.Exec(`INSERT INTO pushed (name) VALUES (?)`, n); err != nil {
+ t.Fatal(err)
+ }
+ }
+}
+
+// restoreRows restores the replica and reads back whichever of the two
+// tables it carries, in id order — so a lineage mix-up shows up as the
+// wrong table, not merely the wrong count.
+func restoreRows(t *testing.T, replica, out string) []string {
+ t.Helper()
+ r := litestream.NewReplicaWithClient(nil, lsfile.NewReplicaClient(replica))
+ opt := litestream.NewRestoreOptions()
+ opt.OutputPath = out
+ if err := r.Restore(context.Background(), opt); err != nil {
+ t.Fatalf("restore: %v", err)
+ }
+ if err := CheckDB(out); err != nil {
+ t.Fatalf("restored file: %v", err)
+ }
+ db, err := sql.Open("sqlite", "file:"+out+"?mode=ro")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer db.Close()
+ var table string
+ // litestream keeps two bookkeeping tables of its own (_litestream_seq,
+ // _litestream_lock) beside the data; they are not the lineage.
+ if err := db.QueryRow(`SELECT group_concat(name, ',') FROM sqlite_master WHERE type='table' AND name NOT LIKE '\_litestream\_%' ESCAPE '\'`).Scan(&table); err != nil {
+ t.Fatal(err)
+ }
+ var q string
+ switch table {
+ case "t":
+ q = `SELECT body FROM t ORDER BY id`
+ case "pushed":
+ q = `SELECT name FROM pushed ORDER BY id`
+ default:
+ t.Fatalf("restored tables = %q, want exactly one of t / pushed", table)
+ }
+ rows, err := db.Query(q)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer rows.Close()
+ var out2 []string
+ for rows.Next() {
+ var s string
+ if err := rows.Scan(&s); err != nil {
+ t.Fatal(err)
+ }
+ out2 = append(out2, s)
+ }
+ return out2
+}
diff --git a/internal/provision/dbstage.go b/internal/provision/dbstage.go
new file mode 100644
index 000000000..18773af1a
--- /dev/null
+++ b/internal/provision/dbstage.go
@@ -0,0 +1,219 @@
+package provision
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "regexp"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// The staging keys a database transfer moves through, all in the CONTROL
+// bucket (release.Store) — the one store the console can reach. Boxes reach
+// both it and their data (hibernate) bucket, so the box is the bridge: a pull
+// restores the replica and puts the snapshot here; a push takes the upload
+// from here and replicates it into the data bucket.
+//
+// apps/<sqid>/<app>/db/<host>/<id>/pull.db the box's snapshot for a pull
+// apps/<sqid>/<app>/db/<host>/<id>/push.db the member's upload for a push
+// apps/<sqid>/<app>/db/<host>/backups/<bid>.db pre-push backups (newest 5 kept)
+// apps/<sqid>/<app>/db/<host>/backups/<bid>.json each backup's size + time
+//
+// `db/` is a literal sibling of `instances/` under the app prefix, never
+// under it: ParseRecordKey walks instances/ and must never mistake a staged
+// database for a record. The host is shape-checked before any key is built
+// — it arrives from a URL segment, and RecordKey's own rule (path-traversal
+// defence, not tidiness) applies to every key that joins it.
+
+// DBStage names one instance's staging area. Built only through NewDBStage,
+// which is where the host shape is checked.
+type DBStage struct{ acct, app, host string }
+
+// NewDBStage validates the coordinates and returns the key builder.
+func NewDBStage(acct, app, host string) (DBStage, error) {
+ if acct == "" || app == "" {
+ return DBStage{}, errors.New("provision: db stage needs an account and an app")
+ }
+ if !ValidHostShape(host) {
+ return DBStage{}, fmt.Errorf("provision: db stage: %q is not a valid host", host)
+ }
+ return DBStage{acct: acct, app: app, host: host}, nil
+}
+
+// Host is the instance the stage belongs to.
+func (d DBStage) Host() string { return d.host }
+
+// Prefix is everything the instance stages, requests and backups alike.
+func (d DBStage) Prefix() string {
+ return release.AppPrefix(d.acct, d.app) + "db/" + d.host + "/"
+}
+
+// RequestPrefix is one request's own directory. The id is checked here
+// too: an id reaches this from the record (which only DBRequest writes) or
+// from a query string (which anyone writes).
+func (d DBStage) RequestPrefix(id string) string {
+ if !ValidDBTransferID(id) {
+ return d.Prefix() + "invalid/"
+ }
+ return d.Prefix() + id + "/"
+}
+
+// PullKey is where the box puts a pull's snapshot.
+func (d DBStage) PullKey(id string) string { return d.RequestPrefix(id) + "pull.db" }
+
+// PushKey is where the console puts a push's upload.
+func (d DBStage) PushKey(id string) string { return d.RequestPrefix(id) + "push.db" }
+
+// BackupsPrefix holds every pre-push backup of the instance.
+func (d DBStage) BackupsPrefix() string { return d.Prefix() + "backups/" }
+
+// BackupKey is one backup's database; BackupMetaKey its sidecar.
+func (d DBStage) BackupKey(id string) string { return d.BackupsPrefix() + id + ".db" }
+func (d DBStage) BackupMetaKey(id string) string { return d.BackupsPrefix() + id + ".json" }
+
+// DeleteRequest removes one request's staged objects (pull.db, push.db —
+// never a backup). Idempotent, like Store.Delete.
+func (d DBStage) DeleteRequest(ctx context.Context, s release.Store, id string) error {
+ if !ValidDBTransferID(id) {
+ return nil
+ }
+ keys, err := s.List(ctx, d.RequestPrefix(id))
+ if err != nil {
+ return err
+ }
+ for _, k := range keys {
+ if err := s.Delete(ctx, k); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// Backups are named by when they were taken, so a plain string sort is a
+// time sort: "20260929-120000-1a2b3c4d". The random tail keeps two backups
+// taken in the same second apart.
+var dbBackupIDRe = regexp.MustCompile(`^[0-9]{8}-[0-9]{6}-[0-9a-f]{8}$`)
+
+// ValidDBBackupID reports whether id has the shape NewDBBackupID mints.
+func ValidDBBackupID(id string) bool { return dbBackupIDRe.MatchString(id) }
+
+// NewDBBackupID mints a backup id for a backup taken at now.
+func NewDBBackupID(now time.Time) string {
+ return now.UTC().Format("20060102-150405") + "-" + NewDBTransferID()[:8]
+}
+
+// DBBackup is one listed backup: what `carlos db backups` prints.
+type DBBackup struct {
+ ID string `json:"id"`
+ Bytes int64 `json:"bytes"`
+ CreatedAt time.Time `json:"created_at"`
+ // Request is the push that took it, so a member can tie a backup to
+ // the push they remember making.
+ Request string `json:"request,omitempty"`
+}
+
+// DBBackupsKept is how many backups the box keeps per instance: after a
+// successful push it prunes everything older than the newest five.
+const DBBackupsKept = 5
+
+// PutBackupMeta writes a backup's sidecar. Create-only, like every staged
+// object: ids are unique, so a second write of one would be a bug.
+func (d DBStage) PutBackupMeta(ctx context.Context, s release.Store, b DBBackup) error {
+ if !ValidDBBackupID(b.ID) {
+ return fmt.Errorf("provision: backup id %q malformed", b.ID)
+ }
+ body, err := json.Marshal(b)
+ if err != nil {
+ return err
+ }
+ _, err = s.Put(ctx, d.BackupMetaKey(b.ID), body, "")
+ return err
+}
+
+// ListBackups lists the instance's backups newest first. A backup whose
+// sidecar is missing or unreadable is listed with what its id says (the
+// time) and zero bytes rather than hidden: the .db is what a restore needs,
+// and the sidecar is bookkeeping.
+func (d DBStage) ListBackups(ctx context.Context, s release.Store) ([]DBBackup, error) {
+ keys, err := s.List(ctx, d.BackupsPrefix())
+ if err != nil {
+ return nil, err
+ }
+ var ids []string
+ for _, k := range keys {
+ rel := strings.TrimPrefix(k, d.BackupsPrefix())
+ id, ok := strings.CutSuffix(rel, ".db")
+ if !ok || !ValidDBBackupID(id) {
+ continue
+ }
+ ids = append(ids, id)
+ }
+ sort.Sort(sort.Reverse(sort.StringSlice(ids)))
+ out := make([]DBBackup, 0, len(ids))
+ for _, id := range ids {
+ b := DBBackup{ID: id}
+ if t, err := time.Parse("20060102-150405", id[:15]); err == nil {
+ b.CreatedAt = t
+ }
+ if raw, _, err := s.Get(ctx, d.BackupMetaKey(id)); err == nil {
+ var meta DBBackup
+ if json.Unmarshal(raw, &meta) == nil {
+ b.Bytes, b.Request = meta.Bytes, meta.Request
+ if !meta.CreatedAt.IsZero() {
+ b.CreatedAt = meta.CreatedAt
+ }
+ }
+ } else if !errors.Is(err, os.ErrNotExist) {
+ return nil, err
+ }
+ out = append(out, b)
+ }
+ return out, nil
+}
+
+// PruneBackups deletes every backup beyond the newest keep. Returns the ids
+// it removed.
+func (d DBStage) PruneBackups(ctx context.Context, s release.Store, keep int) ([]string, error) {
+ all, err := d.ListBackups(ctx, s)
+ if err != nil {
+ return nil, err
+ }
+ var pruned []string
+ for i := keep; i < len(all); i++ {
+ id := all[i].ID
+ if err := s.Delete(ctx, d.BackupKey(id)); err != nil {
+ return pruned, err
+ }
+ if err := s.Delete(ctx, d.BackupMetaKey(id)); err != nil {
+ return pruned, err
+ }
+ pruned = append(pruned, id)
+ }
+ return pruned, nil
+}
+
+// SQLiteMagic is the 16-byte header every SQLite 3 database file starts
+// with; SQLiteMinBytes is the smallest file that can carry one page. The
+// console checks both before it stages an upload, so a stray text file is
+// refused at the door rather than by the box after a downtime window has
+// already opened.
+const (
+ SQLiteMagic = "SQLite format 3\x00"
+ SQLiteMinBytes = 512
+)
+
+// LooksLikeSQLite is that check.
+func LooksLikeSQLite(b []byte) bool {
+ return len(b) >= SQLiteMinBytes && string(b[:len(SQLiteMagic)]) == SQLiteMagic
+}
+
+// DBTransferMaxBytes caps a transfer in either direction: the file is
+// staged through the console and held in memory on both sides of the wire.
+// A var so a test can shrink it; production never writes it.
+var DBTransferMaxBytes int64 = 256 << 20
diff --git a/internal/provision/dbtransfer.go b/internal/provision/dbtransfer.go
new file mode 100644
index 000000000..11c552850
--- /dev/null
+++ b/internal/provision/dbtransfer.go
@@ -0,0 +1,242 @@
+package provision
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "regexp"
+ "strings"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// The database transfer field (`carlos db pull` / `carlos db push`): a
+// member with no box and no bucket access asks the console for a copy of an
+// instance's SQLite database, or hands it a replacement. The console writes
+// a REQUEST onto the record and bumps the serial; the box that owns the
+// route does everything that touches data (internal/host's db worker) and
+// writes the outcome back through the same guarded transitions.
+//
+// The field is additive + omitempty: a record at rest before this existed
+// parses unchanged, and a record whose last transfer is done or failed keeps
+// the field as the answer to "what happened" until the next request or a
+// DELETE replaces it. Written ONLY through SetRecordDB's transitions.
+
+// DBTransfer is the record's database transfer: the last one requested,
+// whatever state it reached. State is one of the DB* constants; Op is
+// DBPull or DBPush; Step is the push worker's resume marker (a push that
+// died mid-way picks up at its last durable milestone rather than at the
+// top).
+type DBTransfer struct {
+ ID string `json:"id"` // random, URL-safe: 16 hex chars
+ Op string `json:"op"` // "pull" | "push"
+ State string `json:"state"` // "requested" | "done" | "failed"
+ // Step is the push's progress marker: "" until the backup is stored,
+ // DBStepBackedUp once it is, DBStepReplaced once the replica carries the
+ // pushed database. A pull never sets it.
+ Step string `json:"step,omitempty"`
+ Error string `json:"error,omitempty"`
+ // Bytes is the size of the staged file (pull: the snapshot the box
+ // uploaded; push: the file the member uploaded).
+ Bytes int64 `json:"bytes,omitempty"`
+ // Backup is the id of the backup the push took before the swap. Empty on
+ // a push of an instance that had no replicated database yet, and on
+ // every pull.
+ Backup string `json:"backup,omitempty"`
+ RequestedAt time.Time `json:"requested_at"`
+ // UpdatedAt is when the state or step last changed — stamped by
+ // SetRecordDB itself, the way SetRecordMove stamps a move.
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+// The ops, states and push steps.
+const (
+ DBPull = "pull"
+ DBPush = "push"
+
+ DBRequested = "requested"
+ DBDone = "done"
+ DBFailed = "failed"
+
+ DBStepBackedUp = "backed-up"
+ DBStepReplaced = "replaced"
+)
+
+// ErrDBConflict is a transition refused because the record's current state
+// does not admit it: a second request while one is in flight, a request
+// while a move is in flight, a completion for an id that is not the one on
+// the record. The console maps it to 409; the box treats it as "someone
+// else already advanced this" and re-reads.
+var ErrDBConflict = errors.New("provision: db transfer state conflict")
+
+// DBInFlight reports whether the record carries a transfer the box has not
+// finished with — the one state that excludes a new request AND a move.
+func (r Record) DBInFlight() bool {
+ return r.DB != nil && r.DB.State == DBRequested
+}
+
+// SetRecordDB applies one guarded transfer transition: the same narrow
+// read-CAS-write with one retry SetRecordMove uses, for the same reason —
+// re-reading and re-applying a transition on a lost race changes nothing
+// about what it meant, and an ILLEGAL transition after the re-read is
+// ErrDBConflict, which is the answer rather than a race.
+func SetRecordDB(ctx context.Context, s release.Store, acct, app, host string, mut func(*Record) error) (*Record, error) {
+ return updateRecord(ctx, s, acct, app, host, func(rec *Record) error {
+ if err := mut(rec); err != nil {
+ return err
+ }
+ if rec.DB != nil {
+ rec.DB.UpdatedAt = time.Now().UTC()
+ }
+ return nil
+ })
+}
+
+// dbTransferIDRe is the id shape: 16 lowercase hex characters, minted by
+// NewDBTransferID. Checked wherever an id arrives from outside (a query
+// string, a path segment) before it is joined into a bucket key.
+var dbTransferIDRe = regexp.MustCompile(`^[0-9a-f]{16}$`)
+
+// ValidDBTransferID reports whether id has the shape NewDBTransferID mints.
+func ValidDBTransferID(id string) bool { return dbTransferIDRe.MatchString(id) }
+
+// NewDBTransferID mints a transfer id: 8 random bytes as hex.
+func NewDBTransferID() string {
+ var b [8]byte
+ if _, err := rand.Read(b[:]); err != nil {
+ panic("provision: crypto/rand failed: " + err.Error())
+ }
+ return hex.EncodeToString(b[:])
+}
+
+// The transitions, each a mut for SetRecordDB. They are the ONLY writers of
+// Record.DB.
+
+// DBRequest starts a transfer. Refused while another transfer is in flight
+// and while a residence move is (the move's cut purges the very database a
+// push would be replacing, and a pull mid-cut could read either lineage).
+// A done or failed transfer is simply replaced: the console deletes its
+// staged objects before writing the new request.
+func DBRequest(op, id string, now time.Time) func(*Record) error {
+ return func(rec *Record) error {
+ if op != DBPull && op != DBPush {
+ return fmt.Errorf("%w: unknown op %q", ErrDBConflict, op)
+ }
+ if !ValidDBTransferID(id) {
+ return fmt.Errorf("%w: malformed transfer id", ErrDBConflict)
+ }
+ if rec.Move != nil {
+ return fmt.Errorf("%w: a move to %s is %s — wait for it to finish or abort it first", ErrDBConflict, rec.Move.To, rec.Move.State)
+ }
+ if rec.DBInFlight() {
+ return fmt.Errorf("%w: a db %s requested %s is still in flight", ErrDBConflict, rec.DB.Op, rec.DB.RequestedAt.UTC().Format(time.RFC3339))
+ }
+ // A replica never gets a route on a platform box, so no box would
+ // ever own a push of one; pulling its replica is fine (the box
+ // side reads the data bucket, which the home's replicator keeps
+ // current). Checked here as well as at the console's door because
+ // records have other writers.
+ if op == DBPush && rec.Role != "" {
+ return fmt.Errorf("%w: a %s holds a one-way mirror — push to the home instance instead", ErrDBConflict, rec.Role)
+ }
+ rec.DB = &DBTransfer{ID: id, Op: op, State: DBRequested, RequestedAt: now.UTC()}
+ return nil
+ }
+}
+
+// dbInFlightFor is the guard every box-side transition shares: the record
+// must carry THIS request, still in flight.
+func dbInFlightFor(rec *Record, id string) error {
+ if rec.DB == nil || rec.DB.ID != id {
+ return fmt.Errorf("%w: the record no longer carries transfer %s", ErrDBConflict, id)
+ }
+ if rec.DB.State != DBRequested {
+ return fmt.Errorf("%w: transfer %s is already %s", ErrDBConflict, id, rec.DB.State)
+ }
+ return nil
+}
+
+// DBBackedUp is the push worker's first milestone: the pre-swap backup is
+// stored under backupID ("" when the instance had nothing to back up).
+// Legal only from the empty step — a resumed push that already recorded it
+// never re-records a second backup over the first.
+func DBBackedUp(id, backupID string) func(*Record) error {
+ return func(rec *Record) error {
+ if err := dbInFlightFor(rec, id); err != nil {
+ return err
+ }
+ if rec.DB.Op != DBPush || rec.DB.Step != "" {
+ return fmt.Errorf("%w: backed-up is only reached from a push's first step", ErrDBConflict)
+ }
+ rec.DB.Step = DBStepBackedUp
+ rec.DB.Backup = backupID
+ return nil
+ }
+}
+
+// DBReplaced is the push worker's second milestone: the replica's lineage
+// IS the pushed database and the warm cache is purged. Legal only from
+// backed-up.
+func DBReplaced(id string) func(*Record) error {
+ return func(rec *Record) error {
+ if err := dbInFlightFor(rec, id); err != nil {
+ return err
+ }
+ if rec.DB.Op != DBPush || rec.DB.Step != DBStepBackedUp {
+ return fmt.Errorf("%w: replaced is only reached from backed-up", ErrDBConflict)
+ }
+ rec.DB.Step = DBStepReplaced
+ return nil
+ }
+}
+
+// DBComplete marks a transfer done. bytes is the staged file's size (a pull
+// reports the snapshot it uploaded; a push carries the size the console
+// recorded at upload, so the box passes it back through).
+func DBComplete(id string, bytes int64) func(*Record) error {
+ return func(rec *Record) error {
+ if err := dbInFlightFor(rec, id); err != nil {
+ return err
+ }
+ rec.DB.State = DBDone
+ rec.DB.Error = ""
+ if bytes > 0 {
+ rec.DB.Bytes = bytes
+ }
+ return nil
+ }
+}
+
+// DBFail marks a transfer failed with the sentence the member will read.
+func DBFail(id, msg string) func(*Record) error {
+ return func(rec *Record) error {
+ if err := dbInFlightFor(rec, id); err != nil {
+ return err
+ }
+ rec.DB.State = DBFailed
+ rec.DB.Error = strings.TrimSpace(msg)
+ if rec.DB.Error == "" {
+ rec.DB.Error = "failed"
+ }
+ return nil
+ }
+}
+
+// DBClear removes a done or failed transfer from the record — the console's
+// DELETE, after it has deleted that request's staged objects. Refused while
+// the transfer is in flight: the box is still writing to it.
+func DBClear() func(*Record) error {
+ return func(rec *Record) error {
+ if rec.DB == nil {
+ return nil
+ }
+ if rec.DB.State == DBRequested {
+ return fmt.Errorf("%w: a db %s is still in flight — wait for it to finish", ErrDBConflict, rec.DB.Op)
+ }
+ rec.DB = nil
+ return nil
+ }
+}
diff --git a/internal/provision/dbtransfer_test.go b/internal/provision/dbtransfer_test.go
new file mode 100644
index 000000000..f41c8dff5
--- /dev/null
+++ b/internal/provision/dbtransfer_test.go
@@ -0,0 +1,299 @@
+package provision
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// The transfer transitions, as a table over the record's state: which mut
+// is legal where, and what it leaves behind. Every 409 the console answers
+// and every "someone else advanced this" the box sees is a row here.
+func TestSetRecordDBTransitions(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ rec := Record{Schema: 1, Host: "a.go.example.com", Slug: "a", App: "app", Account: "bab",
+ Region: "ie", Channel: "stable", CreatedAt: time.Now().UTC()}
+ if err := CreateRecord(ctx, s, rec); err != nil {
+ t.Fatal(err)
+ }
+ apply := func(mut func(*Record) error) error {
+ _, err := SetRecordDB(ctx, s, "bab", "app", "a.go.example.com", mut)
+ return err
+ }
+ read := func() Record {
+ t.Helper()
+ got, err := GetRecord(ctx, s, "bab", "app", "a.go.example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ return *got
+ }
+ now := time.Now()
+ pull, push := NewDBTransferID(), NewDBTransferID()
+
+ // Nothing in flight: the box-side transitions all refuse.
+ for name, mut := range map[string]func(*Record) error{
+ "backed-up": DBBackedUp(push, "x"),
+ "replaced": DBReplaced(push),
+ "complete": DBComplete(pull, 1),
+ "fail": DBFail(pull, "x"),
+ } {
+ if err := apply(mut); !errors.Is(err, ErrDBConflict) {
+ t.Fatalf("%s with nothing in flight = %v, want ErrDBConflict", name, err)
+ }
+ }
+ // Clear with nothing there is a no-op, not a conflict.
+ if err := apply(DBClear()); err != nil {
+ t.Fatal(err)
+ }
+ // A malformed op or id never lands.
+ if err := apply(DBRequest("sync", pull, now)); !errors.Is(err, ErrDBConflict) {
+ t.Fatalf("unknown op = %v", err)
+ }
+ if err := apply(DBRequest(DBPull, "../x", now)); !errors.Is(err, ErrDBConflict) {
+ t.Fatalf("malformed id = %v", err)
+ }
+
+ // Pull: request → done.
+ if err := apply(DBRequest(DBPull, pull, now)); err != nil {
+ t.Fatal(err)
+ }
+ got := read()
+ if got.DB == nil || got.DB.ID != pull || got.DB.Op != DBPull || got.DB.State != DBRequested || got.DB.UpdatedAt.IsZero() || !got.DBInFlight() {
+ t.Fatalf("after pull request: %+v", got.DB)
+ }
+ if err := apply(DBRequest(DBPush, push, now)); !errors.Is(err, ErrDBConflict) {
+ t.Fatalf("second request while in flight = %v, want ErrDBConflict", err)
+ }
+ if err := apply(DBClear()); !errors.Is(err, ErrDBConflict) {
+ t.Fatalf("clear while in flight = %v, want ErrDBConflict", err)
+ }
+ if err := apply(MoveRequest("uk", now)); !errors.Is(err, ErrMoveConflict) {
+ t.Fatalf("move while a db op is in flight = %v, want ErrMoveConflict", err)
+ }
+ // The push milestones do not apply to a pull, and a wrong id is refused.
+ if err := apply(DBBackedUp(pull, "b")); !errors.Is(err, ErrDBConflict) {
+ t.Fatalf("backed-up on a pull = %v", err)
+ }
+ if err := apply(DBComplete(push, 1)); !errors.Is(err, ErrDBConflict) {
+ t.Fatalf("complete with the wrong id = %v", err)
+ }
+ if err := apply(DBComplete(pull, 4096)); err != nil {
+ t.Fatal(err)
+ }
+ if got = read(); got.DB.State != DBDone || got.DB.Bytes != 4096 || got.DBInFlight() {
+ t.Fatalf("after complete: %+v", got.DB)
+ }
+ if err := apply(DBComplete(pull, 1)); !errors.Is(err, ErrDBConflict) {
+ t.Fatalf("double complete = %v, want ErrDBConflict", err)
+ }
+
+ // A done transfer is replaced by a new request. Push: the milestones
+ // in order, each refusing to be skipped or repeated.
+ if err := apply(DBRequest(DBPush, push, now)); err != nil {
+ t.Fatal(err)
+ }
+ if err := apply(DBReplaced(push)); !errors.Is(err, ErrDBConflict) {
+ t.Fatalf("replaced before backed-up = %v", err)
+ }
+ if err := apply(DBBackedUp(push, "20260929-120000-0badf00d")); err != nil {
+ t.Fatal(err)
+ }
+ if got = read(); got.DB.Step != DBStepBackedUp || got.DB.Backup != "20260929-120000-0badf00d" {
+ t.Fatalf("after backed-up: %+v", got.DB)
+ }
+ if err := apply(DBBackedUp(push, "other")); !errors.Is(err, ErrDBConflict) {
+ t.Fatalf("second backed-up = %v, want ErrDBConflict (never a second backup over the first)", err)
+ }
+ if err := apply(DBReplaced(push)); err != nil {
+ t.Fatal(err)
+ }
+ if err := apply(DBReplaced(push)); !errors.Is(err, ErrDBConflict) {
+ t.Fatalf("second replaced = %v", err)
+ }
+ if err := apply(DBFail(push, " the upload is not a SQLite database ")); err != nil {
+ t.Fatal(err)
+ }
+ if got = read(); got.DB.State != DBFailed || got.DB.Error != "the upload is not a SQLite database" || got.DB.Backup == "" {
+ t.Fatalf("after fail: %+v (the backup id must survive a failure)", got.DB)
+ }
+ // A failed transfer clears, and a move is legal again.
+ if err := apply(DBClear()); err != nil {
+ t.Fatal(err)
+ }
+ if got = read(); got.DB != nil {
+ t.Fatalf("after clear: %+v", got.DB)
+ }
+ if err := apply(MoveRequest("uk", now)); err != nil {
+ t.Fatal(err)
+ }
+ // And with a move in flight, no transfer starts.
+ if err := apply(DBRequest(DBPull, NewDBTransferID(), now)); !errors.Is(err, ErrDBConflict) || !strings.Contains(err.Error(), "move") {
+ t.Fatalf("request during a move = %v, want ErrDBConflict naming the move", err)
+ }
+}
+
+// A replica may be pulled (its mirror is real data) but never pushed (the
+// home would overwrite it on the next batch, and a box never owns it).
+func TestDBRequestReplicaRail(t *testing.T) {
+ rec := &Record{Region: "uk", Role: "replica"}
+ if err := DBRequest(DBPush, NewDBTransferID(), time.Now())(rec); !errors.Is(err, ErrDBConflict) || rec.DB != nil {
+ t.Fatalf("push to a replica = %v, db %+v; want ErrDBConflict and no request", err, rec.DB)
+ }
+ if err := DBRequest(DBPull, NewDBTransferID(), time.Now())(rec); err != nil || rec.DB == nil {
+ t.Fatalf("pull of a replica = %v, db %+v; want accepted", err, rec.DB)
+ }
+}
+
+// A record written before the field existed round-trips without growing a
+// db key — the wire-shape rule.
+func TestDBFieldAbsentOnPreTransferRecords(t *testing.T) {
+ raw := []byte(`{"schema":1,"host":"a.go.example.com","slug":"a","app":"app","account":"bab","region":"ie","channel":"stable","created_at":"2026-08-01T00:00:00Z"}`)
+ var rec Record
+ if err := json.Unmarshal(raw, &rec); err != nil {
+ t.Fatal(err)
+ }
+ if rec.DB != nil {
+ t.Fatalf("pre-transfer record decoded a DB: %+v", rec.DB)
+ }
+ out, err := json.Marshal(rec)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if strings.Contains(string(out), `"db"`) {
+ t.Fatalf("re-marshal grew a db key: %s", out)
+ }
+ // And a record carrying one keeps it through an unrelated writer.
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ rec.CreatedAt = time.Now().UTC()
+ if err := CreateRecord(ctx, s, rec); err != nil {
+ t.Fatal(err)
+ }
+ id := NewDBTransferID()
+ if _, err := SetRecordDB(ctx, s, "bab", "app", "a.go.example.com", DBRequest(DBPull, id, time.Now())); err != nil {
+ t.Fatal(err)
+ }
+ if err := SetRecordRegion(ctx, s, "bab", "app", "a.go.example.com", "se"); err != nil {
+ t.Fatal(err)
+ }
+ got, err := GetRecord(ctx, s, "bab", "app", "a.go.example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.DB == nil || got.DB.ID != id || got.Region != "se" {
+ t.Fatalf("an unrelated writer dropped the transfer: %+v region=%q", got.DB, got.Region)
+ }
+}
+
+// The staging keys: shape-checked coordinates, request objects deleted as a
+// unit, backups listed newest first and pruned to the kept count.
+func TestDBStageKeysAndBackups(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ if _, err := NewDBStage("bab", "app", "../x"); err == nil {
+ t.Fatal("a traversal host built a stage")
+ }
+ if _, err := NewDBStage("", "app", "a.go.example.com"); err == nil {
+ t.Fatal("an empty account built a stage")
+ }
+ st, err := NewDBStage("bab", "app", "a.go.example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ id := NewDBTransferID()
+ if got, want := st.PullKey(id), "apps/bab/app/db/a.go.example.com/"+id+"/pull.db"; got != want {
+ t.Fatalf("pull key = %s, want %s", got, want)
+ }
+ if got, want := st.PushKey(id), "apps/bab/app/db/a.go.example.com/"+id+"/push.db"; got != want {
+ t.Fatalf("push key = %s, want %s", got, want)
+ }
+ if !strings.HasPrefix(st.RequestPrefix("../../x"), st.Prefix()+"invalid/") {
+ t.Fatalf("a malformed id escaped the stage: %s", st.RequestPrefix("../../x"))
+ }
+ // A record key never parses as one of ours: db/ is a sibling of
+ // instances/, so the record walker cannot mistake a staged file.
+ if _, _, _, ok := ParseRecordKey(st.PullKey(id), map[string]bool{"bab": true}); ok {
+ t.Fatal("a staged database parsed as an instance record")
+ }
+ if _, err := s.Put(ctx, st.PullKey(id), []byte("x"), ""); err != nil {
+ t.Fatal(err)
+ }
+ if err := st.DeleteRequest(ctx, s, id); err != nil {
+ t.Fatal(err)
+ }
+ if keys, _ := s.List(ctx, st.Prefix()); len(keys) != 0 {
+ t.Fatalf("request objects survived the delete: %v", keys)
+ }
+ if err := st.DeleteRequest(ctx, s, "nope"); err != nil {
+ t.Fatalf("delete of a malformed id = %v, want a no-op", err)
+ }
+
+ // Backups.
+ base := time.Date(2026, 9, 29, 12, 0, 0, 0, time.UTC)
+ var ids []string
+ for i := 0; i < 7; i++ {
+ bid := NewDBBackupID(base.Add(time.Duration(i) * time.Minute))
+ if !ValidDBBackupID(bid) {
+ t.Fatalf("minted an invalid backup id %q", bid)
+ }
+ ids = append(ids, bid)
+ if _, err := s.Put(ctx, st.BackupKey(bid), []byte("db"), ""); err != nil {
+ t.Fatal(err)
+ }
+ if i != 3 { // one backup with no sidecar still lists
+ if err := st.PutBackupMeta(ctx, s, DBBackup{ID: bid, Bytes: int64(100 + i), CreatedAt: base.Add(time.Duration(i) * time.Minute), Request: id}); err != nil {
+ t.Fatal(err)
+ }
+ }
+ }
+ list, err := st.ListBackups(ctx, s)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(list) != 7 || list[0].ID != ids[6] || list[6].ID != ids[0] {
+ t.Fatalf("backups not newest first: %+v", list)
+ }
+ if list[0].Bytes != 106 || list[0].Request != id || !list[0].CreatedAt.Equal(base.Add(6*time.Minute)) {
+ t.Fatalf("newest backup meta: %+v", list[0])
+ }
+ if list[3].ID != ids[3] || list[3].Bytes != 0 || list[3].CreatedAt.IsZero() {
+ t.Fatalf("sidecar-less backup: %+v (want listed, zero bytes, time from the id)", list[3])
+ }
+ pruned, err := st.PruneBackups(ctx, s, DBBackupsKept)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(pruned) != 2 || pruned[0] != ids[1] || pruned[1] != ids[0] {
+ t.Fatalf("pruned %v, want the two oldest", pruned)
+ }
+ list, _ = st.ListBackups(ctx, s)
+ if len(list) != 5 || list[4].ID != ids[2] {
+ t.Fatalf("after prune: %+v", list)
+ }
+ if keys, _ := s.List(ctx, st.BackupsPrefix()); len(keys) != 9 { // 5 db + 4 sidecars (ids[3] has none)
+ t.Fatalf("objects after prune: %v", keys)
+ }
+}
+
+func TestLooksLikeSQLite(t *testing.T) {
+ good := make([]byte, SQLiteMinBytes)
+ copy(good, SQLiteMagic)
+ if !LooksLikeSQLite(good) {
+ t.Fatal("a minimal header was refused")
+ }
+ if LooksLikeSQLite(good[:100]) {
+ t.Fatal("a short file passed")
+ }
+ bad := make([]byte, 4096)
+ copy(bad, "SQLite format 4\x00")
+ if LooksLikeSQLite(bad) {
+ t.Fatal("a wrong magic passed")
+ }
+}
diff --git a/internal/provision/provision.go b/internal/provision/provision.go
index 2b399c424..cbfe8ed04 100644
--- a/internal/provision/provision.go
+++ b/internal/provision/provision.go
@@ -57,6 +57,14 @@ type Record struct {
// unchanged. Written ONLY through SetRecordMove's guarded transitions.
Move *MoveState `json:"move,omitempty"`
+ // DB is the instance's last database transfer (`carlos db pull` /
+ // `carlos db push`, dbtransfer.go): requested by the console, carried
+ // out by the box that owns the route, and left on the record as done
+ // or failed until the next request or a DELETE clears it. Additive +
+ // omitempty — pre-transfer records parse unchanged. Written ONLY
+ // through SetRecordDB's guarded transitions.
+ DB *DBTransfer `json:"db,omitempty"`
+
// --- shape fields (2026-08-10 route-records design §3) --------------
// Every one is omitempty with a zero value that reproduces the mint
// reconcile hardcoded before they existed: a record written by an older
@@ -971,6 +979,13 @@ func MoveRequest(to string, now time.Time) func(*Record) error {
if rec.Region == to {
return fmt.Errorf("%w: the instance is already in %s", ErrMoveConflict, to)
}
+ // A database transfer in flight holds the route out of service
+ // (push) or is reading the replica the cut would purge (pull);
+ // the two never overlap. Refused in the transition, not only at
+ // the console's door, because records have other writers.
+ if rec.DBInFlight() {
+ return fmt.Errorf("%w: a db %s is in flight — wait for it to finish", ErrMoveConflict, rec.DB.Op)
+ }
// A replica never gets a route on a platform box (reconcile's mint
// gate), so no box owns its move and it would wedge in preparing
// (G14). Checked here as well as at the console's door because
diff --git a/main.go b/main.go
index b3b52dff7..169f5f082 100644
--- a/main.go
+++ b/main.go
@@ -73,6 +73,8 @@ func main() {
runDeploy(args)
case "restart":
runRestart(args)
+ case "db":
+ runDB(args)
case "steering":
runSteering(args)
case "schedule":
@@ -197,6 +199,9 @@ Your apps — ship and run software on a CARLOS deployment
nothing the app already serves changes: deploy | ls | rm
restart cycle an app's instances — no new version, no config change;
--host or --environment narrows it, --dry-run names the set
+ db copy an instance's SQLite database down (pull) or a
+ replacement up (push, with a backup and a confirmation);
+ status | backups read what those left behind
schedule recurring or one-shot work per app: ls | set | rm | run
store object storage: create | status | apply | approvals | approve | decline | grant | rotate
relay the platform STUN/TURN relay: serve (what the unit runs)