carlos db: pull and query a restored replica, read-only
carlos db pull --app <a> [--instance <h>] --out <path> restores the instance's replica through the console door into a private 0700 directory and copies it to --out: required, refused if it exists, written 0600, copied rather than renamed so a different filesystem works, and nothing left behind on failure. carlos db query --sql runs one query against a fresh copy opened with mode=ro and query_only, so a write fails with SQLite's own 'attempt to write a readonly database' and the file is unchanged. Rows go to stdout as a header and tab-separated lines; the copy-and-as-of line goes to stderr. Both name the copy's newest transaction and its time, and --instance may be left out only when the app has one. An end-to-end test drives both verbs through a real console reading a fake S3 that holds a real litestream replica, and checks nothing but this instance's LTX files is ever asked of the bucket. User-facing wording is placeholder pending copy review. doStream (auth.go) and the instance-resolution shape are Tony Wood's, from feat/db-push-pull.
Co-Authored-By: Tony Wood <tony@tctl.co.uk> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
7 files changed,
+948
−0
apimode.go+12 −0auth.go+38 −0db.go+351 −0db_e2e_test.go+249 −0db_test.go+264 −0docs/site/cli.md+30 −0main.go+4 −0
diff --git a/apimode.go b/apimode.go| index 1e13c746e..85c63470d 100644 |
| --- a/apimode.go |
| +++ b/apimode.go |
| @@ -37,6 +37,7 @@ import ( |
| "github.com/carlosframework/platform/internal/apperr" |
| "github.com/carlosframework/platform/internal/applog" |
| "github.com/carlosframework/platform/internal/caps" |
| + "github.com/carlosframework/platform/internal/dbdoor" |
| "github.com/carlosframework/platform/internal/ledger" |
| "github.com/carlosframework/platform/internal/objstore" |
| "github.com/carlosframework/platform/internal/schedule" |
| @@ -1055,6 +1056,17 @@ type apiRestartResult struct { |
| App string `json:"app"` |
| } |
| +// dbClient is the `carlos db` replica client for one instance (db.go): a |
| +// read-only litestream.ReplicaClient whose every request is a GET through |
| +// this session's console, as this session's person. The account segment |
| +// and its ?exact=sqid switch ride on apiPath, exactly as every other verb's |
| +// do; the client itself only ever names a coordinate. |
| +func (s *apiSession) dbClient(a apiAcct, app, host string) *dbdoor.Client { |
| + return dbdoor.NewClient(func(ctx context.Context, tail string, h http.Header) (*http.Response, error) { |
| + return s.c.doStream(ctx, http.MethodGet, apiPath(a, app, "/db/"+url.PathEscape(host)+tail), nil, -1, h) |
| + }) |
| +} |
| + |
| // restart asks the console to request a process cycle for app on every box |
| // that serves it (issue #113). Mirrors trashApp's shape — postJSON, a 200 |
| // decodes, anything else is apiErrorFromBody's job — with ONE addition, the |
diff --git a/auth.go b/auth.go| index 3e282cf12..e0b29a9d8 100644 |
| --- a/auth.go |
| +++ b/auth.go |
| @@ -535,6 +535,44 @@ func (c *consoleClient) doMultipartMethod(ctx context.Context, method, path stri |
| return res.StatusCode, body, nil |
| } |
| +// doStream runs one request whose response body is a STREAM — `carlos db` |
| +// reading a replica file down — and hands the response back unread, so the |
| +// caller copies the body where it is going without holding it whole. |
| +// header carries the caller's extras (a Range) on top of the bearer. |
| +// |
| +// Same client shape as doMultipart, for the same reason: no absolute |
| +// Timeout (the caller's context is the budget, and a large file over a slow |
| +// link outlives the JSON client's 30s), and no redirects. A non-2xx status |
| +// is NOT an error here: the caller reads the body itself. The caller closes |
| +// the response body. |
| +func (c *consoleClient) doStream(ctx context.Context, method, path string, body io.Reader, contentLength int64, header http.Header) (*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, vs := range header { |
| + for _, v := range vs { |
| + req.Header.Add(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.go b/db.go| new file mode 100644 |
| index 000000000..f3f3f11b6 |
| --- /dev/null |
| +++ b/db.go |
| @@ -0,0 +1,351 @@ |
| +// db.go is `carlos db`: a member reads an app's database from its replica, |
| +// with no box access and no AWS credentials (spec 2026-09-02). |
| +// |
| +// It never connects to the running database. The console serves the |
| +// replica's LTX files by coordinate (internal/console/apidb.go); this side |
| +// restores them, with litestream's own restore and a client that has no |
| +// write methods (internal/dbdoor), into a private 0700 directory, proves the |
| +// result with lease.CheckDB, and then either copies it to --out (pull) or |
| +// opens it read-only and runs one query (query). The console never executes |
| +// tenant SQL and never holds a restored database. |
| +// |
| +// Read-only is enforced, not assumed (spec §4): mode=ro and query_only. A |
| +// copy is harmless to write to; the point is that nobody, person or agent, |
| +// can come away believing a write reached production. |
| +// |
| +// Unit 1 of three (spec §13): ls and its freshness fields, then shell, |
| +// follow. |
| +package main |
| + |
| +import ( |
| + "context" |
| + "database/sql" |
| + "errors" |
| + "flag" |
| + "fmt" |
| + "io" |
| + "log" |
| + "os" |
| + "os/signal" |
| + "sort" |
| + "strings" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/dbdoor" |
| +) |
| + |
| +const dbUsage = `usage: carlos db <verb> --app <a> [--instance <host>] [flags] |
| + |
| + pull --out <path> restore a copy of the instance's database and save it |
| + at <path>, which must not exist yet (saved 0600) |
| + query --sql <text> restore a copy and run one read-only query against it; |
| + rows go to stdout, tab-separated, with a header row |
| + |
| +Both read the platform's replica of the database, never the live one, and |
| +nothing you do to the copy reaches the instance. --instance names which of |
| +the app's instances to read; it may be left out when the app has one. The |
| +usual --account / --account-id / --console / --destination apply. |
| +` |
| + |
| +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 "query": |
| + runDBQuery(args[1:]) |
| + case "-h", "--help", "help": |
| + fmt.Fprint(stdout, dbUsage) |
| + default: |
| + fmt.Fprintf(os.Stderr, "carlos db: unknown verb %q\n%s", args[0], dbUsage) |
| + os.Exit(2) |
| + } |
| +} |
| + |
| +// dbTarget is what both verbs resolve first: the session, the account, the |
| +// app and the one instance the verb reads. |
| +type dbTarget struct { |
| + sess *apiSession |
| + acct apiAcct |
| + app string |
| + host string |
| +} |
| + |
| +// dbFlags registers the flags both verbs share and returns the resolver. |
| +// Status lines go to stderr: query's stdout is its rows, and must pipe |
| +// cleanly into whatever reads them. |
| +func dbFlags(fs *flag.FlagSet, verb string) func() dbTarget { |
| + app := appFlag(fs, "app whose database to read") |
| + instance := fs.String("instance", "", "the instance whose database to read (may be left out when the app has one)") |
| + acctRaw, acctIDRaw, _ := accountFlags(fs) |
| + consoleFlag := consoleOption(fs, "console to read through (default: the only logged-in one)") |
| + return func() dbTarget { |
| + sess := requireDBAPISession(*consoleFlag) |
| + printUsing(os.Stderr, sess) |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| + name := resolvedApp(sess, acct, strings.TrimSpace(*app), dbUsage) |
| + host, err := resolveDBInstance(context.Background(), sess, acct, name, strings.TrimSpace(*instance)) |
| + if err != nil { |
| + log.Fatalf("carlos db %s: %v", verb, err) |
| + } |
| + return dbTarget{sess: sess, acct: acct, app: name, host: host} |
| + } |
| +} |
| + |
| +// requireDBAPISession resolves signed-in mode, refusing direct bucket mode |
| +// by name: the door is the console's, and an operator holding the bucket |
| +// already has a 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 " + |
| + "(CARLOS_DEPLOYMENT_BUCKET or CARLOS_DEPLOYMENT_DIR is set). Unset it and run: carlos auth login --console <url>.") |
| + } |
| + return sess |
| +} |
| + |
| +// resolveDBInstance is --instance, or the app's only instance when it has |
| +// exactly one. Several is a refusal naming them: guessing would hand |
| +// someone the wrong data while telling them it is the right data (spec §7). |
| +func resolveDBInstance(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("could not list the app's instances to pick one: %w", err) |
| + } |
| + if len(recs) == 1 && !truncated { |
| + return recs[0].Host, nil |
| + } |
| + if len(recs) == 0 { |
| + return "", fmt.Errorf("%s has no instances, so there is no database to read", app) |
| + } |
| + hosts := make([]string, 0, len(recs)) |
| + for _, r := range recs { |
| + hosts = append(hosts, r.Host) |
| + } |
| + sort.Strings(hosts) |
| + more := "" |
| + if truncated { |
| + more = ", and more" |
| + } |
| + return "", fmt.Errorf("%s has %d instances. Name one with --instance: %s%s", app, len(recs), strings.Join(hosts, ", "), more) |
| +} |
| + |
| +// dbRestore restores the target's replica into a private directory under |
| +// the system temp dir. The caller removes Restored.Dir. |
| +func dbRestore(ctx context.Context, t dbTarget) (*dbdoor.Restored, error) { |
| + r, err := dbdoor.RestoreTo(ctx, t.sess.dbClient(t.acct, t.app, t.host), "") |
| + if err != nil { |
| + return nil, dbRestoreError(t.host, err) |
| + } |
| + return r, nil |
| +} |
| + |
| +// dbRestoreError turns a failed restore into the sentence a member needs. |
| +// A refusal from the door carries the console's own words; the others are |
| +// named here so none of them reads as a transient "no replica found". |
| +func dbRestoreError(host string, err error) error { |
| + var door *dbdoor.DoorError |
| + switch { |
| + case errors.As(err, &door) && door.Status == 404: |
| + return fmt.Errorf("no instance %s that you can read", host) |
| + case errors.As(err, &door): |
| + return errors.New(door.Message) |
| + case errors.Is(err, dbdoor.ErrNothingReplicated): |
| + return fmt.Errorf("the platform has no copy of %s's database yet. Nothing has been replicated. "+ |
| + "If the app replicates its own database (replicate: \"app\"), the platform never will", host) |
| + case errors.Is(err, dbdoor.ErrCheckFailed): |
| + return fmt.Errorf("the copy of %s's database failed its integrity check, so it was not kept: %v", host, err) |
| + case errors.Is(err, os.ErrNotExist): |
| + return fmt.Errorf("the replica of %s changed while it was being read, twice. Try again", host) |
| + case errors.Is(err, context.Canceled): |
| + return errors.New("stopped") |
| + } |
| + return fmt.Errorf("could not restore %s's database: %w", host, err) |
| +} |
| + |
| +// dbAsOf renders the copy's newest transaction and its time (R1). |
| +func dbAsOf(r *dbdoor.Restored) string { |
| + if r.At.IsZero() { |
| + return fmt.Sprintf("transaction %d", r.TXID) |
| + } |
| + return fmt.Sprintf("transaction %d, written %s", r.TXID, r.At.UTC().Format("2006-01-02 15:04:05 UTC")) |
| +} |
| + |
| +func runDBPull(args []string) { |
| + fs := flag.NewFlagSet("db pull", flag.ExitOnError) |
| + target := dbFlags(fs, "pull") |
| + out := fs.String("out", "", "where to save the copy; must not exist yet") |
| + fs.Usage = flagUsage(fs, dbUsage) |
| + fs.Parse(args) |
| + if strings.TrimSpace(*out) == "" { |
| + fmt.Fprint(os.Stderr, "carlos db pull: --out is required: name a path for the copy\n\n"+dbUsage) |
| + os.Exit(2) |
| + } |
| + if _, err := os.Lstat(*out); err == nil { |
| + fmt.Fprintf(os.Stderr, "carlos db pull: %s already exists. Choose a new path for --out\n", *out) |
| + os.Exit(2) |
| + } |
| + t := target() |
| + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) |
| + defer stop() |
| + if err := dbPull(ctx, t, *out); err != nil { |
| + stop() |
| + log.Fatalf("carlos db pull: %v", err) |
| + } |
| +} |
| + |
| +// dbPull restores, copies to out, and removes the private directory on |
| +// every path. It returns rather than exits so that cleanup always runs. |
| +func dbPull(ctx context.Context, t dbTarget, out string) error { |
| + r, err := dbRestore(ctx, t) |
| + if err != nil { |
| + return err |
| + } |
| + defer os.RemoveAll(r.Dir) |
| + if err := copyOut(r.Path, out); err != nil { |
| + return err |
| + } |
| + fmt.Fprintf(stdout, "Saved a copy of %s's database to %s.\n", t.host, out) |
| + fmt.Fprintf(stdout, "It holds every change up to %s.\n", dbAsOf(r)) |
| + fmt.Fprintf(stdout, "It is a copy. Nothing you do to it reaches %s.\n", t.host) |
| + return nil |
| +} |
| + |
| +// copyOut copies the restored database to out, 0600, refusing an existing |
| +// out. A copy and never os.Rename: the temp directory and the user's |
| +// destination are routinely on different filesystems, where a rename |
| +// fails. A failed copy leaves nothing behind at out. |
| +func copyOut(src, out string) (err error) { |
| + in, err := os.Open(src) |
| + if err != nil { |
| + return err |
| + } |
| + defer in.Close() |
| + f, err := os.OpenFile(out, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) |
| + if err != nil { |
| + if errors.Is(err, os.ErrExist) { |
| + return fmt.Errorf("%s already exists. Choose a new path for --out", out) |
| + } |
| + return err |
| + } |
| + defer func() { |
| + if err != nil { |
| + f.Close() |
| + os.Remove(out) |
| + } |
| + }() |
| + // The mode passed to OpenFile is filtered by the umask; 0600 is what |
| + // the spec promises, so it is set, not hoped for. |
| + if err = f.Chmod(0o600); err != nil { |
| + return err |
| + } |
| + if _, err = io.Copy(f, in); err != nil { |
| + return err |
| + } |
| + if err = f.Sync(); err != nil { |
| + return err |
| + } |
| + return f.Close() |
| +} |
| + |
| +func runDBQuery(args []string) { |
| + fs := flag.NewFlagSet("db query", flag.ExitOnError) |
| + target := dbFlags(fs, "query") |
| + sqlText := fs.String("sql", "", "the query to run") |
| + fs.Usage = flagUsage(fs, dbUsage) |
| + fs.Parse(args) |
| + if strings.TrimSpace(*sqlText) == "" { |
| + fmt.Fprint(os.Stderr, "carlos db query: --sql is required\n\n"+dbUsage) |
| + os.Exit(2) |
| + } |
| + t := target() |
| + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) |
| + defer stop() |
| + if err := dbQuery(ctx, t, *sqlText); err != nil { |
| + stop() |
| + log.Fatalf("carlos db query: %v", err) |
| + } |
| +} |
| + |
| +func dbQuery(ctx context.Context, t dbTarget, sqlText string) error { |
| + r, err := dbRestore(ctx, t) |
| + if err != nil { |
| + return err |
| + } |
| + defer os.RemoveAll(r.Dir) |
| + fmt.Fprintf(os.Stderr, "Querying a copy of %s's database, up to %s. Nothing you run here reaches %s.\n", |
| + t.host, dbAsOf(r), t.host) |
| + return runQueryContext(ctx, stdout, r.Path, sqlText) |
| +} |
| + |
| +func runQuery(w io.Writer, path, sqlText string) error { |
| + return runQueryContext(context.Background(), w, path, sqlText) |
| +} |
| + |
| +// runQueryContext opens path read-only, both ways (mode=ro and |
| +// query_only, spec §4), runs sqlText, and writes a header row then one |
| +// tab-separated line per row. A write fails with SQLite's own "attempt to |
| +// write a readonly database", returned as it is: the error is the truth. |
| +func runQueryContext(ctx context.Context, w io.Writer, path, sqlText string) error { |
| + db, err := sql.Open("sqlite", "file:"+path+"?mode=ro&_pragma=query_only(1)&_pragma=busy_timeout(5000)") |
| + if err != nil { |
| + return err |
| + } |
| + defer db.Close() |
| + db.SetMaxOpenConns(1) |
| + rows, err := db.QueryContext(ctx, sqlText) |
| + if err != nil { |
| + return err |
| + } |
| + defer rows.Close() |
| + cols, err := rows.Columns() |
| + if err != nil { |
| + return err |
| + } |
| + if len(cols) > 0 { |
| + fmt.Fprintln(w, strings.Join(cols, "\t")) |
| + } |
| + vals := make([]any, len(cols)) |
| + ptrs := make([]any, len(cols)) |
| + for i := range vals { |
| + ptrs[i] = &vals[i] |
| + } |
| + line := make([]string, len(cols)) |
| + for rows.Next() { |
| + if err := rows.Scan(ptrs...); err != nil { |
| + return err |
| + } |
| + for i, v := range vals { |
| + line[i] = dbQueryCell(v) |
| + } |
| + fmt.Fprintln(w, strings.Join(line, "\t")) |
| + } |
| + return rows.Err() |
| +} |
| + |
| +// dbQueryCell renders one value. NULL is spelled out so it cannot be mistaken |
| +// for an empty string; tabs and newlines inside a value are escaped so a |
| +// row stays one line. |
| +func dbQueryCell(v any) string { |
| + var s string |
| + switch x := v.(type) { |
| + case nil: |
| + return "NULL" |
| + case []byte: |
| + s = string(x) |
| + case time.Time: |
| + s = x.UTC().Format(time.RFC3339Nano) |
| + default: |
| + s = fmt.Sprint(x) |
| + } |
| + return strings.NewReplacer("\\", `\\`, "\t", `\t`, "\n", `\n`, "\r", `\r`).Replace(s) |
| +} |
diff --git a/db_e2e_test.go b/db_e2e_test.go| new file mode 100644 |
| index 000000000..29fad228b |
| --- /dev/null |
| +++ b/db_e2e_test.go |
| @@ -0,0 +1,249 @@ |
| +// End-to-end acceptance for `carlos db` (spec 2026-09-02): a REAL |
| +// console.Server whose door reads a fake S3 holding a real litestream |
| +// replica, through the production replica client (lease.ReadReplicaClient |
| +// with CARLOS_S3_ENDPOINT), a device-flow login, then the real runDB verbs |
| +// driven in-process. The member's whole path, with no box access and no |
| +// AWS credentials of their own. TestE2ELogsThroughConsole's shape. |
| +package main |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "encoding/json" |
| + "encoding/xml" |
| + "io/fs" |
| + "net/http" |
| + "net/http/httptest" |
| + "os" |
| + "path" |
| + "path/filepath" |
| + "strconv" |
| + "strings" |
| + "sync" |
| + "testing" |
| + "time" |
| + |
| + "github.com/superfly/ltx" |
| + |
| + "github.com/carlosframework/platform/internal/console" |
| + "github.com/carlosframework/platform/internal/dbdoor/dbdoortest" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// replicaS3 is just enough S3 for litestream's read path (ListObjectsV2 and |
| +// a ranged GetObject), serving file replicas under root in the S3 key |
| +// layout <prefix>/<level %04x>/<file>.ltx. It records every key asked for, |
| +// so a test can assert nothing outside the coordinates was ever requested. |
| +type replicaS3 struct { |
| + t *testing.T |
| + bucket string |
| + root string |
| + mu sync.Mutex |
| + keys []string |
| +} |
| + |
| +func (s *replicaS3) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| + rest := strings.TrimPrefix(r.URL.Path, "/"+s.bucket) |
| + if r.Method != http.MethodGet { |
| + s.t.Errorf("fake S3: %s %s; the door must only read", r.Method, r.URL.Path) |
| + w.WriteHeader(http.StatusForbidden) |
| + return |
| + } |
| + if rest == "" || rest == "/" { |
| + s.list(w, r.URL.Query().Get("prefix")) |
| + return |
| + } |
| + key := strings.TrimPrefix(rest, "/") |
| + s.mu.Lock() |
| + s.keys = append(s.keys, key) |
| + s.mu.Unlock() |
| + f, err := os.Open(s.file(key)) |
| + if err != nil { |
| + w.Header().Set("Content-Type", "application/xml") |
| + w.WriteHeader(http.StatusNotFound) |
| + w.Write([]byte(`<Error><Code>NoSuchKey</Code></Error>`)) |
| + return |
| + } |
| + defer f.Close() |
| + http.ServeContent(w, r, "", time.Time{}, f) |
| +} |
| + |
| +// file maps <p>/<level hex>/<name> to the file client's <p>/ltx/<level>/<name>. |
| +func (s *replicaS3) file(key string) string { |
| + dir, name := path.Split(key) |
| + prefix, levelHex := path.Split(strings.TrimSuffix(dir, "/")) |
| + level, err := strconv.ParseInt(levelHex, 16, 32) |
| + if err != nil { |
| + return filepath.Join(s.root, "nonexistent") |
| + } |
| + return filepath.Join(s.root, filepath.FromSlash(prefix), "ltx", strconv.Itoa(int(level)), name) |
| +} |
| + |
| +func (s *replicaS3) list(w http.ResponseWriter, prefix string) { |
| + type obj struct { |
| + Key string |
| + Size int64 |
| + LastModified string |
| + } |
| + var objs []obj |
| + dir, _ := path.Split(prefix) |
| + p, levelHex := path.Split(strings.TrimSuffix(dir, "/")) |
| + if level, err := strconv.ParseInt(levelHex, 16, 32); err == nil { |
| + local := filepath.Join(s.root, filepath.FromSlash(p), "ltx", strconv.Itoa(int(level))) |
| + ents, _ := os.ReadDir(local) |
| + for _, e := range ents { |
| + if _, _, err := ltx.ParseFilename(e.Name()); err != nil { |
| + continue |
| + } |
| + fi, err := e.Info() |
| + if err != nil { |
| + continue |
| + } |
| + objs = append(objs, obj{Key: prefix + e.Name(), Size: fi.Size(), LastModified: fi.ModTime().UTC().Format(time.RFC3339)}) |
| + } |
| + } |
| + type result struct { |
| + XMLName xml.Name `xml:"ListBucketResult"` |
| + Name string |
| + Prefix string |
| + KeyCount int |
| + IsTruncated bool |
| + Contents []obj |
| + } |
| + w.Header().Set("Content-Type", "application/xml") |
| + xml.NewEncoder(w).Encode(result{Name: s.bucket, Prefix: prefix, KeyCount: len(objs), Contents: objs}) |
| +} |
| + |
| +func TestE2EDBThroughConsole(t *testing.T) { |
| + useTempCredentials(t) |
| + clearDeploymentEnv(t) |
| + checkNoRegistry := requireNoRegistryTouch(t) |
| + |
| + tmp := t.TempDir() |
| + const host = "acme.go.tito.io" |
| + replicaRoot := filepath.Join(tmp, "data") |
| + dbdoortest.Replicate(t, filepath.Join(replicaRoot, "instances", host), 3) |
| + // lock.json sits under the same prefix in production. Nothing may fetch it. |
| + if err := os.WriteFile(filepath.Join(replicaRoot, "instances", host, "lock.json"), []byte(`{"owner":"box"}`), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + fake := &replicaS3{t: t, bucket: "data-test", root: replicaRoot} |
| + s3srv := httptest.NewServer(fake) |
| + t.Cleanup(s3srv.Close) |
| + t.Setenv("CARLOS_S3_ENDPOINT", s3srv.URL) |
| + t.Setenv("AWS_ACCESS_KEY_ID", "test-key") |
| + t.Setenv("AWS_SECRET_ACCESS_KEY", "test-secret") |
| + t.Setenv("AWS_EC2_METADATA_DISABLED", "true") |
| + |
| + relStore := release.NewDirStore(filepath.Join(tmp, "store")) |
| + seed, pubB64, err := release.GenerateKey() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + priv, err := release.ParseSigningKey(seed, "e2e") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + pub, err := release.ParsePublicKey(pubB64, "e2e") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + cst, err := console.Open(filepath.Join(tmp, "console.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { cst.Close() }) |
| + var srv *console.Server |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + srv.ServeHTTP(w, r) |
| + })) |
| + t.Cleanup(ts.Close) |
| + srv, err = console.NewServer(console.Config{ |
| + Origin: ts.URL, |
| + Keymail: "http://keymail.invalid", |
| + Registry: filepath.Join(tmp, "registry.db"), |
| + Releases: relStore, |
| + ReleasePub: pub, |
| + Key: priv, |
| + AppScheme: "http", |
| + HibernateBucket: "data-test", |
| + HibernateRegion: "us-east-1", |
| + HibernatePrefix: "instances", |
| + }, cst) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + const addr = "paul@keymail.dev" |
| + if err := cst.AddMember(console.OpsAccountID, addr, console.RoleOwner, time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := cst.CreateAppIn(console.OpsAccountID, "titogo", addr, time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + yes := true |
| + rec, _ := json.Marshal(provision.Record{Host: host, App: "titogo", Account: release.OpsAccount, Hibernate: &yes}) |
| + if _, err := relStore.Put(context.Background(), provision.RecordKey(release.OpsAccount, "titogo", host), rec, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + token, expiresAt := e2eDeviceLogin(t, ts.URL, cst, addr, "e2e@laptop") |
| + credFile, err := loadCredentials() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + credFile.put(credential{Console: ts.URL, Address: addr, Token: token, ExpiresAt: expiresAt}) |
| + if err := credFile.save(); err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Setenv("TMPDIR", t.TempDir()) |
| + |
| + // pull: the copy lands at --out, 0600, with every row, and the handover |
| + // says it is a copy and names its newest transaction. |
| + out := filepath.Join(t.TempDir(), "pulled.db") |
| + buf := captureStdout(t) |
| + runDB([]string{"pull", "--console", ts.URL, "--app", "titogo", "--account", "ops", "--instance", host, "--out", out}) |
| + said := buf.String() |
| + for _, want := range []string{out, "copy", "transaction", host} { |
| + if !strings.Contains(said, want) { |
| + t.Errorf("pull said %q, want it to mention %q", said, want) |
| + } |
| + } |
| + fi, err := os.Stat(out) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if fi.Mode().Perm() != 0o600 { |
| + t.Errorf("pulled copy is %04o, want 0600", fi.Mode().Perm()) |
| + } |
| + var rows bytes.Buffer |
| + if err := runQuery(&rows, out, "SELECT count(*) AS n FROM t"); err != nil || rows.String() != "n\n"+strconv.Itoa(3*dbdoortest.Rows)+"\n" { |
| + t.Fatalf("pulled copy = %q %v", rows.String(), err) |
| + } |
| + |
| + // query: rows on stdout, nothing else. |
| + buf.Reset() |
| + runDB([]string{"query", "--console", ts.URL, "--app", "titogo", "--account", "ops", "--instance", host, "--sql", "SELECT body FROM t ORDER BY id LIMIT 2"}) |
| + if got := buf.String(); got != "body\nrow-0-0\nrow-0-1\n" { |
| + t.Errorf("query stdout = %q, want the header and two rows only", got) |
| + } |
| + |
| + // Nothing private is left in the temp dir, and nothing but LTX files |
| + // under this instance's prefix was ever asked of the bucket. |
| + _ = filepath.WalkDir(os.Getenv("TMPDIR"), func(p string, d fs.DirEntry, err error) error { |
| + if err == nil && strings.HasPrefix(d.Name(), "carlos-db-") { |
| + t.Errorf("restore directory left behind: %s", p) |
| + } |
| + return nil |
| + }) |
| + fake.mu.Lock() |
| + defer fake.mu.Unlock() |
| + if len(fake.keys) == 0 { |
| + t.Fatal("the door never read the bucket") |
| + } |
| + for _, k := range fake.keys { |
| + if !strings.HasPrefix(k, "instances/"+host+"/") || !strings.HasSuffix(k, ".ltx") { |
| + t.Errorf("the door asked the bucket for %q", k) |
| + } |
| + } |
| + checkNoRegistry() |
| +} |
diff --git a/db_test.go b/db_test.go| new file mode 100644 |
| index 000000000..4d76cb8ed |
| --- /dev/null |
| +++ b/db_test.go |
| @@ -0,0 +1,264 @@ |
| +package main |
| + |
| +// The CLI half of `carlos db` (spec 2026-09-02 §4): the read-only query, the |
| +// pull's landing rules, and the wire shape the session's replica client |
| +// sends. The whole path through a real console is db_e2e_test.go. |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "crypto/sha256" |
| + "database/sql" |
| + "net/http" |
| + "net/http/httptest" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/dbdoor" |
| +) |
| + |
| +// fixtureDB writes a small database the way a restore leaves one: WAL mode, |
| +// one table, a few rows, checkpointed into the main file. |
| +func fixtureDB(t *testing.T) string { |
| + t.Helper() |
| + path := filepath.Join(t.TempDir(), "copy.db") |
| + db, err := sql.Open("sqlite", path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := db.Exec(`PRAGMA journal_mode=WAL; |
| +CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, note TEXT); |
| +INSERT INTO users (name, note) VALUES ('ada', NULL), ('grace', 'x'); |
| +PRAGMA wal_checkpoint(TRUNCATE);`); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := db.Close(); err != nil { |
| + t.Fatal(err) |
| + } |
| + return path |
| +} |
| + |
| +func sha(t *testing.T, path string) [32]byte { |
| + t.Helper() |
| + b, err := os.ReadFile(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return sha256.Sum256(b) |
| +} |
| + |
| +// A copy is harmless to write to. Read-only exists so a caller cannot |
| +// BELIEVE it changed production: an agent that sees "1 row affected" |
| +// concludes it fixed the bug. The error is the truth, in SQLite's words. |
| +func TestQueryRefusesAWriteWithSQLitesOwnError(t *testing.T) { |
| + path := fixtureDB(t) |
| + for _, stmt := range []string{ |
| + "UPDATE users SET name='x'", |
| + "DELETE FROM users", |
| + "INSERT INTO users (name) VALUES ('mallory')", |
| + "CREATE TABLE t2 (x)", |
| + } { |
| + err := runQuery(&bytes.Buffer{}, path, stmt) |
| + if err == nil || !strings.Contains(err.Error(), "attempt to write a readonly database") { |
| + t.Errorf("%s: error = %v, want SQLite's readonly refusal", stmt, err) |
| + } |
| + } |
| +} |
| + |
| +// query_only is set as well as mode=ro, and a statement list cannot talk |
| +// its way past either: switching query_only off first still leaves the |
| +// write refused, and the data unchanged. |
| +func TestQueryCannotTurnOffItsOwnGuard(t *testing.T) { |
| + path := fixtureDB(t) |
| + err := runQuery(&bytes.Buffer{}, path, "PRAGMA query_only=0; UPDATE users SET name='x'") |
| + if err == nil || !strings.Contains(err.Error(), "attempt to write a readonly database") { |
| + t.Fatalf("error = %v, want SQLite's readonly refusal", err) |
| + } |
| + var out bytes.Buffer |
| + if err := runQuery(&out, path, "SELECT name FROM users ORDER BY id"); err != nil || out.String() != "name\nada\ngrace\n" { |
| + t.Fatalf("read after = %v %q, want the rows unchanged", err, out.String()) |
| + } |
| +} |
| + |
| +func TestQueryLeavesTheFileByteIdentical(t *testing.T) { |
| + path := fixtureDB(t) |
| + before := sha(t, path) |
| + _ = runQuery(&bytes.Buffer{}, path, "UPDATE users SET name='x'") |
| + if err := runQuery(&bytes.Buffer{}, path, "SELECT * FROM users"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if sha(t, path) != before { |
| + t.Fatal("the database file changed under a read-only query") |
| + } |
| +} |
| + |
| +// Output is for an agent as much as a person: a header row, then one line |
| +// per row, tab-separated, NULL spelled out. |
| +func TestQueryPrintsAHeaderAndTabSeparatedRows(t *testing.T) { |
| + path := fixtureDB(t) |
| + var out bytes.Buffer |
| + if err := runQuery(&out, path, "SELECT id, name, note FROM users ORDER BY id"); err != nil { |
| + t.Fatal(err) |
| + } |
| + want := "id\tname\tnote\n1\tada\tNULL\n2\tgrace\tx\n" |
| + if out.String() != want { |
| + t.Fatalf("output = %q, want %q", out.String(), want) |
| + } |
| +} |
| + |
| +func TestQueryReportsABadStatement(t *testing.T) { |
| + if err := runQuery(&bytes.Buffer{}, fixtureDB(t), "SELEKT 1"); err == nil { |
| + t.Fatal("a syntax error succeeded") |
| + } |
| +} |
| + |
| +// --out must not exist, and what lands is 0600 whatever the umask. |
| +func TestPullRefusesAnExistingOutAndLands0600(t *testing.T) { |
| + src := fixtureDB(t) |
| + dir := t.TempDir() |
| + existing := filepath.Join(dir, "taken.db") |
| + if err := os.WriteFile(existing, []byte("mine"), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := copyOut(src, existing); err == nil { |
| + t.Fatal("copyOut over an existing file succeeded") |
| + } |
| + if b, _ := os.ReadFile(existing); string(b) != "mine" { |
| + t.Fatal("copyOut touched the existing file") |
| + } |
| + out := filepath.Join(dir, "new.db") |
| + if err := copyOut(src, out); err != nil { |
| + t.Fatal(err) |
| + } |
| + fi, err := os.Stat(out) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if perm := fi.Mode().Perm(); perm != 0o600 { |
| + t.Fatalf("pulled copy is %04o, want 0600", perm) |
| + } |
| + if sha(t, out) != sha(t, src) { |
| + t.Fatal("pulled copy differs from the restored one") |
| + } |
| +} |
| + |
| +// os.Rename fails across devices, and the temp directory and --out are |
| +// routinely on different filesystems. A copy leaves its source in place; a |
| +// rename would not. |
| +func TestPullCopiesAcrossFilesystems(t *testing.T) { |
| + src := fixtureDB(t) |
| + out := filepath.Join(t.TempDir(), "elsewhere", "pulled.db") |
| + if err := os.MkdirAll(filepath.Dir(out), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := copyOut(src, out); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := os.Stat(src); err != nil { |
| + t.Fatalf("the source is gone after copyOut (%v): that was a rename, which fails across filesystems", err) |
| + } |
| +} |
| + |
| +// A failed copy leaves nothing at --out. |
| +func TestPullLeavesNoPartialFile(t *testing.T) { |
| + out := filepath.Join(t.TempDir(), "pulled.db") |
| + if err := copyOut(filepath.Join(t.TempDir(), "missing.db"), out); err == nil { |
| + t.Fatal("copyOut of a missing source succeeded") |
| + } |
| + if _, err := os.Stat(out); !os.IsNotExist(err) { |
| + t.Fatalf("a failed copy left %s behind (%v)", out, err) |
| + } |
| +} |
| + |
| +// The session's replica client sends the coordinate under the app's db |
| +// path, carries the account segment and its ?exact=sqid switch verbatim, |
| +// and authenticates as the logged-in person. |
| +func TestAPISessionDBClientWireShape(t *testing.T) { |
| + var gotURI, gotAuth, gotRange string |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + gotURI, gotAuth, gotRange = r.URL.RequestURI(), r.Header.Get("Authorization"), r.Header.Get("Range") |
| + w.WriteHeader(http.StatusPartialContent) |
| + })) |
| + defer ts.Close() |
| + c := newTestSession(ts.URL).dbClient(apiAcct{Seg: "bab", Exact: true}, "titogo", "a.go.tito.io") |
| + rc, err := c.OpenLTXFile(context.Background(), 3, 16, 32, 0, 0) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + rc.Close() |
| + if want := "/api/cli/apps/bab/titogo/db/a.go.tito.io" + dbdoor.GetTail(3, 16, 32) + "?exact=sqid"; gotURI != want { |
| + t.Errorf("uri = %s, want %s", gotURI, want) |
| + } |
| + if gotAuth != "Bearer cli-token" || gotRange != "bytes=0-" { |
| + t.Errorf("auth %q range %q", gotAuth, gotRange) |
| + } |
| +} |
| + |
| +// Both guards are on, not one: mode=ro alone would pass every write test |
| +// above, so query_only is pinned on its own. |
| +func TestQueryRunsWithQueryOnlySet(t *testing.T) { |
| + var out bytes.Buffer |
| + if err := runQuery(&out, fixtureDB(t), "PRAGMA query_only"); err != nil || out.String() != "query_only\n1\n" { |
| + t.Fatalf("PRAGMA query_only = %v %q, want 1", err, out.String()) |
| + } |
| +} |
| + |
| +// --instance may be left out only when the app has exactly one. Several is |
| +// a refusal that names them all; guessing would hand over the wrong data |
| +// while saying it was the right data (spec §7). |
| +func TestResolveDBInstance(t *testing.T) { |
| + answer := "" |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + if r.URL.Path != "/api/cli/apps/bab/titogo/instances" { |
| + t.Errorf("path = %s", r.URL.Path) |
| + } |
| + w.Write([]byte(answer)) |
| + })) |
| + defer ts.Close() |
| + sess := newTestSession(ts.URL) |
| + ctx := context.Background() |
| + acct := apiAcct{Seg: "bab"} |
| + |
| + if h, err := resolveDBInstance(ctx, sess, acct, "titogo", "named.go.tito.io"); err != nil || h != "named.go.tito.io" { |
| + t.Fatalf("named = %q %v", h, err) |
| + } |
| + answer = `{"instances":[{"host":"only.go.tito.io"}]}` |
| + if h, err := resolveDBInstance(ctx, sess, acct, "titogo", ""); err != nil || h != "only.go.tito.io" { |
| + t.Fatalf("one instance = %q %v", h, err) |
| + } |
| + answer = `{"instances":[{"host":"b.go.tito.io"},{"host":"a.go.tito.io"}]}` |
| + _, err := resolveDBInstance(ctx, sess, acct, "titogo", "") |
| + if err == nil || !strings.Contains(err.Error(), "--instance") || !strings.Contains(err.Error(), "a.go.tito.io, b.go.tito.io") { |
| + t.Fatalf("two instances = %v, want a refusal naming both", err) |
| + } |
| + answer = `{"instances":[{"host":"a.go.tito.io"}],"truncated":true}` |
| + if _, err := resolveDBInstance(ctx, sess, acct, "titogo", ""); err == nil { |
| + t.Fatal("a truncated list of one was taken as the only instance") |
| + } |
| + answer = `{"instances":[]}` |
| + if _, err := resolveDBInstance(ctx, sess, acct, "titogo", ""); err == nil { |
| + t.Fatal("no instances resolved to something") |
| + } |
| +} |
| + |
| +// Every way a restore can fail is named, and none of them reads as a |
| +// transient "no replica found" (spec §5 R4, §7). |
| +func TestDBRestoreErrorsNameTheirCause(t *testing.T) { |
| + cases := []struct { |
| + err error |
| + want string |
| + }{ |
| + {&dbdoor.DoorError{Status: 409, Message: "a.go.tito.io keeps its data in region uk."}, "region uk"}, |
| + {&dbdoor.DoorError{Status: 404}, "no instance"}, |
| + {dbdoor.ErrNothingReplicated, `replicate: "app"`}, |
| + {dbdoor.ErrCheckFailed, "integrity check"}, |
| + {os.ErrNotExist, "changed while it was being read"}, |
| + } |
| + for _, c := range cases { |
| + if got := dbRestoreError("a.go.tito.io", c.err).Error(); !strings.Contains(got, c.want) { |
| + t.Errorf("%v -> %q, want it to mention %q", c.err, got, c.want) |
| + } |
| + } |
| +} |
diff --git a/docs/site/cli.md b/docs/site/cli.md| index c173c7929..6a75143cc 100644 |
| --- a/docs/site/cli.md |
| +++ b/docs/site/cli.md |
| @@ -397,6 +397,36 @@ that predates these flags does not understand a narrowed request: the console |
| answers that it has no such door, and a box that has not been rolled yet simply |
| does nothing. Neither one turns it into a restart of everything. |
| +### carlos db |
| + |
| +Reads a copy of an instance's database. The platform keeps a replica of |
| +every sleeping instance's database. This command restores that replica on |
| +your machine. It never touches the live database. |
| + |
| +```sh |
| +carlos db pull --app hello --out hello.db |
| +carlos db query --app hello --sql "SELECT count(*) FROM users" |
| +``` |
| + |
| +`pull` saves the copy at the path you give with `--out`. The path must not |
| +exist yet, and the file is readable only by you. `query` restores a fresh |
| +copy, runs one query against it, and prints the rows to stdout with a header |
| +row, separated by tabs. The copy is deleted when the query finishes. |
| + |
| +Both say how recent the copy is: the last transaction it holds, and when |
| +that transaction was written. A sleeping instance's copy is as recent as its |
| +last sync before it went to sleep. A busy instance can be ahead of its copy. |
| + |
| +The copy is read-only. A query that tries to change it fails with SQLite's |
| +own error, `attempt to write a readonly database`. That is on purpose. |
| +Changing a copy would change nothing on the instance, and a command that |
| +seemed to succeed would say otherwise. |
| + |
| +If the app has more than one instance, name one with `--instance`. Only |
| +instances that sleep have a replica. An instance on your own fleet, in |
| +another region, or in a pool cannot be read this way, and the command says |
| +which. |
| + |
| ### carlos schedule |
| Gives an app a timetable. A schedule is a time and a path: at each fire the |
diff --git a/main.go b/main.go| index b3b52dff7..16f2ccd5b 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,8 @@ 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 read a copy of an instance's database, restored from its |
| + replica: pull (save it) | query (one read-only query) |
| 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) |