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
2
3package cgroup
4
5import (
6 "context"
7 "errors"
8 "os"
9 "os/exec"
10 "path/filepath"
11 "testing"
12 "time"
13
14 "golang.org/x/sys/unix"
15)
16
17// amadan #272: once cgroup.kill has been written to a cgroup, every later
18// clone3(CLONE_INTO_CGROUP) into that same cgroup is SIGKILLed at once. The
19// fixture has no kernel behind it, so these tests pin the bookkeeping: a
20// cgroup that took a kill is removed and made fresh (its sentinel gone, its limit
21// files written again) before the next spawn into its path, and a removal
22// that fails refuses the spawn.
23
24// fakeCgroupRmdir stands in for rmdir on cgroupfs, where the interface files
25// do not count against it: the fixture's are ordinary files, so they go
26// first. fail, while non-nil, is returned instead. It records each removal.
27func fakeCgroupRmdir(t *testing.T, fail *error) *[]string {
28 t.Helper()
29 var removed []string
30 orig := rmdirCgroup
31 rmdirCgroup = func(path string) error {
32 if fail != nil && *fail != nil {
33 return *fail
34 }
35 entries, err := os.ReadDir(path)
36 if err != nil {
37 return err
38 }
39 for _, e := range entries {
40 if !e.IsDir() {
41 if err := os.Remove(filepath.Join(path, e.Name())); err != nil {
42 return err
43 }
44 }
45 }
46 if err := unix.Rmdir(path); err != nil {
47 return err
48 }
49 removed = append(removed, path)
50 return nil
51 }
52 t.Cleanup(func() { rmdirCgroup = orig })
53 return &removed
54}
55
56// forgetKillsAfter clears the kill marks a test leaves, so a later test's
57// temp dir can never meet one.
58func forgetKillsAfter(t *testing.T) {
59 t.Helper()
60 t.Cleanup(forgetKills)
61}
62
63func init() {
64 forgetKills = func() {
65 killedCgroups.Lock()
66 clear(killedCgroups.ids)
67 killedCgroups.Unlock()
68 }
69}
70
71// sentinelName is a file production code never creates in a cgroup. A test
72// plants it in a directory to learn later whether that very directory was
73// removed and made again (the sentinel is gone) or left alone (it is still
74// there). Freshness is proved this way, not by a changed inode number:
75// ext4 and xfs hand a directory's inode straight back after rmdir+mkdir,
76// so an inode comparison passes or fails by filesystem. Real cgroupfs does
77// not reuse cgroup inode ids, which is why the production marks can key on
78// identity.
79const sentinelName = "test-sentinel"
80
81func plantSentinel(t *testing.T, dir string) {
82 t.Helper()
83 writeFixture(t, filepath.Join(dir, sentinelName), "planted")
84}
85
86func hasSentinel(t *testing.T, dir string) bool {
87 t.Helper()
88 _, err := os.Lstat(filepath.Join(dir, sentinelName))
89 if err != nil && !errors.Is(err, os.ErrNotExist) {
90 t.Fatalf("stat sentinel in %s: %v", dir, err)
91 }
92 return err == nil
93}
94
95// killFixture gives dir the files a cleanup handle reads, empty, and writes
96// cgroup.kill through a cleanup handle on host (child "" is the host's own
97// cgroup), as KillHost and KillSidecar do.
98func killFixture(t *testing.T, m *Manager, host, child, dir string) {
99 t.Helper()
100 for name, body := range map[string]string{"cgroup.events": "populated 0\n", "cgroup.procs": "", "cgroup.kill": ""} {
101 writeFixture(t, filepath.Join(dir, name), body)
102 }
103 c, err := m.openCleanupAt(host, child, func(*os.File) error { return nil })
104 if err != nil {
105 t.Fatal(err)
106 }
107 ctx, cancel := context.WithTimeout(t.Context(), time.Second)
108 defer cancel()
109 if err := c.KillAndEmpty(ctx); err != nil {
110 t.Fatal(err)
111 }
112 if err := c.Close(); err != nil {
113 t.Fatal(err)
114 }
115 if got := readFixture(t, filepath.Join(dir, "cgroup.kill")); got != "1" {
116 t.Fatalf("cgroup.kill = %q, want 1", got)
117 }
118}
119
120// A worker's host cgroup (KillHost's) is killed and kept. The next start
121// into it removes it and creates it again, with its limit files written as
122// a first creation writes them, and a start that follows no kill removes
123// nothing.
124func TestAKilledHostCgroupIsRecreatedBeforeTheNextStart(t *testing.T) {
125 forgetKillsAfter(t)
126 quietOOM(t)
127 m, err := SetupAt(newDelegatedRoot(t), t.Logf)
128 if err != nil {
129 t.Fatal(err)
130 }
131 removed := fakeCgroupRmdir(t, nil)
132 calls := fakeStart(t, func(*exec.Cmd) error { return nil })
133 const host = "worker.example.com"
134 dir := m.hostDir(host)
135 for range 2 {
136 if _, err := m.Start(host, TierDefault, buildTrue); err != nil {
137 t.Fatal(err)
138 }
139 }
140 if len(*removed) != 0 {
141 t.Fatalf("starts that followed no kill removed %v", *removed)
142 }
143 killFixture(t, m, host, "", dir)
144 plantSentinel(t, dir)
145 // A limit file left behind would say nothing about the recreation;
146 // scribble on it so only a fresh write can restore it.
147 writeFixture(t, filepath.Join(dir, "memory.max"), "max")
148 if _, err := m.Start(host, TierDefault, buildTrue); err != nil {
149 t.Fatal(err)
150 }
151 if len(*calls) != 3 {
152 t.Fatalf("started %d times, want 3", len(*calls))
153 }
154 if len(*removed) != 1 || (*removed)[0] != dir {
155 t.Fatalf("removed %v, want only %s", *removed, dir)
156 }
157 if hasSentinel(t, dir) {
158 t.Fatal("the respawn reused the killed cgroup (the sentinel planted in it survived)")
159 }
160 for file, want := range map[string]string{
161 "memory.max": "536870912",
162 "memory.high": "483183819", // HighOf: max/10*9
163 "memory.swap.max": "536870912",
164 "memory.oom.group": "1",
165 } {
166 if got := readFixture(t, filepath.Join(dir, file)); got != want {
167 t.Errorf("%s = %q, want %q", file, got, want)
168 }
169 }
170 // The fresh cgroup took no kill: the next start keeps it.
171 if _, err := m.Start(host, TierDefault, buildTrue); err != nil {
172 t.Fatal(err)
173 }
174 if len(*removed) != 1 {
175 t.Fatalf("a start after the recreation removed again: %v", *removed)
176 }
177}
178
179// A killed cgroup that will not go (EBUSY: something is in it again) is not
180// proven fresh, so the start refuses and execs nothing. Once it can be
181// removed, the next start proceeds into a fresh one.
182func TestAKilledCgroupThatCannotBeRemovedRefusesTheNextStart(t *testing.T) {
183 forgetKillsAfter(t)
184 quietOOM(t)
185 m, err := SetupAt(newDelegatedRoot(t), t.Logf)
186 if err != nil {
187 t.Fatal(err)
188 }
189 fail := error(unix.EBUSY)
190 removed := fakeCgroupRmdir(t, &fail)
191 calls := fakeStart(t, func(*exec.Cmd) error { return nil })
192 const host = "worker.example.com"
193 dir := m.hostDir(host)
194 if _, err := m.Start(host, TierDefault, buildTrue); err != nil {
195 t.Fatal(err)
196 }
197 killFixture(t, m, host, "", dir)
198 plantSentinel(t, dir)
199 built := 0
200 cmd, err := m.Start(host, TierDefault, func() *exec.Cmd { built++; return buildTrue() })
201 if !errors.Is(err, ErrKilledCgroupKept) || !errors.Is(err, unix.EBUSY) || cmd != nil {
202 t.Fatalf("start into a killed cgroup that would not go = %v, %v; want ErrKilledCgroupKept (EBUSY)", cmd, err)
203 }
204 if built != 0 || len(*calls) != 1 {
205 t.Fatalf("the refused start built %d command(s) and started %d time(s) in all, want 0 and 1", built, len(*calls))
206 }
207 if !hasSentinel(t, dir) {
208 t.Fatal("the refused start replaced the cgroup")
209 }
210 // Still refused while it will not go: the mark outlives the failure.
211 if _, err := m.Start(host, TierDefault, buildTrue); !errors.Is(err, ErrKilledCgroupKept) {
212 t.Fatalf("second start = %v, want ErrKilledCgroupKept", err)
213 }
214 fail = nil
215 if _, err := m.Start(host, TierDefault, buildTrue); err != nil {
216 t.Fatal(err)
217 }
218 if len(*removed) != 1 || hasSentinel(t, dir) {
219 t.Fatalf("removed %v; the start after the removal succeeded must be in a fresh cgroup", *removed)
220 }
221}
222
223// The legacy sidecar's role child (KillSidecar's) is killed and kept. The
224// respawn gets a fresh role-sidecar under the very same parent, whose limit
225// and accounting are left as they were.
226func TestAKilledRoleSidecarIsRecreatedUnderTheSameParent(t *testing.T) {
227 forgetKillsAfter(t)
228 const host = "sc.example.com"
229 m, parent := genManager(t, host)
230 removed := fakeCgroupRmdir(t, nil)
231 fakeStart(t, func(*exec.Cmd) error { return nil })
232 child := RoleChild("sidecar")
233 dir := filepath.Join(parent, child)
234 if _, err := m.StartChild(host, child, TierDefault, Limit{}, buildTrue); err != nil {
235 t.Fatal(err)
236 }
237 killFixture(t, m, host, child, dir)
238 plantSentinel(t, dir)
239 plantSentinel(t, parent)
240 writeFixture(t, filepath.Join(parent, "memory.max"), "1073741824") // a raise the respawn must not undo
241 writeFixture(t, filepath.Join(parent, "cgroup.events"), "populated 1\n")
242 if _, err := m.StartChild(host, child, TierDefault, Limit{}, buildTrue); err != nil {
243 t.Fatal(err)
244 }
245 if len(*removed) != 1 || (*removed)[0] != dir {
246 t.Fatalf("removed %v, want only %s", *removed, dir)
247 }
248 if hasSentinel(t, dir) {
249 t.Fatal("the sidecar respawn reused the killed role child")
250 }
251 if !hasSentinel(t, parent) {
252 t.Fatal("the sidecar respawn replaced the parent")
253 }
254 if got := readFixture(t, filepath.Join(parent, "memory.max")); got != "1073741824" {
255 t.Fatalf("parent memory.max = %q, the respawn touched it", got)
256 }
257 if _, err := os.Stat(filepath.Join(dir, "memory.max")); !errors.Is(err, os.ErrNotExist) {
258 t.Fatalf("a role child carries no limit of its own: %v", err)
259 }
260}
261
262// KillHost on a generation parent kills everything under it. The next start
263// of any child removes the whole killed tree, children first, and builds
264// the parent again as a cold start does: controllers delegated, the tier
265// written with memory.oom.group=0.
266func TestAKilledParentIsRecreatedWithItsChildren(t *testing.T) {
267 forgetKillsAfter(t)
268 const host = "gp.example.com"
269 m, parent := genManager(t, host)
270 removed := fakeCgroupRmdir(t, nil)
271 fakeStart(t, func(*exec.Cmd) error { return nil })
272 sidecar := RoleChild("sidecar")
273 if _, err := m.StartChild(host, sidecar, TierDefault, Limit{}, buildTrue); err != nil {
274 t.Fatal(err)
275 }
276 if _, err := m.StartChild(host, "g4", TierDefault, TierDefault, buildTrue); err != nil {
277 t.Fatal(err)
278 }
279 killFixture(t, m, host, "", parent)
280 plantSentinel(t, parent)
281 plantSentinel(t, filepath.Join(parent, sidecar))
282 plantSentinel(t, filepath.Join(parent, "g4"))
283 // The fixture's parent would lose its controller list with it; the
284 // kernel lists them on every new cgroup, so the fake rmdir's recreation
285 // needs it back.
286 orig := rmdirCgroup
287 rmdirCgroup = func(path string) error {
288 if err := orig(path); err != nil {
289 return err
290 }
291 if path == parent {
292 if err := os.Mkdir(parent, 0o755); err != nil {
293 return err
294 }
295 writeFixture(t, filepath.Join(parent, "cgroup.controllers"), "cpu io memory pids\n")
296 }
297 return nil
298 }
299 t.Cleanup(func() { rmdirCgroup = orig })
300 if _, err := m.StartChild(host, sidecar, TierDefault, Limit{}, buildTrue); err != nil {
301 t.Fatal(err)
302 }
303 want := map[string]bool{filepath.Join(parent, sidecar): true, filepath.Join(parent, "g4"): true}
304 if len(*removed) != 3 || !want[(*removed)[0]] || !want[(*removed)[1]] || (*removed)[2] != parent {
305 t.Fatalf("removed %v, want both children then %s", *removed, parent)
306 }
307 if hasSentinel(t, parent) {
308 t.Fatal("the respawn reused the killed parent")
309 }
310 if _, err := os.Stat(filepath.Join(parent, "g4")); !errors.Is(err, os.ErrNotExist) {
311 t.Fatalf("the killed generation child survived: %v", err)
312 }
313 for file, want := range map[string]string{
314 "cgroup.subtree_control": "+cpu +memory +io +pids",
315 "memory.max": "536870912",
316 "memory.oom.group": "0",
317 } {
318 if got := readFixture(t, filepath.Join(parent, file)); got != want {
319 t.Errorf("parent %s = %q, want %q", file, got, want)
320 }
321 }
322}
323
324// A killed cgroup that a strict handle still pins (its reservation is
325// held) is not removed under it: removing it would strand the handle, whose
326// identity is that directory. The start refuses until the handle lets go.
327func TestAKilledCgroupAStrictHandleHoldsIsNotRemoved(t *testing.T) {
328 forgetKillsAfter(t)
329 quietOOM(t)
330 m, err := SetupAt(newDelegatedRoot(t), t.Logf)
331 if err != nil {
332 t.Fatal(err)
333 }
334 removed := fakeCgroupRmdir(t, nil)
335 fakeStart(t, func(*exec.Cmd) error { return nil })
336 const host = "pinned.example.com"
337 dir := m.hostDir(host)
338 if _, err := m.Start(host, TierDefault, buildTrue); err != nil {
339 t.Fatal(err)
340 }
341 killFixture(t, m, host, "", dir)
342 var st unix.Stat_t
343 if err := unix.Lstat(dir, &st); err != nil {
344 t.Fatal(err)
345 }
346 id := strictIdentity{uint64(st.Dev), st.Ino}
347 strictReservations.Lock()
348 strictReservations.held[id] = true
349 strictReservations.Unlock()
350 release := func() {
351 strictReservations.Lock()
352 delete(strictReservations.held, id)
353 strictReservations.Unlock()
354 }
355 t.Cleanup(release)
356 if _, err := m.Start(host, TierDefault, buildTrue); !errors.Is(err, ErrStrictState) || !errors.Is(err, ErrKilledCgroupKept) {
357 t.Fatalf("start into a pinned killed cgroup = %v, want ErrKilledCgroupKept and ErrStrictState", err)
358 }
359 if len(*removed) != 0 {
360 t.Fatalf("removed %v under a strict handle", *removed)
361 }
362 release()
363 if _, err := m.Start(host, TierDefault, buildTrue); err != nil {
364 t.Fatal(err)
365 }
366 if len(*removed) != 1 {
367 t.Fatalf("removed %v once the handle let go, want the killed cgroup", *removed)
368 }
369}
370
371// A strict role child that took a kill is never pinned again for a spawn.
372func TestAKilledStrictRoleChildIsNotPinnedAgain(t *testing.T) {
373 forgetKillsAfter(t)
374 p := strictFixture(t)
375 makeRoleFixture(t, p, "seed", "populated 0\n")
376 child, err := p.PrepareRoleChild("seed")
377 if err != nil {
378 t.Fatal(err)
379 }
380 ctx, cancel := context.WithTimeout(t.Context(), time.Second)
381 defer cancel()
382 if err := child.KillAndEmpty(ctx); err != nil {
383 t.Fatal(err)
384 }
385 if err := child.Close(); err != nil {
386 t.Fatal(err)
387 }
388 again, err := p.PrepareRoleChild("seed")
389 if err == nil {
390 again.Close()
391 t.Fatal("a killed role child was pinned again for a spawn")
392 }
393 if !errors.Is(err, ErrStrictRefused) {
394 t.Fatalf("err = %v, want ErrStrictRefused", err)
395 }
396}
397
398// A killed parent that a strict handle still pins keeps everything below it
399// too: the reservation is checked before the walk touches a child (Task 19
400// fix round 1).
401func TestAPinnedKilledParentLosesNoChild(t *testing.T) {
402 forgetKillsAfter(t)
403 const host = "pinnedparent.example.com"
404 m, parent := genManager(t, host)
405 removed := fakeCgroupRmdir(t, nil)
406 fakeStart(t, func(*exec.Cmd) error { return nil })
407 sidecar := RoleChild("sidecar")
408 if _, err := m.StartChild(host, sidecar, TierDefault, Limit{}, buildTrue); err != nil {
409 t.Fatal(err)
410 }
411 killFixture(t, m, host, "", parent)
412 var st unix.Stat_t
413 if err := unix.Lstat(parent, &st); err != nil {
414 t.Fatal(err)
415 }
416 id := strictIdentity{uint64(st.Dev), st.Ino}
417 strictReservations.Lock()
418 strictReservations.held[id] = true
419 strictReservations.Unlock()
420 t.Cleanup(func() {
421 strictReservations.Lock()
422 delete(strictReservations.held, id)
423 strictReservations.Unlock()
424 })
425 if _, err := m.StartChild(host, sidecar, TierDefault, Limit{}, buildTrue); !errors.Is(err, ErrStrictState) {
426 t.Fatalf("start under a pinned killed parent = %v, want ErrStrictState", err)
427 }
428 if len(*removed) != 0 {
429 t.Fatalf("removed %v under a pinned parent", *removed)
430 }
431 if _, err := os.Stat(filepath.Join(parent, sidecar)); err != nil {
432 t.Fatalf("the pinned parent's child went: %v", err)
433 }
434}
435
436// retainedTree builds what an earlier run left under a delegated root:
437// carlos-exec@<host> with cgroup.events and cgroup.procs, and children
438// (name to events body) with the same files, empty.
439func retainedTree(t *testing.T, root, host, events, procs string, kids map[string]string) string {
440 t.Helper()
441 dir := filepath.Join(root, ExecName(host))
442 if err := os.Mkdir(dir, 0o755); err != nil {
443 t.Fatal(err)
444 }
445 writeFixture(t, filepath.Join(dir, "cgroup.events"), events)
446 writeFixture(t, filepath.Join(dir, "cgroup.procs"), procs)
447 writeFixture(t, filepath.Join(dir, "cgroup.controllers"), "cpu io memory pids\n")
448 for kid, body := range kids {
449 k := filepath.Join(dir, kid)
450 if err := os.Mkdir(k, 0o755); err != nil {
451 t.Fatal(err)
452 }
453 writeFixture(t, filepath.Join(k, "cgroup.events"), body)
454 writeFixture(t, filepath.Join(k, "cgroup.procs"), "")
455 }
456 return dir
457}
458
459// Claim-entry mode (SetupRetaining) skips the sweep, and the kill marks die
460// with the process that made them, so a cgroup an earlier run killed and
461// kept would be cloned into again. The retaining setup marks every empty
462// exec cgroup (and every empty child of one) instead: the first start into
463// each makes it fresh with its limits written again. One that holds a
464// process, and a claim containment group, are left exactly as they are
465// (Task 19 fix round 1).
466func TestARetainingSetupMakesEmptyLeftoversFresh(t *testing.T) {
467 forgetKillsAfter(t)
468 quietOOM(t)
469 root := newDelegatedRoot(t)
470 empty := retainedTree(t, root, "worker.example.com", "populated 0\n", "", nil)
471 busy := retainedTree(t, root, "busy.example.com", "populated 1\n", "4242\n", nil)
472 // A generation parent whose live g3 survived, beside an empty sidecar
473 // child a KillSidecar left.
474 live := retainedTree(t, root, "live.example.com", "populated 1\n", "", map[string]string{"g3": "populated 1\n", "role-sidecar": "populated 0\n"})
475 writeFixture(t, filepath.Join(live, "cgroup.subtree_control"), "cpu memory io pids\n")
476 claim := retainedTree(t, root, "claim-0123abcd.local", "populated 0\n", "", nil)
477 for _, d := range []string{empty, busy, live, filepath.Join(live, "g3"), filepath.Join(live, "role-sidecar")} {
478 plantSentinel(t, d)
479 }
480 m, err := SetupAtRetaining(root, t.Logf)
481 if err != nil {
482 t.Fatal(err)
483 }
484 removed := fakeCgroupRmdir(t, nil)
485 fakeStart(t, func(*exec.Cmd) error { return nil })
486 FakePlacementForTest(t) // a temp dir is no cgroupfs, and a role child is never moved by PID
487
488 if _, err := m.Start("worker.example.com", TierDefault, buildTrue); err != nil {
489 t.Fatal(err)
490 }
491 if hasSentinel(t, empty) {
492 t.Fatal("the first start reused an empty retained host cgroup")
493 }
494 if got := readFixture(t, filepath.Join(empty, "memory.max")); got != "536870912" {
495 t.Fatalf("recreated memory.max = %q, want the tier", got)
496 }
497
498 if _, err := m.Start("busy.example.com", TierDefault, buildTrue); err != nil {
499 t.Fatal(err)
500 }
501 if !hasSentinel(t, busy) {
502 t.Fatal("a retained cgroup holding a process was replaced")
503 }
504
505 if _, err := m.StartChild("live.example.com", RoleChild("sidecar"), TierDefault, Limit{}, buildTrue); err != nil {
506 t.Fatal(err)
507 }
508 if hasSentinel(t, filepath.Join(live, "role-sidecar")) {
509 t.Fatal("the sidecar respawn reused an empty retained role child")
510 }
511 if !hasSentinel(t, live) || !hasSentinel(t, filepath.Join(live, "g3")) {
512 t.Fatal("a populated parent or its live generation was replaced")
513 }
514
515 if id, ok, err := pathIdentity(claim); err != nil || !ok || wasKilled(id) {
516 t.Fatalf("a claim containment group was marked (ok=%v err=%v)", ok, err)
517 }
518 want := map[string]bool{empty: true, filepath.Join(live, "role-sidecar"): true}
519 if len(*removed) != 2 || !want[(*removed)[0]] || !want[(*removed)[1]] {
520 t.Fatalf("removed %v, want only the empty host cgroup and the empty role child", *removed)
521 }
522}
523