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 "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>".
29func 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.
34func 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.
39func 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.
52func 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).
57func 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.
64func 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.
83func 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.
96func 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).
110var 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.
115func 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.
134var 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).
148var 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.
155func 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.
172func 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.
211func (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.
269func (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).
281func (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.
306func (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.
331func (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