| 1 | //go:build linux |
| 2 | |
| 3 | package cgroup |
| 4 | |
| 5 | import ( |
| 6 | "errors" |
| 7 | "fmt" |
| 8 | "os" |
| 9 | "path/filepath" |
| 10 | "strings" |
| 11 | "sync" |
| 12 | |
| 13 | "golang.org/x/sys/unix" |
| 14 | ) |
| 15 | |
| 16 | // A cgroup that took cgroup.kill is never spawned into again (amadan #272). |
| 17 | // Found on the dev box (arm64, kernel 6.12.105, systemd 257): once |
| 18 | // cgroup.kill has been written to a cgroup, every later |
| 19 | // clone3(CLONE_INTO_CGROUP) into that same cgroup is SIGKILLed at once, |
| 20 | // while a cgroup made fresh at the same path is not. The Manager's starts |
| 21 | // now place through the exec-shim, a cgroup.procs write rather than a |
| 22 | // clone3, but the strict claims path still clones, and a fresh cgroup is |
| 23 | // also the one place a kept cgroup's limits are written again, so every |
| 24 | // start still makes a killed cgroup fresh first. Generation kills |
| 25 | // (KillGen, CloseRemove) remove their child and never meet it. The kills |
| 26 | // that keep their cgroup are a worker's host cgroup (KillHost) and the |
| 27 | // legacy sidecar's role child (KillSidecar); a strict group's own kill |
| 28 | // keeps its directory too. |
| 29 | // |
| 30 | // Every kill marks the cgroup it is about to kill, by identity (device and |
| 31 | // inode), before it writes cgroup.kill. The next start into that path |
| 32 | // (Start, StartChild, PrepareStrict) removes the killed cgroup, and any |
| 33 | // empty cgroups left below it, and then creates it as a first start does, |
| 34 | // so it gets the same limits and controller settings from the same code, |
| 35 | // under the same parent. The recreation happens at the start, not right |
| 36 | // after the kill, for three reasons. The start is the one place that knows |
| 37 | // what the cgroup must carry (its tier, or the parent's), so a recreation |
| 38 | // cannot drift from a creation. One check there covers every kill, a strict |
| 39 | // one included, without each kill site having to remember it. And a strict |
| 40 | // handle may still pin the killed directory when the kill returns (it is the |
| 41 | // handle's identity), so removing it then would strand the handle; at the |
| 42 | // start a pinned cgroup is refused instead, and removed once the handle lets |
| 43 | // go. |
| 44 | // |
| 45 | // A removal that fails (EBUSY: something is in it again) refuses the start |
| 46 | // with ErrKilledCgroupKept. The mark stays, so every later start refuses too |
| 47 | // until the cgroup can be removed. Marks are by identity, so a cgroup made |
| 48 | // fresh at the same path is never mistaken for the killed one, and a |
| 49 | // cgroup someone else removed leaves only a harmless stale mark (cgroupfs |
| 50 | // inode numbers are not reused). |
| 51 | // |
| 52 | // The marks live in this process only. An agent that restarts with Setup |
| 53 | // sweeps the empty carlos-exec@ directories a previous run left, killed |
| 54 | // ones included, so it starts with fresh cgroups. |
| 55 | |
| 56 | // killedCgroups is every cgroup this process wrote cgroup.kill to that may |
| 57 | // still exist, by identity. Package-wide, not per Manager: a strict handle's |
| 58 | // kill has no Manager, and an identity names one cgroup on the box. |
| 59 | var killedCgroups = struct { |
| 60 | sync.Mutex |
| 61 | ids map[strictIdentity]bool |
| 62 | }{ids: make(map[strictIdentity]bool)} |
| 63 | |
| 64 | // markKilled records that id is about to take cgroup.kill. It is called |
| 65 | // before the write, so a kill that fails half way still counts. |
| 66 | func markKilled(id strictIdentity) { |
| 67 | killedCgroups.Lock() |
| 68 | killedCgroups.ids[id] = true |
| 69 | killedCgroups.Unlock() |
| 70 | } |
| 71 | |
| 72 | func forgetKilled(id strictIdentity) { |
| 73 | killedCgroups.Lock() |
| 74 | delete(killedCgroups.ids, id) |
| 75 | killedCgroups.Unlock() |
| 76 | } |
| 77 | |
| 78 | func wasKilled(id strictIdentity) bool { |
| 79 | killedCgroups.Lock() |
| 80 | defer killedCgroups.Unlock() |
| 81 | return killedCgroups.ids[id] |
| 82 | } |
| 83 | |
| 84 | // anyKilled lets a start skip every stat while nothing was ever killed: on |
| 85 | // a box that never kills a kept cgroup (the flag off, before Task 15's F6 |
| 86 | // path) the start path is the one it always was. |
| 87 | func anyKilled() bool { |
| 88 | killedCgroups.Lock() |
| 89 | defer killedCgroups.Unlock() |
| 90 | return len(killedCgroups.ids) > 0 |
| 91 | } |
| 92 | |
| 93 | func reservedStrict(id strictIdentity) bool { |
| 94 | strictReservations.Lock() |
| 95 | defer strictReservations.Unlock() |
| 96 | return strictReservations.held[id] |
| 97 | } |
| 98 | |
| 99 | // rmdirCgroup removes one cgroup directory. A seam so the temp-dir fixture, |
| 100 | // whose interface files are ordinary files, can stand in for cgroupfs. |
| 101 | var rmdirCgroup = func(path string) error { return unix.Rmdir(path) } |
| 102 | |
| 103 | // pathIdentity is the identity of the directory at path, not following a |
| 104 | // symlink. ok is false when nothing is there. |
| 105 | func pathIdentity(path string) (id strictIdentity, ok bool, err error) { |
| 106 | var st unix.Stat_t |
| 107 | if err := unix.Lstat(path, &st); err != nil { |
| 108 | if errors.Is(err, unix.ENOENT) { |
| 109 | return strictIdentity{}, false, nil |
| 110 | } |
| 111 | return strictIdentity{}, false, err |
| 112 | } |
| 113 | return strictIdentity{uint64(st.Dev), st.Ino}, true, nil |
| 114 | } |
| 115 | |
| 116 | // freshen makes sure no start into dir (the host's cgroup, or one of its |
| 117 | // children) lands in a cgroup that took a kill: the host's cgroup first, |
| 118 | // since a kill there killed everything below it, then dir. It runs under |
| 119 | // the host's start lock (hostLock), so two starts of one host never both |
| 120 | // remove: the first removes the killed cgroup, and the second finds the |
| 121 | // fresh one, whose identity took no kill, and keeps it. The lock is held |
| 122 | // only for the check and the removal, not across the start's placement: a |
| 123 | // start's own directory is never removed by another start, because only a |
| 124 | // killed identity is removed and every start checks under this lock first. |
| 125 | // What it does not order is a kill landing while a start is between its |
| 126 | // check and its placement; the activator never does that (a kept cgroup is |
| 127 | // killed only once its leader has exited, and nothing of that host or role |
| 128 | // is started until the proof returns). |
| 129 | func (m *Manager) freshen(host, dir string) error { |
| 130 | if !anyKilled() { |
| 131 | return nil |
| 132 | } |
| 133 | mu := m.hostLock(host) |
| 134 | mu.Lock() |
| 135 | defer mu.Unlock() |
| 136 | hostDir := m.hostDir(host) |
| 137 | if err := m.freshenOne(host, hostDir); err != nil { |
| 138 | return err |
| 139 | } |
| 140 | if dir == hostDir { |
| 141 | return nil |
| 142 | } |
| 143 | return m.freshenOne(host, dir) |
| 144 | } |
| 145 | |
| 146 | func (m *Manager) freshenOne(host, dir string) error { |
| 147 | id, ok, err := pathIdentity(dir) |
| 148 | if err != nil { |
| 149 | // Unknown is not fresh. |
| 150 | return fmt.Errorf("%w: %s: %w", ErrKilledCgroupKept, dir, err) |
| 151 | } |
| 152 | if !ok || !wasKilled(id) { |
| 153 | return nil |
| 154 | } |
| 155 | groups := 0 |
| 156 | if err := removeKilledTree(dir, 0, &groups); err != nil { |
| 157 | m.logf("cgroup: %s: %s took cgroup.kill and could not be removed, so nothing is started in it: %v", host, filepath.Base(dir), err) |
| 158 | return fmt.Errorf("%w: %s: %w", ErrKilledCgroupKept, dir, err) |
| 159 | } |
| 160 | m.logf("cgroup: %s: %s took cgroup.kill; removed so the next spawn gets a fresh cgroup", host, filepath.Base(dir)) |
| 161 | return nil |
| 162 | } |
| 163 | |
| 164 | // removeKilledTree removes dir and every cgroup below it, deepest first: |
| 165 | // rmdir refuses a cgroup with child cgroups, empty or not, and a tenant |
| 166 | // running as the agent's uid may have made some. The kernel refuses to |
| 167 | // remove a cgroup with a process in it, so a removal that succeeds also |
| 168 | // proves it empty. A cgroup a strict handle still pins is refused rather |
| 169 | // than removed under it, and each directory is checked before anything |
| 170 | // below it is touched, so a pinned killed parent loses none of its |
| 171 | // children. The walk is bounded as the cleanup walk is. |
| 172 | func removeKilledTree(dir string, depth int, groups *int) error { |
| 173 | id, ok, err := pathIdentity(dir) |
| 174 | if err != nil || !ok { |
| 175 | return err |
| 176 | } |
| 177 | if reservedStrict(id) { |
| 178 | return fmt.Errorf("%w: a strict handle still holds %s", ErrStrictState, filepath.Base(dir)) |
| 179 | } |
| 180 | entries, err := os.ReadDir(dir) |
| 181 | if errors.Is(err, os.ErrNotExist) { |
| 182 | return nil |
| 183 | } |
| 184 | if err != nil { |
| 185 | return err |
| 186 | } |
| 187 | for _, e := range entries { |
| 188 | if !e.IsDir() { |
| 189 | continue |
| 190 | } |
| 191 | if depth+1 > maxCleanupDepth { |
| 192 | return fmt.Errorf("%w: cgroup tree deeper than %d", ErrStrictRefused, maxCleanupDepth) |
| 193 | } |
| 194 | if *groups++; *groups > maxCleanupGroups { |
| 195 | return fmt.Errorf("%w: more than %d cgroups below one host", ErrStrictRefused, maxCleanupGroups) |
| 196 | } |
| 197 | if err := removeKilledTree(filepath.Join(dir, e.Name()), depth+1, groups); err != nil { |
| 198 | return err |
| 199 | } |
| 200 | } |
| 201 | if err := rmdirCgroup(dir); err != nil && !errors.Is(err, unix.ENOENT) { |
| 202 | return err |
| 203 | } |
| 204 | forgetKilled(id) |
| 205 | return nil |
| 206 | } |
| 207 | |
| 208 | // removeEmptyDir is CleanupHost's and RemoveChild's os.Remove, which also |
| 209 | // drops a kill mark the removed cgroup carried, so the set does not grow. |
| 210 | func removeEmptyDir(path string) error { |
| 211 | id, ok, _ := pathIdentity(path) |
| 212 | err := os.Remove(path) |
| 213 | if err == nil && ok { |
| 214 | forgetKilled(id) |
| 215 | } |
| 216 | return err |
| 217 | } |
| 218 | |
| 219 | // refuseKilledChild is pinChild's check on a child that already exists: a |
| 220 | // strict child that took a kill is never pinned again for a spawn. Generation |
| 221 | // numbers are never reused, and no production path re-pins a role child, so |
| 222 | // a refusal costs nothing there; it keeps the rule whole. |
| 223 | func refuseKilledChild(parent *Prepared, name string) error { |
| 224 | var st unix.Stat_t |
| 225 | if err := unix.Fstatat(int(parent.dir.Fd()), name, &st, unix.AT_SYMLINK_NOFOLLOW); err != nil { |
| 226 | return ErrStrictRefused |
| 227 | } |
| 228 | if wasKilled(strictIdentity{uint64(st.Dev), st.Ino}) { |
| 229 | return ErrStrictRefused |
| 230 | } |
| 231 | return nil |
| 232 | } |
| 233 | |
| 234 | // seedKilled is a retaining setup's stand-in for the sweep it skips |
| 235 | // (Task 19 fix round 1). The marks are this process's own, so a host |
| 236 | // cgroup or a role child that an earlier run killed and kept is unknown to |
| 237 | // it, and claim-entry mode (SetupRetaining) does not sweep the empty |
| 238 | // carlos-exec@ directories as Setup does. Every one that holds no process |
| 239 | // is marked instead, and each of its child directories that holds none, so |
| 240 | // the first start into it makes it fresh. Being marked costs an empty |
| 241 | // cgroup only a recreation. A directory that holds a process, or whose |
| 242 | // population cannot be read, is never marked: removing it is not ours to |
| 243 | // do. The claim containment groups (IsClaimContainment: carlos-exec@claim-<id>.local, unique |
| 244 | // to one controller) are left alone: they are the retained evidence the |
| 245 | // retaining mode exists to keep, and nothing starts into them again. |
| 246 | func (m *Manager) seedKilled() { |
| 247 | entries, err := os.ReadDir(m.root) |
| 248 | if err != nil { |
| 249 | return |
| 250 | } |
| 251 | seeded := 0 |
| 252 | for _, e := range entries { |
| 253 | if !e.IsDir() || !IsExecName(e.Name()) || IsClaimContainment(e.Name()) { |
| 254 | continue |
| 255 | } |
| 256 | dir := filepath.Join(m.root, e.Name()) |
| 257 | if kids, err := os.ReadDir(dir); err == nil { |
| 258 | for _, k := range kids { |
| 259 | if k.IsDir() && seedIfEmpty(filepath.Join(dir, k.Name())) { |
| 260 | seeded++ |
| 261 | } |
| 262 | } |
| 263 | } |
| 264 | if seedIfEmpty(dir) { |
| 265 | seeded++ |
| 266 | } |
| 267 | } |
| 268 | if seeded > 0 { |
| 269 | m.logf("cgroup: %d retained empty cgroup(s) will be made fresh before their next spawn", seeded) |
| 270 | } |
| 271 | } |
| 272 | |
| 273 | // seedIfEmpty marks dir when its cgroup.events reads "populated 0" (no |
| 274 | // process in it or below it) and its own cgroup.procs, when present, lists |
| 275 | // none. It reports whether it marked. |
| 276 | func seedIfEmpty(dir string) bool { |
| 277 | b, err := os.ReadFile(filepath.Join(dir, "cgroup.events")) |
| 278 | if err != nil { |
| 279 | return false |
| 280 | } |
| 281 | populated := true |
| 282 | for _, line := range strings.Split(string(b), "\n") { |
| 283 | if f := strings.Fields(line); len(f) == 2 && f[0] == "populated" { |
| 284 | populated = f[1] != "0" |
| 285 | } |
| 286 | } |
| 287 | if populated { |
| 288 | return false |
| 289 | } |
| 290 | if procs, err := os.ReadFile(filepath.Join(dir, "cgroup.procs")); err == nil && len(strings.Fields(string(procs))) > 0 { |
| 291 | return false |
| 292 | } else if err != nil && !errors.Is(err, os.ErrNotExist) { |
| 293 | return false |
| 294 | } |
| 295 | id, ok, err := pathIdentity(dir) |
| 296 | if err != nil || !ok { |
| 297 | return false |
| 298 | } |
| 299 | markKilled(id) |
| 300 | return true |
| 301 | } |
| 302 | |