carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1// db.go is `carlos db`: a member reads an app's database from its replica,
2// with no box access and no AWS credentials (spec 2026-09-02).
3//
4// It never connects to the running database. The console serves the
5// replica's LTX files by coordinate (internal/console/apidb.go); this side
6// restores them, with litestream's own restore and a client that has no
7// write methods (internal/dbdoor), into a private 0700 directory, proves the
8// result with lease.CheckDB, and then either copies it to --out (pull) or
9// opens it read-only and runs one query (query). The console never executes
10// tenant SQL and never holds a restored database.
11//
12// Read-only is enforced, not assumed (spec §4): mode=ro and query_only. A
13// copy is harmless to write to; the point is that nobody, person or agent,
14// can come away believing a write reached production.
15//
16// Unit 1 of three (spec §13): ls and its freshness fields, then shell,
17// follow.
18package main
19
20import (
21 "context"
22 "errors"
23 "flag"
24 "fmt"
25 "io"
26 "log"
27 "os"
28 "os/signal"
29 "slices"
30 "sort"
31 "strings"
32 "syscall"
33 "time"
34
35 "github.com/carlosframework/platform/internal/dbdoor"
36 "github.com/carlosframework/platform/internal/dbquery"
37 "github.com/carlosframework/platform/internal/dbreads"
38)
39
40const dbUsage = `usage: carlos db <verb> --app <a> [--instance <host>] [flags]
41
42 pull --out <path> restore a copy of the instance's database and save it
43 at <path>, which must not exist yet (saved 0600)
44 query --sql <text> restore a copy and run one read-only query against it;
45 rows go to stdout, tab-separated, with a header row
46
47Add --at <time> to either verb to read the database as it was at that
48time, for example --at 2026-10-07T14:00:00Z. The copy holds every change
49replicated before then, so it can be a little older than the time you give.
50How far back you can go depends on how much history the platform keeps.
51
52Both read the platform's replica of the database, never the live one, and
53nothing you do to the copy reaches the instance. --instance names which of
54the app's instances to read; it may be left out when the app has one. The
55usual --account / --account-id / --console / --destination apply.
56`
57
58func runDB(args []string) {
59 if len(args) == 0 {
60 fmt.Fprint(os.Stderr, dbUsage)
61 os.Exit(2)
62 }
63 switch args[0] {
64 case "pull":
65 runDBPull(args[1:])
66 case "query":
67 runDBQuery(args[1:])
68 case "-h", "--help", "help":
69 fmt.Fprint(stdout, dbUsage)
70 default:
71 fmt.Fprintf(os.Stderr, "carlos db: unknown verb %q\n%s", args[0], dbUsage)
72 os.Exit(2)
73 }
74}
75
76// dbTarget is what both verbs resolve first: the session, the account, the
77// app and the one instance the verb reads.
78type dbTarget struct {
79 sess *apiSession
80 acct apiAcct
81 app string
82 host string
83 // at is --at: the copy holds what was replicated before it. Zero is
84 // the newest copy.
85 at time.Time
86}
87
88// dbFlags registers the flags both verbs share and returns the resolver.
89// Status lines go to stderr: query's stdout is its rows, and must pipe
90// cleanly into whatever reads them.
91func dbFlags(fs *flag.FlagSet, verb string) func() dbTarget {
92 app := appFlag(fs, "app whose database to read")
93 instance := fs.String("instance", "", "the instance whose database to read (may be left out when the app has one)")
94 acctRaw, acctIDRaw, _ := accountFlags(fs)
95 consoleFlag := consoleOption(fs, "console to read through (default: the only logged-in one)")
96 atRaw := fs.String("at", "", "read the database as it was at this time (RFC 3339, for example 2026-10-07T14:00:00Z)")
97 return func() dbTarget {
98 // Before any network: a time that can't be read is the person's
99 // to fix, and costs nothing to refuse.
100 at, err := parseDBAt(*atRaw, time.Now())
101 if err != nil {
102 log.Fatalf("carlos db %s: %v", verb, err)
103 }
104 sess := requireDBAPISession(*consoleFlag)
105 printUsing(os.Stderr, sess)
106 acct := resolvedAccount(sess, acctRaw, acctIDRaw)
107 name := resolvedApp(sess, acct, strings.TrimSpace(*app), dbUsage)
108 host, err := resolveDBInstance(context.Background(), sess, acct, name, strings.TrimSpace(*instance))
109 if err != nil {
110 log.Fatalf("carlos db %s: %v", verb, err)
111 }
112 return dbTarget{sess: sess, acct: acct, app: name, host: host, at: at}
113 }
114}
115
116// parseDBAt reads --at. Empty is the zero time, the newest copy. A time
117// must carry its zone: a bare clock time would be read in whichever zone
118// this shell is in, and the copy would silently be hours off. A time after
119// now is refused rather than quietly answered with the newest copy, so
120// nobody holds a copy labelled with a moment that hasn't happened.
121func parseDBAt(raw string, now time.Time) (time.Time, error) {
122 raw = strings.TrimSpace(raw)
123 if raw == "" {
124 return time.Time{}, nil
125 }
126 at, err := time.Parse(time.RFC3339, raw)
127 if err != nil {
128 return time.Time{}, fmt.Errorf("--at %q is not a time. Write it in RFC 3339 with a time zone, for example 2026-10-07T14:00:00Z for UTC", raw)
129 }
130 if at.After(now) {
131 return time.Time{}, fmt.Errorf("--at %s is in the future. Leave --at out to read the newest copy", raw)
132 }
133 // 0001-01-01T00:00:00Z parses to Go's zero time, which means "no
134 // --at". It is before any history, so it is asked as the earliest
135 // time instead, and gets the before-history answer.
136 if at.Before(dbdoor.EarliestAsOf) {
137 at = dbdoor.EarliestAsOf
138 }
139 return at, nil
140}
141
142// requireDBAPISession resolves signed-in mode, refusing direct bucket mode
143// by name: the door is the console's, and an operator holding the bucket
144// already has a box.
145func requireDBAPISession(consoleFlag string) *apiSession {
146 sess, err := resolveMode(consoleFlag)
147 if err != nil {
148 log.Fatal(err)
149 }
150 if sess == nil {
151 log.Fatal("carlos db works through a console, and this shell is in direct bucket mode " +
152 "(CARLOS_DEPLOYMENT_BUCKET or CARLOS_DEPLOYMENT_DIR is set). Unset it and run: carlos auth login --console <url>.")
153 }
154 return sess
155}
156
157// resolveDBInstance is --instance, or the app's only instance when it has
158// exactly one. Several is a refusal naming them: guessing would hand
159// someone the wrong data while telling them it is the right data (spec §7).
160func resolveDBInstance(ctx context.Context, sess *apiSession, acct apiAcct, app, host string) (string, error) {
161 if host != "" {
162 return host, nil
163 }
164 recs, truncated, _, err := sess.instanceList(ctx, acct, app, "", "")
165 if err != nil {
166 return "", fmt.Errorf("could not list the app's instances to pick one: %w", err)
167 }
168 if len(recs) == 1 && !truncated {
169 return recs[0].Host, nil
170 }
171 if len(recs) == 0 {
172 return "", fmt.Errorf("%s has no instances, so there is no database to read", app)
173 }
174 hosts := make([]string, 0, len(recs))
175 for _, r := range recs {
176 hosts = append(hosts, r.Host)
177 }
178 sort.Strings(hosts)
179 more := ""
180 if truncated {
181 more = ", and more"
182 }
183 return "", fmt.Errorf("%s has %d instances. Name one with --instance: %s%s", app, len(recs), strings.Join(hosts, ", "), more)
184}
185
186// dbRestore restores the target's replica into a private directory under
187// the system temp dir. The caller removes Restored.Dir.
188//
189// A door refusal carrying the db region forward marker comes back as an
190// *errDBForward, so a verb can switch to the forwarded read (dbforward.go).
191func dbRestore(ctx context.Context, t dbTarget) (*dbdoor.Restored, error) {
192 r, err := dbdoor.RestoreAsOf(ctx, t.sess.dbClient(t.acct, t.app, t.host), "", t.at)
193 if err != nil {
194 var door *dbdoor.DoorError
195 if errors.As(err, &door) && door.Forward != nil {
196 return nil, &errDBForward{door: door}
197 }
198 return nil, dbRestoreError(t.host, err)
199 }
200 return r, nil
201}
202
203// dbRestoreError turns a failed restore into the sentence a member needs.
204// A refusal from the door carries the console's own words; the others are
205// named here so none of them reads as a transient "no replica found".
206func dbRestoreError(host string, err error) error {
207 var door *dbdoor.DoorError
208 var before *dbdoor.BeforeHistoryError
209 switch {
210 case errors.As(err, &before):
211 return fmt.Errorf("%s's replica does not go back that far. The oldest point it keeps is %s", host, dbTime(before.Oldest))
212 case errors.Is(err, dbdoor.ErrNoTimes):
213 return fmt.Errorf("%s's replica can't be read as of a time yet. Leave --at out to read the newest copy", host)
214 case errors.As(err, &door) && door.Status == 404:
215 return fmt.Errorf("no instance %s that you can read", host)
216 case errors.As(err, &door):
217 return errors.New(door.Error())
218 case errors.Is(err, dbdoor.ErrNothingReplicated):
219 return fmt.Errorf("the platform has no copy of %s's database yet. Nothing has been replicated. "+
220 "If the app replicates its own database (replicate: \"app\"), the platform never will", host)
221 case errors.Is(err, dbdoor.ErrCheckFailed):
222 return fmt.Errorf("the copy of %s's database failed its integrity check, so it was not kept: %v", host, err)
223 case errors.Is(err, os.ErrNotExist):
224 return fmt.Errorf("the replica of %s changed while it was being read, twice. Try again", host)
225 case errors.Is(err, context.Canceled):
226 return errors.New("stopped")
227 }
228 return fmt.Errorf("could not restore %s's database: %w", host, err)
229}
230
231// dbAsOf renders the copy's newest transaction and its time (R1).
232func dbAsOf(r *dbdoor.Restored) string {
233 if r.At.IsZero() {
234 return fmt.Sprintf("transaction %d", r.TXID)
235 }
236 return fmt.Sprintf("transaction %d, written %s", r.TXID, dbTime(r.At))
237}
238
239func dbTime(t time.Time) string { return t.UTC().Format("2006-01-02 15:04:05 UTC") }
240
241// dbPullHolds is pull's line saying what the copy holds; with --at it also
242// names the requested time, since the copy can be older than it.
243func dbPullHolds(r *dbdoor.Restored, at time.Time) string {
244 if at.IsZero() {
245 return fmt.Sprintf("It holds every change up to %s.\n", dbAsOf(r))
246 }
247 return fmt.Sprintf("It holds every change up to %s, the last one replicated before %s.\n", dbAsOf(r), dbTime(at))
248}
249
250// dbQueryStatus is query's stderr line before the rows.
251func dbQueryStatus(host string, r *dbdoor.Restored, at time.Time) string {
252 if at.IsZero() {
253 return fmt.Sprintf("Querying a copy of %s's database, up to %s. Nothing you run here reaches %s.\n", host, dbAsOf(r), host)
254 }
255 return fmt.Sprintf("Querying a copy of %s's database as it was before %s, up to %s. Nothing you run here reaches %s.\n", host, dbTime(at), dbAsOf(r), host)
256}
257
258func runDBPull(args []string) {
259 fs := flag.NewFlagSet("db pull", flag.ExitOnError)
260 target := dbFlags(fs, "pull")
261 out := fs.String("out", "", "where to save the copy; must not exist yet")
262 fs.Usage = flagUsage(fs, dbUsage)
263 fs.Parse(args)
264 if strings.TrimSpace(*out) == "" {
265 fmt.Fprint(os.Stderr, "carlos db pull: --out is required: name a path for the copy\n\n"+dbUsage)
266 os.Exit(2)
267 }
268 if _, err := os.Lstat(*out); err == nil {
269 fmt.Fprintf(os.Stderr, "carlos db pull: %s already exists. Choose a new path for --out\n", *out)
270 os.Exit(2)
271 }
272 t := target()
273 ctx, stop := dbSignalContext()
274 defer stop()
275 if err := dbPull(ctx, t, *out); err != nil {
276 stop()
277 log.Fatalf("carlos db pull: %v", err)
278 }
279}
280
281// dbPull restores, copies to out, and removes the private directory on
282// every path. It returns rather than exits so that cleanup always runs.
283func dbPull(ctx context.Context, t dbTarget, out string) error {
284 r, err := dbRestore(ctx, t)
285 var fwd *errDBForward
286 if errors.As(err, &fwd) {
287 // The copy's files come from the region that holds them (db region
288 // forward spec §6), when the console forwards pulls there.
289 if !slices.Contains(fwd.door.Forward.Ops, dbreads.OpPull) {
290 // A console that forwards queries and not pulls (plan 2's): DBF-8.
291 return fmt.Errorf(dbfNoPull, t.host, fwd.door.Forward.Region)
292 }
293 r, err = dbPullForward(ctx, t, *fwd.door.Forward)
294 }
295 if err != nil {
296 return err
297 }
298 defer os.RemoveAll(r.Dir)
299 if err := copyOut(ctx, r.Path, out); err != nil {
300 return err
301 }
302 fmt.Fprintf(stdout, "Saved a copy of %s's database to %s.\n", t.host, out)
303 fmt.Fprint(stdout, dbPullHolds(r, t.at))
304 fmt.Fprintf(stdout, "It is a copy. Nothing you do to it reaches %s.\n", t.host)
305 return nil
306}
307
308// copyOut copies the restored database to out, 0600, refusing an existing
309// out. A copy and never os.Rename: the temp directory and the user's
310// destination are routinely on different filesystems, where a rename
311// fails. A failed copy leaves nothing behind at out.
312func copyOut(ctx context.Context, src, out string) (err error) {
313 // Nothing is published once the person has asked to stop.
314 if err := ctx.Err(); err != nil {
315 return err
316 }
317 in, err := os.Open(src)
318 if err != nil {
319 return err
320 }
321 defer in.Close()
322 f, err := os.OpenFile(out, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
323 if err != nil {
324 if errors.Is(err, os.ErrExist) {
325 return fmt.Errorf("%s already exists. Choose a new path for --out", out)
326 }
327 return err
328 }
329 defer func() {
330 if err != nil {
331 f.Close()
332 os.Remove(out)
333 }
334 }()
335 // The mode passed to OpenFile is filtered by the umask; 0600 is what
336 // the spec promises, so it is set, not hoped for.
337 if err = f.Chmod(0o600); err != nil {
338 return err
339 }
340 if _, err = io.Copy(f, ctxReader{ctx, in}); err != nil {
341 return err
342 }
343 if err = f.Sync(); err != nil {
344 return err
345 }
346 if err = f.Close(); err != nil {
347 return err
348 }
349 // A stop that lands as the copy finishes still withdraws it: the
350 // caller reports success only for a copy that was not interrupted.
351 if err = ctx.Err(); err != nil {
352 os.Remove(out)
353 return err
354 }
355 return nil
356}
357
358// ctxReader stops a copy at the next read once ctx is done.
359type ctxReader struct {
360 ctx context.Context
361 r io.Reader
362}
363
364func (c ctxReader) Read(p []byte) (int, error) {
365 if err := c.ctx.Err(); err != nil {
366 return 0, err
367 }
368 return c.r.Read(p)
369}
370
371func runDBQuery(args []string) {
372 fs := flag.NewFlagSet("db query", flag.ExitOnError)
373 target := dbFlags(fs, "query")
374 sqlText := fs.String("sql", "", "the query to run")
375 fs.Usage = flagUsage(fs, dbUsage)
376 fs.Parse(args)
377 if strings.TrimSpace(*sqlText) == "" {
378 fmt.Fprint(os.Stderr, "carlos db query: --sql is required\n\n"+dbUsage)
379 os.Exit(2)
380 }
381 t := target()
382 ctx, stop := dbSignalContext()
383 defer stop()
384 if err := dbQuery(ctx, t, *sqlText); err != nil {
385 stop()
386 log.Fatalf("carlos db query: %v", err)
387 }
388}
389
390func dbQuery(ctx context.Context, t dbTarget, sqlText string) error {
391 r, err := dbRestore(ctx, t)
392 var fwd *errDBForward
393 if errors.As(err, &fwd) {
394 // The instance is read in its own region (db region forward spec
395 // §5.5), when the console forwards queries there.
396 if !slices.Contains(fwd.door.Forward.Ops, dbreads.OpQuery) {
397 return errors.New(fwd.door.Error())
398 }
399 return dbQueryForward(ctx, t, sqlText, *fwd.door.Forward)
400 }
401 if err != nil {
402 return err
403 }
404 defer os.RemoveAll(r.Dir)
405 fmt.Fprint(os.Stderr, dbQueryStatus(t.host, r, t.at))
406 return runQueryContext(ctx, stdout, r.Path, sqlText)
407}
408
409// The query runner and its guards live in internal/dbquery (spec 2026-10-22
410// §8.3), so the CLI and the db-read worker run one copy of them. These
411// names stay for db.go's callers and tests.
412var (
413 errOneStatement = dbquery.ErrOneStatement
414 errPragmaChange = dbquery.ErrPragmaChange
415)
416
417func checkQuerySQL(q string) error { return dbquery.CheckSQL(q) }
418
419func dbQueryCell(v any) string { return dbquery.Cell(v) }
420
421func runQuery(w io.Writer, path, sqlText string) error {
422 return runQueryContext(context.Background(), w, path, sqlText)
423}
424
425// runQueryContext reads nothing itself: the open and the query are
426// dbquery.RunContext's.
427func runQueryContext(ctx context.Context, w io.Writer, path, sqlText string) error {
428 return dbquery.RunContext(ctx, w, path, sqlText, dbquery.Options{})
429}
430
431// dbStopSignals end a db verb early. Each one cancels the context instead of
432// killing the process, so the deferred removal of the private directory
433// always runs: Ctrl-C, a closed terminal (SIGHUP), a supervisor's SIGTERM,
434// and SIGPIPE. The last is the one that matters most: `carlos db query ...
435// | head` closes stdout early, and with SIGPIPE left to the runtime Go
436// kills the process on the next write, which would leave a plaintext copy
437// of production in the temp directory. Caught, the write returns EPIPE
438// instead and the query loop stops on it.
439var dbStopSignals = []os.Signal{os.Interrupt, syscall.SIGTERM, syscall.SIGHUP, syscall.SIGPIPE}
440
441func dbSignalContext() (context.Context, context.CancelFunc) {
442 return signal.NotifyContext(context.Background(), dbStopSignals...)
443}
444