carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1//go:build linux && rootrunner
2
3package host
4
5import (
6 "bufio"
7 "bytes"
8 "context"
9 "crypto/ecdh"
10 "crypto/ed25519"
11 "crypto/rand"
12 "crypto/sha256"
13 "database/sql"
14 "encoding/base64"
15 "encoding/hex"
16 "encoding/json"
17 "fmt"
18 "io"
19 "os"
20 "os/exec"
21 "os/user"
22 "path/filepath"
23 "strconv"
24 "strings"
25 "syscall"
26 "testing"
27 "time"
28
29 lsfile "github.com/benbjohnson/litestream/file"
30 "github.com/superfly/ltx"
31
32 "github.com/carlosframework/platform/internal/dbdoor"
33 "github.com/carlosframework/platform/internal/dbdoor/dbdoortest"
34 "github.com/carlosframework/platform/internal/dbreads"
35 "github.com/carlosframework/platform/internal/hostclaim"
36 "github.com/carlosframework/platform/internal/provision"
37 "github.com/carlosframework/platform/internal/release"
38)
39
40const (
41 dbreadRunnerHost = "shop.example.com"
42 // dbreadReportPrefix marks the payload's one report line. m.Run prints
43 // PASS after the payload test returns, so the report is never the last
44 // line (plan review finding 3).
45 dbreadReportPrefix = "DBREAD-REPORT "
46 // dbreadServiceFile is where ensureSystemUnits("/etc", ...) installs the
47 // worker's service (plan review finding 4).
48 dbreadServiceFile = "/etc/systemd/system/carlos-dbread@.service"
49)
50
51// dbreadRunner is the production wiring on a throwaway root runner: the real
52// units installed by the real ensureSystemUnits into /etc, as the agent's
53// adopt pass installs them (agent.go), this test binary at /opt/carlos/carlos
54// where the unit's ExecStart finds it, and the agent half run as carlos in a
55// child process, because the worker serves only the carlos user's
56// connections. /var/tmp is a tmpfs of tmpSize: the runner's small root
57// volume would refuse every read's 5 GiB floor, and tmpfs only spends memory
58// on what is written.
59type dbreadRunner struct {
60 t *testing.T
61 dir string // owned by carlos: the store and the replica
62 uid, gid int
63 rootPub ed25519.PublicKey
64 rootPriv ed25519.PrivateKey
65}
66
67func runSystemctl(args ...string) (string, error) {
68 out, err := exec.Command("systemctl", args...).CombinedOutput()
69 return string(out), err
70}
71
72func mustSystemctl(t *testing.T, args ...string) {
73 t.Helper()
74 if out, err := runSystemctl(args...); err != nil {
75 t.Fatalf("systemctl %s: %v: %s", strings.Join(args, " "), err, out)
76 }
77}
78
79// sqliteFixture writes a real SQLite database at path, so an ATTACH of it
80// fails only when the file cannot be reached (plan review finding 17).
81func sqliteFixture(t *testing.T, path string) {
82 t.Helper()
83 db, err := sql.Open("sqlite", path)
84 if err != nil {
85 t.Fatal(err)
86 }
87 defer db.Close()
88 if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS secret (v TEXT); INSERT INTO secret VALUES ('not for tenants')`); err != nil {
89 t.Fatal(err)
90 }
91}
92
93func newDBReadRunner(t *testing.T, tmpSize string) *dbreadRunner {
94 t.Helper()
95 if os.Geteuid() != 0 {
96 t.Fatal("run on the root runner, as root")
97 }
98 r := &dbreadRunner{t: t}
99 if out, err := exec.Command("mount", "-t", "tmpfs", "-o", "size="+tmpSize+",mode=1777", "tmpfs", "/var/tmp").CombinedOutput(); err != nil {
100 t.Fatalf("mount /var/tmp: %v: %s", err, out)
101 }
102 t.Cleanup(func() { exec.Command("umount", "/var/tmp").Run() })
103 u, err := user.Lookup("carlos")
104 if err != nil {
105 if out, err := exec.Command("useradd", "--system", "--user-group", "--no-create-home", "--shell", "/usr/sbin/nologin", "carlos").CombinedOutput(); err != nil {
106 t.Fatalf("useradd: %v: %s", err, out)
107 }
108 t.Cleanup(func() { exec.Command("userdel", "carlos").Run() })
109 if u, err = user.Lookup("carlos"); err != nil {
110 t.Fatal(err)
111 }
112 }
113 r.uid, _ = strconv.Atoi(u.Uid)
114 r.gid, _ = strconv.Atoi(u.Gid)
115 if _, err := os.Stat("/opt/carlos/carlos"); err == nil {
116 t.Fatal("/opt/carlos/carlos exists: this test runs only on a fresh runner")
117 }
118 self, err := os.ReadFile(os.Args[0])
119 if err != nil {
120 t.Fatal(err)
121 }
122 for _, d := range []string{"/opt/carlos", "/etc/carlos", "/data/instances/other.example.com", "/run/carlos", dbreadTestMarkers} {
123 if err := os.MkdirAll(d, 0o755); err != nil {
124 t.Fatal(err)
125 }
126 }
127 // Things the witness must not reach, present on the host: real SQLite
128 // databases where the witness ATTACHes, a host.env it opens.
129 sqliteFixture(t, "/data/registry.db")
130 sqliteFixture(t, "/etc/carlos/witness.db")
131 sqliteFixture(t, "/data/instances/other.example.com/other.db")
132 // host.env arms db reads (dbReadsSettings), so the adopt pass's
133 // ensureSystemUnits arms the socket: it stays dark otherwise.
134 r.rootPub, r.rootPriv, _ = ed25519.GenerateKey(rand.Reader)
135 if err := os.WriteFile("/etc/carlos/host.env", []byte(dbreadRunnerHostEnv(r.rootPub)), 0o640); err != nil {
136 t.Fatal(err)
137 }
138 if err := os.WriteFile("/opt/carlos/carlos", self, 0o755); err != nil {
139 t.Fatal(err)
140 }
141 t.Cleanup(func() {
142 for _, p := range []string{"/opt/carlos/carlos", "/data/registry.db", "/etc/carlos/witness.db", "/etc/carlos/host.env"} {
143 os.Remove(p)
144 }
145 os.RemoveAll("/data/instances/other.example.com")
146 os.RemoveAll(dbreadTestMarkers)
147 })
148 ensureSystemUnits("/etc", filepath.Join(t.TempDir(), "lock"), runSystemctl, systemctlProbe, t.Logf)
149 t.Cleanup(func() {
150 runSystemctl("stop", "carlos-dbread.socket", "carlos-instance-control.socket", "carlos-system-update.path")
151 runSystemctl("disable", "carlos-dbread.socket", "carlos-instance-control.socket", "carlos-system-update.path", "carlos-system-recover.service")
152 for _, u := range systemUnitFiles {
153 os.Remove(filepath.Join("/etc/systemd/system", u))
154 }
155 runSystemctl("daemon-reload")
156 })
157 if _, err := os.Stat(dbreadServiceFile); err != nil {
158 t.Fatalf("ensureSystemUnits installed no %s: %v", dbreadServiceFile, err)
159 }
160 if out, _ := systemctlProbe("is-active", dbReadSocketUnit); strings.TrimSpace(out) != "active" {
161 t.Fatalf("carlos-dbread.socket is %q after ensureSystemUnits", out)
162 }
163 r.dir, err = os.MkdirTemp("/var/lib", "carlos-dbread-runner-")
164 if err != nil {
165 t.Fatal(err)
166 }
167 t.Cleanup(func() { os.RemoveAll(r.dir) })
168 replica := filepath.Join(r.dir, "replica", "instances", dbreadRunnerHost)
169 dbdoortest.Replicate(t, replica, 3)
170 yes := true
171 rec := provision.Record{Host: dbreadRunnerHost, App: "shop", Account: "bab", Region: "uk", Hibernate: &yes,
172 CreatedAt: time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)}
173 body, _ := json.Marshal(rec)
174 if _, err := r.store().Put(context.Background(), provision.RecordKey("bab", "shop", dbreadRunnerHost), body, ""); err != nil {
175 t.Fatal(err)
176 }
177 r.chown()
178 return r
179}
180
181// dbreadRunnerHostEnv is a host.env that arms db reads, plus extra lines.
182func dbreadRunnerHostEnv(pub ed25519.PublicKey, extra ...string) string {
183 lines := append([]string{"CARLOS_REGION=uk", "CARLOS_HIBERNATE_BUCKET=data-uk", "CARLOS_RELEASE_PUBKEY=" + base64.StdEncoding.EncodeToString(pub)}, extra...)
184 return strings.Join(lines, "\n") + "\n"
185}
186
187func (r *dbreadRunner) store() release.Store {
188 return release.NewDirStore(filepath.Join(r.dir, "store"))
189}
190
191func (r *dbreadRunner) chown() {
192 filepath.Walk(r.dir, func(p string, _ os.FileInfo, _ error) error { return os.Lchown(p, r.uid, r.gid) })
193}
194
195func (r *dbreadRunner) marker(name string, on bool) {
196 r.t.Helper()
197 p := filepath.Join(dbreadTestMarkers, name)
198 var err error
199 if on {
200 err = os.WriteFile(p, nil, 0o644)
201 } else if err = os.Remove(p); os.IsNotExist(err) {
202 err = nil
203 }
204 if err != nil {
205 r.t.Fatal(err)
206 }
207}
208
209// submit is the console's two creates for a read of op; it returns the
210// read's id and reply key.
211func (r *dbreadRunner) submit(op, sqlText string) (string, *ecdh.PrivateKey) {
212 r.t.Helper()
213 reply, _ := dbreads.NewReplyKey()
214 id, _ := dbreads.MintID()
215 inc, _ := hostclaim.Incarnation("bab", "shop", dbreadRunnerHost, time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
216 req := dbreads.Request{ID: id, Account: "bab", App: "shop", Host: dbreadRunnerHost, Incarnation: inc, Region: "uk",
217 Op: op, SQL: sqlText, ReplyKey: dbreads.EncodeReplyKey(reply.PublicKey()),
218 By: "member@example.com", IssuedAt: time.Now().UTC().Format(time.RFC3339)}
219 wire, err := dbreads.Sign(release.RootSigner(r.rootPriv), req)
220 if err != nil {
221 r.t.Fatal(err)
222 }
223 s := r.store()
224 if _, err := s.Put(context.Background(), dbreads.RequestKey(id), wire, ""); err != nil {
225 r.t.Fatal(err)
226 }
227 issued, _ := req.Issued()
228 if _, err := s.Put(context.Background(), dbreads.QueueKey("uk", issued, id), nil, ""); err != nil {
229 r.t.Fatal(err)
230 }
231 r.chown()
232 return id, reply
233}
234
235// payload runs the agent half as carlos. With wait it returns the payload's
236// report, the line marked dbreadReportPrefix; without, the running command.
237// id, when set, is the read the scenario waits on or reports about.
238func (r *dbreadRunner) payload(scenario string, wait bool, id string) (map[string]any, *exec.Cmd) {
239 r.t.Helper()
240 // The copy at /opt/carlos/carlos, not os.Args[0]: the runner keeps its
241 // binary under /root, which carlos cannot enter.
242 cmd := exec.Command("/opt/carlos/carlos")
243 cmd.Env = append(os.Environ(), "DBREAD_RUNNER_PAYLOAD="+scenario, "DBREAD_RUNNER_DIR="+r.dir,
244 "DBREAD_RUNNER_ID="+id, "DBREAD_RUNNER_PUB="+base64.StdEncoding.EncodeToString(r.rootPub))
245 cmd.SysProcAttr = &syscall.SysProcAttr{Credential: &syscall.Credential{Uid: uint32(r.uid), Gid: uint32(r.gid)}}
246 var out bytes.Buffer
247 cmd.Stdout, cmd.Stderr = &out, os.Stderr
248 if !wait {
249 if err := cmd.Start(); err != nil {
250 r.t.Fatal(err)
251 }
252 return nil, cmd
253 }
254 if err := cmd.Run(); err != nil {
255 r.t.Fatalf("payload %s: %v: %s", scenario, err, out.String())
256 }
257 sc := bufio.NewScanner(&out)
258 for sc.Scan() {
259 if line, ok := strings.CutPrefix(sc.Text(), dbreadReportPrefix); ok {
260 var rep map[string]any
261 if err := json.Unmarshal([]byte(line), &rep); err != nil {
262 r.t.Fatalf("payload %s: unreadable report %q", scenario, line)
263 }
264 return rep, cmd
265 }
266 }
267 r.t.Fatalf("payload %s gave no report: %s", scenario, out.String())
268 return nil, cmd
269}
270
271func (r *dbreadRunner) answer(id string, reply *ecdh.PrivateKey, stream string) ([]byte, dbreads.Result) {
272 r.t.Helper()
273 s := r.store()
274 raw, _, err := s.Get(context.Background(), dbreads.ResultKey(id))
275 if err != nil {
276 r.t.Fatalf("no result: %v", err)
277 }
278 var res dbreads.Result
279 json.Unmarshal(raw, &res)
280 var out bytes.Buffer
281 for seq := int64(1); seq <= res.LastSeq; seq++ {
282 sealed, _, _ := s.Get(context.Background(), dbreads.ChunkKey(id, seq))
283 plain, err := dbreads.OpenChunk(reply, id, seq, stream, seq == res.LastSeq, sealed)
284 if err != nil {
285 r.t.Fatal(err)
286 }
287 out.Write(plain)
288 }
289 return out.Bytes(), res
290}
291
292// privateTmpGone waits up to limit for every worker's private /var/tmp to
293// be removed.
294func privateTmpGone(t *testing.T, limit time.Duration) {
295 t.Helper()
296 deadline := time.Now().Add(limit)
297 for {
298 left, _ := filepath.Glob("/var/tmp/systemd-private-*-carlos-dbread@*")
299 if len(left) == 0 {
300 return
301 }
302 if time.Now().After(deadline) {
303 t.Fatalf("a worker's private tmp outlived it: %v", left)
304 }
305 time.Sleep(200 * time.Millisecond)
306 }
307}
308
309func unitsActive(t *testing.T) int {
310 n, err := dbReadUnits(context.Background())
311 if err != nil {
312 t.Fatal(err)
313 }
314 return n
315}
316
317// dbreadWaitFor polls ok until it holds or limit passes. (waitFor is
318// housekeeper_test.go's, with another signature: plan review finding 2.)
319func dbreadWaitFor(t *testing.T, limit time.Duration, ok func() bool) {
320 t.Helper()
321 deadline := time.Now().Add(limit)
322 for !ok() {
323 if time.Now().After(deadline) {
324 t.Fatal("timed out")
325 }
326 time.Sleep(200 * time.Millisecond)
327 }
328}
329
330// T2: a forwarded query through the real socket and the real service,
331// installed by the real ensureSystemUnits; the socket is the worker's stdin
332// and stdout (codex 2). The probe that makes the box capable runs the
333// default SelfView inside the unit (plan review finding 1).
334func TestRootDBReadServesThroughTheInstalledUnits(t *testing.T) {
335 r := newDBReadRunner(t, "40g")
336 id, reply := r.submit(dbreads.OpQuery, "SELECT count(*) AS n FROM t")
337 rep, _ := r.payload("serve", true, id)
338 if rep["capable"] != true {
339 t.Fatalf("the installed worker failed its probe: %v", rep)
340 }
341 rows, res := r.answer(id, reply, dbreads.StreamRows)
342 if res.State != dbreads.StateDone || string(rows) != "n\n30\n" {
343 t.Fatalf("result %+v rows %q", res, rows)
344 }
345 privateTmpGone(t, 10*time.Second)
346}
347
348// A forwarded pull through the real socket and the real service (plan 3
349// Task 5): the installed worker verifies and holds the planned files and
350// sends them back after check B; the answer, opened as the ltx stream, is
351// the planned files byte for byte in plan order, and adds up to the sealed
352// summary. The worker's private tmp is gone afterwards.
353func TestRootDBReadPullThroughTheInstalledUnits(t *testing.T) {
354 r := newDBReadRunner(t, "40g")
355 id, reply := r.submit(dbreads.OpPull, "")
356 rep, _ := r.payload("serve", true, id)
357 if rep["capable"] != true {
358 t.Fatalf("the installed worker failed its probe: %v", rep)
359 }
360 got, res := r.answer(id, reply, dbreads.StreamLTX)
361 if res.State != dbreads.StateDone || res.Code != "" {
362 t.Fatalf("result %+v", res)
363 }
364 sum, err := dbreads.OpenSummary(reply, id, res.Sealed)
365 if err != nil {
366 t.Fatal(err)
367 }
368 h := sha256.Sum256(got)
369 if hex.EncodeToString(h[:]) != sum.SHA256 || int64(len(got)) != sum.Bytes {
370 t.Fatalf("%d bytes do not add up to the summary %+v", len(got), sum)
371 }
372 rd := lsfile.NewReplicaClient(filepath.Join(r.dir, "replica", "instances", dbreadRunnerHost))
373 plan, err := dbdoor.PlanFrom(context.Background(), rd, time.Time{})
374 if err != nil {
375 t.Fatal(err)
376 }
377 var want bytes.Buffer
378 for _, f := range plan.Planned {
379 rc, err := rd.OpenLTXFile(context.Background(), f.Level, ltx.TXID(f.MinTXID), ltx.TXID(f.MaxTXID), 0, 0)
380 if err != nil {
381 t.Fatal(err)
382 }
383 io.Copy(&want, rc)
384 rc.Close()
385 }
386 if !bytes.Equal(got, want.Bytes()) || sum.TXID != plan.TXID || len(sum.Files) != len(plan.Planned) {
387 t.Fatalf("pulled %d bytes at %d in %d files; planned %d bytes at %d in %d files",
388 len(got), sum.TXID, len(sum.Files), want.Len(), plan.TXID, len(plan.Planned))
389 }
390 privateTmpGone(t, 10*time.Second)
391}
392
393// T3: from inside the worker, nothing outside its scratch is reachable,
394// including any network interface but loopback; the private tmp is gone
395// after a normal end, a cancel and kill -9 of the agent. Mutation: remove
396// PrivateNetwork=yes from the installed unit; "see an interface other than
397// loopback" succeeds and this test is red (plan review finding 17).
398func TestRootDBReadWorkerSandbox(t *testing.T) {
399 r := newDBReadRunner(t, "40g")
400 r.marker("witness", true)
401 rep, _ := r.payload("witness", true, "")
402 r.marker("witness", false)
403 report, _ := rep["report"].(map[string]any)
404 if len(report) != 14 {
405 t.Fatalf("an incomplete witness report: %v", rep)
406 }
407 for name, v := range report {
408 failed := v.(string) != ""
409 if name == "write /var/tmp" {
410 if failed {
411 t.Errorf("the positive control failed: %s", v)
412 }
413 continue
414 }
415 if !failed {
416 t.Errorf("%s succeeded inside the worker", name)
417 }
418 }
419 // A cancel, then kill -9 of the agent, mid-read.
420 r.marker("hang-restore", true)
421 defer r.marker("hang-restore", false)
422 id, _ := r.submit(dbreads.OpQuery, "SELECT 1")
423 _, cmd := r.payload("serve-and-hang", false, "")
424 dbreadWaitFor(t, 30*time.Second, func() bool { return unitsActive(t) >= 1 })
425 if _, err := r.store().Put(context.Background(), dbreads.CancelKey(id), nil, ""); err != nil {
426 t.Fatal(err)
427 }
428 dbreadWaitFor(t, 20*time.Second, func() bool { return unitsActive(t) == 0 })
429 privateTmpGone(t, 10*time.Second)
430 cmd.Process.Kill()
431 cmd.Wait()
432 r.submit(dbreads.OpQuery, "SELECT 1")
433 _, cmd = r.payload("serve-and-hang", false, "")
434 dbreadWaitFor(t, 30*time.Second, func() bool { return unitsActive(t) >= 1 })
435 cmd.Process.Signal(syscall.SIGKILL)
436 cmd.Wait()
437 dbreadWaitFor(t, 20*time.Second, func() bool { return unitsActive(t) == 0 })
438 privateTmpGone(t, 10*time.Second)
439}
440
441// T12 with real units. The disk floor: /var/tmp on a 12 GiB tmpfs fits one
442// read's floor reservation (5 GiB and 10%) and not two. The first read is
443// running (its unit observed) before the second is submitted, so which read
444// waits never depends on queue order (plan review finding 16); the agent
445// passes every second and still never picks the second while the first
446// holds its floor. A fixture database small enough for the runner cannot
447// make the plan's growth bind, so the no-room result itself is pinned by
448// TestReadTooLargeAndNoRoom. Then the agent is killed mid-read: the
449// restarted agent takes no read and no probe while the old unit is active,
450// the old worker ends within 5 s of losing the connection though its
451// restore ignores the context, and the restarted agent admits the held-back
452// read once the unit and its scratch are gone. Mutations: drop the orphan
453// rule in reconcileLocked (the restarted agent picks beside the old unit),
454// or drop the worker's 5 s exit (the old unit outlives the 15 s bound).
455func TestRootDBReadAdmissionWithRealUnits(t *testing.T) {
456 r := newDBReadRunner(t, "12g")
457 r.marker("hang-restore", true)
458 r.submit(dbreads.OpQuery, "SELECT 1")
459 _, hung := r.payload("serve-and-hang", false, "")
460 dbreadWaitFor(t, 30*time.Second, func() bool { return unitsActive(t) == 1 })
461 second, reply := r.submit(dbreads.OpQuery, "SELECT count(*) AS n FROM t")
462 time.Sleep(5 * time.Second) // five passes
463 if _, _, err := r.store().Get(context.Background(), dbreads.PickedKey(second)); err == nil {
464 t.Fatal("a second read was picked with room for one")
465 }
466 hung.Process.Signal(syscall.SIGKILL)
467 hung.Wait()
468 killedAt := time.Now()
469 rep, _ := r.payload("restarted", true, second)
470 if rep["units_at_start"].(float64) < 1 {
471 t.Fatalf("the old unit was gone before the restarted agent looked: %v", rep)
472 }
473 if rep["capable"] == true || rep["picked"] == true {
474 t.Fatalf("a restarted agent admitted beside the old unit: %v", rep)
475 }
476 dbreadWaitFor(t, 20*time.Second, func() bool { return unitsActive(t) == 0 })
477 if gone := time.Since(killedAt); gone > 15*time.Second {
478 t.Fatalf("the old worker took %s to stop; its 5 s exit did not hold", gone)
479 }
480 privateTmpGone(t, 10*time.Second)
481 r.marker("hang-restore", false)
482 rep, _ = r.payload("serve", true, second)
483 if rep["capable"] != true {
484 t.Fatalf("the agent did not admit once the unit was gone: %v", rep)
485 }
486 if rows, res := r.answer(second, reply, dbreads.StreamRows); res.State != dbreads.StateDone || string(rows) != "n\n30\n" {
487 t.Fatalf("the held-back read: %+v %q", res, rows)
488 }
489}
490
491// T13: a socket whose service is missing, masked, failing to start or
492// speaking another protocol version withdraws db-read-1 within one probe and
493// edges pass; a read whose worker fails to start withdraws it at once
494// through the real loop (plan review finding 15); a box running two reads
495// keeps it (codex 9).
496func TestRootDBReadReadiness(t *testing.T) {
497 r := newDBReadRunner(t, "40g")
498 capable := func() bool {
499 rep, _ := r.payload("probe", true, "")
500 return rep["capable"] == true && rep["published"] == true
501 }
502 socketState := func(when string) string {
503 out, _ := runSystemctl("show", "-p", "ActiveState,SubState,Result", "carlos-dbread.socket")
504 t.Logf("carlos-dbread.socket %s: %s", when, strings.Join(strings.Fields(out), " "))
505 return out
506 }
507 // repair is the box's own way back once the service is restored: a
508 // connection to a socket whose service cannot load fails the socket unit
509 // (systemd stops listening), and the next adopt pass's ensureSystemUnits
510 // sees it inactive and arms it again. Nothing else restarts it.
511 repair := func(when string) {
512 t.Helper()
513 socketState(when + ", before the adopt pass")
514 ensureSystemUnits("/etc", filepath.Join(t.TempDir(), "lock"), runSystemctl, systemctlProbe, t.Logf)
515 if out, _ := systemctlProbe("is-active", dbReadSocketUnit); strings.TrimSpace(out) != "active" {
516 t.Fatalf("carlos-dbread.socket is %q after the adopt pass", out)
517 }
518 }
519 if !capable() {
520 t.Fatal("baseline: not capable")
521 }
522 body, err := os.ReadFile(dbreadServiceFile)
523 if err != nil {
524 t.Fatal(err)
525 }
526 // Cleanup removes first: during the mask the path is a symlink to
527 // /dev/null, and writing through it would write nothing.
528 t.Cleanup(func() {
529 os.Remove(dbreadServiceFile)
530 os.WriteFile(dbreadServiceFile, body, 0o644)
531 runSystemctl("daemon-reload")
532 })
533 if err := os.Remove(dbreadServiceFile); err != nil {
534 t.Fatal(err)
535 }
536 mustSystemctl(t, "daemon-reload")
537 if capable() {
538 t.Error("capable with the service missing")
539 }
540 if err := os.WriteFile(dbreadServiceFile, body, 0o644); err != nil {
541 t.Fatal(err)
542 }
543 mustSystemctl(t, "daemon-reload")
544 repair("with the service missing")
545 if !capable() {
546 t.Fatal("not capable once the service file is back")
547 }
548 // Masked where the unit is installed (plan review finding 27): a mask is
549 // a /dev/null symlink, and only one in /etc/systemd/system itself
550 // outranks the unit file ensureSystemUnits writes there. `systemctl
551 // mask` refuses while the file exists, and `--runtime` would land in
552 // /run/systemd/system, which /etc outranks; so swap the file for the
553 // symlink by hand, and prove the mask took before asking.
554 if err := os.Remove(dbreadServiceFile); err != nil {
555 t.Fatal(err)
556 }
557 if err := os.Symlink("/dev/null", dbreadServiceFile); err != nil {
558 t.Fatal(err)
559 }
560 mustSystemctl(t, "daemon-reload")
561 if out, _ := runSystemctl("show", "-p", "LoadState", "--value", "carlos-dbread@probe.service"); strings.TrimSpace(out) != "masked" {
562 t.Fatalf("the mask did not take: LoadState %q", out)
563 }
564 if capable() {
565 t.Error("capable with the service masked")
566 }
567 if err := os.Remove(dbreadServiceFile); err != nil {
568 t.Fatal(err)
569 }
570 if err := os.WriteFile(dbreadServiceFile, body, 0o644); err != nil {
571 t.Fatal(err)
572 }
573 mustSystemctl(t, "daemon-reload")
574 repair("with the service masked")
575 for _, m := range []string{"exit-1", "version-2"} {
576 r.marker(m, true)
577 if capable() {
578 t.Errorf("capable with %s", m)
579 }
580 socketState("with " + m)
581 r.marker(m, false)
582 }
583 if !capable() {
584 t.Fatal("not capable again once restored")
585 }
586
587 // A read whose worker fails to start, through the real loop.
588 _, loop := r.payload("fail-start", false, "")
589 dbreadWaitFor(t, 30*time.Second, func() bool { _, err := os.Stat(filepath.Join(r.dir, "probed")); return err == nil })
590 r.marker("exit-1", true)
591 r.submit(dbreads.OpQuery, "SELECT 1")
592 if err := os.WriteFile(filepath.Join(r.dir, "go"), nil, 0o644); err != nil {
593 t.Fatal(err)
594 }
595 if err := loop.Wait(); err != nil {
596 t.Fatalf("fail-start payload: %v", err)
597 }
598 if _, err := os.Stat(filepath.Join(r.dir, "withdrawn")); err != nil {
599 t.Fatal("the loop kept db-read-1 after a worker failed to start")
600 }
601 r.marker("exit-1", false)
602
603 r.marker("hang-restore", true)
604 defer r.marker("hang-restore", false)
605 r.submit(dbreads.OpQuery, "SELECT 1")
606 r.submit(dbreads.OpQuery, "SELECT 1")
607 rep, _ := r.payload("busy-probe", true, "")
608 if rep["capable"] != true {
609 t.Fatalf("a box running two reads lost the capability: %v", rep)
610 }
611}
612
613// TestRootDBReadPayload is the agent half, run as carlos by payload();
614// never run it by name.
615func TestRootDBReadPayload(t *testing.T) {
616 scenario := os.Getenv("DBREAD_RUNNER_PAYLOAD")
617 if scenario == "" {
618 t.Skip("run by the db-read root-runner tests as their carlos child")
619 }
620 dir := os.Getenv("DBREAD_RUNNER_DIR")
621 pubRaw, _ := base64.StdEncoding.DecodeString(os.Getenv("DBREAD_RUNNER_PUB"))
622 store := release.NewDirStore(filepath.Join(dir, "store"))
623 a := dbreads.NewAgent(dbreads.AgentConfig{
624 Store: store, Region: "uk", Box: "runner", Boot: newBootID(), Pub: ed25519.PublicKey(pubRaw),
625 HibernatePrefix: "instances",
626 OpenReplica: func(_ context.Context, prefix string) (dbdoor.ReplicaReader, error) {
627 return lsfile.NewReplicaClient(filepath.Join(dir, "replica", prefix)), nil
628 },
629 DialWorker: dialDBReadWorker, Units: dbReadUnits, Free: varTmpFree, Logf: t.Logf,
630 })
631 ctx := context.Background()
632 report := map[string]any{}
633 say := func() {
634 b, _ := json.Marshal(report)
635 fmt.Println(dbreadReportPrefix + string(b))
636 }
637 switch scenario {
638 case "probe":
639 a.Probe(ctx)
640 report["capable"] = a.Capable()
641 report["published"] = len(withDBReadCapability(nil, a.Capable())) == 1
642 case "serve":
643 a.Probe(ctx)
644 report["capable"] = a.Capable()
645 id := os.Getenv("DBREAD_RUNNER_ID")
646 deadline := time.Now().Add(2 * time.Minute)
647 for time.Now().Before(deadline) {
648 a.Pass(ctx)
649 a.Wait()
650 if _, _, err := store.Get(ctx, dbreads.ResultKey(id)); err == nil {
651 break
652 }
653 time.Sleep(time.Second)
654 }
655 case "serve-and-hang":
656 a.Probe(ctx)
657 for { // the parent kills this process
658 a.Pass(ctx)
659 time.Sleep(time.Second)
660 }
661 case "restarted":
662 units, _ := dbReadUnits(ctx)
663 report["units_at_start"] = units
664 a.Probe(ctx)
665 a.Pass(ctx)
666 report["capable"] = a.Capable()
667 _, _, err := store.Get(ctx, dbreads.PickedKey(os.Getenv("DBREAD_RUNNER_ID")))
668 report["picked"] = err == nil
669 case "fail-start":
670 // The production loop: probe at start, a pass on a nudge, a probe
671 // as soon as a read's worker fails to start.
672 nudge := make(chan struct{}, 1)
673 rctx, cancel := context.WithCancel(ctx)
674 defer cancel()
675 go a.Run(rctx, nudge)
676 for !a.Capable() {
677 time.Sleep(100 * time.Millisecond)
678 }
679 os.WriteFile(filepath.Join(dir, "probed"), nil, 0o644)
680 for {
681 if _, err := os.Stat(filepath.Join(dir, "go")); err == nil {
682 break
683 }
684 time.Sleep(100 * time.Millisecond)
685 }
686 nudge <- struct{}{}
687 deadline := time.Now().Add(30 * time.Second)
688 for a.Capable() && time.Now().Before(deadline) {
689 time.Sleep(100 * time.Millisecond)
690 }
691 if !a.Capable() {
692 os.WriteFile(filepath.Join(dir, "withdrawn"), nil, 0o644)
693 }
694 case "busy-probe":
695 a.Probe(ctx)
696 a.Pass(ctx)
697 deadline := time.Now().Add(30 * time.Second)
698 for n, _ := dbReadUnits(ctx); n < 2 && time.Now().Before(deadline); n, _ = dbReadUnits(ctx) {
699 time.Sleep(200 * time.Millisecond)
700 }
701 a.Probe(ctx)
702 report["capable"] = a.Capable()
703 case "witness":
704 c, err := dialDBReadWorker(ctx)
705 if err != nil {
706 t.Fatal(err)
707 }
708 var w map[string]string
709 json.NewDecoder(io.LimitReader(c, 1<<20)).Decode(&w)
710 c.Close()
711 report["report"] = w
712 }
713 say()
714}
715
716// dbreadTenantUnit is the real tenant template, carlos-instance@.service,
717// installed under its own name with only what a runner cannot provide taken
718// out: the edge and mount dependencies, the per-instance slice, the stop
719// hook and the log socket. Its sandbox (User=carlos, the hidden /run/carlos,
720// /data, /opt/carlos and /etc/carlos, the address families) is the
721// production one, line for line. ExecStart is this binary as a tenant that
722// tries to reach the db-read worker.
723const dbreadTenantUnit = "carlos-dbread-tenant@.service"
724
725func installDBReadTenantUnit(t *testing.T, instance string) {
726 t.Helper()
727 raw, err := unitsFS.ReadFile("units/carlos-instance@.service")
728 if err != nil {
729 t.Fatal(err)
730 }
731 var out []string
732 for _, line := range strings.Split(string(raw), "\n") {
733 switch {
734 case strings.HasPrefix(line, "BindsTo="), strings.HasPrefix(line, "Requires="), strings.HasPrefix(line, "After="),
735 strings.HasPrefix(line, "ExecStopPost="), strings.HasPrefix(line, "Slice="):
736 continue
737 case strings.HasPrefix(line, "ExecStart="):
738 line = "ExecStart=/opt/carlos/carlos dbread-tenant-witness"
739 case line == "Type=simple":
740 line = "Type=oneshot"
741 case strings.HasPrefix(line, "StandardOutput=file:"):
742 line = "StandardOutput=journal"
743 }
744 out = append(out, line)
745 }
746 unit := strings.Join(out, "\n")
747 if !strings.Contains(unit, "TemporaryFileSystem=/run/carlos:ro") || !strings.Contains(unit, "User=carlos") {
748 t.Fatal("the tenant template no longer hides /run/carlos as carlos; the witness would prove nothing")
749 }
750 path := filepath.Join("/etc/systemd/system", dbreadTenantUnit)
751 if err := os.WriteFile(path, []byte(unit), 0o644); err != nil {
752 t.Fatal(err)
753 }
754 t.Cleanup(func() { os.Remove(path); runSystemctl("daemon-reload") })
755 mustSystemctl(t, "daemon-reload")
756 uid, gid := carlosIDs(t)
757 for _, d := range []string{"/data/instances/" + instance, "/run/carlos/instances/" + instance} {
758 if err := os.MkdirAll(d, 0o750); err != nil {
759 t.Fatal(err)
760 }
761 os.Chown(d, uid, gid)
762 }
763 for _, d := range []string{"/run/carlos-control/exe/" + instance + "/app", "/run/carlos-control/exe/" + instance + "/live"} {
764 if err := os.MkdirAll(d, 0o755); err != nil {
765 t.Fatal(err)
766 }
767 }
768 t.Cleanup(func() {
769 os.RemoveAll("/data/instances/" + instance)
770 os.RemoveAll("/run/carlos/instances/" + instance)
771 os.RemoveAll("/run/carlos-control/exe/" + instance)
772 })
773}
774
775func carlosIDs(t *testing.T) (int, int) {
776 t.Helper()
777 u, err := user.Lookup("carlos")
778 if err != nil {
779 t.Fatal(err)
780 }
781 uid, _ := strconv.Atoi(u.Uid)
782 gid, _ := strconv.Atoi(u.Gid)
783 return uid, gid
784}
785
786// tenantReach runs the installed tenant once and returns its report, after
787// checking that no connection reached the socket and no worker started.
788func tenantReach(t *testing.T, instance string) map[string]string {
789 t.Helper()
790 accepted := func() string {
791 out, _ := runSystemctl("show", "-p", "NAccepted", "--value", dbReadSocketUnit)
792 return strings.TrimSpace(out)
793 }
794 before := accepted()
795 report := filepath.Join("/data/instances", instance, "dbread-witness.json")
796 os.Remove(report)
797 if out, err := runSystemctl("start", strings.Replace(dbreadTenantUnit, "@", "@"+instance, 1)); err != nil {
798 jout, _ := exec.Command("journalctl", "-u", strings.Replace(dbreadTenantUnit, "@", "@"+instance, 1), "-n", "30", "--no-pager").CombinedOutput()
799 t.Fatalf("the tenant unit did not run: %v: %s\n%s", err, out, jout)
800 }
801 raw, err := os.ReadFile(report)
802 if err != nil {
803 t.Fatalf("the tenant wrote no report: %v", err)
804 }
805 var rep map[string]string
806 if err := json.Unmarshal(raw, &rep); err != nil {
807 t.Fatal(err)
808 }
809 t.Logf("tenant report: %v", rep)
810 if rep["uid"] == "" || rep["uid"] == "0" {
811 t.Fatalf("the witness did not run as the tenant user: %v", rep)
812 }
813 for _, k := range []string{"connect " + dbReadSocketPath, "connect /run/carlos-dbread/worker.sock"} {
814 if v, ok := rep[k]; !ok || v == "" {
815 t.Errorf("a tenant could %s: %v", k, rep)
816 }
817 }
818 if after := accepted(); after != before {
819 t.Errorf("the socket accepted a tenant connection: NAccepted %s -> %s", before, after)
820 }
821 if n := unitsActive(t); n != 0 {
822 t.Errorf("%d db-read workers running after the tenant's attempt", n)
823 }
824 return rep
825}
826
827// Astra round 1, finding 1, on real units: a real tenant unit (the
828// production carlos-instance@ sandbox, running as carlos) can neither
829// connect to nor activate a db-read worker, with db reads armed on the box
830// and with them off; and with them off nothing listens, for the agent
831// either. Mutations: put the socket back at /run/carlos-dbread (the tenant
832// connects, NAccepted moves); drop the gate in ensureSystemUnits (the
833// socket listens with CARLOS_DB_READS=0).
834func TestRootDBReadTenantCannotReachTheWorker(t *testing.T) {
835 r := newDBReadRunner(t, "40g")
836 const instance = "tenant.example.com"
837 installDBReadTenantUnit(t, instance)
838
839 // Armed: the socket listens, the agent's probe passes, the tenant
840 // reaches nothing.
841 if out, _ := systemctlProbe("is-active", dbReadSocketUnit); strings.TrimSpace(out) != "active" {
842 t.Fatalf("armed box: carlos-dbread.socket is %q", out)
843 }
844 if rep, _ := r.payload("probe", true, ""); rep["capable"] != true {
845 t.Fatalf("armed box: the agent's probe failed: %v", rep)
846 }
847 tenantReach(t, instance)
848
849 for _, off := range [][]string{{"CARLOS_DB_READS=0"}, {"CARLOS_HIBERNATE_BUCKET="}} {
850 if err := os.WriteFile("/etc/carlos/host.env", []byte(dbreadRunnerHostEnv(r.rootPub, off...)), 0o640); err != nil {
851 t.Fatal(err)
852 }
853 ensureSystemUnits("/etc", filepath.Join(t.TempDir(), "lock"), runSystemctl, systemctlProbe, t.Logf)
854 active, _ := systemctlProbe("is-active", dbReadSocketUnit)
855 enabled, _ := systemctlProbe("is-enabled", dbReadSocketUnit)
856 if strings.TrimSpace(active) == "active" || strings.TrimSpace(enabled) == "enabled" {
857 t.Fatalf("%v: carlos-dbread.socket is %s and %s", off, strings.TrimSpace(active), strings.TrimSpace(enabled))
858 }
859 if rep, _ := r.payload("probe", true, ""); rep["capable"] == true {
860 t.Fatalf("%v: something still answers on the worker socket: %v", off, rep)
861 }
862 tenantReach(t, instance)
863 }
864}
865