| 1 | package cgroup |
| 2 | |
| 3 | import ( |
| 4 | "errors" |
| 5 | "fmt" |
| 6 | "os" |
| 7 | "os/exec" |
| 8 | "path/filepath" |
| 9 | "slices" |
| 10 | "strconv" |
| 11 | "strings" |
| 12 | "syscall" |
| 13 | |
| 14 | "github.com/carlosframework/platform/internal/controlname" |
| 15 | ) |
| 16 | |
| 17 | // The generation layout (zero-downtime deploys spec §7.3). A host's |
| 18 | // carlos-exec@<host> becomes a PARENT that holds no processes. Each |
| 19 | // instance spawn lands in its own generation child g<n>, which carries the |
| 20 | // tier's full limits and memory.oom.group=1, so a successor that overruns |
| 21 | // dies alone and the incumbent is untouched. The legacy co-spawned sidecar |
| 22 | // and the seed land in role- children with no limit of their own. The |
| 23 | // parent carries the tier with memory.oom.group=0: a backstop for the |
| 24 | // instance as a whole, raised to OverlapLimit while two generations run. |
| 25 | // Nothing here runs unless the exec backend's GenContainment is set |
| 26 | // (plan 5's CARLOS_SWAP). |
| 27 | |
| 28 | // GenChild is generation n's child cgroup: "g<n>". |
| 29 | func GenChild(n uint64) string { return "g" + strconv.FormatUint(n, 10) } |
| 30 | |
| 31 | // RoleChild is the child cgroup a non-generation spawn of a host lands in |
| 32 | // under the generation layout: "role-<role>", the name PrepareRoleChild |
| 33 | // pins. |
| 34 | func RoleChild(role string) string { return "role-" + role } |
| 35 | |
| 36 | // isChildName reports whether name is one of the layout's child names: |
| 37 | // g<n> with n canonical and above zero, or role-<label>. Only these are |
| 38 | // ever created, removed or swept. |
| 39 | func isChildName(name string) bool { |
| 40 | if rest, ok := strings.CutPrefix(name, "role-"); ok { |
| 41 | return controlname.ValidLabel(rest) |
| 42 | } |
| 43 | rest, ok := strings.CutPrefix(name, "g") |
| 44 | if !ok || rest == "" { |
| 45 | return false |
| 46 | } |
| 47 | n, err := strconv.ParseUint(rest, 10, 64) |
| 48 | return err == nil && n > 0 && strconv.FormatUint(n, 10) == rest |
| 49 | } |
| 50 | |
| 51 | // isGenName reports whether name is a generation child's name. |
| 52 | func isGenName(name string) bool { return strings.HasPrefix(name, "g") && isChildName(name) } |
| 53 | |
| 54 | // OverlapLimit is the parent's limit while two generations overlap: the two |
| 55 | // tiers summed. With equal tiers that is the spec's "twice the tier". When |
| 56 | // a release changes tier it still has room for both (plan 2, D3). |
| 57 | func OverlapLimit(incumbent, successor Limit) Limit { |
| 58 | return Limit{MemoryMax: incumbent.MemoryMax + successor.MemoryMax} |
| 59 | } |
| 60 | |
| 61 | // ValidParentLimit reports whether l is a limit a generation parent may |
| 62 | // carry: one tier, or the sum of two. Strict containment and the root |
| 63 | // control service accept nothing else. |
| 64 | func ValidParentLimit(l Limit) bool { |
| 65 | tiers := []int64{TierDefault.MemoryMax, TierChildren.MemoryMax} |
| 66 | for _, a := range tiers { |
| 67 | if l.MemoryMax == a { |
| 68 | return true |
| 69 | } |
| 70 | for _, b := range tiers { |
| 71 | if l.MemoryMax == a+b { |
| 72 | return true |
| 73 | } |
| 74 | } |
| 75 | } |
| 76 | return false |
| 77 | } |
| 78 | |
| 79 | // delegatedControllers is the subtree_control write that hands a parent's |
| 80 | // children every controller of cpu, memory, io and pids that the parent |
| 81 | // itself has (its cgroup.controllers). A user manager delegates no io, for |
| 82 | // one, and a write naming a controller the parent lacks fails as a whole. |
| 83 | func delegatedControllers(have string) string { |
| 84 | fields := strings.Fields(have) |
| 85 | var want []string |
| 86 | for _, c := range []string{"cpu", "memory", "io", "pids"} { |
| 87 | if slices.Contains(fields, c) { |
| 88 | want = append(want, "+"+c) |
| 89 | } |
| 90 | } |
| 91 | return strings.Join(want, " ") |
| 92 | } |
| 93 | |
| 94 | // enableChildren delegates dir's controllers to its children. Memory must |
| 95 | // be among them, or a generation's limits could not exist. |
| 96 | func enableChildren(dir string) error { |
| 97 | have, err := os.ReadFile(filepath.Join(dir, "cgroup.controllers")) |
| 98 | if err != nil { |
| 99 | return err |
| 100 | } |
| 101 | want := delegatedControllers(string(have)) |
| 102 | if !strings.Contains(want, "+memory") { |
| 103 | return errors.New("no memory controller to delegate") |
| 104 | } |
| 105 | return writeSubtreeControl(filepath.Join(dir, "cgroup.subtree_control"), []byte(want)) |
| 106 | } |
| 107 | |
| 108 | // writeSubtreeControl is enableChildren's write, a seam so a test can |
| 109 | // answer it as the kernel would (EBUSY for a parent a process landed in). |
| 110 | var writeSubtreeControl = func(path string, b []byte) error { return os.WriteFile(path, b, 0o644) } |
| 111 | |
| 112 | // parentPopulated reads dir's cgroup.events. An absent file means nothing |
| 113 | // has ever run there: not populated. A file that cannot be read counts as |
| 114 | // populated, so a raised limit is never lowered on a guess. |
| 115 | func parentPopulated(dir string) bool { |
| 116 | b, err := os.ReadFile(filepath.Join(dir, "cgroup.events")) |
| 117 | if errors.Is(err, os.ErrNotExist) { |
| 118 | return false |
| 119 | } |
| 120 | if err != nil { |
| 121 | return true |
| 122 | } |
| 123 | return !strings.Contains(string(b), "populated 0") |
| 124 | } |
| 125 | |
| 126 | // ErrLegacyParentPopulated is a generation start refused because the host's |
| 127 | // parent cgroup holds a process directly. A cgroup with processes of its own |
| 128 | // cannot delegate the memory controller to children (cgroup v2's |
| 129 | // no-internal-process rule), so a generation started under it would run with |
| 130 | // no memory limit at all, silently (amadan #272, real-unit check (d), kernel |
| 131 | // 6.12, systemd 257). An older agent's flat start is the usual way a parent |
| 132 | // gets one. The start fails closed instead: nothing is created and nothing |
| 133 | // is exec'd, and it is no fault of the build. |
| 134 | var ErrLegacyParentPopulated = errors.New("cgroup: the host's parent cgroup holds a process directly") |
| 135 | |
| 136 | // ErrLimitNotApplied is a generation start refused because a limit it would |
| 137 | // run under could not be put in place: the parent could not be made, its |
| 138 | // controllers could not be delegated (or a live parent does not delegate |
| 139 | // memory), the parent's backstop could not be written, the generation's own |
| 140 | // child could not be made or one of its four limit writes failed, or the |
| 141 | // placement shim is not available in this process, so the process could |
| 142 | // only be started outside its cgroup and moved after, or the shim could |
| 143 | // not place it before exec (a placement error, never the command's own |
| 144 | // failure). The flat start (StartGated) stays best-effort and never |
| 145 | // returns it. Nothing is exec'd, and it is no fault of the build: like |
| 146 | // ErrLegacyParentPopulated, the condition is the box's (Task 7, zdd flag |
| 147 | // readiness round 2). |
| 148 | var ErrLimitNotApplied = errors.New("cgroup: a generation's limits could not be applied") |
| 149 | |
| 150 | // delegatesMemory reports whether dir hands the memory controller to its |
| 151 | // children: its cgroup.subtree_control lists memory. The kernel reads it |
| 152 | // back without the "+" this package writes; either spelling counts. An |
| 153 | // absent or unreadable file is not known to delegate, so it is refused as |
| 154 | // one that does not. |
| 155 | func delegatesMemory(dir string) error { |
| 156 | b, err := os.ReadFile(filepath.Join(dir, "cgroup.subtree_control")) |
| 157 | if err != nil { |
| 158 | return fmt.Errorf("%w: cannot read the parent's cgroup.subtree_control: %w", ErrLimitNotApplied, err) |
| 159 | } |
| 160 | for _, f := range strings.Fields(string(b)) { |
| 161 | if strings.TrimPrefix(f, "+") == "memory" { |
| 162 | return nil |
| 163 | } |
| 164 | } |
| 165 | return fmt.Errorf("%w: the parent does not delegate memory (cgroup.subtree_control %q)", ErrLimitNotApplied, strings.TrimSpace(string(b))) |
| 166 | } |
| 167 | |
| 168 | // parentProcs counts the processes dir holds directly: its own cgroup.procs, |
| 169 | // not cgroup.events, whose "populated" also counts every descendant. An |
| 170 | // absent file is a directory nothing has run in. A file that cannot be read |
| 171 | // is an error, so the caller never takes it for empty. |
| 172 | func parentProcs(dir string) (int, error) { |
| 173 | b, err := os.ReadFile(filepath.Join(dir, "cgroup.procs")) |
| 174 | if errors.Is(err, os.ErrNotExist) { |
| 175 | return 0, nil |
| 176 | } |
| 177 | if err != nil { |
| 178 | return 0, err |
| 179 | } |
| 180 | return len(strings.Fields(string(b))), nil |
| 181 | } |
| 182 | |
| 183 | // ensureParent makes carlos-exec@<host> a generation parent. It first |
| 184 | // refuses a parent that holds a process directly, whatever children it has |
| 185 | // (plan review #12): a stale empty g<n> beside a legacy process does not make |
| 186 | // the parent one. A process being placed (the in-flight registry) counts as |
| 187 | // one already there: into the parent itself, as a direct process; anywhere |
| 188 | // below it, as something alive under it. Then, when nothing is alive under |
| 189 | // it (a cold wake), it delegates the controllers and writes the tier with |
| 190 | // memory.oom.group=0. With a live generation under it, the parent is left |
| 191 | // as it is, because a swap raised it on purpose, once it is shown to |
| 192 | // delegate memory to its children. |
| 193 | // |
| 194 | // The decision and the writes it allows are one step against the registry |
| 195 | // (whole-branch review, finding 1): from before the first read until the |
| 196 | // limits are written, no placement into the parent or below it can |
| 197 | // register (holdPlacements), and the placements already in flight are |
| 198 | // counted when the hold begins. So no sibling can move from "being placed" |
| 199 | // to "in the kernel's view" unseen between the reads, and none can start |
| 200 | // while a cold parent is being reset. A placement that waits on the hold |
| 201 | // waits only for these cgroupfs reads and writes. |
| 202 | // |
| 203 | // Nothing here is best-effort (Task 7): a generation started past any of |
| 204 | // these failures would run without a limit, with nothing to say so. A |
| 205 | // populated parent is ErrLegacyParentPopulated, and an unreadable |
| 206 | // cgroup.procs is refused as the same (unknown is not empty), as is a |
| 207 | // subtree_control write the kernel answers EBUSY (a process landed in the |
| 208 | // parent after the check). A parent that cannot be made, a live parent that |
| 209 | // does not delegate memory, controllers that cannot be delegated, and a |
| 210 | // parent backstop that cannot be written are ErrLimitNotApplied. |
| 211 | func (m *Manager) ensureParent(host string, tier Limit) error { |
| 212 | dir := m.hostDir(host) |
| 213 | if err := os.MkdirAll(dir, 0o755); err != nil { |
| 214 | m.logf("cgroup: %s: create parent cgroup: %v (generation start refused)", host, err) |
| 215 | return fmt.Errorf("%w: %s: create parent: %w", ErrLimitNotApplied, host, err) |
| 216 | } |
| 217 | inflightAt, inflightUnder, release := m.holdPlacements(dir) |
| 218 | defer release() |
| 219 | n, err := parentProcs(dir) |
| 220 | if err != nil { |
| 221 | m.logf("cgroup: %s: generation start refused: cannot read the parent's cgroup.procs: %v", host, err) |
| 222 | return fmt.Errorf("%w (cgroup.procs unreadable, treated as populated): %v", ErrLegacyParentPopulated, err) |
| 223 | } |
| 224 | // A flat start being placed into the parent itself is a direct process |
| 225 | // the moment it lands (Ruling 6): count it now. |
| 226 | n += inflightAt |
| 227 | if n > 0 { |
| 228 | m.logf("cgroup: %s: generation start refused: the parent cgroup holds %d process(es) directly, so no memory limit could apply to a generation under it; stop them or let them exit", host, n) |
| 229 | return fmt.Errorf("%w: %s holds %d process(es)", ErrLegacyParentPopulated, host, n) |
| 230 | } |
| 231 | // A sibling generation being placed is a live one: the parent is not |
| 232 | // cold, and its limits (a swap may have raised them) are left alone. |
| 233 | populated := parentPopulated(dir) |
| 234 | if placementReadHook != nil { |
| 235 | placementReadHook(dir) |
| 236 | } |
| 237 | if populated || inflightUnder > 0 { |
| 238 | if err := delegatesMemory(dir); err != nil { |
| 239 | m.logf("cgroup: %s: generation start refused: %v", host, err) |
| 240 | return fmt.Errorf("%s: %w", host, err) |
| 241 | } |
| 242 | return nil |
| 243 | } |
| 244 | if err := enableChildren(dir); err != nil { |
| 245 | if errors.Is(err, syscall.EBUSY) { |
| 246 | m.logf("cgroup: %s: generation start refused: delegate controllers: %v (a process holds the parent directly)", host, err) |
| 247 | return fmt.Errorf("%w: %s: delegate controllers: %w", ErrLegacyParentPopulated, host, err) |
| 248 | } |
| 249 | m.logf("cgroup: %s: delegate controllers to generations: %v (generation start refused)", host, err) |
| 250 | return fmt.Errorf("%w: %s: delegate controllers: %w", ErrLimitNotApplied, host, err) |
| 251 | } |
| 252 | if tier.MemoryMax > 0 { |
| 253 | if err := m.SetParentLimit(host, tier); err != nil { |
| 254 | m.logf("cgroup: %s: parent limit: %v (generation start refused)", host, err) |
| 255 | return fmt.Errorf("%w: %w", ErrLimitNotApplied, err) |
| 256 | } |
| 257 | } |
| 258 | return nil |
| 259 | } |
| 260 | |
| 261 | // StartChild is Start for the generation layout: the child lands in |
| 262 | // carlos-exec@<host>/<child>. own is the child's own limit: the tier for a |
| 263 | // generation, zero for a role child, which then lives under the parent's |
| 264 | // limit alone. parent is the tier the parent takes when this spawn finds |
| 265 | // nothing alive under it. Nil-safe like Start, but not best-effort: a |
| 266 | // generation whose limits cannot all be applied is refused with |
| 267 | // ErrLimitNotApplied before anything is built or exec'd (ensureParent, |
| 268 | // startIn), and it is never started outside its cgroup and moved by PID. |
| 269 | func (m *Manager) StartChild(host, child string, parent, own Limit, build func() *exec.Cmd) (*exec.Cmd, error) { |
| 270 | return m.StartChildGated(host, child, parent, own, build, nil) |
| 271 | } |
| 272 | |
| 273 | // StartChildGated is StartChild with StartGated's before-start hook: asked |
| 274 | // after the parent and the child are prepared and the process is placed in |
| 275 | // the child, immediately before it execs. A refusal runs nothing of the |
| 276 | // command, leaves the child removed (if empty) and the parent as a failed |
| 277 | // start leaves it, and comes back as before's own error. A nil before is |
| 278 | // StartChild exactly. A parent that holds a process directly refuses the |
| 279 | // start with ErrLegacyParentPopulated before anything is created or exec'd |
| 280 | // (ensureParent). |
| 281 | func (m *Manager) StartChildGated(host, child string, parent, own Limit, build func() *exec.Cmd, before func() error) (*exec.Cmd, error) { |
| 282 | if !isChildName(child) { |
| 283 | return nil, fmt.Errorf("cgroup: %s: %q is not a child cgroup name", host, child) |
| 284 | } |
| 285 | if m == nil { |
| 286 | return startPlain(build, before) |
| 287 | } |
| 288 | // A parent or child that took cgroup.kill is removed and made again |
| 289 | // first (amadan #272): KillSidecar keeps role-sidecar, and KillHost keeps |
| 290 | // the parent. ensureParent and startIn then build them as on a cold start. |
| 291 | if err := m.freshen(host, filepath.Join(m.hostDir(host), child)); err != nil { |
| 292 | return nil, err |
| 293 | } |
| 294 | if err := m.ensureParent(host, parent); err != nil { |
| 295 | return nil, err |
| 296 | } |
| 297 | return m.startIn(host, filepath.Join(m.hostDir(host), child), own, true, build, before) |
| 298 | } |
| 299 | |
| 300 | // SetParentLimit writes l onto host's parent: memory.max, memory.high |
| 301 | // (HighOf), memory.swap.max, and memory.oom.group=0 (spec §7.3). The swap |
| 302 | // raises it to OverlapLimit before the successor spawns and restores the |
| 303 | // tier once the retiring child is empty. Unlike applyLimit it reports the |
| 304 | // first failure, because a raise that did not land must stop the overlap. |
| 305 | // A nil manager has no parent to set and answers nil. |
| 306 | func (m *Manager) SetParentLimit(host string, l Limit) error { |
| 307 | if m == nil { |
| 308 | return nil |
| 309 | } |
| 310 | if l.MemoryMax <= 0 { |
| 311 | return fmt.Errorf("cgroup: %s: parent limit %d is not a limit", host, l.MemoryMax) |
| 312 | } |
| 313 | dir := m.hostDir(host) |
| 314 | for _, w := range []struct{ name, value string }{ |
| 315 | {"memory.max", strconv.FormatInt(l.MemoryMax, 10)}, |
| 316 | {"memory.high", strconv.FormatInt(HighOf(l.MemoryMax), 10)}, |
| 317 | {"memory.swap.max", strconv.FormatInt(l.MemoryMax, 10)}, |
| 318 | {"memory.oom.group", "0"}, |
| 319 | } { |
| 320 | if err := os.WriteFile(filepath.Join(dir, w.name), []byte(w.value), 0o644); err != nil { |
| 321 | return fmt.Errorf("cgroup: %s: parent %s: %w", host, w.name, err) |
| 322 | } |
| 323 | } |
| 324 | m.logf("cgroup: %s: parent memory.max=%dM", host, l.MemoryMax>>20) |
| 325 | return nil |
| 326 | } |
| 327 | |
| 328 | // RemoveChild removes host's child cgroup once it is empty. Nil-safe and |
| 329 | // rmdir-tolerant like CleanupHost, and it touches only the layout's own |
| 330 | // child names. The parent is CleanupHost's to remove. |
| 331 | func (m *Manager) RemoveChild(host, child string) { |
| 332 | if m == nil || !isChildName(child) { |
| 333 | return |
| 334 | } |
| 335 | _ = removeEmptyDir(filepath.Join(m.hostDir(host), child)) |
| 336 | } |
| 337 | |