carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1//go:build linux
2
3package cgroup
4
5import (
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.
59var 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.
66func markKilled(id strictIdentity) {
67 killedCgroups.Lock()
68 killedCgroups.ids[id] = true
69 killedCgroups.Unlock()
70}
71
72func forgetKilled(id strictIdentity) {
73 killedCgroups.Lock()
74 delete(killedCgroups.ids, id)
75 killedCgroups.Unlock()
76}
77
78func 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.
87func anyKilled() bool {
88 killedCgroups.Lock()
89 defer killedCgroups.Unlock()
90 return len(killedCgroups.ids) > 0
91}
92
93func 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.
101var 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.
105func 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).
129func (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
146func (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.
172func 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.
210func 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.
223func 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.
246func (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.
276func 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