| 1 | //go:build linux |
| 2 | |
| 3 | package cgroup |
| 4 | |
| 5 | import ( |
| 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 | |
| 22 | type Cleanup struct{ state *cleanupState } |
| 23 | type 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 | |
| 34 | func 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 | |
| 45 | func (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. |
| 53 | func (m *Manager) OpenCleanupChild(host, child string) (*Cleanup, error) { |
| 54 | return m.openCleanupAt(host, child, cleanupFilesystem) |
| 55 | } |
| 56 | |
| 57 | func (m *Manager) openCleanup(host string, checkFS func(*os.File) error) (*Cleanup, error) { |
| 58 | return m.openCleanupAt(host, "", checkFS) |
| 59 | } |
| 60 | |
| 61 | func (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 | |
| 135 | func (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). |
| 153 | func (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 | |
| 172 | func (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. |
| 184 | const ( |
| 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. |
| 198 | func (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. |
| 229 | func 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. |
| 248 | const 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. |
| 252 | func 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. |
| 292 | func 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. |
| 325 | var 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. |
| 347 | func (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 | |
| 420 | func (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 | |