carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package cgroup
2
3import (
4 "errors"
5 "os"
6 "os/exec"
7 "path/filepath"
8 "strconv"
9 "testing"
10)
11
12func TestExecNameAndIsExecName(t *testing.T) {
13 if got := ExecName("keymail.dev"); got != "carlos-exec@keymail.dev" {
14 t.Fatalf("ExecName = %q, want %q", got, "carlos-exec@keymail.dev")
15 }
16 for name, want := range map[string]bool{
17 "carlos-exec@keymail.dev": true,
18 "carlos-exec@": false, // no host — not one of ours
19 "supervise": false, // the agent's own leaf
20 "carlos-app@x.service": false,
21 "cgroup.procs": false,
22 } {
23 if got := IsExecName(name); got != want {
24 t.Errorf("IsExecName(%q) = %v, want %v", name, got, want)
25 }
26 }
27}
28
29// TestParseSelfCgroup pins the /proc/self/cgroup v2 line format: the "0::"
30// line carries the unified-hierarchy path. v1-only content (no 0:: line)
31// and the root cgroup ("0::/") both report not-ok — neither is a subtree
32// systemd could have delegated to us.
33func TestParseSelfCgroup(t *testing.T) {
34 cases := []struct {
35 name, data, want string
36 ok bool
37 }{
38 {"systemd service", "0::/system.slice/carlos-edge.service\n", "system.slice/carlos-edge.service", true},
39 {"hybrid box, v2 line among v1", "12:pids:/system.slice\n0::/system.slice/carlos-edge.service\n", "system.slice/carlos-edge.service", true},
40 {"v1 only", "12:pids:/system.slice\n11:cpu:/system.slice\n", "", false},
41 {"root cgroup", "0::/\n", "", false},
42 {"empty", "", "", false},
43 }
44 for _, c := range cases {
45 got, ok := ParseSelfCgroup(c.data)
46 if got != c.want || ok != c.ok {
47 t.Errorf("%s: ParseSelfCgroup = (%q, %v), want (%q, %v)", c.name, got, ok, c.want, c.ok)
48 }
49 }
50}
51
52// newDelegatedRoot builds the temp-dir shape of a delegated cgroup: the two
53// control files systemd chowns to the unit's user when Delegate= is set.
54// Everything the dance does is plain file I/O, so a temp dir is a faithful
55// fixture — the same seam listCarlosUnits uses in internal/host.
56// forgetKills clears the process-wide kill marks (Linux builds set it). The
57// marks are keyed by (device, inode), so a mark an earlier test left would
58// name a fresh test directory on a filesystem that reuses inodes (ext4,
59// xfs), and the start would remove a cgroup that never took a kill.
60// cgroupfs does not reuse cgroup inode ids; a temp dir can.
61var forgetKills = func() {}
62
63func newDelegatedRoot(t *testing.T) string {
64 t.Helper()
65 forgetKills()
66 root := t.TempDir()
67 for _, f := range []string{"cgroup.procs", "cgroup.subtree_control"} {
68 if err := os.WriteFile(filepath.Join(root, f), nil, 0o644); err != nil {
69 t.Fatal(err)
70 }
71 }
72 return root
73}
74
75// TestSetupAtRefusesUndelegatedRoot: without Delegate= the control files are
76// root-owned (here: absent) — SetupAt must error so Setup degrades to the
77// nil manager instead of half-arming.
78func TestSetupAtRefusesUndelegatedRoot(t *testing.T) {
79 if _, err := SetupAt(t.TempDir(), t.Logf); err == nil {
80 t.Fatal("SetupAt on an undelegated root succeeded, want error")
81 }
82}
83
84// TestSetupAtDance pins the dance's observable results: the agent's own PID
85// moved into the supervise/ leaf (cgroup v2 forbids enabling controllers on
86// a cgroup with member processes), then the four controllers enabled in
87// subtree_control, and Root reporting the subtree.
88func TestSetupAtDance(t *testing.T) {
89 root := newDelegatedRoot(t)
90 m, err := SetupAt(root, t.Logf)
91 if err != nil {
92 t.Fatal(err)
93 }
94 if m.Root() != root {
95 t.Errorf("Root = %q, want %q", m.Root(), root)
96 }
97 pid, err := os.ReadFile(filepath.Join(root, "supervise", "cgroup.procs"))
98 if err != nil {
99 t.Fatalf("supervise leaf not populated: %v", err)
100 }
101 if string(pid) != strconv.Itoa(os.Getpid()) {
102 t.Errorf("supervise/cgroup.procs = %q, want our own pid %d", pid, os.Getpid())
103 }
104 ctrl, err := os.ReadFile(filepath.Join(root, "cgroup.subtree_control"))
105 if err != nil {
106 t.Fatal(err)
107 }
108 if string(ctrl) != "+cpu +memory +io +pids" {
109 t.Errorf("subtree_control = %q, want %q", ctrl, "+cpu +memory +io +pids")
110 }
111}
112
113// TestSetupAtSweepsStaleExecDirs: a previous agent's per-host dirs are empty
114// after its children died with the service; the dance removes them. A
115// non-empty one (something still alive in it — shouldn't happen, but rmdir
116// would fail anyway) and a foreign name are left alone.
117func TestSetupAtSweepsStaleExecDirs(t *testing.T) {
118 root := newDelegatedRoot(t)
119 stale := filepath.Join(root, "carlos-exec@old.example.com")
120 if err := os.Mkdir(stale, 0o755); err != nil {
121 t.Fatal(err)
122 }
123 occupied := filepath.Join(root, "carlos-exec@busy.example.com")
124 if err := os.MkdirAll(filepath.Join(occupied, "child"), 0o755); err != nil {
125 t.Fatal(err)
126 }
127 foreign := filepath.Join(root, "not-ours")
128 if err := os.Mkdir(foreign, 0o755); err != nil {
129 t.Fatal(err)
130 }
131
132 if _, err := SetupAt(root, t.Logf); err != nil {
133 t.Fatal(err)
134 }
135 if _, err := os.Stat(stale); !os.IsNotExist(err) {
136 t.Errorf("stale exec dir survived the sweep: %v", err)
137 }
138 if _, err := os.Stat(occupied); err != nil {
139 t.Errorf("non-empty exec dir must be tolerated, not force-removed: %v", err)
140 }
141 if _, err := os.Stat(foreign); err != nil {
142 t.Errorf("foreign dir must be untouched: %v", err)
143 }
144}
145
146func TestNilManagerRoot(t *testing.T) {
147 var m *Manager
148 if got := m.Root(); got != "" {
149 t.Fatalf("nil Root = %q, want \"\"", got)
150 }
151}
152
153// fakeStart swaps the package's spawn seam for a recorder, restoring it on
154// cleanup. The fake never spawns anything: "success" fabricates cmd.Process
155// from our own PID so the PID-move path has a real number to write.
156func fakeStart(t *testing.T, fail func(cmd *exec.Cmd) error) *[]*exec.Cmd {
157 t.Helper()
158 var calls []*exec.Cmd
159 orig := startCmd
160 startCmd = func(cmd *exec.Cmd) error {
161 calls = append(calls, cmd)
162 if err := fail(cmd); err != nil {
163 return err
164 }
165 cmd.Process, _ = os.FindProcess(os.Getpid())
166 return nil
167 }
168 t.Cleanup(func() { startCmd = orig })
169 return &calls
170}
171
172func buildTrue() *exec.Cmd { return exec.Command("/bin/true") }
173
174// TestStartNilManagerSpawnsPlainly: the nil manager is the no-delegation
175// state — one plain start, no SysProcAttr, no filesystem writes.
176func TestStartNilManagerSpawnsPlainly(t *testing.T) {
177 calls := fakeStart(t, func(*exec.Cmd) error { return nil })
178 var m *Manager
179 cmd, err := m.Start("h.example.com", Limit{}, buildTrue)
180 if err != nil || cmd == nil {
181 t.Fatalf("Start = (%v, %v), want started cmd", cmd, err)
182 }
183 if len(*calls) != 1 || (*calls)[0].SysProcAttr != nil {
184 t.Fatalf("nil manager must start exactly once with no SysProcAttr: %d calls", len(*calls))
185 }
186}
187
188// TestStartGenuineSpawnFailureIsReturned: when the start itself fails (here
189// the PID-move start a temp dir's placement failure falls back to), the
190// error propagates and the host dir this spawn made does not survive it.
191func TestStartGenuineSpawnFailureIsReturned(t *testing.T) {
192 root := newDelegatedRoot(t)
193 m, err := SetupAt(root, t.Logf)
194 if err != nil {
195 t.Fatal(err)
196 }
197 fakeStart(t, func(*exec.Cmd) error { return errors.New("no such binary") })
198 if _, err := m.Start("h.example.com", Limit{}, buildTrue); err == nil {
199 t.Fatal("Start with a failing spawn succeeded, want error")
200 }
201 if _, err := os.Stat(m.hostDir("h.example.com")); !os.IsNotExist(err) {
202 t.Errorf("ghost host dir survived a failed spawn: %v", err)
203 }
204}
205
206// TestCleanupHost: removes an empty per-host dir, tolerates a non-empty
207// one, and is nil-safe.
208func TestCleanupHost(t *testing.T) {
209 root := newDelegatedRoot(t)
210 m, err := SetupAt(root, t.Logf)
211 if err != nil {
212 t.Fatal(err)
213 }
214 empty := filepath.Join(root, ExecName("gone.example.com"))
215 if err := os.Mkdir(empty, 0o755); err != nil {
216 t.Fatal(err)
217 }
218 m.CleanupHost("gone.example.com")
219 if _, err := os.Stat(empty); !os.IsNotExist(err) {
220 t.Errorf("empty host dir not removed: %v", err)
221 }
222
223 busy := filepath.Join(root, ExecName("busy.example.com"))
224 if err := os.MkdirAll(filepath.Join(busy, "child"), 0o755); err != nil {
225 t.Fatal(err)
226 }
227 m.CleanupHost("busy.example.com") // must not panic or force-remove
228 if _, err := os.Stat(busy); err != nil {
229 t.Errorf("non-empty host dir must survive: %v", err)
230 }
231
232 var nilM *Manager
233 nilM.CleanupHost("h.example.com") // must not panic
234}
235
236// TestStartAndMoveRemovesGhostDirOnSpawnFailure: when the spawn itself
237// never happens, the host cgroup dir must not survive it — a ghost dir
238// would make the sampler report permanent zeros for a host with nothing
239// running.
240func TestStartAndMoveRemovesGhostDirOnSpawnFailure(t *testing.T) {
241 root := newDelegatedRoot(t)
242 m, err := SetupAt(root, t.Logf)
243 if err != nil {
244 t.Fatal(err)
245 }
246 dir := m.hostDir("h.example.com")
247 if err := os.MkdirAll(dir, 0o755); err != nil {
248 t.Fatal(err)
249 }
250 fakeStart(t, func(*exec.Cmd) error { return errors.New("no such binary") })
251 if _, err := m.startAndMove("h.example.com", buildTrue); err == nil {
252 t.Fatal("startAndMove with a failing spawn succeeded, want error")
253 }
254 if _, err := os.Stat(dir); !os.IsNotExist(err) {
255 t.Errorf("ghost host dir survived a failed spawn: %v", err)
256 }
257}
258
259// TestStartAndMoveToleratesBrokenSeam: a startCmd seam that reports success
260// without ever setting cmd.Process must not panic while writing the PID —
261// this package must never panic the agent over a broken seam.
262func TestStartAndMoveToleratesBrokenSeam(t *testing.T) {
263 root := newDelegatedRoot(t)
264 m, err := SetupAt(root, t.Logf)
265 if err != nil {
266 t.Fatal(err)
267 }
268 if err := os.MkdirAll(m.hostDir("h.example.com"), 0o755); err != nil {
269 t.Fatal(err)
270 }
271 orig := startCmd
272 startCmd = func(cmd *exec.Cmd) error { return nil } // never sets cmd.Process
273 t.Cleanup(func() { startCmd = orig })
274 if _, err := m.startAndMove("h.example.com", buildTrue); err != nil {
275 t.Fatal(err)
276 }
277}
278
279// TestStartWritesMemoryLimit: a non-zero Limit lands in the host cgroup as
280// memory.max and memory.high (high = 90% of max, so a tenant is throttled
281// and reclaimed before it is killed). The temp-dir fixture has no kernel
282// behind it, so the assertion is on the bytes written.
283func TestStartWritesMemoryLimit(t *testing.T) {
284 root := newDelegatedRoot(t)
285 m, err := SetupAt(root, t.Logf)
286 if err != nil {
287 t.Fatal(err)
288 }
289 fakeStart(t, func(*exec.Cmd) error { return nil })
290 if _, err := m.Start("h.example.com", Limit{MemoryMax: 1 << 30}, buildTrue); err != nil {
291 t.Fatal(err)
292 }
293 dir := m.hostDir("h.example.com")
294 for file, want := range map[string]string{
295 "memory.max": "1073741824",
296 "memory.high": "966367638", // max/10*9, integer arithmetic
297 "memory.oom.group": "1",
298 "memory.swap.max": "1073741824", // = max: the cap is total, not RAM-only
299 } {
300 got, err := os.ReadFile(filepath.Join(dir, file))
301 if err != nil {
302 t.Fatalf("%s not written: %v", file, err)
303 }
304 if string(got) != want {
305 t.Errorf("%s = %q, want %q", file, got, want)
306 }
307 }
308}
309
310// TestStartZeroLimitWritesNothing: the zero Limit is "no cap" — the
311// pre-spec behaviour, byte for byte, for any caller that has no tier.
312func TestStartZeroLimitWritesNothing(t *testing.T) {
313 root := newDelegatedRoot(t)
314 m, err := SetupAt(root, t.Logf)
315 if err != nil {
316 t.Fatal(err)
317 }
318 fakeStart(t, func(*exec.Cmd) error { return nil })
319 if _, err := m.Start("h.example.com", Limit{}, buildTrue); err != nil {
320 t.Fatal(err)
321 }
322 for _, file := range []string{"memory.max", "memory.high"} {
323 if _, err := os.Stat(filepath.Join(m.hostDir("h.example.com"), file)); !os.IsNotExist(err) {
324 t.Errorf("%s exists for a zero limit", file)
325 }
326 }
327}
328
329// TestStartLimitWriteFailureStillSpawns: a cgroup that refuses the limit
330// (read-only control file, an old kernel) costs one log line and an
331// uncapped child — never a failed spawn. Same discipline as every other
332// accounting failure in this package.
333func TestStartLimitWriteFailureStillSpawns(t *testing.T) {
334 root := newDelegatedRoot(t)
335 m, err := SetupAt(root, t.Logf)
336 if err != nil {
337 t.Fatal(err)
338 }
339 dir := m.hostDir("h.example.com")
340 if err := os.MkdirAll(dir, 0o755); err != nil {
341 t.Fatal(err)
342 }
343 // A directory where the file should go: WriteFile fails with EISDIR.
344 if err := os.Mkdir(filepath.Join(dir, "memory.max"), 0o755); err != nil {
345 t.Fatal(err)
346 }
347 fakeStart(t, func(*exec.Cmd) error { return nil })
348 cmd, err := m.Start("h.example.com", TierDefault, buildTrue)
349 if err != nil || cmd == nil {
350 t.Fatalf("Start = (%v, %v), want a started cmd despite the limit failure", cmd, err)
351 }
352 // max failed, so high must NOT have been written: a high with no max
353 // is a throttle with no wall, and the doc comment promises the short
354 // circuit.
355 if _, err := os.Stat(filepath.Join(dir, "memory.high")); !os.IsNotExist(err) {
356 t.Errorf("memory.high written after memory.max failed: %v", err)
357 }
358}
359
360// TestStartBiasesChildOOMScore: a started child gets ExecOOMScoreAdj
361// written to its oom_score_adj (redirected into a temp dir here), on both
362// spawn paths. Without it the agent — the largest process in the edge
363// tree — is the memcg OOM killer's first pick when the outer wall fires.
364func TestStartBiasesChildOOMScore(t *testing.T) {
365 root := newDelegatedRoot(t)
366 m, err := SetupAt(root, t.Logf)
367 if err != nil {
368 t.Fatal(err)
369 }
370 adj := t.TempDir()
371 orig := oomScoreAdjPath
372 oomScoreAdjPath = func(pid int) string { return filepath.Join(adj, strconv.Itoa(pid)) }
373 t.Cleanup(func() { oomScoreAdjPath = orig })
374 // A real start: /bin/true gives the child a pid to write for.
375 cmd, err := m.startAndMove("h.example.com", buildTrue)
376 if err != nil {
377 t.Fatal(err)
378 }
379 _ = cmd.Wait()
380 got, err := os.ReadFile(filepath.Join(adj, strconv.Itoa(cmd.Process.Pid)))
381 if err != nil {
382 t.Fatalf("oom_score_adj not written: %v", err)
383 }
384 if string(got) != ExecOOMScoreAdj {
385 t.Errorf("oom_score_adj = %q, want %q", got, ExecOOMScoreAdj)
386 }
387}
388
389func TestTiers(t *testing.T) {
390 if TierDefault.MemoryMax != 512<<20 {
391 t.Errorf("TierDefault = %d, want 512M — the carlos-app@ template's number", TierDefault.MemoryMax)
392 }
393 if TierChildren.MemoryMax != 1<<30 {
394 t.Errorf("TierChildren = %d, want 1G — caps/render.go's children tier", TierChildren.MemoryMax)
395 }
396}
397
398func TestSetupAtRetainingPreservesClaimEvidence(t *testing.T) {
399 root := newDelegatedRoot(t)
400 retained := filepath.Join(root, "carlos-exec@claim-old.local")
401 if err := os.Mkdir(retained, 0755); err != nil {
402 t.Fatal(err)
403 }
404 if _, err := SetupAtRetaining(root, t.Logf); err != nil {
405 t.Fatal(err)
406 }
407 if _, err := os.Stat(retained); err != nil {
408 t.Fatalf("retained claim tree removed: %v", err)
409 }
410}
411