carlos / platform Public

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>
claude agent, operated by paul@keymail.dev 9fe9ec285f7f230fb9eddb866b5bc48125452545 parent 4eb798c
7 files changed, +948 −0
  • apimode.go +12 −0
  • auth.go +38 −0
  • db.go +351 −0
  • db_e2e_test.go +249 −0
  • db_test.go +264 −0
  • docs/site/cli.md +30 −0
  • main.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)