| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "os" |
| 5 | "path/filepath" |
| 6 | "testing" |
| 7 | ) |
| 8 | |
| 9 | // TestListCarlosUnitsFindsFlatAndNestedUnits pins the cgroup layout systemd |
| 10 | // actually produces: the platform's fixed units sit directly under |
| 11 | // system.slice, but an INSTANTIATED unit (carlos-app@<host>.service, and |
| 12 | // internal/host/units/carlos-app@.service sets no Slice=) is placed by |
| 13 | // systemd inside an implicit per-template slice — |
| 14 | // system.slice/system-carlos\x2dapp.slice/carlos-app@<host>.service. Reading |
| 15 | // only the top level would find every platform unit and not one tenant, which |
| 16 | // is exactly the set the per-unit tables and owner metrics exist for. A |
| 17 | // non-CARLOS directory at either level must stay invisible. |
| 18 | func TestListCarlosUnitsFindsFlatAndNestedUnits(t *testing.T) { |
| 19 | root := t.TempDir() |
| 20 | mk := func(parts ...string) string { |
| 21 | t.Helper() |
| 22 | dir := filepath.Join(append([]string{root}, parts...)...) |
| 23 | if err := os.MkdirAll(dir, 0o755); err != nil { |
| 24 | t.Fatal(err) |
| 25 | } |
| 26 | return dir |
| 27 | } |
| 28 | |
| 29 | flatDir := mk("carlos-edge.service") |
| 30 | nestedDir := mk(`system-carlos\x2dapp.slice`, "carlos-app@x.example.com.service") |
| 31 | // Decoys: a non-CARLOS unit at each level, and a nested slice that holds |
| 32 | // nothing of ours. |
| 33 | mk("sshd.service") |
| 34 | mk(`system-carlos\x2dapp.slice`, "sshd.service") |
| 35 | mk("system-getty.slice", "getty@tty1.service") |
| 36 | |
| 37 | got := listCarlosUnits(root) |
| 38 | dirs := map[string]string{} |
| 39 | for _, u := range got { |
| 40 | if _, dup := dirs[u.name]; dup { |
| 41 | t.Fatalf("unit %q discovered twice: %+v", u.name, got) |
| 42 | } |
| 43 | dirs[u.name] = u.dir |
| 44 | } |
| 45 | |
| 46 | if len(got) != 2 { |
| 47 | t.Fatalf("discovered %d units, want 2 (one flat, one nested): %+v", len(got), got) |
| 48 | } |
| 49 | if dirs["carlos-edge.service"] != flatDir { |
| 50 | t.Errorf("carlos-edge.service dir = %q, want %q", dirs["carlos-edge.service"], flatDir) |
| 51 | } |
| 52 | if dirs["carlos-app@x.example.com.service"] != nestedDir { |
| 53 | t.Errorf("carlos-app@x.example.com.service dir = %q, want %q (the per-template slice)", dirs["carlos-app@x.example.com.service"], nestedDir) |
| 54 | } |
| 55 | } |
| 56 | |
| 57 | // TestListStampedUnits pins the second discovery walk: units named by the |
| 58 | // registry's Unit column (#110) rather than by the carlos- convention. The |
| 59 | // same two systemd levels are in play — a bespoke flat service, and a |
| 60 | // template instance inside its implicit per-template slice — because the |
| 61 | // stamped name says nothing about where systemd put its cgroup. A unit |
| 62 | // present on disk but NOT stamped stays invisible (the convention walk owns |
| 63 | // what it owns), and a stamped unit with no cgroup dir yields nothing at all: |
| 64 | // absence is the honest report for a unit that isn't running. |
| 65 | func TestListStampedUnits(t *testing.T) { |
| 66 | root := t.TempDir() |
| 67 | mk := func(parts ...string) string { |
| 68 | t.Helper() |
| 69 | dir := filepath.Join(append([]string{root}, parts...)...) |
| 70 | if err := os.MkdirAll(dir, 0o755); err != nil { |
| 71 | t.Fatal(err) |
| 72 | } |
| 73 | return dir |
| 74 | } |
| 75 | |
| 76 | // Flat bespoke unit. |
| 77 | flatDir := mk("console.service") |
| 78 | // Template instance inside its implicit per-template slice. The literal |
| 79 | // directory name carries systemd's \x2d escaping of the dash. |
| 80 | nestedDir := mk(`system-titogo\x2dcanary.slice`, "titogo-canary@demo.service") |
| 81 | // Present on disk but NOT stamped — must not be returned. |
| 82 | mk("postgres.service") |
| 83 | |
| 84 | got := listStampedUnits(root, []string{"console.service", "titogo-canary@demo.service", "absent@x.service"}) |
| 85 | want := map[string]string{"console.service": flatDir, "titogo-canary@demo.service": nestedDir} |
| 86 | if len(got) != 2 { |
| 87 | t.Fatalf("got %d units, want 2 (the stamped ones that exist): %+v", len(got), got) |
| 88 | } |
| 89 | for _, u := range got { |
| 90 | dir, ok := want[u.name] |
| 91 | if !ok { |
| 92 | t.Fatalf("unexpected unit %q", u.name) |
| 93 | } |
| 94 | if u.dir != dir { |
| 95 | t.Fatalf("unit %q dir = %q, want %q (the real cgroup dir)", u.name, u.dir, dir) |
| 96 | } |
| 97 | if _, err := os.Stat(u.dir); err != nil { |
| 98 | t.Fatalf("dir %q not readable: %v", u.dir, err) |
| 99 | } |
| 100 | } |
| 101 | |
| 102 | if out := listStampedUnits(root, nil); out != nil { |
| 103 | t.Fatalf("no stamped units must scan nothing, got %+v", out) |
| 104 | } |
| 105 | } |
| 106 | |
| 107 | // TestReadUnitRawReadsFromUnitDir pins that a unit's cgroup files are read |
| 108 | // from the directory listCarlosUnits reported for it — the nested case, since |
| 109 | // a reader that rebuilt the path as <root>/<unit> would find nothing there. |
| 110 | func TestReadUnitRawReadsFromUnitDir(t *testing.T) { |
| 111 | root := t.TempDir() |
| 112 | dir := filepath.Join(root, `system-carlos\x2dapp.slice`, "carlos-app@x.example.com.service") |
| 113 | if err := os.MkdirAll(dir, 0o755); err != nil { |
| 114 | t.Fatal(err) |
| 115 | } |
| 116 | write := func(name, body string) { |
| 117 | t.Helper() |
| 118 | if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil { |
| 119 | t.Fatal(err) |
| 120 | } |
| 121 | } |
| 122 | write("cpu.stat", "usage_usec 12345\nuser_usec 1\n") |
| 123 | write("memory.current", "4096\n") |
| 124 | // memory.peak and io.stat deliberately absent: each read fails on its own. |
| 125 | |
| 126 | units := listCarlosUnits(root) |
| 127 | if len(units) != 1 { |
| 128 | t.Fatalf("discovered %d units, want 1: %+v", len(units), units) |
| 129 | } |
| 130 | raw := readUnitRaw(units[0].dir) |
| 131 | if raw.cpuUsec == nil || *raw.cpuUsec != 12345 { |
| 132 | t.Errorf("cpuUsec = %v, want 12345", raw.cpuUsec) |
| 133 | } |
| 134 | if raw.memBytes == nil || *raw.memBytes != 4096 { |
| 135 | t.Errorf("memBytes = %v, want 4096", raw.memBytes) |
| 136 | } |
| 137 | if raw.memPeakBytes != nil || raw.readBytes != nil || raw.writeBytes != nil { |
| 138 | t.Errorf("absent files must stay nil, got peak=%v r=%v w=%v", raw.memPeakBytes, raw.readBytes, raw.writeBytes) |
| 139 | } |
| 140 | } |
| 141 | |
| 142 | // TestListExecCgroupsFindsPerHostDirs pins the delegated subtree's scan: |
| 143 | // carlos-exec@<host> dirs with live members come out with their names |
| 144 | // VERBATIM as sample keys (deliberately not dressed up as .service units — |
| 145 | // an operator who takes a dashboard row to systemctl must not be told a |
| 146 | // unit exists that doesn't). The agent's own supervise leaf and foreign |
| 147 | // names are invisible; so is a MEMBER-LESS dir (empty or missing |
| 148 | // cgroup.procs) — a failed seed's leftover must read as absent, never as a |
| 149 | // live instance at zero. An empty root — the no-delegation state — scans |
| 150 | // nothing. |
| 151 | func TestListExecCgroupsFindsPerHostDirs(t *testing.T) { |
| 152 | root := t.TempDir() |
| 153 | mk := func(name, procs string) string { |
| 154 | t.Helper() |
| 155 | dir := filepath.Join(root, name) |
| 156 | if err := os.MkdirAll(dir, 0o755); err != nil { |
| 157 | t.Fatal(err) |
| 158 | } |
| 159 | if procs != "" { |
| 160 | if err := os.WriteFile(filepath.Join(dir, "cgroup.procs"), []byte(procs), 0o644); err != nil { |
| 161 | t.Fatal(err) |
| 162 | } |
| 163 | } |
| 164 | return dir |
| 165 | } |
| 166 | execDir := mk("carlos-exec@keymail.dev", "1234\n") |
| 167 | mk("supervise", "1\n") |
| 168 | mk("carlos-exec@", "99\n") // no host — not one of ours |
| 169 | mk("random-dir", "77\n") // foreign name |
| 170 | mk("carlos-exec@failed.example.com", "\n") // member-less: whitespace-only procs |
| 171 | mk("carlos-exec@aborted.example.com", "") // member-less: no procs file at all |
| 172 | |
| 173 | got := listExecCgroups(root) |
| 174 | if len(got) != 1 { |
| 175 | t.Fatalf("discovered %d cgroups, want 1: %+v", len(got), got) |
| 176 | } |
| 177 | if got[0].name != "carlos-exec@keymail.dev" || got[0].dir != execDir { |
| 178 | t.Fatalf("got %+v, want name carlos-exec@keymail.dev dir %s", got[0], execDir) |
| 179 | } |
| 180 | |
| 181 | if out := listExecCgroups(""); out != nil { |
| 182 | t.Fatalf("empty root must scan nothing, got %+v", out) |
| 183 | } |
| 184 | } |
| 185 | |
| 186 | // TestListCarlosUnitsFindsUnitsUnderCarlosSlice pins the review fix: once |
| 187 | // carlos-edge.service moved into carlos.slice (isolated-exec design §4.3), |
| 188 | // realUnitReadings must scan cgroupCarlosSliceDir as a second root or the |
| 189 | // edge's metrics row silently vanishes. listCarlosUnits itself is root- |
| 190 | // agnostic (it takes root as an argument precisely so callers can point it |
| 191 | // at either slice), so this pins that a fixture shaped like the real |
| 192 | // carlos.slice — a flat carlos-edge.service leaf holding memory.current, the |
| 193 | // same file readUnitRaw actually reads — is discovered from that second |
| 194 | // root, and that scanning it does not also resurface anything from a |
| 195 | // system.slice-shaped root scanned separately. |
| 196 | func TestListCarlosUnitsFindsUnitsUnderCarlosSlice(t *testing.T) { |
| 197 | root := t.TempDir() |
| 198 | carlosSlice := filepath.Join(root, "carlos.slice") |
| 199 | edgeDir := filepath.Join(carlosSlice, "carlos-edge.service") |
| 200 | if err := os.MkdirAll(edgeDir, 0o755); err != nil { |
| 201 | t.Fatal(err) |
| 202 | } |
| 203 | if err := os.WriteFile(filepath.Join(edgeDir, "memory.current"), []byte("12345\n"), 0o644); err != nil { |
| 204 | t.Fatal(err) |
| 205 | } |
| 206 | |
| 207 | got := listCarlosUnits(carlosSlice) |
| 208 | if len(got) != 1 { |
| 209 | t.Fatalf("discovered %d units under carlos.slice, want 1: %+v", len(got), got) |
| 210 | } |
| 211 | if got[0].name != "carlos-edge.service" || got[0].dir != edgeDir { |
| 212 | t.Fatalf("got %+v, want name carlos-edge.service dir %s", got[0], edgeDir) |
| 213 | } |
| 214 | |
| 215 | // A system.slice-shaped root scanned on its own must not also produce |
| 216 | // the edge: the two roots are independent, so an edge found only under |
| 217 | // carlos.slice must not be double-reported when both scans are combined |
| 218 | // the way realUnitReadings combines them. |
| 219 | systemSlice := filepath.Join(root, "system.slice") |
| 220 | if err := os.MkdirAll(systemSlice, 0o755); err != nil { |
| 221 | t.Fatal(err) |
| 222 | } |
| 223 | systemGot := listCarlosUnits(systemSlice) |
| 224 | if len(systemGot) != 0 { |
| 225 | t.Fatalf("system.slice root found %+v, want none: the edge only lives under carlos.slice now", systemGot) |
| 226 | } |
| 227 | |
| 228 | combined := append(append([]unitCgroup{}, systemGot...), got...) |
| 229 | seen := map[string]bool{} |
| 230 | for _, u := range combined { |
| 231 | if seen[u.name] { |
| 232 | t.Fatalf("unit %q double-reported when combining both scan roots: %+v", u.name, combined) |
| 233 | } |
| 234 | seen[u.name] = true |
| 235 | } |
| 236 | if !seen["carlos-edge.service"] { |
| 237 | t.Fatalf("carlos-edge.service missing from the combined scan: %+v", combined) |
| 238 | } |
| 239 | } |
| 240 | |
| 241 | // TestDBFileBytes: the main file plus its -wal, nil (unknown) for a database |
| 242 | // that does not exist, and the main file alone once a checkpoint has removed |
| 243 | // the WAL. |
| 244 | func TestDBFileBytes(t *testing.T) { |
| 245 | dir := t.TempDir() |
| 246 | db := filepath.Join(dir, "h.test.db") |
| 247 | if got := dbFileBytes(db); got != nil { |
| 248 | t.Fatalf("missing db = %d, want nil", *got) |
| 249 | } |
| 250 | if err := os.WriteFile(db, make([]byte, 4096), 0o600); err != nil { |
| 251 | t.Fatal(err) |
| 252 | } |
| 253 | if got := dbFileBytes(db); got == nil || *got != 4096 { |
| 254 | t.Fatalf("db alone = %v, want 4096", got) |
| 255 | } |
| 256 | if err := os.WriteFile(db+"-wal", make([]byte, 1000), 0o600); err != nil { |
| 257 | t.Fatal(err) |
| 258 | } |
| 259 | if got := dbFileBytes(db); got == nil || *got != 5096 { |
| 260 | t.Fatalf("db + wal = %v, want 5096", got) |
| 261 | } |
| 262 | if got := dbFileBytes(dir); got != nil { |
| 263 | t.Fatalf("a directory = %d, want nil", *got) |
| 264 | } |
| 265 | } |
| 266 | |
| 267 | // A generation parent (zero-downtime spec §7.3) holds no process of its |
| 268 | // own, so its cgroup.procs is empty while its generations run. It must |
| 269 | // still be sampled, under its own name: its memory, cpu, io and pids |
| 270 | // files already count the whole subtree. |
| 271 | func TestListExecCgroupsCountsAGenerationParent(t *testing.T) { |
| 272 | root := t.TempDir() |
| 273 | mk := func(rel, file, body string) { |
| 274 | t.Helper() |
| 275 | dir := filepath.Join(root, rel) |
| 276 | if err := os.MkdirAll(dir, 0o755); err != nil { |
| 277 | t.Fatal(err) |
| 278 | } |
| 279 | if file != "" { |
| 280 | if err := os.WriteFile(filepath.Join(dir, file), []byte(body), 0o644); err != nil { |
| 281 | t.Fatal(err) |
| 282 | } |
| 283 | } |
| 284 | } |
| 285 | mk("carlos-exec@flat.example.com", "cgroup.procs", "1234\n") |
| 286 | mk("carlos-exec@gens.example.com", "cgroup.procs", "") |
| 287 | mk("carlos-exec@gens.example.com", "cgroup.events", "populated 1\nfrozen 0\n") |
| 288 | mk("carlos-exec@gens.example.com/g7", "cgroup.procs", "5678\n") |
| 289 | mk("carlos-exec@empty.example.com", "cgroup.procs", "") |
| 290 | mk("carlos-exec@empty.example.com", "cgroup.events", "populated 0\nfrozen 0\n") |
| 291 | |
| 292 | got := listExecCgroups(root) |
| 293 | names := map[string]bool{} |
| 294 | for _, u := range got { |
| 295 | names[u.name] = true |
| 296 | } |
| 297 | if len(got) != 2 || !names["carlos-exec@flat.example.com"] || !names["carlos-exec@gens.example.com"] { |
| 298 | t.Fatalf("sampled %+v, want the flat host and the populated generation parent", got) |
| 299 | } |
| 300 | } |
| 301 | |