carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Acceptance for the forwarded `carlos db query` through the real CLI
2// binary and the real console server: T6 (parity with the in-region door,
3// through the fake S3 the db door's own e2e uses), T7 (the long request
4// through the real edge and the console's real http.Server), T8 (mixed
5// versions) and T11 (nothing printed for an answer that does not check out).
6// The box is plan 1's real agent and worker code joined in process
7// (dbreadstest): not acceptance for the units, which plan 1's root-runner
8// tests are. A scripted box (e2eScript) is a stand-in and says so.
9package main
10
11import (
12 "bufio"
13 "bytes"
14 "context"
15 "crypto/ecdh"
16 "crypto/ed25519"
17 "crypto/rand"
18 "crypto/sha256"
19 "database/sql"
20 "encoding/hex"
21 "encoding/json"
22 "fmt"
23 "io"
24 "net"
25 "net/http"
26 "net/http/httptest"
27 "os"
28 "os/exec"
29 "path/filepath"
30 "strings"
31 "sync"
32 "testing"
33 "time"
34
35 "github.com/benbjohnson/litestream"
36 lsfile "github.com/benbjohnson/litestream/file"
37 "github.com/superfly/ltx"
38
39 "github.com/carlosframework/platform/internal/console"
40 "github.com/carlosframework/platform/internal/dbdoor"
41 "github.com/carlosframework/platform/internal/dbdoor/dbdoortest"
42 "github.com/carlosframework/platform/internal/dbreads"
43 "github.com/carlosframework/platform/internal/dbreads/dbreadstest"
44 "github.com/carlosframework/platform/internal/edge"
45 "github.com/carlosframework/platform/internal/lease"
46 "github.com/carlosframework/platform/internal/provision"
47 "github.com/carlosframework/platform/internal/registry"
48 "github.com/carlosframework/platform/internal/release"
49)
50
51const (
52 dbfE2EHere = "shop.example.com" // in the console's region, ie
53 dbfE2EFar = "far.example.com" // in uk, with the same database bytes
54)
55
56type dbfE2EOptions struct {
57 forward bool
58 // serve serves the console's handler and returns the URL the CLI uses
59 // and any extra environment it needs; nil is the httptest server.
60 serve func(t *testing.T, h http.Handler) (consoleURL string, env []string)
61 // worker is the in-process box's worker options (a slow restore for T7).
62 worker dbreads.WorkerOptions
63 // noBox leaves the uk box out; the test scripts its own.
64 noBox bool
65}
66
67type dbfE2E struct {
68 bin, creds, url, tok, root string
69 env []string
70 store release.Store
71 pub ed25519.PublicKey
72 priv ed25519.PrivateKey
73}
74
75// newDBFE2E is a console in ie over a DirStore and the fake S3: shop in ie
76// and far in uk with byte-identical replicas (the same files with the same
77// modification times, so the fake S3 lists the same LastModified for both),
78// a capable live uk box, and a signed-in CLI.
79func newDBFE2E(t *testing.T, o dbfE2EOptions) *dbfE2E {
80 t.Helper()
81 creds := useTempCredentials(t)
82 clearDeploymentEnv(t)
83 tmp := t.TempDir()
84 e := &dbfE2E{bin: buildCarlos(t), creds: creds, store: release.NewDirStore(filepath.Join(tmp, "store")), root: filepath.Join(tmp, "data")}
85 var err error
86 if e.pub, e.priv, err = ed25519.GenerateKey(rand.Reader); err != nil {
87 t.Fatal(err)
88 }
89 here := filepath.Join(e.root, "instances", dbfE2EHere)
90 dbfE2EFixture(t, here)
91 copyTreeKeepingTimes(t, here, filepath.Join(e.root, "instances", dbfE2EFar))
92 s3 := httptest.NewServer(&replicaS3{t: t, bucket: "data", root: e.root})
93 t.Cleanup(s3.Close)
94 t.Setenv("CARLOS_S3_ENDPOINT", s3.URL)
95 t.Setenv("AWS_ACCESS_KEY_ID", "test")
96 t.Setenv("AWS_SECRET_ACCESS_KEY", "test")
97 t.Setenv("AWS_EC2_METADATA_DISABLED", "true")
98
99 cst, err := console.Open(filepath.Join(tmp, "console.db"))
100 if err != nil {
101 t.Fatal(err)
102 }
103 t.Cleanup(func() { cst.Close() })
104 var srv *console.Server
105 h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv.ServeHTTP(w, r) })
106 direct := httptest.NewServer(h)
107 t.Cleanup(direct.Close)
108 e.url = direct.URL
109 if o.serve != nil {
110 e.url, e.env = o.serve(t, h)
111 }
112 srv, err = console.NewServer(console.Config{
113 Origin: direct.URL, Keymail: "http://keymail.invalid", Registry: filepath.Join(tmp, "registry.db"),
114 Releases: e.store, ReleasePub: e.pub, Key: e.priv, AppScheme: "http",
115 Region: "ie", HibernateBucket: "data", HibernateRegion: "us-east-1", HibernatePrefix: "instances",
116 DBForward: o.forward,
117 }, cst)
118 if err != nil {
119 t.Fatal(err)
120 }
121 const addr = "member@example.com"
122 if err := cst.AddMember(console.OpsAccountID, addr, console.RoleOwner, time.Now()); err != nil {
123 t.Fatal(err)
124 }
125 if _, err := cst.CreateAppIn(console.OpsAccountID, "shop", addr, time.Now()); err != nil {
126 t.Fatal(err)
127 }
128 created := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)
129 for host, region := range map[string]string{dbfE2EHere: "ie", dbfE2EFar: "uk"} {
130 yes := true
131 body, _ := json.Marshal(provision.Record{Host: host, App: "shop", Account: release.OpsAccount, Region: region, Hibernate: &yes, CreatedAt: created})
132 if _, err := e.store.Put(context.Background(), provision.RecordKey(release.OpsAccount, "shop", host), body, ""); err != nil {
133 t.Fatal(err)
134 }
135 }
136 edges, _ := json.Marshal(map[string]any{"v": 1, "box": "uk-1", "eip": "203.0.113.10", "placement": "uk",
137 "capabilities": []string{dbreads.Capability}, "hosts": map[string]any{}})
138 e.store.Put(context.Background(), "control/edges/uk-1.json", edges, "")
139 status, _ := json.Marshal(map[string]any{"generated_at": time.Now().UTC(), "label": "uk-1"})
140 e.store.Put(context.Background(), "status/boxes/uk-1.json", status, "")
141 token, expiresAt := e2eDeviceLogin(t, direct.URL, cst, addr, "e2e@laptop")
142 e.tok = token
143 cf, err := loadCredentials()
144 if err != nil {
145 t.Fatal(err)
146 }
147 cf.put(credential{Console: e.url, Address: addr, Token: token, ExpiresAt: expiresAt})
148 if err := cf.save(); err != nil {
149 t.Fatal(err)
150 }
151 if !o.noBox {
152 box := dbreadstest.Start(t, dbreadstest.Options{Store: e.store, Region: "uk", Box: "uk-1", Pub: e.pub,
153 HibernatePrefix: "instances", Worker: o.worker,
154 OpenReplica: func(ctx context.Context, prefix string) (dbdoor.ReplicaReader, error) {
155 return lease.ReadReplicaClient(ctx, "data", prefix, "us-east-1")
156 }})
157 ctx, cancel := context.WithCancel(context.Background())
158 done := make(chan struct{})
159 go func() {
160 defer close(done)
161 for ctx.Err() == nil {
162 box.Agent.Pass(ctx)
163 time.Sleep(50 * time.Millisecond)
164 }
165 }()
166 t.Cleanup(func() { cancel(); <-done; box.Agent.Wait() })
167 }
168 return e
169}
170
171// dbfE2EFixture is T6's database (NULL, a tab and a newline in one value, a
172// blob, a time) in two syncs a whole second apart.
173func dbfE2EFixture(t *testing.T, dir string) {
174 t.Helper()
175 src := filepath.Join(t.TempDir(), "shop.db")
176 db, err := sql.Open("sqlite", src)
177 if err != nil {
178 t.Fatal(err)
179 }
180 defer db.Close()
181 for _, q := range []string{
182 `PRAGMA journal_mode=WAL`,
183 `CREATE TABLE orders (id INTEGER PRIMARY KEY, note TEXT, data BLOB, at DATETIME)`,
184 } {
185 if _, err := db.Exec(q); err != nil {
186 t.Fatal(err)
187 }
188 }
189 if _, err := db.Exec(`INSERT INTO orders (note, data, at) VALUES (?, ?, ?), (NULL, NULL, NULL)`,
190 "a\ttab\nline", []byte{0, 0xff}, time.Date(2026, 10, 7, 14, 0, 0, 0, time.UTC)); err != nil {
191 t.Fatal(err)
192 }
193 dbdoortest.Sync(t, src, dir)
194 time.Sleep(time.Until(time.Now().Truncate(time.Second).Add(1100 * time.Millisecond)))
195 if _, err := db.Exec(`INSERT INTO orders (note) VALUES ('later')`); err != nil {
196 t.Fatal(err)
197 }
198 dbdoortest.Sync(t, src, dir)
199}
200
201func copyTreeKeepingTimes(t *testing.T, from, to string) {
202 t.Helper()
203 err := filepath.Walk(from, func(p string, info os.FileInfo, err error) error {
204 if err != nil {
205 return err
206 }
207 rel, _ := filepath.Rel(from, p)
208 dst := filepath.Join(to, rel)
209 if info.IsDir() {
210 return os.MkdirAll(dst, 0o755)
211 }
212 b, err := os.ReadFile(p)
213 if err != nil {
214 return err
215 }
216 if err := os.WriteFile(dst, b, 0o644); err != nil {
217 return err
218 }
219 return os.Chtimes(dst, info.ModTime(), info.ModTime())
220 })
221 if err != nil {
222 t.Fatal(err)
223 }
224}
225
226// run runs the real CLI. Callers name the account (--account ops), as the
227// db door's own e2e does: a defaulted account prints its note on stdout,
228// which is main's behaviour for every db verb and not this plan's to change.
229func (e *dbfE2E) run(t *testing.T, args ...string) (string, string, error) {
230 t.Helper()
231 cmd := exec.Command(e.bin, args...)
232 cmd.Env = carlosTestEnv(t, append([]string{"CARLOS_CREDENTIALS=" + e.creds}, e.env...)...)
233 cmd.Dir = t.TempDir()
234 var stdout, stderr bytes.Buffer
235 cmd.Stdout, cmd.Stderr = &stdout, &stderr
236 err := cmd.Run()
237 return stdout.String(), stderr.String(), err
238}
239
240// betweenTheSyncs is an --at that holds the first sync and not the second:
241// half a second into the second after the first L0 file was written. The
242// second sync is at least 1.1 s after the first (dbfE2EFixture), so its
243// listed second is never before this time's.
244func (e *dbfE2E) betweenTheSyncs(t *testing.T) string {
245 t.Helper()
246 ents, err := os.ReadDir(filepath.Join(e.root, "instances", dbfE2EHere, "ltx", "0"))
247 if err != nil || len(ents) < 2 {
248 t.Fatalf("the fixture has %d L0 files: %v", len(ents), err)
249 }
250 var first time.Time
251 for _, ent := range ents {
252 info, err := ent.Info()
253 if err != nil {
254 t.Fatal(err)
255 }
256 if first.IsZero() || info.ModTime().Before(first) {
257 first = info.ModTime()
258 }
259 }
260 return first.Truncate(time.Second).Add(1500 * time.Millisecond).UTC().Format(time.RFC3339Nano)
261}
262
263// doer is an old CLI's view of the db door: GETs with the saved token.
264func (e *dbfE2E) doer(host string) dbdoor.Doer {
265 return func(ctx context.Context, tail string, h http.Header) (*http.Response, error) {
266 req, err := http.NewRequestWithContext(ctx, http.MethodGet, e.url+dbdoor.Base(release.OpsAccount, "shop", host)+tail, nil)
267 if err != nil {
268 return nil, err
269 }
270 for k, vs := range h {
271 req.Header[k] = vs
272 }
273 req.Header.Set("Authorization", "Bearer "+e.tok)
274 return http.DefaultClient.Do(req)
275 }
276}
277
278// e2eScript writes one read's objects itself: a stand-in for a box, sealing
279// real chunks and a real summary to the request's reply key.
280type e2eScript struct {
281 store release.Store
282 id string
283 pub *ecdh.PublicKey
284}
285
286func (b *e2eScript) picked(deadline time.Time) {
287 p, _ := json.Marshal(dbreads.Picked{Box: "uk-1", Nonce: "scripted", Deadline: deadline})
288 b.store.Put(context.Background(), dbreads.PickedKey(b.id), p, "")
289}
290
291func (b *e2eScript) chunk(seq int64, last bool, plain string) {
292 sealed, _ := dbreads.SealChunk(b.pub, b.id, seq, dbreads.StreamRows, last, []byte(plain))
293 b.store.Put(context.Background(), dbreads.ChunkKey(b.id, seq), sealed, "")
294}
295
296func (b *e2eScript) result(last int64, rows string) {
297 sum := sha256.Sum256([]byte(rows))
298 sealed, _ := dbreads.SealSummary(b.pub, b.id, dbreads.Summary{TXID: 9, Bytes: int64(len(rows)), SHA256: hex.EncodeToString(sum[:])})
299 r, _ := json.Marshal(dbreads.Result{State: dbreads.StateDone, LastSeq: last, Box: "uk-1", Sealed: sealed})
300 b.store.Put(context.Background(), dbreads.ResultKey(b.id), r, "")
301}
302
303// scriptBox runs script once for each new request in uk's queue, after
304// verifying it as a box would.
305func (e *dbfE2E) scriptBox(t *testing.T, script func(*e2eScript)) func() {
306 ctx, cancel := context.WithCancel(context.Background())
307 done := make(chan struct{})
308 go func() {
309 defer close(done)
310 seen := map[string]bool{}
311 for ctx.Err() == nil {
312 keys, _ := e.store.List(ctx, dbreads.QueueRegionPrefix("uk"))
313 for _, k := range keys {
314 q, ok := dbreads.ParseQueueKey(k)
315 if !ok || seen[q.ID] {
316 continue
317 }
318 seen[q.ID] = true
319 raw, _, err := e.store.Get(ctx, dbreads.RequestKey(q.ID))
320 if err != nil {
321 continue
322 }
323 req, err := dbreads.Verify(e.pub, raw, q.ID, "uk", time.Now())
324 if err != nil {
325 t.Errorf("the scripted box refused %s: %v", q.ID, err)
326 continue
327 }
328 pub, _ := dbreads.ParseReplyKey(req.ReplyKey)
329 go script(&e2eScript{store: e.store, id: q.ID, pub: pub})
330 }
331 time.Sleep(50 * time.Millisecond)
332 }
333 }()
334 return func() { cancel(); <-done }
335}
336
337// throughTheEdge serves the console's handler with console.NewHTTPServer on
338// 127.0.0.1, its ReadTimeout set to readTimeout, behind a real edge route
339// for console.test. The CLI reaches it by that name through HTTP_PROXY,
340// which stands in for DNS.
341func throughTheEdge(readTimeout time.Duration, edgeURL *string, tap **dbfTap) func(t *testing.T, h http.Handler) (string, []string) {
342 return func(t *testing.T, h http.Handler) (string, []string) {
343 ln, err := net.Listen("tcp", "127.0.0.1:0")
344 if err != nil {
345 t.Fatal(err)
346 }
347 hs := console.NewHTTPServer(h)
348 hs.ReadTimeout = readTimeout
349 go hs.Serve(ln)
350 t.Cleanup(func() { hs.Close() })
351 reg, err := registry.Open(filepath.Join(t.TempDir(), "edge.db"))
352 if err != nil {
353 t.Fatal(err)
354 }
355 if err := reg.Upsert(registry.Route{Host: "console.test", Kind: "service", Addr: ln.Addr().String(), Status: "ready"}); err != nil {
356 t.Fatal(err)
357 }
358 ed, err := edge.New(reg, nil)
359 if err != nil {
360 t.Fatal(err)
361 }
362 if err := ed.Reload(); err != nil {
363 t.Fatal(err)
364 }
365 es := httptest.NewServer(ed)
366 t.Cleanup(es.Close)
367 *edgeURL = es.URL
368 // The CLI's proxy is a tap in front of the edge, so the test sees
369 // what reaches the CLI's own socket (plan review finding 34).
370 tp := newDBFTap(t, es.Listener.Addr().String(), false)
371 *tap = tp
372 return "http://console.test", []string{"HTTP_PROXY=http://" + tp.addr, "NO_PROXY="}
373 }
374}
375
376// dbfTap stands between the CLI and the first edge, forwarding bytes both
377// ways and noting when each read from the edge's side was passed to the
378// CLI's socket: what the CLI process received, and when. With connect it is
379// an HTTPS proxy (CONNECT), which is how the CLI reaches a TLS edge by name
380// here; without, a plain forwarder for HTTP_PROXY.
381type dbfTap struct {
382 addr string
383 mu sync.Mutex
384 down []time.Time
385}
386
387func newDBFTap(t *testing.T, target string, connect bool) *dbfTap {
388 t.Helper()
389 ln, err := net.Listen("tcp", "127.0.0.1:0")
390 if err != nil {
391 t.Fatal(err)
392 }
393 t.Cleanup(func() { ln.Close() })
394 tp := &dbfTap{addr: ln.Addr().String()}
395 go func() {
396 for {
397 c, err := ln.Accept()
398 if err != nil {
399 return
400 }
401 go tp.serve(c, target, connect)
402 }
403 }()
404 return tp
405}
406
407func (tp *dbfTap) serve(c net.Conn, target string, connect bool) {
408 defer c.Close()
409 var in io.Reader = c
410 if connect {
411 br := bufio.NewReader(c)
412 req, err := http.ReadRequest(br)
413 if err != nil || req.Method != http.MethodConnect {
414 return
415 }
416 if _, err := io.WriteString(c, "HTTP/1.1 200 Connection established\r\n\r\n"); err != nil {
417 return
418 }
419 in = br
420 }
421 up, err := net.Dial("tcp", target)
422 if err != nil {
423 return
424 }
425 defer up.Close()
426 go func() {
427 io.Copy(up, in)
428 up.(*net.TCPConn).CloseWrite()
429 }()
430 buf := make([]byte, 32<<10)
431 for {
432 n, err := up.Read(buf)
433 if n > 0 {
434 tp.mu.Lock()
435 tp.down = append(tp.down, time.Now())
436 tp.mu.Unlock()
437 if _, werr := c.Write(buf[:n]); werr != nil {
438 return
439 }
440 }
441 if err != nil {
442 return
443 }
444 }
445}
446
447// longestGap is the longest silence at the CLI's socket between start and
448// end, both of which count as arrivals.
449func (tp *dbfTap) longestGap(start, end time.Time) time.Duration {
450 tp.mu.Lock()
451 defer tp.mu.Unlock()
452 prev, longest := start, time.Duration(0)
453 for _, at := range append(append([]time.Time(nil), tp.down...), end) {
454 if at.Before(start) || at.After(end) {
455 continue
456 }
457 if d := at.Sub(prev); d > longest {
458 longest = d
459 }
460 prev = at
461 }
462 return longest
463}
464
465// checkProgressAtTheCLI is T7's witness at the CLI process itself (plan
466// review finding 34): it runs the real CLI and needs DBF-11 on its stderr
467// within a second of the process starting (stricter than the spec's "of the
468// request": the process starts before it sends anything), no silence over
469// 16 s (15 s and a poll) at its socket, the read lasting at least atLeast, and
470// its rows. atLeast is the quiet restore's length.
471func checkProgressAtTheCLI(t *testing.T, e *dbfE2E, tap *dbfTap, atLeast time.Duration) {
472 t.Helper()
473 cmd := exec.Command(e.bin, "db", "query", "--account", "ops", "--app", "shop", "--instance", dbfE2EFar, "--sql", "SELECT count(*) AS n FROM orders")
474 cmd.Env = carlosTestEnv(t, append([]string{"CARLOS_CREDENTIALS=" + e.creds}, e.env...)...)
475 cmd.Dir = t.TempDir()
476 var out bytes.Buffer
477 cmd.Stdout = &out
478 pipe, err := cmd.StderrPipe()
479 if err != nil {
480 t.Fatal(err)
481 }
482 start := time.Now()
483 if err := cmd.Start(); err != nil {
484 t.Fatal(err)
485 }
486 var stderr []string
487 var reading time.Time
488 sc := bufio.NewScanner(pipe)
489 for sc.Scan() {
490 if reading.IsZero() && strings.Contains(sc.Text(), fmt.Sprintf(dbfReading, dbfE2EFar, "uk")) {
491 reading = time.Now()
492 }
493 stderr = append(stderr, sc.Text())
494 }
495 err = cmd.Wait()
496 end := time.Now()
497 if err != nil || out.String() != "n\n3\n" {
498 t.Fatalf("%q %v\n%s", out.String(), err, strings.Join(stderr, "\n"))
499 }
500 if reading.IsZero() || reading.Sub(start) > time.Second {
501 t.Fatalf("DBF-11 after %s: %q", reading.Sub(start), stderr)
502 }
503 if gap := tap.longestGap(start, end); gap > 16*time.Second {
504 t.Fatalf("%s of silence at the CLI's socket", gap)
505 }
506 if took := end.Sub(start); took < atLeast {
507 t.Fatalf("the read took %s, under %s: it did not hold the request", took, atLeast)
508 }
509}
510
511// slowRestore holds the box's restore for d before restoring for real.
512func slowRestore(d time.Duration) dbreads.WorkerOptions {
513 return dbreads.WorkerOptions{Restore: func(ctx context.Context, c litestream.ReplicaClient, out string, target uint64) error {
514 select {
515 case <-ctx.Done():
516 return ctx.Err()
517 case <-time.After(d):
518 }
519 return dbdoor.RestoreLocal(ctx, c, out, target)
520 }}
521}
522
523// T6: the forwarded query and the in-region query print byte-identical
524// stdout, for the newest copy and as of a time between the two syncs.
525func TestDBForwardParityThroughTheCLI(t *testing.T) {
526 e := newDBFE2E(t, dbfE2EOptions{forward: true})
527 const q = "SELECT id, note, data, at FROM orders ORDER BY id"
528 for _, at := range []string{"", e.betweenTheSyncs(t)} {
529 args := []string{"db", "query", "--account", "ops", "--app", "shop", "--sql", q}
530 if at != "" {
531 args = append(args, "--at", at)
532 }
533 here, hereErr, err := e.run(t, append(args, "--instance", dbfE2EHere)...)
534 if err != nil {
535 t.Fatalf("in region: %v\n%s", err, hereErr)
536 }
537 far, farErr, err := e.run(t, append(args, "--instance", dbfE2EFar)...)
538 if err != nil {
539 t.Fatalf("forwarded: %v\n%s", err, farErr)
540 }
541 if here != far || here == "" {
542 t.Fatalf("at %q: in region %q, forwarded %q", at, here, far)
543 }
544 if !strings.Contains(farErr, fmt.Sprintf(dbfReading, dbfE2EFar, "uk")) {
545 t.Fatalf("no DBF-11 on stderr: %s", farErr)
546 }
547 if at != "" && strings.Contains(far, "later") {
548 t.Fatalf("as of %s the copy holds the second sync: %q", at, far)
549 }
550 }
551}
552
553// T7, scaled: the console's real http.Server with ReadTimeout 2 s instead of
554// 5 minutes, behind the real edge, and a read held 20 s by a slow restore.
555// The connection outlives ReadTimeout tenfold, admitted arrives within a
556// second, a line arrives at least every 15 s (plus a poll), and the real CLI
557// gets its rows through the same path.
558func TestDBForwardLongRequestThroughTheEdge(t *testing.T) {
559 var edgeURL string
560 var tap *dbfTap
561 e := newDBFE2E(t, dbfE2EOptions{forward: true, serve: throughTheEdge(2*time.Second, &edgeURL, &tap), worker: slowRestore(20 * time.Second)})
562 key, _ := dbreads.NewReplyKey()
563 body, _ := json.Marshal(dbreads.ReadBody{Op: dbreads.OpQuery, SQL: "SELECT count(*) AS n FROM orders", ReplyKey: dbreads.EncodeReplyKey(key.PublicKey())})
564 req, _ := http.NewRequest(http.MethodPost, edgeURL+"/api/cli/apps/"+release.OpsAccount+"/shop/db/"+dbfE2EFar+"/reads", bytes.NewReader(body))
565 req.Host = "console.test"
566 req.Header.Set("Content-Type", "application/json")
567 req.Header.Set("Authorization", "Bearer "+e.tok)
568 start := time.Now()
569 res, err := http.DefaultClient.Do(req)
570 if err != nil {
571 t.Fatal(err)
572 }
573 defer res.Body.Close()
574 br := bufio.NewReader(res.Body)
575 prev, first, sawResult := start, true, false
576 for {
577 line, err := br.ReadBytes('\n')
578 if err == io.EOF {
579 break
580 }
581 if err != nil {
582 t.Fatal(err)
583 }
584 gap := time.Since(prev)
585 if first && gap > time.Second {
586 t.Fatalf("admitted took %s", gap)
587 }
588 if gap > 16*time.Second {
589 t.Fatalf("%s with no line", gap)
590 }
591 prev, first = time.Now(), false
592 sawResult = sawResult || bytes.Contains(line, []byte(`"result"`))
593 }
594 if !sawResult || time.Since(start) < 20*time.Second {
595 t.Fatalf("result %v after %s", sawResult, time.Since(start))
596 }
597 // The same through the real CLI process, observed at its own stderr and
598 // socket (plan review finding 34).
599 checkProgressAtTheCLI(t, e, tap, 20*time.Second)
600}
601
602// T7, unscaled: a read that holds the request for 21 minutes through the
603// real edge and the console's unmodified http.Server (5-minute ReadTimeout),
604// inside the CLI's 23-minute context. The box is scripted (a stand-in):
605// picked at 1 minute 50 seconds with a 20-minute deadline, one chunk at 10
606// minutes, the last and the result at 21. It waits; it does not compute.
607// Run once before handoff with CARLOS_DBF_LONG=1.
608func TestDBForwardTwentyOneMinuteRead(t *testing.T) {
609 if os.Getenv("CARLOS_DBF_LONG") != "1" {
610 t.Skip("set CARLOS_DBF_LONG=1 to run the unscaled 21-minute read, once, before handoff")
611 }
612 var edgeURL string
613 var tap *dbfTap
614 e := newDBFE2E(t, dbfE2EOptions{forward: true, noBox: true, serve: throughTheEdge(5*time.Minute, &edgeURL, &tap)})
615 stop := e.scriptBox(t, func(b *e2eScript) {
616 time.Sleep(110 * time.Second)
617 b.picked(time.Now().Add(20 * time.Minute))
618 time.Sleep(8 * time.Minute)
619 b.chunk(1, false, "n\n")
620 time.Sleep(11*time.Minute + 10*time.Second)
621 b.chunk(2, true, "3\n")
622 b.result(2, "n\n3\n")
623 })
624 defer stop()
625 start := time.Now()
626 out, stderr, err := e.run(t, "db", "query", "--account", "ops", "--app", "shop", "--instance", dbfE2EFar, "--sql", "SELECT count(*) AS n FROM orders")
627 if err != nil || out != "n\n3\n" || time.Since(start) < 21*time.Minute {
628 t.Fatalf("after %s: %q %v\n%s", time.Since(start), out, err, stderr)
629 }
630}
631
632// T8: the rows of spec §9.2 the CLI sees.
633func TestDBForwardMixedVersions(t *testing.T) {
634 off := newDBFE2E(t, dbfE2EOptions{forward: false, noBox: true})
635 // A new CLI and a console with forwarding off (or an old console): the
636 // residency sentence as it is, and nothing on stdout.
637 out, stderr, err := off.run(t, "db", "query", "--account", "ops", "--app", "shop", "--instance", dbfE2EFar, "--sql", "SELECT 1")
638 if err == nil || out != "" || !strings.Contains(stderr, "far.example.com keeps its data in region uk. This console can only read data kept in region ie.") {
639 t.Fatalf("new CLI, forwarding off: %q %q %v", out, stderr, err)
640 }
641 on := newDBFE2E(t, dbfE2EOptions{forward: true, noBox: true})
642 // An old CLI and a console with forwarding on: every CLI from before
643 // this plan runs dbRestoreError on a door refusal, which prints the
644 // door's sentence. That sentence is DBF-14.
645 _, derr := dbdoor.NewClient(on.doer(dbfE2EFar)).LTXFiles(context.Background(), 9, 0, false)
646 want := "far.example.com keeps its data in region uk. this version of carlos cannot read it from there. run carlos update, then try again."
647 if got := dbRestoreError(dbfE2EFar, derr).Error(); got != want {
648 t.Fatalf("old CLI, new console: %q", got)
649 }
650}
651
652// T11 (codex 6): the real CLI prints nothing for a dropped last chunk, a
653// summary hash mismatch, or a response that ends without a result. The box
654// is scripted: a stand-in that writes picked, chunks and result itself.
655func TestDBForwardPrintsNothingForABadAnswer(t *testing.T) {
656 for name, c := range map[string]struct {
657 script func(b *e2eScript)
658 want string
659 }{
660 // The bucket lost the real last chunk: the result names chunk 1,
661 // which was sealed as not the last.
662 "the last chunk dropped": {func(b *e2eScript) {
663 b.picked(time.Now().Add(time.Minute))
664 b.chunk(1, false, "n\n")
665 b.result(1, "n\n")
666 }, "was invalid, so carlos db printed nothing"},
667 "a summary hash mismatch": {func(b *e2eScript) {
668 b.picked(time.Now().Add(time.Minute))
669 b.chunk(1, true, "n\n31\n")
670 b.result(1, "n\n30\n")
671 }, "was invalid, so carlos db printed nothing"},
672 // The console waits the owner's deadline plus its 1-minute grace.
673 "no result": {func(b *e2eScript) {
674 b.picked(time.Now().Add(2 * time.Second))
675 b.chunk(1, false, "n\n")
676 }, "did not finish, so nothing was printed"},
677 } {
678 t.Run(name, func(t *testing.T) {
679 e := newDBFE2E(t, dbfE2EOptions{forward: true, noBox: true})
680 stop := e.scriptBox(t, c.script)
681 defer stop()
682 out, stderr, err := e.run(t, "db", "query", "--account", "ops", "--app", "shop", "--instance", dbfE2EFar, "--sql", "SELECT count(*) AS n FROM orders")
683 if err == nil || out != "" || !strings.Contains(stderr, c.want) {
684 t.Fatalf("%q %q %v", out, stderr, err)
685 }
686 })
687 }
688}
689
690// Plan review finding 36, through the binary: a query holding a byte that
691// is not UTF-8 is refused with DBF-32 before anything is sent, so no altered
692// query reaches the bucket. (An argv may carry any bytes on Linux, where
693// this runs.)
694func TestDBForwardRefusesSQLThatIsNotUTF8(t *testing.T) {
695 e := newDBFE2E(t, dbfE2EOptions{forward: true, noBox: true})
696 out, stderr, err := e.run(t, "db", "query", "--account", "ops", "--app", "shop", "--instance", dbfE2EFar, "--sql", "SELECT 'caf\xe9'")
697 if err == nil || out != "" || !strings.Contains(stderr, fmt.Sprintf(dbfNotUTF8, "uk")) {
698 t.Fatalf("%q %q %v", out, stderr, err)
699 }
700 if keys, _ := e.store.List(context.Background(), dbreads.Prefix); len(keys) != 0 {
701 t.Fatalf("a query JSON would rewrite reached the bucket: %v", keys)
702 }
703}
704
705// throughBackhaul serves the console's handler with console.NewHTTPServer
706// (its ReadTimeout set to readTimeout) on 127.0.0.1, behind two real edges
707// that package edge's test binary runs as a helper process: the CLI reaches
708// betty, which backhauls to annie, the console's home. The CLI finds betty
709// through a CONNECT proxy (a dbfTap, standing in for DNS) and trusts the
710// edges' test CA through SSL_CERT_FILE. Building the helper needs the go
711// command on PATH, as every CI step has.
712func throughBackhaul(readTimeout time.Duration, tap **dbfTap) func(t *testing.T, h http.Handler) (string, []string) {
713 return func(t *testing.T, h http.Handler) (string, []string) {
714 ln, err := net.Listen("tcp", "127.0.0.1:0")
715 if err != nil {
716 t.Fatal(err)
717 }
718 hs := console.NewHTTPServer(h)
719 hs.ReadTimeout = readTimeout
720 go hs.Serve(ln)
721 t.Cleanup(func() { hs.Close() })
722 dir := t.TempDir()
723 bin := filepath.Join(dir, "edge.test")
724 // From the module root with the environment TestMain kept for the
725 // carlos build (the real HOME and Go cache), as sharedCarlos builds.
726 build := exec.Command("go", "test", "-c", "-o", bin, "./internal/edge")
727 build.Dir, build.Env = moduleRoot, carlosBuildEnv
728 if out, err := build.CombinedOutput(); err != nil {
729 t.Fatalf("build the edge test binary: %v\n%s", err, out)
730 }
731 cmd := exec.Command(bin, "-test.run", "^TestDBForwardBackhaulPairHelper$", "-test.v")
732 cmd.Env = append(os.Environ(), "DBF_BACKHAUL_HELPER=1", "DBF_BACKHAUL_UPSTREAM="+ln.Addr().String(), "DBF_BACKHAUL_DIR="+dir)
733 stdin, err := cmd.StdinPipe()
734 if err != nil {
735 t.Fatal(err)
736 }
737 pr, pw := io.Pipe()
738 cmd.Stdout = pw
739 var helperErr bytes.Buffer
740 cmd.Stderr = &helperErr
741 if err := cmd.Start(); err != nil {
742 t.Fatal(err)
743 }
744 t.Cleanup(func() {
745 stdin.Close()
746 cmd.Wait()
747 pw.Close()
748 if t.Failed() {
749 t.Logf("backhaul helper stderr:\n%s", helperErr.String())
750 }
751 })
752 lines := bufio.NewScanner(pr)
753 sender := ""
754 for lines.Scan() {
755 if addr, ok := strings.CutPrefix(lines.Text(), "DBF-BACKHAUL "); ok {
756 sender = addr
757 break
758 }
759 }
760 if sender == "" {
761 t.Fatalf("the backhaul helper never came up:\n%s", helperErr.String())
762 }
763 go func() {
764 for lines.Scan() {
765 }
766 }()
767 tp := newDBFTap(t, sender, true)
768 *tap = tp
769 return "https://console.backhaul.test", []string{"HTTPS_PROXY=http://" + tp.addr, "NO_PROXY=", "SSL_CERT_FILE=" + filepath.Join(dir, "ca.pem")}
770 }
771}
772
773// T7, the backhaul half, end to end (plan review finding 34): the real CLI
774// binary reaches the real console through two real edges, the second hop a
775// backhaul with its 30 s header bound, during a 40 s quiet restore. At the
776// CLI's own stderr and socket: DBF-11 within a second, no silence over 16 s,
777// and its rows after more than the hop's 30 s. The console's real
778// http.Server runs with ReadTimeout 2 s, as in
779// TestDBForwardLongRequestThroughTheEdge.
780// Mutations: drop the stream's flush (red: the wait lines sit in the
781// console's buffer and the CLI's socket is silent); stream nothing before
782// the box's first chunk (red: no headers inside the hop's 30 s).
783func TestDBForwardThroughTheBackhaulHop(t *testing.T) {
784 var tap *dbfTap
785 e := newDBFE2E(t, dbfE2EOptions{forward: true, serve: throughBackhaul(2*time.Second, &tap), worker: slowRestore(40 * time.Second)})
786 checkProgressAtTheCLI(t, e, tap, 40*time.Second)
787}
788
789func sha256File(t *testing.T, path string) string {
790 t.Helper()
791 b, err := os.ReadFile(path)
792 if err != nil {
793 t.Fatal(err)
794 }
795 sum := sha256.Sum256(b)
796 return hex.EncodeToString(sum[:])
797}
798
799// The pull's parity (plan 3): a forwarded pull and an in-region pull of the
800// same replica save byte-identical copies, for the newest copy and as of a
801// time, and print the same lines but for the host and the path.
802func TestDBForwardPullParityThroughTheCLI(t *testing.T) {
803 e := newDBFE2E(t, dbfE2EOptions{forward: true})
804 for _, at := range []string{"", e.betweenTheSyncs(t)} {
805 dir := t.TempDir()
806 args := []string{"db", "pull", "--account", "ops", "--app", "shop"}
807 if at != "" {
808 args = append(args, "--at", at)
809 }
810 hereOut, farOut := filepath.Join(dir, "here.db"), filepath.Join(dir, "far.db")
811 here, hereErr, err := e.run(t, append(args, "--instance", dbfE2EHere, "--out", hereOut)...)
812 if err != nil {
813 t.Fatalf("in region: %v\n%s", err, hereErr)
814 }
815 far, farErr, err := e.run(t, append(args, "--instance", dbfE2EFar, "--out", farOut)...)
816 if err != nil {
817 t.Fatalf("forwarded: %v\n%s", err, farErr)
818 }
819 if sha256File(t, hereOut) != sha256File(t, farOut) {
820 t.Fatalf("at %q the copies differ", at)
821 }
822 if here == "" || strings.ReplaceAll(strings.ReplaceAll(far, dbfE2EFar, dbfE2EHere), farOut, hereOut) != here {
823 t.Fatalf("at %q: in region %q, forwarded %q", at, here, far)
824 }
825 if !strings.Contains(farErr, fmt.Sprintf(dbfFetching, dbfE2EFar, "uk")) {
826 t.Fatalf("no DBF-18: %s", farErr)
827 }
828 db, _ := sql.Open("sqlite", "file:"+farOut+"?mode=ro")
829 var later int
830 err = db.QueryRow(`SELECT count(*) FROM orders WHERE note = 'later'`).Scan(&later)
831 db.Close()
832 if err != nil || (at == "") != (later == 1) {
833 t.Fatalf("at %q the copy holds %d of the second sync's rows (%v)", at, later, err)
834 }
835 }
836}
837
838// T11 for a pull: a hash mismatch saves nothing at --out. Everything else
839// is a pull the CLI would accept: far's real planned files, whole and
840// valid, a manifest that passes checkPullManifest, and a summary whose only
841// fault is its SHA-256 (plan review finding 32). Mutation: openDBPull skips
842// the hash comparison (red: the files restore and a copy is saved).
843func TestDBForwardPullSavesNothingForABadAnswer(t *testing.T) {
844 e := newDBFE2E(t, dbfE2EOptions{forward: true, noBox: true})
845 stop := e.scriptBox(t, func(b *e2eScript) {
846 ctx := context.Background()
847 rd := lsfile.NewReplicaClient(filepath.Join(e.root, "instances", dbfE2EFar))
848 plan, err := dbdoor.PlanFrom(ctx, rd, time.Time{})
849 if err != nil {
850 t.Errorf("plan far's replica: %v", err)
851 return
852 }
853 var all bytes.Buffer
854 for _, f := range plan.Planned {
855 rc, err := rd.OpenLTXFile(ctx, f.Level, ltx.TXID(f.MinTXID), ltx.TXID(f.MaxTXID), 0, 0)
856 if err != nil {
857 t.Errorf("open %+v: %v", f, err)
858 return
859 }
860 io.Copy(&all, rc)
861 rc.Close()
862 }
863 b.picked(time.Now().Add(time.Minute))
864 sealed, _ := dbreads.SealChunk(b.pub, b.id, 1, dbreads.StreamLTX, true, all.Bytes())
865 b.store.Put(ctx, dbreads.ChunkKey(b.id, 1), sealed, "")
866 wrong := sha256.Sum256([]byte("not these bytes"))
867 summary, _ := dbreads.SealSummary(b.pub, b.id, dbreads.Summary{TXID: plan.TXID, At: plan.At, Files: plan.Planned,
868 Bytes: int64(all.Len()), SHA256: hex.EncodeToString(wrong[:])})
869 r, _ := json.Marshal(dbreads.Result{State: dbreads.StateDone, LastSeq: 1, Box: "uk-1", Sealed: summary})
870 b.store.Put(ctx, dbreads.ResultKey(b.id), r, "")
871 })
872 defer stop()
873 out := filepath.Join(t.TempDir(), "far.db")
874 _, stderr, err := e.run(t, "db", "pull", "--account", "ops", "--app", "shop", "--instance", dbfE2EFar, "--out", out)
875 if err == nil || !strings.Contains(stderr, fmt.Sprintf(dbfUnchecked, "uk")) {
876 t.Fatalf("%q %v", stderr, err)
877 }
878 if _, err := os.Stat(out); !os.IsNotExist(err) {
879 t.Fatalf("something was saved at --out: %v", err)
880 }
881}
882
883// Review Focus 4 and plan review finding 23, through the binary: SIGINT
884// while the CLI is spooling a pull prints "stopped", saves nothing at --out
885// and removes the pull's directory. The scripted box (a stand-in) writes two
886// chunks and never a result, so the CLI is mid-pull when the signal lands.
887// Mutation: drop dbPullForward's deferred RemoveAll (red: carlos-db-* is
888// left in the CLI's TMPDIR).
889func TestDBForwardPullInterruptedLeavesNothing(t *testing.T) {
890 e := newDBFE2E(t, dbfE2EOptions{forward: true, noBox: true})
891 stop := e.scriptBox(t, func(b *e2eScript) {
892 b.picked(time.Now().Add(time.Minute))
893 for seq := int64(1); seq <= 2; seq++ {
894 sealed, _ := dbreads.SealChunk(b.pub, b.id, seq, dbreads.StreamLTX, false, bytes.Repeat([]byte{'x'}, 4096))
895 b.store.Put(context.Background(), dbreads.ChunkKey(b.id, seq), sealed, "")
896 }
897 })
898 defer stop()
899 spool := t.TempDir()
900 out := filepath.Join(t.TempDir(), "far.db")
901 cmd := exec.Command(e.bin, "db", "pull", "--account", "ops", "--app", "shop", "--instance", dbfE2EFar, "--out", out)
902 // The last TMPDIR wins (os/exec keeps the last of a duplicated key).
903 cmd.Env = append(carlosTestEnv(t, append([]string{"CARLOS_CREDENTIALS=" + e.creds}, e.env...)...), "TMPDIR="+spool)
904 cmd.Dir = t.TempDir()
905 var stderr bytes.Buffer
906 cmd.Stderr = &stderr
907 if err := cmd.Start(); err != nil {
908 t.Fatal(err)
909 }
910 deadline := time.Now().Add(30 * time.Second)
911 for {
912 if got, _ := filepath.Glob(filepath.Join(spool, "carlos-db-*", "sealed", "2")); len(got) == 1 {
913 break
914 }
915 if time.Now().After(deadline) {
916 cmd.Process.Kill()
917 cmd.Wait()
918 t.Fatalf("the CLI never spooled two chunks: %s", stderr.String())
919 }
920 time.Sleep(50 * time.Millisecond)
921 }
922 if err := cmd.Process.Signal(os.Interrupt); err != nil {
923 t.Fatal(err)
924 }
925 if err := cmd.Wait(); err == nil || !strings.Contains(stderr.String(), "stopped") {
926 t.Fatalf("%v: %s", err, stderr.String())
927 }
928 if _, err := os.Stat(out); !os.IsNotExist(err) {
929 t.Fatalf("something was saved at --out: %v", err)
930 }
931 if left, _ := filepath.Glob(filepath.Join(spool, "carlos-db-*")); len(left) != 0 {
932 t.Fatalf("the pull's directory is still there: %v", left)
933 }
934}
935
936// A console from plan 2 forwards queries and not pulls. Its marker is
937// played here by a wrapper that drops "pull" from forward_ops on the way
938// out: the new CLI says DBF-8 and saves nothing. The wrapper buffers each
939// response, so it serves only where no response streams (the list door's
940// 409 is all this test reads).
941func TestDBForwardPullOnAQueryOnlyConsole(t *testing.T) {
942 queryOnly := func(t *testing.T, h http.Handler) (string, []string) {
943 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
944 rec := httptest.NewRecorder()
945 h.ServeHTTP(rec, r)
946 body := rec.Body.Bytes()
947 if rec.Code == http.StatusConflict {
948 body = bytes.Replace(body, []byte(`"forward_ops":["query","pull"]`), []byte(`"forward_ops":["query"]`), 1)
949 }
950 for k, vs := range rec.Header() {
951 w.Header()[k] = vs
952 }
953 w.Header().Del("Content-Length")
954 w.WriteHeader(rec.Code)
955 w.Write(body)
956 }))
957 t.Cleanup(ts.Close)
958 return ts.URL, nil
959 }
960 e := newDBFE2E(t, dbfE2EOptions{forward: true, noBox: true, serve: queryOnly})
961 out := filepath.Join(t.TempDir(), "far.db")
962 _, stderr, err := e.run(t, "db", "pull", "--account", "ops", "--app", "shop", "--instance", dbfE2EFar, "--out", out)
963 if err == nil || !strings.Contains(stderr, fmt.Sprintf(dbfNoPull, dbfE2EFar, "uk")) {
964 t.Fatalf("%q %v", stderr, err)
965 }
966 if _, err := os.Stat(out); !os.IsNotExist(err) {
967 t.Fatalf("something was saved at --out: %v", err)
968 }
969}
970