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 "errors"
6 "fmt"
7 "os"
8 "os/exec"
9 "path/filepath"
10 "strconv"
11 "strings"
12 "syscall"
13 "testing"
14
15 "github.com/carlosframework/platform/internal/cgroup"
16 "github.com/carlosframework/platform/internal/instancectl"
17 "github.com/carlosframework/platform/internal/registry"
18)
19
20// fakeGenControl adds the generation verbs to fakeControl (unitproc_test.go).
21type fakeGenControl struct{ *fakeControl }
22
23func (f fakeGenControl) StartGen(_ context.Context, host string, n uint64, max int64) (string, error) {
24 f.rec(fmt.Sprintf("startgen %s %d %d", host, n, max))
25 if f.onStart != nil {
26 f.onStart()
27 }
28 return f.inv, f.startErr
29}
30func (f fakeGenControl) StartGenExe(ctx context.Context, host string, n uint64, max int64, _ instancectl.StagedExe) (string, error) {
31 return f.StartGen(ctx, host, n, max)
32}
33func (f fakeGenControl) StartSidecarExe(_ context.Context, host string, max int64, _ instancectl.StagedExe) (string, error) {
34 f.rec(fmt.Sprintf("startsidecarexe %s %d", host, max))
35 return f.inv, f.startErr
36}
37func (f fakeGenControl) StopGen(_ context.Context, host string, n uint64, kill bool) error {
38 f.rec(fmt.Sprintf("stopgen %s %d kill=%v", host, n, kill))
39 if f.stopHook != nil {
40 f.stopHook(host, instancectl.RoleRun)
41 }
42 return f.stopGenErr
43}
44func (f fakeGenControl) ResetGen(_ context.Context, host string, n uint64) error {
45 f.rec(fmt.Sprintf("resetgen %s %d", host, n))
46 return nil
47}
48func (f fakeGenControl) Parent(_ context.Context, host string, max int64) error {
49 f.rec(fmt.Sprintf("parent %s %d", host, max))
50 return nil
51}
52
53func TestPlaceForIsSingleLevelWithContainmentOff(t *testing.T) {
54 b := testExecBackend(t)
55 for _, kind := range []spawnKind{spawnInstance, spawnSidecar, spawnSeed, spawnWorker} {
56 child, own, err := b.placeFor(kind, 7)
57 if child != "" || own || err != nil {
58 t.Errorf("kind %d with containment off: %q %v %v", kind, child, own, err)
59 }
60 }
61}
62
63func TestPlaceForUnderContainment(t *testing.T) {
64 b := testExecBackend(t)
65 b.GenContainment = true
66 for _, tc := range []struct {
67 kind spawnKind
68 child string
69 own bool
70 }{
71 {spawnInstance, "g7", true},
72 {spawnSidecar, "role-sidecar", false},
73 {spawnSeed, "role-seed", false},
74 {spawnWorker, "", false},
75 } {
76 child, own, err := b.placeFor(tc.kind, 7)
77 if child != tc.child || own != tc.own || err != nil {
78 t.Errorf("kind %d: %q %v %v, want %q %v", tc.kind, child, own, err, tc.child, tc.own)
79 }
80 }
81 if _, _, err := b.placeFor(spawnInstance, 0); err == nil {
82 t.Error("an instance spawn with generation 0 was placed")
83 }
84}
85
86func delegatedBackend(t *testing.T, host string) (*ExecBackend, string) {
87 t.Helper()
88 root := t.TempDir()
89 for _, f := range []string{"cgroup.procs", "cgroup.subtree_control"} {
90 if err := os.WriteFile(filepath.Join(root, f), nil, 0o644); err != nil {
91 t.Fatal(err)
92 }
93 }
94 m, err := cgroup.SetupAt(root, t.Logf)
95 if err != nil {
96 t.Fatal(err)
97 }
98 parent := filepath.Join(root, cgroup.ExecName(host))
99 if err := os.MkdirAll(parent, 0o755); err != nil {
100 t.Fatal(err)
101 }
102 if err := os.WriteFile(filepath.Join(parent, "cgroup.controllers"), []byte("cpu io memory pids\n"), 0o644); err != nil {
103 t.Fatal(err)
104 }
105 b := testExecBackend(t)
106 b.Cgroups = m
107 return b, parent
108}
109
110func TestSpawnInCgroupAtPutsAGenerationUnderItsParent(t *testing.T) {
111 const host = "h.example.com"
112 b, parent := delegatedBackend(t, host)
113 cgroup.FakePlacementForTest(t) // a temp dir is no cgroupfs, and a generation is never moved by PID
114 b.GenContainment = true
115 cmd, _, err := b.spawnInCgroupAt(registry.Route{Host: host}, "g7", true, func() *exec.Cmd { return exec.Command("/bin/true") })
116 if err != nil {
117 t.Fatal(err)
118 }
119 _ = cmd.Wait()
120 for file, want := range map[string]string{
121 filepath.Join(parent, "g7", "memory.max"): "536870912",
122 filepath.Join(parent, "g7", "memory.oom.group"): "1",
123 filepath.Join(parent, "memory.oom.group"): "0",
124 } {
125 if got, err := os.ReadFile(file); err != nil || string(got) != want {
126 t.Errorf("%s = %q, %v; want %q", file, got, err, want)
127 }
128 }
129}
130
131// Flag off: the spawn lands in the host cgroup itself, as today.
132func TestSpawnInCgroupAtWithNoChildKeepsTheHostCgroup(t *testing.T) {
133 const host = "h.example.com"
134 b, parent := delegatedBackend(t, host)
135 cmd, _, err := b.spawnInCgroupAt(registry.Route{Host: host}, "", false, func() *exec.Cmd { return exec.Command("/bin/true") })
136 if err != nil {
137 t.Fatal(err)
138 }
139 _ = cmd.Wait()
140 if got, err := os.ReadFile(filepath.Join(parent, "memory.oom.group")); err != nil || string(got) != "1" {
141 t.Errorf("host cgroup memory.oom.group = %q, %v; want 1", got, err)
142 }
143 entries, _ := os.ReadDir(parent)
144 for _, e := range entries {
145 if e.IsDir() {
146 t.Errorf("a flag-off spawn made child cgroup %s", e.Name())
147 }
148 }
149}
150
151func TestParentLimitRefusesWithContainmentOff(t *testing.T) {
152 b, _ := delegatedBackend(t, "h.example.com")
153 if err := b.ParentLimit(context.Background(), registry.Route{Host: "h.example.com"}, cgroup.TierDefault); !errors.Is(err, errNoGenContainment) {
154 t.Fatalf("ParentLimit with containment off: %v", err)
155 }
156 if err := b.KillGen(context.Background(), registry.Route{Host: "h.example.com"}, 7); !errors.Is(err, errNoGenContainment) {
157 t.Fatalf("KillGen with containment off: %v", err)
158 }
159}
160
161func TestParentLimitFlatWritesTheParent(t *testing.T) {
162 const host = "h.example.com"
163 b, parent := delegatedBackend(t, host)
164 b.GenContainment = true
165 if err := b.ParentLimit(context.Background(), registry.Route{Host: host}, cgroup.OverlapLimit(cgroup.TierDefault, cgroup.TierDefault)); err != nil {
166 t.Fatal(err)
167 }
168 if got, err := os.ReadFile(filepath.Join(parent, "memory.max")); err != nil || string(got) != "1073741824" {
169 t.Errorf("parent memory.max = %q, %v", got, err)
170 }
171}
172
173// No cgroup manager (no delegation): there is no parent to raise, so a
174// flat ParentLimit refuses rather than let a swap admit an overlap with
175// nothing raised.
176func TestParentLimitFlatRefusesWithoutACgroupManager(t *testing.T) {
177 b := testExecBackend(t)
178 b.GenContainment = true
179 b.Cgroups = nil
180 if err := b.ParentLimit(context.Background(), registry.Route{Host: "h.example.com"}, cgroup.OverlapLimit(cgroup.TierDefault, cgroup.TierDefault)); !errors.Is(err, cgroup.ErrNoDelegation) {
181 t.Fatalf("ParentLimit with no cgroup manager: %v, want cgroup.ErrNoDelegation", err)
182 }
183}
184
185// A fixture is not cgroup v2: KillGen must refuse rather than claim an
186// empty child it never proved.
187func TestKillGenFlatRefusesWithoutCgroupV2(t *testing.T) {
188 const host = "h.example.com"
189 b, parent := delegatedBackend(t, host)
190 b.GenContainment = true
191 if err := os.Mkdir(filepath.Join(parent, "g7"), 0o755); err != nil {
192 t.Fatal(err)
193 }
194 if err := b.KillGen(context.Background(), registry.Route{Host: host}, 7); err == nil {
195 t.Fatal("KillGen reported an unproven kill as done")
196 }
197}
198
199func TestParentLimitAndKillGenNestedAskTheControlService(t *testing.T) {
200 ctl := &fakeControl{}
201 b, rt := nestedBackend(t, ctl, &fakeUnit{})
202 b.GenContainment = true
203 b.Control = fakeGenControl{ctl}
204 if err := b.ParentLimit(context.Background(), rt, cgroup.OverlapLimit(cgroup.TierDefault, cgroup.TierDefault)); err != nil {
205 t.Fatal(err)
206 }
207 if err := b.KillGen(context.Background(), rt, 7); err != nil {
208 t.Fatal(err)
209 }
210 if want := "parent h.example.com 1073741824\nstopgen h.example.com 7 kill=true"; ctl.joined() != want {
211 t.Errorf("calls =\n%s\nwant\n%s", ctl.joined(), want)
212 }
213 b.Control = ctl // a Control without the generation verbs
214 if err := b.ParentLimit(context.Background(), rt, cgroup.TierDefault); !errors.Is(err, errNoGenControl) {
215 t.Errorf("ParentLimit through a Control without generations: %v", err)
216 }
217}
218
219// SplitBackend forwards KillGen to the exec half for an exec row. A
220// unit-backed row, and an exec half that cannot kill a generation, refuse:
221// a caller must never believe a kill it could not make.
222func TestSplitBackendKillGen(t *testing.T) {
223 ctl := &fakeControl{}
224 b, rt := nestedBackend(t, ctl, &fakeUnit{})
225 b.GenContainment = true
226 b.Control = fakeGenControl{ctl}
227 sb := &SplitBackend{Exec: b, Unit: &UnitBackend{}}
228 if err := sb.KillGen(context.Background(), rt, 7); err != nil {
229 t.Fatal(err)
230 }
231 if want := "stopgen h.example.com 7 kill=true"; ctl.joined() != want {
232 t.Errorf("calls =\n%s\nwant\n%s", ctl.joined(), want)
233 }
234 unitRt := rt
235 unitRt.Backing = registry.BackingUnit
236 if err := sb.KillGen(context.Background(), unitRt, 7); !errors.Is(err, errNoGenContainment) {
237 t.Errorf("KillGen of a unit-backed row: %v", err)
238 }
239 if want := "stopgen h.example.com 7 kill=true"; ctl.joined() != want {
240 t.Errorf("a unit-backed KillGen reached the control service:\n%s", ctl.joined())
241 }
242 plain := &SplitBackend{Exec: &fakeBackend{}, Unit: &UnitBackend{}}
243 if err := plain.KillGen(context.Background(), rt, 7); !errors.Is(err, errNoGenContainment) {
244 t.Errorf("KillGen through an exec half without KillGen: %v", err)
245 }
246}
247
248// A placed spawn that fails to start leaves no child cgroup behind.
249func TestSpawnInCgroupAtRemovesTheChildOfAFailedSpawn(t *testing.T) {
250 const host = "h.example.com"
251 b, parent := delegatedBackend(t, host)
252 b.GenContainment = true
253 // A zero tier writes no limit files, so the fixture's child directory is
254 // as empty as a real cgroup's after its control files vanish.
255 b.Tier = func(registry.Route) cgroup.Limit { return cgroup.Limit{} }
256 _, _, err := b.spawnInCgroupAt(registry.Route{Host: host}, "g7", true, func() *exec.Cmd {
257 return exec.Command(filepath.Join(t.TempDir(), "no-such-binary"))
258 })
259 if err == nil {
260 t.Fatal("a spawn of a missing binary succeeded")
261 }
262 if _, err := os.Stat(filepath.Join(parent, "g7")); !os.IsNotExist(err) {
263 t.Errorf("the failed spawn's child cgroup is still there: %v", err)
264 }
265}
266
267// A placed spawn never starts outside a strict host's group: a host that is
268// strict, or one whose group a strict owner holds, is refused.
269func TestSpawnInCgroupAtRefusesAStrictHost(t *testing.T) {
270 const host = "h.example.com"
271 b, parent := delegatedBackend(t, host)
272 b.GenContainment = true
273 b.Strict = func(string) bool { return true }
274 build := func() *exec.Cmd { return exec.Command("/bin/true") }
275 if _, _, err := b.spawnInCgroupAt(registry.Route{Host: host}, "g7", true, build); !errors.Is(err, ErrStrictRequired) {
276 t.Fatalf("a strict host's placed spawn: %v", err)
277 }
278 b.Strict = func(string) bool { return false }
279 b.gate(host).owner = &strictOwner{}
280 if _, _, err := b.spawnInCgroupAt(registry.Route{Host: host}, "role-seed", false, build); !errors.Is(err, ErrStrictGroupInUse) {
281 t.Fatalf("a placed spawn beside a strict owner: %v", err)
282 }
283 if _, err := os.Stat(filepath.Join(parent, "g7")); !os.IsNotExist(err) {
284 t.Errorf("a refused spawn made a child cgroup: %v", err)
285 }
286}
287
288// Amadan #272: a host whose parent cgroup still holds a process directly
289// (a legacy flat start) gets no generation, in any child shape. The refusal
290// comes back before the exec, leaves no child, and reads as nothing of the
291// build having run, so no swap or wake counts it as "may have run" or as a
292// failure of the build.
293func TestSpawnInCgroupAtRefusesAPopulatedLegacyParent(t *testing.T) {
294 const host = "h.example.com"
295 b, parent := delegatedBackend(t, host)
296 b.GenContainment = true
297 if err := os.WriteFile(filepath.Join(parent, "cgroup.procs"), []byte("4242\n"), 0o644); err != nil {
298 t.Fatal(err)
299 }
300 ran := false
301 build := func() *exec.Cmd { ran = true; return exec.Command("/bin/true") }
302 for _, child := range []string{"g7", "role-seed", "role-sidecar"} {
303 cmd, _, err := b.spawnInCgroupAt(registry.Route{Host: host}, child, child == "g7", build)
304 if !errors.Is(err, cgroup.ErrLegacyParentPopulated) || cmd != nil {
305 t.Fatalf("%s under a populated parent: %v, %v", child, cmd, err)
306 }
307 if startMayHaveRun(nil, err) {
308 t.Errorf("%s: the refusal reads as a start that may have run", child)
309 }
310 if verdictOf(err) != gateNoOwnership {
311 t.Errorf("%s: the refusal is not an abandonment", child)
312 }
313 if _, serr := os.Stat(filepath.Join(parent, child)); !os.IsNotExist(serr) {
314 t.Errorf("%s: a refused spawn made a child cgroup: %v", child, serr)
315 }
316 }
317 if ran {
318 t.Error("the build was prepared for exec under a populated parent")
319 }
320}
321
322// zdd-readiness Task 6: errStartedThenExited is found through every wrap
323// spawnUnitFrom gives it (the uncertain mark, and beside errGenUnproven when
324// the kill is unproven), and the text is the one it always was: a flag-off
325// nested wake fails with it, and the member reads it.
326func TestStartedThenExitedIsUncertainAndKeepsItsText(t *testing.T) {
327 unit := exitedUnobserved{unit: "carlos-instance-gen@h.example.com:7.service"}
328 proven := markStartUncertain(fmt.Errorf("%s: start unit: %w", "h.example.com", unit))
329 unproven := markStartUncertain(fmt.Errorf("%s: start unit: %w; %w: %v", "h.example.com", unit, errGenUnproven, errors.New("stop timed out")))
330 for _, err := range []error{proven, unproven} {
331 if !errors.Is(err, errStartedThenExited) || !startMayHaveRun(nil, err) {
332 t.Fatalf("%v: errStartedThenExited %v, may have run %v; want both", err, errors.Is(err, errStartedThenExited), startMayHaveRun(nil, err))
333 }
334 }
335 if want := "h.example.com: start unit: carlos-instance-gen@h.example.com:7.service exited before it was observed"; proven.Error() != want {
336 t.Fatalf("text %q, want %q", proven.Error(), want)
337 }
338 if !genStartUnproven(unproven) || genStartUnproven(proven) {
339 t.Fatal("only the unproven kill carries errGenUnproven")
340 }
341 if errors.Is(markStartUncertain(errors.New("the unit failed to start")), errStartedThenExited) {
342 t.Fatal("another uncertain start reads as a crash at the start")
343 }
344}
345
346// Task 8: a failed flat seed's processes are killed and proven gone before
347// its database is removed. Here they cannot be (a temp dir is no cgroupfs,
348// so the host cgroup's kill is unproven): the seed answers ErrSeedUnproven,
349// its database stays, and a durable obligation is left beside it.
350func TestAFailedSeedWhoseCleanupIsUnprovenKeepsTheDBAndLeavesAnObligation(t *testing.T) {
351 const host = "unproven-seed.example.com"
352 b, _ := delegatedBackend(t, host)
353 stageSeedScript(t, b, host, "v1", "printf half > \"$3\"\nexit 1\n")
354 rt := registry.Route{Host: host, Kind: "instance", Seed: `["seed"]`}
355
356 err := b.Seed(context.Background(), rt)
357 if !errors.Is(err, ErrSeedUnproven) {
358 t.Fatalf("Seed = %v, want ErrSeedUnproven: the seed's leftovers were never proven gone", err)
359 }
360 db := filepath.Join(b.DataDir, host+".db")
361 if got, err := os.ReadFile(db); err != nil || string(got) != "half" {
362 t.Fatalf("database = %q, %v; want it kept: a process may still be writing it", got, err)
363 }
364 if _, err := os.Stat(db + seedUnprovenSuffix); err != nil {
365 t.Fatalf("no obligation beside the database: %v", err)
366 }
367}
368
369// Task 8, plan 3: the obligation survives to the next wake. While the proof
370// still fails, Restore refuses and the database is never reported warm.
371// Once it passes, the database, its WAL and SHM and the obligation are
372// removed, the wake is fresh, and the seed runs again.
373func TestTheNextWakeAfterAnUnprovenSeedProvesItBeforeTheDBIsWarm(t *testing.T) {
374 const host = "obligation.example.com"
375 b, _ := delegatedBackend(t, host)
376 runs := filepath.Join(t.TempDir(), "runs")
377 stageSeedScript(t, b, host, "v1", fmt.Sprintf(
378 "echo run >> %q\nprintf half > \"$3\"\n[ \"$(wc -l < %q)\" -ge 2 ] && exit 0\nexit 1\n", runs, runs))
379 swapRestoreReplica(t, func(context.Context, string, string, string, string) (bool, error) {
380 return false, nil
381 })
382 rt := registry.Route{Host: host, Kind: "instance", Seed: `["seed"]`}
383 db := filepath.Join(b.DataDir, host+".db")
384
385 if err := b.Seed(context.Background(), rt); !errors.Is(err, ErrSeedUnproven) {
386 t.Fatalf("first Seed = %v, want ErrSeedUnproven", err)
387 }
388 for _, p := range []string{db + "-wal", db + "-shm"} {
389 if err := os.WriteFile(p, []byte("x"), 0o600); err != nil {
390 t.Fatal(err)
391 }
392 }
393
394 // The proof still fails: refused, and the database is not warm.
395 fresh, err := b.Restore(context.Background(), rt)
396 if !errors.Is(err, ErrSeedUnproven) || fresh {
397 t.Fatalf("Restore = %v, %v; want a refusal with ErrSeedUnproven", fresh, err)
398 }
399 if got, _ := os.ReadFile(db); string(got) != "half" {
400 t.Fatalf("database = %q after a refused wake, want it untouched", got)
401 }
402 if _, err := os.Stat(db + seedUnprovenSuffix); err != nil {
403 t.Fatalf("the obligation went with a refused wake: %v", err)
404 }
405
406 // The proof passes: everything of the failed seed goes, and it is fresh.
407 var asked []string
408 b.cgroupKill = func(_ context.Context, _ registry.Route, child string) error {
409 asked = append(asked, child)
410 return nil
411 }
412 fresh, err = b.Restore(context.Background(), rt)
413 if err != nil || !fresh {
414 t.Fatalf("Restore = %v, %v; want fresh once the proof holds", fresh, err)
415 }
416 if len(asked) != 1 || asked[0] != "" {
417 t.Fatalf("proof asked of %q, want the host cgroup once", asked)
418 }
419 for _, p := range []string{db, db + "-wal", db + "-shm", db + seedUnprovenSuffix} {
420 if _, err := os.Stat(p); !os.IsNotExist(err) {
421 t.Fatalf("%s still there (%v) after the proof held", filepath.Base(p), err)
422 }
423 }
424 if err := b.Seed(context.Background(), rt); err != nil {
425 t.Fatalf("second Seed: %v", err)
426 }
427 got, err := os.ReadFile(runs)
428 if err != nil || strings.Count(string(got), "run\n") != 2 {
429 t.Fatalf("seed runs = %q, %v; want the seed to have run twice", got, err)
430 }
431}
432
433// Task 8, plan 6: flag on, a successful seed's role child is killed and
434// proven empty before the seed returns, so a helper that redirected its
435// output (Wait returns a plain nil) does not outlive it.
436func TestAFlagOnSeedKillsAHelperThatOutlivesIt(t *testing.T) {
437 const host = "helper-seed.example.com"
438 b, _ := delegatedBackend(t, host)
439 cgroup.FakePlacementForTest(t) // a temp dir is no cgroupfs, and a role child is never moved by PID
440 b.GenContainment = true
441 pidFile := filepath.Join(t.TempDir(), "helper.pid")
442 stageSeedScript(t, b, host, "v1", fmt.Sprintf(
443 "sleep 30 >/dev/null 2>&1 &\necho $! > %q\nprintf seeded > \"$3\"\nexit 0\n", pidFile))
444 helper := func() int {
445 raw, err := os.ReadFile(pidFile)
446 if err != nil {
447 return 0
448 }
449 pid, _ := strconv.Atoi(strings.TrimSpace(string(raw)))
450 return pid
451 }
452 t.Cleanup(func() {
453 if pid := helper(); pid > 0 {
454 _ = syscall.Kill(pid, syscall.SIGKILL)
455 }
456 })
457 var asked []string
458 b.cgroupKill = func(_ context.Context, _ registry.Route, child string) error {
459 asked = append(asked, child)
460 if pid := helper(); pid > 0 {
461 _ = syscall.Kill(pid, syscall.SIGKILL) // what cgroup.kill does to the role child
462 }
463 return nil
464 }
465
466 if err := b.Seed(context.Background(), registry.Route{Host: host, Kind: "instance", Seed: `["seed"]`}); err != nil {
467 t.Fatalf("Seed: %v", err)
468 }
469 if len(asked) != 1 || asked[0] != cgroup.RoleChild("seed") {
470 t.Fatalf("killed %q before the seed returned, want the seed's role child once", asked)
471 }
472 if got, err := os.ReadFile(filepath.Join(b.DataDir, host+".db")); err != nil || string(got) != "seeded" {
473 t.Fatalf("database = %q, %v; want the seed's work kept", got, err)
474 }
475}
476
477// Flag on, a successful seed whose role child cannot be proven empty is
478// unproven: the helper may still write the database, so the wake must not
479// go on to serve it, and the next wake must prove it first.
480func TestAFlagOnSeedWhoseHelperIsNotProvenGoneIsUnproven(t *testing.T) {
481 const host = "helper-unproven.example.com"
482 b, _ := delegatedBackend(t, host)
483 cgroup.FakePlacementForTest(t)
484 b.GenContainment = true
485 stageSeedScript(t, b, host, "v1", "printf seeded > \"$3\"\nexit 0\n")
486 b.cgroupKill = func(context.Context, registry.Route, string) error {
487 return errors.New("the role child still holds a process")
488 }
489
490 err := b.Seed(context.Background(), registry.Route{Host: host, Kind: "instance", Seed: `["seed"]`})
491 if !errors.Is(err, ErrSeedUnproven) {
492 t.Fatalf("Seed = %v, want ErrSeedUnproven", err)
493 }
494 if _, err := os.Stat(filepath.Join(b.DataDir, host+".db"+seedUnprovenSuffix)); err != nil {
495 t.Fatalf("no obligation beside the database: %v", err)
496 }
497}
498
499// Task 8 fix round 1, finding 1: the obligation is written ahead. It is on
500// disk, durably, before the seed's first instruction runs, so an agent that
501// dies mid-seed leaves it beside the partial database, and the next wake
502// proves the seed gone before the database can count as warm. A seed that
503// succeeds (flag off: no proof of its own) removes it at once.
504func TestASeedRecordsItsObligationBeforeItRuns(t *testing.T) {
505 const host = "write-ahead.example.com"
506 b, _ := delegatedBackend(t, host)
507 snap := filepath.Join(t.TempDir(), "obligation-at-run")
508 stageSeedScript(t, b, host, "v1", fmt.Sprintf(
509 "cp \"$3%s\" %q || exit 7\nprintf seeded > \"$3\"\nexit 0\n", seedUnprovenSuffix, snap))
510 swapRestoreReplica(t, func(context.Context, string, string, string, string) (bool, error) {
511 return false, nil
512 })
513 rt := registry.Route{Host: host, Kind: "instance", Seed: `["seed"]`}
514 db := filepath.Join(b.DataDir, host+".db")
515 ob := db + seedUnprovenSuffix
516
517 if err := b.Seed(context.Background(), rt); err != nil {
518 t.Fatalf("Seed = %v; want success, with the obligation there while it ran", err)
519 }
520 if _, err := os.Stat(ob); !os.IsNotExist(err) {
521 t.Fatalf("obligation still there (%v) after a successful flag-off seed", err)
522 }
523
524 // The agent died mid-seed: what is on disk is what the seed saw while
525 // it ran, the obligation and a partial database.
526 raw, err := os.ReadFile(snap)
527 if err != nil {
528 t.Fatal(err)
529 }
530 if err := os.WriteFile(ob, raw, 0o600); err != nil {
531 t.Fatal(err)
532 }
533 if err := os.WriteFile(db, []byte("half"), 0o600); err != nil {
534 t.Fatal(err)
535 }
536 b.cgroupKill = func(context.Context, registry.Route, string) error {
537 return errors.New("the host cgroup still holds a process")
538 }
539 if fresh, err := b.Restore(context.Background(), rt); !errors.Is(err, ErrSeedUnproven) || fresh {
540 t.Fatalf("Restore = %v, %v; want a refusal while the dead agent's seed is unproven", fresh, err)
541 }
542 if got, _ := os.ReadFile(db); string(got) != "half" {
543 t.Fatalf("database = %q after a refused wake, want it untouched", got)
544 }
545 b.cgroupKill = func(context.Context, registry.Route, string) error { return nil }
546 if fresh, err := b.Restore(context.Background(), rt); err != nil || !fresh {
547 t.Fatalf("Restore = %v, %v; want fresh once the proof holds", fresh, err)
548 }
549 for _, p := range []string{db, ob} {
550 if _, err := os.Stat(p); !os.IsNotExist(err) {
551 t.Fatalf("%s still there (%v) after the proof held", filepath.Base(p), err)
552 }
553 }
554}
555
556// Finding 1: an obligation that cannot be written (a full disk) refuses the
557// seed before anything runs. Nothing ran, so this is an ordinary failure
558// (not ErrSeedUnproven), and the wake may release its lease.
559func TestASeedWhoseObligationCannotBeWrittenRunsNothing(t *testing.T) {
560 const host = "no-room.example.com"
561 b, _ := delegatedBackend(t, host)
562 runs := filepath.Join(t.TempDir(), "runs")
563 stageSeedScript(t, b, host, "v1", fmt.Sprintf("echo run >> %q\nprintf half > \"$3\"\nexit 0\n", runs))
564 orig := writeSeedObligationFile
565 t.Cleanup(func() { writeSeedObligationFile = orig })
566 writeSeedObligationFile = func(string, []byte, os.FileMode) error { return syscall.ENOSPC }
567
568 err := b.Seed(context.Background(), registry.Route{Host: host, Kind: "instance", Seed: `["seed"]`})
569 if err == nil || errors.Is(err, ErrSeedUnproven) || !errors.Is(err, syscall.ENOSPC) {
570 t.Fatalf("Seed = %v, want a refusal naming the failed write, not ErrSeedUnproven", err)
571 }
572 if _, err := os.Stat(runs); !os.IsNotExist(err) {
573 t.Fatalf("the seed ran (%v) with no obligation recorded", err)
574 }
575 if _, err := os.Stat(filepath.Join(b.DataDir, host+".db")); !os.IsNotExist(err) {
576 t.Fatalf("a database is there (%v) though no seed ran", err)
577 }
578}
579
580// Findings 1 and 7: a failed seed proven gone has its database removed with
581// every error checked, and the obligation goes only after. A database file
582// that cannot be removed keeps the obligation (the next wake proves and
583// removes again) and fails the wake as an ordinary failure: the seed's
584// processes are proven gone, so the lease may go.
585func TestAProvenFailedSeedWhoseDBCannotBeRemovedKeepsItsObligation(t *testing.T) {
586 const host = "stuck-wal.example.com"
587 b, _ := delegatedBackend(t, host)
588 stageSeedScript(t, b, host, "v1", "printf half > \"$3\"\nmkdir -p \"$3-wal/x\"\nexit 1\n")
589 b.cgroupKill = func(context.Context, registry.Route, string) error { return nil }
590 db := filepath.Join(b.DataDir, host+".db")
591
592 err := b.Seed(context.Background(), registry.Route{Host: host, Kind: "instance", Seed: `["seed"]`})
593 if err == nil || errors.Is(err, ErrSeedUnproven) {
594 t.Fatalf("Seed = %v, want the seed's own failure", err)
595 }
596 if !strings.Contains(err.Error(), "-wal") {
597 t.Fatalf("Seed = %v, want it to name the file that could not be removed", err)
598 }
599 if _, err := os.Stat(db + seedUnprovenSuffix); err != nil {
600 t.Fatalf("obligation gone (%v) though the database was not removed", err)
601 }
602}
603
604// Finding 6: a database that was there before the seed is never the
605// obligation's to remove, but it is still refused as warm while the seed
606// beside it is unproven. Once the proof holds, the obligation goes and the
607// database stays.
608func TestAPreexistingDBBesideAnUnprovenSeedIsRefusedThenKept(t *testing.T) {
609 const host = "preexisting.example.com"
610 b, _ := delegatedBackend(t, host)
611 stageSeedScript(t, b, host, "v1", "exit 1\n")
612 db := filepath.Join(b.DataDir, host+".db")
613 if err := os.MkdirAll(filepath.Dir(db), 0o755); err != nil {
614 t.Fatal(err)
615 }
616 if err := os.WriteFile(db, []byte("data"), 0o600); err != nil {
617 t.Fatal(err)
618 }
619 rt := registry.Route{Host: host, Kind: "instance", Seed: `["seed"]`}
620
621 if err := b.Seed(context.Background(), rt); !errors.Is(err, ErrSeedUnproven) {
622 t.Fatalf("Seed = %v, want ErrSeedUnproven", err)
623 }
624 if _, err := os.Stat(db + seedUnprovenSuffix); err != nil {
625 t.Fatalf("no obligation beside a pre-existing database (%v): the next wake would serve it warm", err)
626 }
627 if fresh, err := b.Restore(context.Background(), rt); !errors.Is(err, ErrSeedUnproven) || fresh {
628 t.Fatalf("Restore = %v, %v; want a refusal while the seed is unproven", fresh, err)
629 }
630 b.cgroupKill = func(context.Context, registry.Route, string) error { return nil }
631 if fresh, err := b.Restore(context.Background(), rt); err != nil || fresh {
632 t.Fatalf("Restore = %v, %v; want the warm database once the proof holds", fresh, err)
633 }
634 if got, err := os.ReadFile(db); err != nil || string(got) != "data" {
635 t.Fatalf("database = %q, %v; want the pre-existing one kept", got, err)
636 }
637 if _, err := os.Stat(db + seedUnprovenSuffix); !os.IsNotExist(err) {
638 t.Fatalf("obligation still there (%v) after the proof held", err)
639 }
640}
641
642// Finding 5, on the seed's own proof: a kernel with no cgroup.kill can kill
643// nothing, so the leader's exit is the only proof there is, as with no
644// delegation. Read as unproven instead, an obligation could never be
645// discharged on such a box and the row would stay down for good.
646func TestASeedProofWithNoCgroupKillIsTheLeaderOnlyProof(t *testing.T) {
647 const host = "old-kernel-seed.example.com"
648 b, _ := delegatedBackend(t, host)
649 b.cgroupKill = func(context.Context, registry.Route, string) error {
650 return fmt.Errorf("%w: open cgroup.kill: no such file or directory", cgroup.ErrNoKill)
651 }
652 if err := b.seedDescendantsGone(context.Background(), registry.Route{Host: host}, ""); err != nil {
653 t.Fatalf("seedDescendantsGone = %v, want the leader-only proof (nil)", err)
654 }
655}
656
657// Flag on, a failed seed's role child is proven empty before the database
658// it built is removed.
659func TestAFlagOnFailedSeedProvesItsRoleChildBeforeRemovingTheDB(t *testing.T) {
660 const host = "failed-role-seed.example.com"
661 b, _ := delegatedBackend(t, host)
662 cgroup.FakePlacementForTest(t)
663 b.GenContainment = true
664 stageSeedScript(t, b, host, "v1", "printf half > \"$3\"\nexit 1\n")
665 db := filepath.Join(b.DataDir, host+".db")
666 var asked []string
667 b.cgroupKill = func(_ context.Context, _ registry.Route, child string) error {
668 if _, err := os.Stat(db); err != nil {
669 t.Errorf("the database was gone (%v) before the seed's processes were proven gone", err)
670 }
671 asked = append(asked, child)
672 return nil
673 }
674
675 err := b.Seed(context.Background(), registry.Route{Host: host, Kind: "instance", Seed: `["seed"]`})
676 if err == nil || errors.Is(err, ErrSeedUnproven) {
677 t.Fatalf("Seed = %v, want the seed's own failure", err)
678 }
679 if len(asked) != 1 || asked[0] != cgroup.RoleChild("seed") {
680 t.Fatalf("proved %q, want the seed's role child once", asked)
681 }
682 for _, p := range []string{db, db + seedUnprovenSuffix} {
683 if _, err := os.Stat(p); !os.IsNotExist(err) {
684 t.Fatalf("%s still there (%v) after a proven failed seed", filepath.Base(p), err)
685 }
686 }
687}
688
689// Fix wave 1, item 2(a) (Astra final review 1, finding 2): a successful
690// flag-off seed has no proof of its own (a helper it left stays in the host
691// cgroup), so when its obligation cannot be discharged the wake fails with
692// that helper's termination unknown. The proof is tried then: here the
693// host cgroup still holds a process, so the seed answers ErrSeedUnproven and
694// the lease is left to its TTL. The seed below swaps its obligation for a
695// non-empty directory, which the discharge cannot remove.
696func TestAFlagOffSeedWhoseDischargeFailsIsUnprovenWhileItsHelperRemains(t *testing.T) {
697 const host = "discharge-fails.example.com"
698 b, _ := delegatedBackend(t, host)
699 stageSeedScript(t, b, host, "v1", fmt.Sprintf(
700 "rm -f \"$3%[1]s\" && mkdir -p \"$3%[1]s/x\"\nprintf seeded > \"$3\"\nexit 0\n", seedUnprovenSuffix))
701 var asked []string
702 b.cgroupKill = func(_ context.Context, _ registry.Route, child string) error {
703 asked = append(asked, child)
704 return errors.New("the host cgroup still holds a process")
705 }
706
707 err := b.Seed(context.Background(), registry.Route{Host: host, Kind: "instance", Seed: `["seed"]`})
708 if !errors.Is(err, ErrSeedUnproven) {
709 t.Fatalf("Seed = %v, want ErrSeedUnproven: the discharge failed and the seed's helper is not proven gone", err)
710 }
711 if len(asked) != 1 || asked[0] != "" {
712 t.Fatalf("proof asked of %q, want the host cgroup once", asked)
713 }
714}
715
716// Fix wave 1, item 2(a), the other side: the discharge fails but the proof,
717// tried then, holds. Nothing of the seed is left, so the failure is an
718// ordinary one and the lease may go; the obligation stays for the next
719// wake.
720func TestAFlagOffSeedWhoseDischargeFailsAfterAProofIsAnOrdinaryFailure(t *testing.T) {
721 const host = "discharge-fails-proven.example.com"
722 b, _ := delegatedBackend(t, host)
723 stageSeedScript(t, b, host, "v1", fmt.Sprintf(
724 "rm -f \"$3%[1]s\" && mkdir -p \"$3%[1]s/x\"\nprintf seeded > \"$3\"\nexit 0\n", seedUnprovenSuffix))
725 b.cgroupKill = func(context.Context, registry.Route, string) error { return nil }
726
727 err := b.Seed(context.Background(), registry.Route{Host: host, Kind: "instance", Seed: `["seed"]`})
728 if err == nil || errors.Is(err, ErrSeedUnproven) {
729 t.Fatalf("Seed = %v, want an ordinary failure: the proof held", err)
730 }
731 if _, err := os.Stat(filepath.Join(b.DataDir, host+".db"+seedUnprovenSuffix)); err != nil {
732 t.Fatalf("the obligation is gone (%v); it must stay for the next wake", err)
733 }
734}
735
736// Fix wave 1, item 2(b): an obligation that is there but cannot be read
737// says nothing of whether its seed's processes are gone, so Restore refuses
738// with ErrSeedUnproven (the lease is left to its TTL), never an ordinary
739// error that releases it. The host cgroup here still holds a process.
740func TestAnUnreadableObligationIsUnproven(t *testing.T) {
741 if os.Geteuid() == 0 {
742 t.Skip("root reads a mode-000 file")
743 }
744 const host = "unreadable-obligation.example.com"
745 b, _ := delegatedBackend(t, host)
746 b.cgroupKill = func(context.Context, registry.Route, string) error {
747 return errors.New("the host cgroup still holds a process")
748 }
749 db := filepath.Join(b.DataDir, host+".db")
750 if err := os.MkdirAll(filepath.Dir(db), 0o755); err != nil {
751 t.Fatal(err)
752 }
753 if err := os.WriteFile(db, []byte("half"), 0o600); err != nil {
754 t.Fatal(err)
755 }
756 ob := db + seedUnprovenSuffix
757 if err := os.WriteFile(ob, []byte(seedObligationHeader+"\n"+seedObligationOwned+"\n"+seedObligationFlat+"\n"), 0o000); err != nil {
758 t.Fatal(err)
759 }
760 t.Cleanup(func() { _ = os.Chmod(ob, 0o600) })
761
762 fresh, err := b.Restore(context.Background(), registry.Route{Host: host, Kind: "instance", Seed: `["seed"]`})
763 if !errors.Is(err, ErrSeedUnproven) || fresh {
764 t.Fatalf("Restore = %v, %v; want a refusal with ErrSeedUnproven", fresh, err)
765 }
766 if got, _ := os.ReadFile(db); string(got) != "half" {
767 t.Fatalf("database = %q after a refused wake, want it untouched", got)
768 }
769}
770