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 "bufio"
7 "context"
8 "errors"
9 "fmt"
10 "io"
11 "os"
12 "path/filepath"
13 "slices"
14 "strconv"
15 "strings"
16 "time"
17
18 "github.com/carlosframework/platform/internal/controlname"
19 "golang.org/x/sys/unix"
20)
21
22type Cleanup struct{ state *cleanupState }
23type cleanupState struct {
24 pin *Prepared
25 procs *os.File
26 // m and path are the manager that opened the cleanup and the directory
27 // it pinned, as the manager's starts name it: the in-flight registry's
28 // key (inflight.go). A process being placed there or below counts as a
29 // member (Ruling 6).
30 m *Manager
31 path string
32}
33
34func cleanupFilesystem(root *os.File) error {
35 var st unix.Statfs_t
36 if err := unix.Fstatfs(int(root.Fd()), &st); err != nil {
37 return err
38 }
39 if uint64(st.Type) != uint64(unix.CGROUP2_SUPER_MAGIC) {
40 return ErrStrictUnsupported
41 }
42 return nil
43}
44
45func (m *Manager) OpenCleanup(host string) (*Cleanup, error) {
46 return m.openCleanupAt(host, "", cleanupFilesystem)
47}
48
49// OpenCleanupChild pins one child of host's generation parent (zero-
50// downtime spec §7.3): its KillAndEmpty writes that child's cgroup.kill
51// and proves that child empty, and never touches a sibling. ErrNoCgroup
52// when the child is not there.
53func (m *Manager) OpenCleanupChild(host, child string) (*Cleanup, error) {
54 return m.openCleanupAt(host, child, cleanupFilesystem)
55}
56
57func (m *Manager) openCleanup(host string, checkFS func(*os.File) error) (*Cleanup, error) {
58 return m.openCleanupAt(host, "", checkFS)
59}
60
61func (m *Manager) openCleanupAt(host, child string, checkFS func(*os.File) error) (out *Cleanup, err error) {
62 if m == nil || m.root == "" {
63 return nil, ErrNoDelegation
64 }
65 if !controlname.ValidHostShape(host) || (child != "" && !isChildName(child)) {
66 return nil, ErrStrictRefused
67 }
68 root, err := os.OpenFile(m.root, os.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW, 0)
69 if err != nil {
70 return nil, err
71 }
72 p := &Prepared{strictPreparedState: &strictPreparedState{root: root, host: host}}
73 path := filepath.Join(m.root, ExecName(host))
74 if child != "" {
75 path = filepath.Join(path, child)
76 }
77 c := &Cleanup{state: &cleanupState{pin: p, m: m, path: path}}
78 defer func() {
79 if err != nil {
80 if c.state.procs != nil {
81 c.state.procs.Close()
82 }
83 p.closeFiles()
84 }
85 }()
86 if err = checkFS(root); err != nil {
87 return nil, err
88 }
89 p.dir, err = strictOpen(root, ExecName(host), unix.O_RDONLY|unix.O_DIRECTORY)
90 if errors.Is(err, unix.ENOENT) {
91 return nil, ErrNoCgroup
92 }
93 if err != nil {
94 return nil, err
95 }
96 if child != "" {
97 // The child is pinned beneath the parent's own descriptor, and its
98 // identity is checked against that descriptor from now on.
99 p.entryParent, p.entryName = p.dir, child
100 p.dir, err = strictOpen(p.entryParent, child, unix.O_RDONLY|unix.O_DIRECTORY)
101 if errors.Is(err, unix.ENOENT) {
102 return nil, ErrNoCgroup
103 }
104 if err != nil {
105 return nil, err
106 }
107 }
108 p.identity, err = strictID(p.dir)
109 if err != nil {
110 return nil, err
111 }
112 p.events, err = strictOpen(p.dir, "cgroup.events", unix.O_RDONLY)
113 if err != nil {
114 return nil, err
115 }
116 p.kill, err = strictOpen(p.dir, "cgroup.kill", unix.O_WRONLY)
117 if errors.Is(err, unix.ENOENT) {
118 // The cgroup is there (its events were opened above), so a missing
119 // cgroup.kill is the kernel's, not a cgroup that went away.
120 return nil, fmt.Errorf("%w: %w", ErrNoKill, err)
121 }
122 if err != nil {
123 return nil, err
124 }
125 c.state.procs, err = strictOpen(p.dir, "cgroup.procs", unix.O_RDONLY)
126 if err != nil {
127 return nil, err
128 }
129 if err = p.identityOK(); err != nil {
130 return nil, err
131 }
132 return c, nil
133}
134
135func (c *Cleanup) Populated() (bool, error) {
136 if c == nil || c.state == nil {
137 return false, ErrStrictState
138 }
139 p := c.state.pin
140 p.mu.Lock()
141 defer p.mu.Unlock()
142 return c.populationLocked()
143}
144
145// populationLocked is the pinned cgroup's population. A process being
146// placed there or below it (the in-flight registry) makes it populated: a
147// shim spends its first milliseconds in the agent's cgroup, and a flat
148// fallback's child runs there until its PID is moved, so the kernel cannot
149// see either yet. cgroup.events is read inside readQuiet, so a placement
150// that registers during the read, or lands and ends its registration
151// between the read and the registry's, still counts (whole-branch review,
152// finding 1).
153func (c *Cleanup) populationLocked() (bool, error) {
154 p := c.state.pin
155 if err := p.identityOK(); err != nil {
156 return false, err
157 }
158 var (
159 v bool
160 err error
161 )
162 quiet := c.state.m.readQuiet(c.state.path, func() { v, err = strictPopulation(p.events) })
163 if err != nil {
164 return false, err
165 }
166 if err := p.identityOK(); err != nil {
167 return false, err
168 }
169 return v || !quiet, nil
170}
171
172func (c *Cleanup) Members() ([]int, error) {
173 if c == nil || c.state == nil {
174 return nil, ErrStrictState
175 }
176 p := c.state.pin
177 p.mu.Lock()
178 defer p.mu.Unlock()
179 return c.membersLocked()
180}
181
182// Cleanup's walk is bounded: a tree deeper or wider than this is refused
183// rather than vouched for unread.
184const (
185 maxCleanupDepth = 8
186 maxCleanupGroups = 1024
187)
188
189// membersLocked lists every process in the pinned cgroup AND in every
190// cgroup below it. A generation parent holds no process itself (zero-
191// downtime spec §7.3), so stop evidence taken on it must read its
192// generations'. A flat tenant runs as the agent's uid and can make cgroups
193// of its own under its generation, so the walk goes to any depth, bounded.
194// While a process is being placed there or below it, its PID is not in any
195// cgroup.procs yet, so the list would be short: ErrPlacementInFlight. The
196// walk runs inside readQuiet, so a placement in flight at any moment of it
197// counts, not only one in flight before it began.
198func (c *Cleanup) membersLocked() ([]int, error) {
199 p := c.state.pin
200 if err := p.identityOK(); err != nil {
201 return nil, err
202 }
203 var (
204 members []int
205 err error
206 )
207 quiet := c.state.m.readQuiet(c.state.path, func() {
208 if members, err = readProcs(c.state.procs); err != nil {
209 return
210 }
211 groups := 0
212 err = walkMembers(p.dir, 1, &groups, &members)
213 })
214 if err != nil {
215 return nil, err
216 }
217 if !quiet {
218 return nil, ErrPlacementInFlight
219 }
220 slices.Sort(members)
221 if err := p.identityOK(); err != nil {
222 return nil, err
223 }
224 return members, nil
225}
226
227// readProcs reads every PID in one cgroup.procs. It may exceed
228// strictRead's 4096-byte bound, so every line is scanned.
229func readProcs(f *os.File) ([]int, error) {
230 if _, err := f.Seek(0, 0); err != nil {
231 return nil, err
232 }
233 scan := bufio.NewScanner(f)
234 var pids []int
235 for scan.Scan() {
236 raw := strings.TrimSpace(scan.Text())
237 pid, err := strconv.Atoi(raw)
238 if err != nil || pid <= 0 {
239 return nil, fmt.Errorf("%w: invalid cgroup member", ErrStrictRefused)
240 }
241 pids = append(pids, pid)
242 }
243 return pids, scan.Err()
244}
245
246// cleanupReadBatch is how many directory entries one read takes, so the
247// group budget is checked as the listing is read, not after all of it.
248const cleanupReadBatch = 64
249
250// walkMembers appends the members of every cgroup below dir. A child that
251// vanishes between the listing and its open was removed, so it is empty.
252func walkMembers(dir *os.File, depth int, groups *int, members *[]int) error {
253 names, err := childGroups(dir, depth, groups)
254 if err != nil {
255 return err
256 }
257 for _, name := range names {
258 child, err := strictOpen(dir, name, unix.O_RDONLY|unix.O_DIRECTORY)
259 if errors.Is(err, unix.ENOENT) {
260 continue
261 }
262 if err != nil {
263 return err
264 }
265 procs, err := strictOpen(child, "cgroup.procs", unix.O_RDONLY)
266 if errors.Is(err, unix.ENOENT) {
267 child.Close()
268 continue
269 }
270 if err != nil {
271 child.Close()
272 return err
273 }
274 pids, err := readProcs(procs)
275 procs.Close()
276 if err == nil {
277 *members = append(*members, pids...)
278 err = walkMembers(child, depth+1, groups, members)
279 }
280 child.Close()
281 if err != nil {
282 return err
283 }
284 }
285 return nil
286}
287
288// childGroups lists the child cgroups of dir in bounded batches, counting
289// each against the walk's budget as it is read. A tree past the depth or
290// group bound is refused before any more of it is read, so a tenant's many
291// empty cgroups cost at most the budget.
292func childGroups(dir *os.File, depth int, groups *int) ([]string, error) {
293 fd, err := unix.Openat(int(dir.Fd()), ".", unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0)
294 if err != nil {
295 return nil, err
296 }
297 list := os.NewFile(uintptr(fd), dir.Name())
298 defer list.Close()
299 var names []string
300 for {
301 entries, err := list.ReadDir(cleanupReadBatch)
302 for _, e := range entries {
303 if !e.IsDir() {
304 continue
305 }
306 if depth > maxCleanupDepth {
307 return nil, fmt.Errorf("%w: cgroup tree deeper than %d", ErrStrictRefused, maxCleanupDepth)
308 }
309 if *groups++; *groups > maxCleanupGroups {
310 return nil, fmt.Errorf("%w: more than %d cgroups below one host", ErrStrictRefused, maxCleanupGroups)
311 }
312 names = append(names, e.Name())
313 }
314 if errors.Is(err, io.EOF) {
315 return names, nil
316 }
317 if err != nil {
318 return nil, err
319 }
320 }
321}
322
323// cleanupKillWrite writes a pinned cgroup's cgroup.kill: a seam so a test
324// can act right after the kill lands.
325var cleanupKillWrite = func(f *os.File) error { return strictWrite(f, "1") }
326
327// KillAndEmpty kills everything in the pinned cgroup and below it and
328// returns nil only once it is proven empty: unpopulated and no members.
329// Any error is unproven, and ctx must carry a deadline.
330//
331// A process being placed into the cgroup or below it (the in-flight
332// registry, Ruling 6) is a member the kernel cannot see yet. KillAndEmpty
333// first waits for every such placement to resolve, bounded by ctx; an
334// error there is unproven (ErrPlacementInFlight with ctx's error), and
335// nothing is killed. cgroup.kill is then written once. Every emptiness
336// check after it reads the kernel inside readQuiet, so a placement in
337// flight at any moment of a check makes that check populated. A placement
338// that registers after the wait is therefore never missed, but it is not
339// killed either: the kill was written before it landed, and is not written
340// again. Its process keeps the cgroup populated, so the proof ends
341// unproven when ctx ends, unless that process exits first. Never a false
342// empty; a late placement costs the proof, not the tenant. Callers still
343// exclude new starts during a proof as they always have (the lease, and
344// the before hook a start asks immediately before exec); the registry only
345// closes the window a shim, or a flat start awaiting its PID move, spends
346// outside its cgroup.
347func (c *Cleanup) KillAndEmpty(ctx context.Context) error {
348 if c == nil || c.state == nil {
349 return ErrStrictState
350 }
351 if ctx == nil {
352 return ErrStrictDeadline
353 }
354 deadline, ok := ctx.Deadline()
355 if !ok {
356 return ErrStrictDeadline
357 }
358 if err := ctx.Err(); err != nil {
359 return err
360 }
361 if !time.Now().Before(deadline) {
362 return context.DeadlineExceeded
363 }
364 if err := c.state.m.waitPlacementsUnder(ctx, c.state.path); err != nil {
365 return fmt.Errorf("%w: %w", ErrPlacementInFlight, err)
366 }
367 p := c.state.pin
368 lockTick := time.NewTicker(time.Millisecond)
369 defer lockTick.Stop()
370 for !p.mu.TryLock() {
371 select {
372 case <-ctx.Done():
373 return ctx.Err()
374 case <-lockTick.C:
375 }
376 }
377 defer p.mu.Unlock()
378 if err := ctx.Err(); err != nil {
379 return err
380 }
381 if err := p.identityOK(); err != nil {
382 return err
383 }
384 // Marked first: the next start into this cgroup makes it fresh, since
385 // the kernel kills anything cloned into one that took cgroup.kill
386 // (amadan #272, recreate_linux.go).
387 markKilled(p.identity)
388 if err := cleanupKillWrite(p.kill); err != nil {
389 return err
390 }
391 tick := time.NewTicker(5 * time.Millisecond)
392 defer tick.Stop()
393 for {
394 if err := ctx.Err(); err != nil {
395 return err
396 }
397 populated, err := c.populationLocked()
398 if err != nil {
399 return err
400 }
401 members, err := c.membersLocked()
402 if errors.Is(err, ErrPlacementInFlight) {
403 // In flight during the members read: not empty yet.
404 populated, members, err = true, nil, nil
405 }
406 if err != nil {
407 return err
408 }
409 if !populated && len(members) == 0 {
410 return ctx.Err()
411 }
412 select {
413 case <-ctx.Done():
414 return ctx.Err()
415 case <-tick.C:
416 }
417 }
418}
419
420func (c *Cleanup) Close() error {
421 if c == nil || c.state == nil {
422 return ErrStrictState
423 }
424 p := c.state.pin
425 p.mu.Lock()
426 defer p.mu.Unlock()
427 if p.closed {
428 return nil
429 }
430 p.closed = true
431 var errs []error
432 for _, f := range []*os.File{c.state.procs, p.events, p.kill, p.dir, p.entryParent, p.root} {
433 if f != nil {
434 errs = append(errs, f.Close())
435 }
436 }
437 return errors.Join(errs...)
438}
439