carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package activator
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "net"
8 "net/http"
9 "os"
10 "path/filepath"
11 "strings"
12 "sync"
13 "testing"
14 "time"
15
16 "golang.org/x/sys/unix"
17
18 "github.com/carlosframework/platform/internal/instancectl"
19 "github.com/carlosframework/platform/internal/provision"
20 "github.com/carlosframework/platform/internal/registry"
21)
22
23// fakeControl records verbs and lets a test drive the unit's state.
24type fakeControl struct {
25 mu sync.Mutex
26 calls []string
27 inv string
28 seed instancectl.SeedResponse
29 startErr error
30 // stopGenErr is what fakeGenControl.StopGen answers.
31 stopGenErr error
32 stopHook func(host string, role instancectl.Role)
33 readyErr error
34 // onStart runs inside Start, in the fake control's own goroutine, the
35 // same place the real control service's trampoline would exec the app
36 // and the app would bind its socket: prepare's stale-socket removal
37 // races a test's OWN pre-bound fixture socket when that bind happens
38 // before Start is even called, so tests move it here instead.
39 onStart func()
40 // onSeed runs inside Seed, standing in for whatever a real seed process
41 // might have written to disk before it failed. A test uses it to give
42 // seedOwnsDB cleanup something real to (not) remove.
43 onSeed func()
44
45 cmdInv string
46 cmdErr error
47 cmdStatus instancectl.CmdStatus
48}
49
50func (f *fakeControl) rec(s string) { f.mu.Lock(); f.calls = append(f.calls, s); f.mu.Unlock() }
51func (f *fakeControl) Start(_ context.Context, host string, role instancectl.Role, max int64) (string, error) {
52 f.rec("start " + host + " " + string(role))
53 if f.onStart != nil {
54 f.onStart()
55 }
56 return f.inv, f.startErr
57}
58func (f *fakeControl) Seed(_ context.Context, host string, max int64) (instancectl.SeedResponse, error) {
59 f.rec("seed " + host)
60 if f.onSeed != nil {
61 f.onSeed()
62 }
63 return f.seed, nil
64}
65func (f *fakeControl) Cmd(_ context.Context, host string, max int64) (string, error) {
66 f.rec("cmd " + host)
67 return f.cmdInv, f.cmdErr
68}
69func (f *fakeControl) CmdStatus(_ context.Context, host, inv string) (instancectl.CmdStatus, error) {
70 f.rec("cmdstatus " + host + " " + inv)
71 return f.cmdStatus, nil
72}
73func (f *fakeControl) Stop(_ context.Context, host string, role instancectl.Role) error {
74 f.rec("stop " + host + " " + string(role))
75 if f.stopHook != nil {
76 f.stopHook(host, role)
77 }
78 return nil
79}
80func (f *fakeControl) Reset(_ context.Context, host string, role instancectl.Role) error {
81 f.rec("reset")
82 return nil
83}
84func (f *fakeControl) Sweep(context.Context) (instancectl.SweepResponse, error) {
85 f.rec("sweep")
86 return instancectl.SweepResponse{}, nil
87}
88func (f *fakeControl) Ready(context.Context) error { f.rec("ready"); return f.readyErr }
89func (f *fakeControl) joined() string {
90 f.mu.Lock()
91 defer f.mu.Unlock()
92 return strings.Join(f.calls, "\n")
93}
94
95// fakeUnit is the unit's observable state: what `systemctl show` and
96// cgroup.events would say.
97type fakeUnit struct {
98 mu sync.Mutex
99 active string
100 inv string
101 exists bool
102 populated bool
103}
104
105func (u *fakeUnit) set(active, inv string, exists, populated bool) {
106 u.mu.Lock()
107 u.active, u.inv, u.exists, u.populated = active, inv, exists, populated
108 u.mu.Unlock()
109}
110func (u *fakeUnit) state(context.Context, string) (string, string, error) {
111 u.mu.Lock()
112 defer u.mu.Unlock()
113 return u.active, u.inv, nil
114}
115func (u *fakeUnit) events(string, string) (bool, bool, error) {
116 u.mu.Lock()
117 defer u.mu.Unlock()
118 return u.exists, u.populated, nil
119}
120
121// nestedBackend returns an ExecBackend wired for a nested host with fakes,
122// and the route. The data and run dirs are temp dirs standing in for
123// /data/instances/<iid> and /run/carlos/instances/<iid>; provision's
124// helpers are not used for the temp paths, so the route is built to
125// classify as nested by construction through the Layout seam.
126func nestedBackend(t *testing.T, ctl *fakeControl, unit *fakeUnit) (*ExecBackend, registry.Route) {
127 t.Helper()
128 root, err := os.MkdirTemp("", "nb")
129 if err != nil {
130 t.Fatal(err)
131 }
132 t.Cleanup(func() { os.RemoveAll(root) })
133 host := "h.example.com"
134 b := testExecBackend(t)
135 b.DataDir = filepath.Join(root, "data")
136 b.Control = ctl
137 b.UnitState = unit.state
138 b.CgroupEvents = unit.events
139 b.ObservePoll = 20 * time.Millisecond
140 b.RunDirOverride = filepath.Join(root, "run")
141 stageLive(t, b, host, "v1")
142 rt := registry.Route{Host: host, Kind: "instance", Hibernate: true,
143 DBPath: provision.NestedDBPath(b.DataDir, host), Socket: filepath.Join(b.RunDirOverride, "instances", host, "app.sock")}
144 return b, rt
145}
146
147func TestStartUnitWritesTheLaunchFileAndReturnsAUnitProc(t *testing.T) {
148 t.Parallel()
149 ctl := &fakeControl{inv: "inv1"}
150 unit := &fakeUnit{}
151 b, rt := nestedBackend(t, ctl, unit)
152 // The fake control does not start anything, so health must be faked:
153 // bind a listener at rt.Socket that answers 200, from inside Start
154 // itself, where the app's own bind would actually happen, so
155 // prepare's stale-socket removal (which runs before Start ever calls
156 // the fake control) cannot race it.
157 var stopServing func()
158 ctl.onStart = func() { stopServing = serveOK(t, rt.Socket) }
159 defer func() {
160 if stopServing != nil {
161 stopServing()
162 }
163 }()
164 unit.set("active", "inv1", true, true)
165 p, ver, err := b.Start(context.Background(), rt)
166 if err != nil {
167 t.Fatalf("Start: %v", err)
168 }
169 _ = ver
170 up, ok := p.(*unitProc)
171 if !ok || up.invocation != "inv1" {
172 t.Fatalf("proc = %T %+v", p, p)
173 }
174 raw, err := os.ReadFile(filepath.Join(provision.InstanceDir(b.DataDir, rt.Host), "launch.run.json"))
175 if err != nil {
176 t.Fatal(err)
177 }
178 var l instancectl.Launch
179 if err := json.Unmarshal(raw, &l); err != nil {
180 t.Fatal(err)
181 }
182 if l.Schema != 1 || l.Host != rt.Host || len(l.Argv) != 5 || l.Argv[0] != filepath.Join(b.OptDir, "live", rt.Host) || l.Argv[2] != rt.Socket || l.Argv[4] != rt.DBPath {
183 t.Errorf("launch = %+v", l)
184 }
185 seenCtl := false
186 for _, kv := range l.Env {
187 if strings.HasPrefix(kv, "CARLOS_CONTROL_SOCKET=") {
188 seenCtl = true
189 }
190 if strings.HasPrefix(kv, "CARLOS_ADMIN_TOKEN=") && len(kv) < 30 {
191 t.Errorf("token missing from env: %s", kv)
192 }
193 }
194 if !seenCtl {
195 t.Error("CARLOS_CONTROL_SOCKET not in the launch env")
196 }
197 if !strings.Contains(ctl.joined(), "start h.example.com run") {
198 t.Errorf("control calls: %s", ctl.joined())
199 }
200 if p.Exited() {
201 t.Error("exited before the unit went inactive")
202 }
203 // Termination: terminal state plus empty cgroup.
204 unit.set("inactive", "inv1", false, false)
205 waitFor(t, "the unit to exit", func() bool { return p.Exited() })
206 ctx, cancel := context.WithTimeout(context.Background(), time.Second)
207 defer cancel()
208 if err := p.Stop(ctx); err != nil {
209 t.Errorf("Stop after exit: %v", err)
210 }
211}
212
213func TestUnitProcDoesNotExitOnTerminalStateWhileTheCgroupIsPopulated(t *testing.T) {
214 t.Parallel()
215 ctl := &fakeControl{inv: "inv1"}
216 unit := &fakeUnit{}
217 b, rt := nestedBackend(t, ctl, unit)
218 var stopServing func()
219 ctl.onStart = func() { stopServing = serveOK(t, rt.Socket) }
220 defer func() {
221 if stopServing != nil {
222 stopServing()
223 }
224 }()
225 unit.set("active", "inv1", true, true)
226 p, _, err := b.Start(context.Background(), rt)
227 if err != nil {
228 t.Fatal(err)
229 }
230 unit.set("failed", "inv1", true, true) // a process survived SIGKILL
231 time.Sleep(150 * time.Millisecond)
232 if p.Exited() {
233 t.Fatal("exited while the cgroup is still populated (codex R2-5)")
234 }
235 ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
236 defer cancel()
237 if err := p.Stop(ctx); !errors.Is(err, ErrStillRunning) {
238 t.Fatalf("Stop = %v, want ErrStillRunning", err)
239 }
240 unit.set("failed", "inv1", false, false)
241 waitFor(t, "the unit to exit", func() bool { return p.Exited() })
242}
243
244func TestUnitProcStopsAForeignInvocation(t *testing.T) {
245 t.Parallel()
246 ctl := &fakeControl{inv: "inv1"}
247 unit := &fakeUnit{}
248 b, rt := nestedBackend(t, ctl, unit)
249 var stopServing func()
250 ctl.onStart = func() { stopServing = serveOK(t, rt.Socket) }
251 defer func() {
252 if stopServing != nil {
253 stopServing()
254 }
255 }()
256 unit.set("active", "inv1", true, true)
257 p, _, err := b.Start(context.Background(), rt)
258 if err != nil {
259 t.Fatal(err)
260 }
261 ctl.stopHook = func(string, instancectl.Role) { unit.set("inactive", "", false, false) }
262 unit.set("active", "inv2", true, true) // someone restarted it outside the activator
263 waitFor(t, "the unit to exit", func() bool { return p.Exited() })
264 if !strings.Contains(ctl.joined(), "stop h.example.com run") {
265 t.Errorf("foreign invocation was not stopped: %s", ctl.joined())
266 }
267}
268
269func TestStartUnitRefusesWhenTheBoxIsNotReadyOrFenced(t *testing.T) {
270 ctl := &fakeControl{inv: "inv1"}
271 unit := &fakeUnit{}
272 b, rt := nestedBackend(t, ctl, unit)
273 b.NestedOK = func() error { return errors.New("probe: pid1 fail") }
274 if _, _, err := b.Start(context.Background(), rt); err == nil || !strings.Contains(err.Error(), "pid1") {
275 t.Fatalf("not-ready wake = %v", err)
276 }
277 if strings.Contains(ctl.joined(), "start") {
278 t.Error("a start was issued on a box that is not ready")
279 }
280 b.NestedOK = nil
281 b.LayoutFacts.Journal = func(string) bool { return true }
282 if _, _, err := b.Start(context.Background(), rt); err == nil || !strings.Contains(err.Error(), "migration in flight") {
283 t.Fatalf("fenced wake = %v", err)
284 }
285}
286
287func TestSeedUnitCleansUpOnlyItsOwnDBOnFailure(t *testing.T) {
288 ctl := &fakeControl{seed: instancectl.SeedResponse{InvocationID: "s1", Result: "exit-code", Status: "3", Code: "exited"}}
289 unit := &fakeUnit{}
290 b, rt := nestedBackend(t, ctl, unit)
291 rt.Seed = `["seed","--fast"]`
292 unit.set("inactive", "", false, false)
293 err := b.Seed(context.Background(), rt)
294 if err == nil || !strings.Contains(err.Error(), "exit 3") {
295 t.Fatalf("Seed = %v", err)
296 }
297 raw, err := os.ReadFile(filepath.Join(provision.InstanceDir(b.DataDir, rt.Host), "launch.seed.json"))
298 if err != nil {
299 t.Fatal(err)
300 }
301 var l instancectl.Launch
302 _ = json.Unmarshal(raw, &l)
303 if len(l.Argv) != 5 || l.Argv[1] != "seed" || l.Argv[2] != "--fast" || l.Argv[3] != "--db" || l.Argv[4] != rt.DBPath {
304 t.Errorf("seed argv = %v", l.Argv)
305 }
306 gotHost := false
307 for _, kv := range l.Env {
308 if strings.HasPrefix(kv, "CARLOS_CONTROL_SOCKET=") || strings.HasPrefix(kv, "CARLOS_PURGE=") {
309 t.Errorf("seed env carries %s", kv)
310 }
311 gotHost = gotHost || kv == "CARLOS_HOST="+rt.Host
312 }
313 if !gotHost {
314 t.Errorf("seed env lacks CARLOS_HOST=%s (#256): %v", rt.Host, l.Env)
315 }
316 if _, err := os.Stat(rt.DBPath); !os.IsNotExist(err) {
317 t.Errorf("failed seed left its database: %v", err)
318 }
319}
320
321// capturedEvent is one call eventCapture recorded.
322type capturedEvent struct{ level, msg string }
323
324// eventCapture is a minimal EventSink that records Event calls for
325// assertions; App is unused by these tests but required by the interface.
326type eventCapture struct {
327 mu sync.Mutex
328 events []capturedEvent
329}
330
331func (e *eventCapture) App(acct, app, host, line string) {}
332func (e *eventCapture) Event(acct, app, host, level, msg string) {
333 e.mu.Lock()
334 e.events = append(e.events, capturedEvent{level, msg})
335 e.mu.Unlock()
336}
337func (e *eventCapture) last() (capturedEvent, bool) {
338 e.mu.Lock()
339 defer e.mu.Unlock()
340 if len(e.events) == 0 {
341 return capturedEvent{}, false
342 }
343 return e.events[len(e.events)-1], true
344}
345
346// TestUnitProcExitLineBoundsAForgedExitRecord covers the exit record's
347// ruling: it is tenant-writable text, so a record with fields no real
348// trampoline would ever write must still produce a bounded, single-line,
349// member-safe event, and the record must be consumed (removed) exactly
350// once.
351func TestUnitProcExitLineBoundsAForgedExitRecord(t *testing.T) {
352 t.Parallel()
353 ctl := &fakeControl{inv: "inv1"}
354 unit := &fakeUnit{}
355 b, rt := nestedBackend(t, ctl, unit)
356 sink := &eventCapture{}
357 b.Logs = sink
358 var stopServing func()
359 ctl.onStart = func() { stopServing = serveOK(t, rt.Socket) }
360 defer func() {
361 if stopServing != nil {
362 stopServing()
363 }
364 }()
365 unit.set("active", "inv1", true, true)
366 p, _, err := b.Start(context.Background(), rt)
367 if err != nil {
368 t.Fatal(err)
369 }
370 up, ok := p.(*unitProc)
371 if !ok {
372 t.Fatalf("proc = %T", p)
373 }
374 rec := instancectl.ExitRecord{
375 Invocation: up.invocation,
376 Result: "boom\nboom", // a newline: must never reach the event line raw
377 Status: strings.Repeat("9", 200), // far past recordToken's 32-byte bound
378 Code: "exi\x00ted", // a control character
379 }
380 raw, err := json.Marshal(rec)
381 if err != nil {
382 t.Fatal(err)
383 }
384 recordPath := filepath.Join(up.runDir, instancectl.ExitRecordName(instancectl.RoleRun, up.invocation))
385 if err := os.WriteFile(recordPath, raw, 0o600); err != nil {
386 t.Fatal(err)
387 }
388 unit.set("inactive", "inv1", false, false)
389 waitFor(t, "the unit to exit", func() bool { return p.Exited() })
390 ev, ok := sink.last()
391 if !ok {
392 t.Fatal("no event emitted")
393 }
394 if ev.level != "warn" {
395 t.Errorf("level = %q, want warn", ev.level)
396 }
397 if ev.msg != "instance exited: exit ? (?)" {
398 t.Errorf("msg = %q, want %q", ev.msg, "instance exited: exit ? (?)")
399 }
400 if strings.Contains(ev.msg, "\n") {
401 t.Errorf("event line contains a newline: %q", ev.msg)
402 }
403 if _, err := os.Stat(recordPath); !os.IsNotExist(err) {
404 t.Errorf("exit record was not removed after being consumed: %v", err)
405 }
406}
407
408// TestStartUnitWithEmptyInvocationFailsCleanly covers the case the control
409// service reports no invocation at all: the unit already exited before its
410// start was observed (plan 2 final review). The wake must fail with a
411// diagnosis naming that, and, because nothing is running, no unitProc
412// is ever created to observe, so Control must never see a stop.
413func TestStartUnitWithEmptyInvocationFailsCleanly(t *testing.T) {
414 ctl := &fakeControl{inv: ""}
415 unit := &fakeUnit{}
416 b, rt := nestedBackend(t, ctl, unit)
417 if _, _, err := b.Start(context.Background(), rt); err == nil || !strings.Contains(err.Error(), "exited before it was observed") {
418 t.Fatalf("Start = %v", err)
419 }
420 if strings.Contains(ctl.joined(), "stop") {
421 t.Errorf("nothing was ever running; nothing should have been stopped: %s", ctl.joined())
422 }
423}
424
425// TestStopKeepsListenersOpenUntilTerminationIsProven covers Stop's
426// ordering contract: an unproven stop (ErrStillRunning) must leave the
427// control and log listeners open, since a writer that may still exist must
428// still be able to reach them, and only a stop that observes done may
429// close them.
430func TestStopKeepsListenersOpenUntilTerminationIsProven(t *testing.T) {
431 ctl := &fakeControl{inv: "inv1"}
432 unit := &fakeUnit{}
433 b, rt := nestedBackend(t, ctl, unit)
434 var stopServing func()
435 ctl.onStart = func() { stopServing = serveOK(t, rt.Socket) }
436 defer func() {
437 if stopServing != nil {
438 stopServing()
439 }
440 }()
441 unit.set("active", "inv1", true, true)
442 p, _, err := b.Start(context.Background(), rt)
443 if err != nil {
444 t.Fatal(err)
445 }
446 logSocket := b.logSocket(rt.Host, instancectl.RoleRun)
447
448 ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
449 defer cancel()
450 if err := p.Stop(ctx); !errors.Is(err, ErrStillRunning) {
451 t.Fatalf("Stop = %v, want ErrStillRunning", err)
452 }
453 c, err := net.Dial("unix", logSocket)
454 if err != nil {
455 t.Fatalf("log socket refused a dial after an unproven stop: %v", err)
456 }
457 c.Close()
458
459 unit.set("inactive", "inv1", false, false)
460 waitFor(t, "the unit to exit", func() bool { return p.Exited() })
461 ctx2, cancel2 := context.WithTimeout(context.Background(), time.Second)
462 defer cancel2()
463 if err := p.Stop(ctx2); err != nil {
464 t.Errorf("second Stop after exit: %v", err)
465 }
466 if c2, err := net.Dial("unix", logSocket); err == nil {
467 c2.Close()
468 t.Error("log socket still accepts a dial after Stop closed the listeners")
469 }
470}
471
472// TestStopClosesListenersAfterDoneWithNoSecondStopCall pins the fix for the
473// leak Stop's ctx.Done branch used to have: several real callers (the
474// sidecar co-stop, a discarded sidecar, a failed health wait) never call
475// Stop a second time once it returns ErrStillRunning. The listeners must
476// still close once termination is proven, with nobody asking again.
477func TestStopClosesListenersAfterDoneWithNoSecondStopCall(t *testing.T) {
478 t.Parallel()
479 ctl := &fakeControl{inv: "inv1"}
480 unit := &fakeUnit{}
481 b, rt := nestedBackend(t, ctl, unit)
482 var stopServing func()
483 ctl.onStart = func() { stopServing = serveOK(t, rt.Socket) }
484 defer func() {
485 if stopServing != nil {
486 stopServing()
487 }
488 }()
489 unit.set("active", "inv1", true, true)
490 p, _, err := b.Start(context.Background(), rt)
491 if err != nil {
492 t.Fatal(err)
493 }
494 logSocket := b.logSocket(rt.Host, instancectl.RoleRun)
495
496 ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
497 defer cancel()
498 if err := p.Stop(ctx); !errors.Is(err, ErrStillRunning) {
499 t.Fatalf("Stop = %v, want ErrStillRunning", err)
500 }
501
502 unit.set("inactive", "inv1", false, false)
503 waitFor(t, "the unit to exit", func() bool { return p.Exited() })
504
505 deadline := time.Now().Add(2 * time.Second)
506 for {
507 c, err := net.Dial("unix", logSocket)
508 if err != nil {
509 break // the listener closed on its own; no second Stop call was made
510 }
511 c.Close()
512 if time.Now().After(deadline) {
513 t.Fatal("log socket still accepts a dial 2s after the unit exited, with no second Stop call")
514 }
515 time.Sleep(10 * time.Millisecond)
516 }
517}
518
519// TestSeedUnitFailureStopsBeforeCleanupAndKeepsTheDBWhenProcessesRemain
520// covers the ordering rule: Control.Stop for the seed unit runs, and is
521// proven by an empty cgroup, BEFORE any database cleanup, and when the
522// cgroup never reports empty, nothing is removed at all. The wait is
523// bounded by roleEmptyBound (actStopTimeout in production), which this test
524// genuinely waits out, shrunk to a fraction of a second.
525func TestSeedUnitFailureStopsBeforeCleanupAndKeepsTheDBWhenProcessesRemain(t *testing.T) {
526 if roleEmptyBound != actStopTimeout {
527 t.Fatalf("roleEmptyBound = %v, want actStopTimeout (%v) outside a test", roleEmptyBound, actStopTimeout)
528 }
529 old := roleEmptyBound
530 roleEmptyBound = 300 * time.Millisecond
531 t.Cleanup(func() { roleEmptyBound = old })
532 ctl := &fakeControl{seed: instancectl.SeedResponse{InvocationID: "s1", Result: "exit-code", Status: "3", Code: "exited"}}
533 unit := &fakeUnit{}
534 b, rt := nestedBackend(t, ctl, unit)
535 rt.Seed = `["seed","--fast"]`
536 // Stand in for a real seed process that wrote its half-built database
537 // before dying, created only once Seed actually runs, so the PRE-seed
538 // stat that decides seedOwnsDB still sees it absent (seedOwnsDB=true).
539 ctl.onSeed = func() {
540 if err := os.WriteFile(rt.DBPath, []byte("half-built"), 0o600); err != nil {
541 t.Fatal(err)
542 }
543 }
544 // The cgroup stays populated for the whole wait: a process from the
545 // seed survived SIGKILL and is still there when the bound elapses.
546 unit.set("active", "s1", true, true)
547 err := b.Seed(context.Background(), rt)
548 if err == nil || !strings.Contains(err.Error(), "processes remain") {
549 t.Fatalf("Seed = %v, want a processes-remain error", err)
550 }
551 if !strings.Contains(ctl.joined(), "stop h.example.com seed") {
552 t.Errorf("seed unit was never stopped before cleanup was attempted: %s", ctl.joined())
553 }
554 if _, err := os.Stat(rt.DBPath); err != nil {
555 t.Errorf("the database was removed despite its processes remaining: %v", err)
556 }
557}
558
559// switchableEvents is a CgroupEvents seam a test can turn from "cannot
560// read" (an unproven cgroup, answered at once rather than after
561// actStopTimeout) to "gone".
562type switchableEvents struct {
563 mu sync.Mutex
564 err error
565 reads []string
566}
567
568func (s *switchableEvents) set(err error) { s.mu.Lock(); s.err = err; s.mu.Unlock() }
569func (s *switchableEvents) events(slice, unit string) (bool, bool, error) {
570 s.mu.Lock()
571 defer s.mu.Unlock()
572 s.reads = append(s.reads, unit)
573 if s.err != nil {
574 return false, false, s.err
575 }
576 return false, false, nil
577}
578func (s *switchableEvents) read() []string {
579 s.mu.Lock()
580 defer s.mu.Unlock()
581 return append([]string(nil), s.reads...)
582}
583
584// Task 8 fix round 1, finding 2: a nested seed writes its obligation ahead
585// as a flat one does, before root is asked to run anything, and a seed
586// whose unit is proven empty removes it.
587func TestANestedSeedRecordsItsObligationBeforeItRuns(t *testing.T) {
588 ctl := &fakeControl{seed: instancectl.SeedResponse{InvocationID: "s1", Result: "success"}}
589 unit := &fakeUnit{}
590 b, rt := nestedBackend(t, ctl, unit)
591 rt.Seed = `["seed"]`
592 ob := rt.DBPath + seedUnprovenSuffix
593 seen := false
594 ctl.onSeed = func() {
595 _, err := os.Stat(ob)
596 seen = err == nil
597 }
598 unit.set("inactive", "", false, false)
599 if err := b.Seed(context.Background(), rt); err != nil {
600 t.Fatalf("Seed: %v", err)
601 }
602 if !seen {
603 t.Fatal("root ran the seed with no obligation on disk")
604 }
605 if _, err := os.Stat(ob); !os.IsNotExist(err) {
606 t.Fatalf("obligation still there (%v) after a proven seed", err)
607 }
608}
609
610// Finding 2: a nested seed whose unit is not proven empty (failed or
611// successful) keeps its database AND leaves the obligation. The next wake
612// proves the nested seed's own unit empty (stopped, then its cgroup read),
613// never carlos-exec@<host>, which a nested seed never ran in: while that
614// proof fails the wake is refused and the database is untouched; once it
615// holds, the database and the obligation go and the wake is fresh.
616func TestAnUnprovenNestedSeedLeavesItsObligationAndTheNextWakeProvesItsUnit(t *testing.T) {
617 for _, result := range []string{"exit-code", "success"} {
618 t.Run(result, func(t *testing.T) {
619 ctl := &fakeControl{seed: instancectl.SeedResponse{InvocationID: "s1", Result: result, Status: "3"}}
620 unit := &fakeUnit{}
621 b, rt := nestedBackend(t, ctl, unit)
622 rt.Seed = `["seed"]`
623 ev := &switchableEvents{err: errors.New("cgroup.events: unreadable")}
624 b.CgroupEvents = ev.events
625 var flat []string
626 b.cgroupKill = func(_ context.Context, _ registry.Route, child string) error {
627 flat = append(flat, child)
628 return errors.New("a nested seed never ran in carlos-exec@<host>")
629 }
630 swapRestoreReplica(t, func(context.Context, string, string, string, string) (bool, error) {
631 return false, nil
632 })
633 ctl.onSeed = func() {
634 if err := os.WriteFile(rt.DBPath, []byte("half"), 0o600); err != nil {
635 t.Fatal(err)
636 }
637 }
638 ob := rt.DBPath + seedUnprovenSuffix
639
640 if err := b.Seed(context.Background(), rt); !errors.Is(err, ErrSeedUnproven) {
641 t.Fatalf("Seed = %v, want ErrSeedUnproven", err)
642 }
643 if _, err := os.Stat(ob); err != nil {
644 t.Fatalf("no obligation beside the nested seed's database: %v", err)
645 }
646
647 if fresh, err := b.Restore(context.Background(), rt); !errors.Is(err, ErrSeedUnproven) || fresh {
648 t.Fatalf("Restore = %v, %v; want a refusal while the seed unit is unproven", fresh, err)
649 }
650 if got, _ := os.ReadFile(rt.DBPath); string(got) != "half" {
651 t.Fatalf("database = %q after a refused wake, want it untouched", got)
652 }
653
654 ev.set(nil)
655 before := strings.Count(ctl.joined(), "stop h.example.com seed")
656 if fresh, err := b.Restore(context.Background(), rt); err != nil || !fresh {
657 t.Fatalf("Restore = %v, %v; want fresh once the seed unit is proven empty", fresh, err)
658 }
659 if strings.Count(ctl.joined(), "stop h.example.com seed") <= before {
660 t.Fatalf("the wake did not stop the seed unit before its proof: %s", ctl.joined())
661 }
662 if len(flat) != 0 {
663 t.Fatalf("the nested seed's proof asked carlos-exec@<host> (%q)", flat)
664 }
665 if reads := ev.read(); len(reads) == 0 || reads[len(reads)-1] != instancectl.UnitName(instancectl.RoleSeed, rt.Host) {
666 t.Fatalf("cgroup reads %q, want the seed unit's", reads)
667 }
668 for _, p := range []string{rt.DBPath, ob} {
669 if _, err := os.Stat(p); !os.IsNotExist(err) {
670 t.Fatalf("%s still there (%v) after the proof held", filepath.Base(p), err)
671 }
672 }
673 })
674 }
675}
676
677// The obligation sits beside the database, on ground the instance can
678// write. A FIFO planted at its name must not hang the wake (the read never
679// waits for a writer), and it must not hold the row down either: it is an
680// obligation that does not say what the seed owned, so it is proven, the
681// database is kept, and the planted name is removed.
682func TestAPlantedFIFOAtTheObligationIsAnsweredNotWaitedOn(t *testing.T) {
683 const host = "fifo-obligation.example.com"
684 b, _ := delegatedBackend(t, host)
685 b.cgroupKill = func(context.Context, registry.Route, string) error { return nil }
686 rt := registry.Route{Host: host, Kind: "instance", Seed: `["seed"]`}
687 db := b.pathsFor(rt).dbPath
688 if err := os.MkdirAll(filepath.Dir(db), 0o755); err != nil {
689 t.Fatal(err)
690 }
691 if err := os.WriteFile(db, []byte("data"), 0o600); err != nil {
692 t.Fatal(err)
693 }
694 if err := unix.Mkfifo(db+seedUnprovenSuffix, 0o600); err != nil {
695 t.Fatalf("Mkfifo: %v", err)
696 }
697 type result struct {
698 fresh bool
699 err error
700 }
701 done := make(chan result, 1)
702 go func() {
703 fresh, err := b.Restore(context.Background(), rt)
704 done <- result{fresh, err}
705 }()
706 select {
707 case r := <-done:
708 if r.err != nil || r.fresh {
709 t.Fatalf("Restore = %v, %v; want the warm database once the proof holds", r.fresh, r.err)
710 }
711 case <-time.After(5 * time.Second):
712 t.Fatal("Restore hung on a FIFO planted at the obligation's name")
713 }
714 if got, err := os.ReadFile(db); err != nil || string(got) != "data" {
715 t.Fatalf("database = %q, %v; want it kept: the planted name says nothing the seed owned", got, err)
716 }
717 if _, err := os.Lstat(db + seedUnprovenSuffix); !os.IsNotExist(err) {
718 t.Fatalf("the planted name is still there (%v)", err)
719 }
720}
721
722// TestPrepareRemovesAStaleSocketFile covers prepare's stale-socket removal:
723// a file left behind at rt.Socket by an earlier, SIGKILLed invocation must
724// not survive into the next one, or every later wake's health check would
725// fail with nothing but a dial timeout.
726func TestPrepareRemovesAStaleSocketFile(t *testing.T) {
727 ctl := &fakeControl{inv: "inv1"}
728 unit := &fakeUnit{}
729 b, rt := nestedBackend(t, ctl, unit)
730 if err := os.MkdirAll(filepath.Dir(rt.Socket), 0o750); err != nil {
731 t.Fatal(err)
732 }
733 if err := os.WriteFile(rt.Socket, []byte("stale"), 0o600); err != nil {
734 t.Fatal(err)
735 }
736 _, ctl2, logs, _, err := b.prepare(rt, instancectl.RoleRun)
737 if err != nil {
738 t.Fatalf("prepare: %v", err)
739 }
740 defer logs.Close()
741 if ctl2 != nil {
742 defer ctl2.Close()
743 }
744 if _, err := os.Stat(rt.Socket); !os.IsNotExist(err) {
745 t.Errorf("stale socket file was not removed by prepare: %v", err)
746 }
747}
748
749// TestPrepareRefusesAFIFOAtTheAdminTokenPath pins EnsureAdminTokenBeneath's
750// use from the nested prepare: the token file lives on the same
751// tenant-writable ground as everything else prepare touches, so a FIFO
752// planted at that name must fail prepare fast rather than hang it forever
753// behind the token mint's own read.
754func TestPrepareRefusesAFIFOAtTheAdminTokenPath(t *testing.T) {
755 ctl := &fakeControl{inv: "inv1"}
756 unit := &fakeUnit{}
757 b, rt := nestedBackend(t, ctl, unit)
758 instDir := provision.InstanceDir(b.DataDir, rt.Host)
759 if err := os.MkdirAll(instDir, 0o700); err != nil {
760 t.Fatal(err)
761 }
762 if err := unix.Mkfifo(filepath.Join(instDir, rt.Host+".env"), 0o600); err != nil {
763 t.Fatalf("Mkfifo: %v", err)
764 }
765 done := make(chan error, 1)
766 go func() {
767 _, _, _, _, err := b.prepare(rt, instancectl.RoleRun)
768 done <- err
769 }()
770 select {
771 case err := <-done:
772 if err == nil {
773 t.Fatal("prepare against a FIFO'd admin token file succeeded, want an error")
774 }
775 case <-time.After(2 * time.Second):
776 t.Fatal("prepare blocked on a FIFO'd admin token file instead of refusing it")
777 }
778}
779
780// serveOK binds a unix socket at path and answers 200 to every request, so
781// waitHealthyCtx passes without a real unit.
782func serveOK(t *testing.T, path string) func() {
783 t.Helper()
784 if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
785 t.Fatal(err)
786 }
787 ln, err := net.Listen("unix", path)
788 if err != nil {
789 t.Skipf("cannot bind here (%v); run with TMPDIR=/tmp/g outside the sandbox", err)
790 }
791 srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })}
792 go srv.Serve(ln) //nolint:errcheck
793 return func() { srv.Close(); ln.Close() }
794}
795