Merge pull request #91 from carlosframework/worktree-metrics-instances-fixes
console + agent: instances page gets real readings for exec-backed apps, stops flapping, stops paying the sweep
25 files changed,
+3678
−553
docs/superpowers/plans/2026-08-08-exec-child-accounting.md+1530 −0docs/superpowers/specs/2026-08-02-host-process-monitoring-design.md+14 −3docs/superpowers/specs/2026-08-07-exec-child-accounting-design.md+170 −0docs/superpowers/specs/2026-08-08-exec-instance-metrics-design.md+11 −0internal/activator/activator.go+0 −29internal/activator/activator_test.go+0 −73internal/activator/backend_exec.go+83 −54internal/activator/backend_unit.go+8 −0internal/cgroup/cgroup.go+274 −0internal/cgroup/cgroup_linux.go+38 −0internal/cgroup/cgroup_linux_test.go+185 −0internal/cgroup/cgroup_other.go+14 −0internal/cgroup/cgroup_test.go+269 −0internal/console/metricsview.go+252 −56internal/console/metricsview_test.go+698 −0internal/console/server.go+5 −3internal/host/agent.go+10 −1internal/host/host_test.go+13 −0internal/host/metrics_loop.go+12 −22internal/host/metrics_probes.go+41 −76internal/host/metrics_probes_test.go+35 −49internal/host/units/carlos-edge.service+12 −1internal/metrics/pidparse.go+0 −101internal/metrics/pidparse_test.go+0 −83internal/metrics/sample.go+4 −2
diff --git a/docs/superpowers/plans/2026-08-08-exec-child-accounting.md b/docs/superpowers/plans/2026-08-08-exec-child-accounting.md| new file mode 100644 |
| index 000000000..52aec680a |
| --- /dev/null |
| +++ b/docs/superpowers/plans/2026-08-08-exec-child-accounting.md |
| @@ -0,0 +1,1530 @@ |
| +# Exec-Child Accounting Implementation Plan |
| + |
| +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. |
| + |
| +**Goal:** Per-instance resource readings for exec-backed routes — each exec |
| +child gets its own cgroup under a subtree systemd delegates to the agent, the |
| +sampler reads it, the owner Instances page shows it. |
| + |
| +**Architecture:** A new `internal/cgroup` package owns the delegated subtree |
| +(delegation dance at agent start, per-host `carlos-exec@<host>` cgroups, |
| +clone3-into-cgroup spawn with a latched write-PID fallback). The activator |
| +spawns instance/sidecar/seed through it; the host sampler gains the subtree |
| +as a third scan root; the console allowlist gains a second key per host. |
| +Everything degrades to today's behavior when delegation is absent (nil |
| +manager) — fail toward absent readings, never toward a broken spawn path. |
| + |
| +**Tech Stack:** Go 1.26 (stdlib only — `syscall.SysProcAttr{UseCgroupFD}` |
| +for clone3 `CLONE_INTO_CGROUP`; no new dependencies). Module |
| +`github.com/carlosframework/platform`. |
| + |
| +**Spec:** `docs/superpowers/specs/2026-08-07-exec-child-accounting-design.md` |
| +— read it before starting a task. |
| + |
| +## Global Constraints |
| + |
| +- Build/test env (sandboxed shells): `GOCACHE=/tmp/claude-1001/gocache |
| + GOFLAGS=-mod=mod GOPROXY=off`. Tests in `internal/activator`, |
| + `internal/host`, `internal/edge` need the sandbox disabled (unix-socket |
| + permission errors are the tell — pre-existing, not yours). |
| +- Before every commit: `go build ./...`, `go vet ./...`, and the touched |
| + package's tests green; `gofmt -l` clean for files you touch. |
| +- Wire shape is additive only: `Sample.Units` gains new keys |
| + (`carlos-exec@<host>` — no `.service` suffix, deliberately); nothing |
| + already in a production bucket may stop parsing. |
| +- Probe convention (`internal/host/probes.go`): fail toward nil, never a |
| + guessed zero. Thin OS glue is untested by convention; directory WALKS and |
| + pure parsers are tested against fixtures. Tests never touch `/proc`, |
| + live cgroups, or a live syscall. |
| +- The agent is NOT root (`User=carlos`). Nothing here may assume privilege; |
| + cgroup writability comes from systemd delegation (`Delegate=`) only. |
| +- Every cgroup-manager failure degrades to a logged line + today's |
| + behavior. A spawn must never fail because accounting failed. |
| +- Naming discipline: the string `carlos-exec@` is defined ONCE, in |
| + `internal/cgroup` (`ExecName`/`IsExecName`); everyone else calls those. |
| +- Commit messages: repo style is `component: what changed` (lowercase), body |
| + optional, ending with `Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>`. |
| + |
| +--- |
| + |
| +### Task 1: `internal/cgroup` — names and the /proc/self/cgroup parser |
| + |
| +**Files:** |
| +- Create: `internal/cgroup/cgroup.go` |
| +- Create: `internal/cgroup/cgroup_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: nothing (new leaf package; stdlib only). |
| +- Produces: `ExecName(host string) string` (returns `"carlos-exec@" + host`), |
| + `IsExecName(name string) bool`, `ParseSelfCgroup(data string) (string, bool)` |
| + (relative v2 cgroup path, no leading slash). Tasks 2–5 and 7 use these. |
| + |
| +- [ ] **Step 1: Write the failing tests** |
| + |
| +Create `internal/cgroup/cgroup_test.go`: |
| + |
| +```go |
| +package cgroup |
| + |
| +import "testing" |
| + |
| +func TestExecNameAndIsExecName(t *testing.T) { |
| + if got := ExecName("keymail.dev"); got != "carlos-exec@keymail.dev" { |
| + t.Fatalf("ExecName = %q, want %q", got, "carlos-exec@keymail.dev") |
| + } |
| + for name, want := range map[string]bool{ |
| + "carlos-exec@keymail.dev": true, |
| + "carlos-exec@": false, // no host — not one of ours |
| + "supervise": false, // the agent's own leaf |
| + "carlos-app@x.service": false, |
| + "cgroup.procs": false, |
| + } { |
| + if got := IsExecName(name); got != want { |
| + t.Errorf("IsExecName(%q) = %v, want %v", name, got, want) |
| + } |
| + } |
| +} |
| + |
| +// TestParseSelfCgroup pins the /proc/self/cgroup v2 line format: the "0::" |
| +// line carries the unified-hierarchy path. v1-only content (no 0:: line) |
| +// and the root cgroup ("0::/") both report not-ok — neither is a subtree |
| +// systemd could have delegated to us. |
| +func TestParseSelfCgroup(t *testing.T) { |
| + cases := []struct { |
| + name, data, want string |
| + ok bool |
| + }{ |
| + {"systemd service", "0::/system.slice/carlos-edge.service\n", "system.slice/carlos-edge.service", true}, |
| + {"hybrid box, v2 line among v1", "12:pids:/system.slice\n0::/system.slice/carlos-edge.service\n", "system.slice/carlos-edge.service", true}, |
| + {"v1 only", "12:pids:/system.slice\n11:cpu:/system.slice\n", "", false}, |
| + {"root cgroup", "0::/\n", "", false}, |
| + {"empty", "", "", false}, |
| + } |
| + for _, c := range cases { |
| + got, ok := ParseSelfCgroup(c.data) |
| + if got != c.want || ok != c.ok { |
| + t.Errorf("%s: ParseSelfCgroup = (%q, %v), want (%q, %v)", c.name, got, ok, c.want, c.ok) |
| + } |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run tests to verify they fail** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/cgroup/ -count=1` |
| +Expected: FAIL — package does not exist / `ExecName` undefined. |
| + |
| +- [ ] **Step 3: Write the implementation** |
| + |
| +Create `internal/cgroup/cgroup.go`: |
| + |
| +```go |
| +// Package cgroup gives each exec-backed child its own cgroup under the |
| +// subtree systemd delegates to carlos-edge.service (Delegate= in the unit |
| +// file), so the metrics sampler can report per-instance figures for routes |
| +// that run as exec children of the agent — today those live inside the |
| +// agent's own cgroup and can never show readings. Design: |
| +// docs/superpowers/specs/2026-08-07-exec-child-accounting-design.md. |
| +// |
| +// The agent is NOT root: everything here works through ownership of the |
| +// delegated subtree, never privilege. When delegation is absent (old unit |
| +// file, a test, darwin) Setup returns a nil *Manager and every method on it |
| +// degrades to today's behavior — fail toward absent readings, never toward |
| +// a broken spawn path. |
| +package cgroup |
| + |
| +import "strings" |
| + |
| +// execPrefix is the ONE definition of the per-host exec cgroup naming — |
| +// activator.ExecName and the sampler's directory match both come through |
| +// this package. |
| +const execPrefix = "carlos-exec@" |
| + |
| +// ExecName is the cgroup directory name — and therefore the metrics sample |
| +// key — for an exec-backed host's children. Deliberately NOT dressed up as |
| +// a fake ".service": an operator who takes a dashboard row to `systemctl |
| +// status` must not be told a unit exists that doesn't. |
| +func ExecName(host string) string { return execPrefix + host } |
| + |
| +// IsExecName reports whether a cgroup directory name is one of our per-host |
| +// exec cgroups: the carlos-exec@ prefix with a non-empty host after it. |
| +func IsExecName(name string) bool { |
| + return strings.HasPrefix(name, execPrefix) && len(name) > len(execPrefix) |
| +} |
| + |
| +// ParseSelfCgroup extracts the cgroup v2 path from /proc/self/cgroup |
| +// content — the "0::" unified-hierarchy line — relative (no leading slash), |
| +// ready to join under /sys/fs/cgroup. Not-ok when there is no v2 line (a |
| +// v1-only box) or the process sits in the root cgroup: neither is a subtree |
| +// systemd could have delegated to us. |
| +func ParseSelfCgroup(data string) (string, bool) { |
| + for _, line := range strings.Split(data, "\n") { |
| + rest, found := strings.CutPrefix(line, "0::") |
| + if !found { |
| + continue |
| + } |
| + p := strings.TrimPrefix(rest, "/") |
| + if p == "" { |
| + return "", false |
| + } |
| + return p, true |
| + } |
| + return "", false |
| +} |
| +``` |
| + |
| +- [ ] **Step 4: Run tests to verify they pass** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/cgroup/ -count=1` |
| +Expected: PASS |
| + |
| +- [ ] **Step 5: Commit** |
| + |
| +```bash |
| +git add internal/cgroup/cgroup.go internal/cgroup/cgroup_test.go |
| +git commit -m "cgroup: the exec-child cgroup names and the /proc/self/cgroup parser |
| + |
| +Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>" |
| +``` |
| + |
| +--- |
| + |
| +### Task 2: `internal/cgroup` — the delegation dance |
| + |
| +**Files:** |
| +- Modify: `internal/cgroup/cgroup.go` (append) |
| +- Modify: `internal/cgroup/cgroup_test.go` (append) |
| + |
| +**Interfaces:** |
| +- Consumes: `IsExecName` from Task 1. |
| +- Produces: `type Manager struct` (unexported fields `root string`, |
| + `logf func(string, ...any)`, `fallback atomic.Bool`), |
| + `SetupAt(root string, logf func(string, ...any)) (*Manager, error)` |
| + (the testable core), `Setup(logf func(string, ...any)) *Manager` |
| + (production entry, nil on any failure), and nil-safe |
| + `func (m *Manager) Root() string` ("" for nil). Tasks 3, 4, 6 use these. |
| + |
| +- [ ] **Step 1: Write the failing tests** |
| + |
| +Append to `internal/cgroup/cgroup_test.go` (add imports `os`, |
| +`path/filepath`, `strconv` to the existing import block): |
| + |
| +```go |
| +// newDelegatedRoot builds the temp-dir shape of a delegated cgroup: the two |
| +// control files systemd chowns to the unit's user when Delegate= is set. |
| +// Everything the dance does is plain file I/O, so a temp dir is a faithful |
| +// fixture — the same seam listCarlosUnits uses in internal/host. |
| +func newDelegatedRoot(t *testing.T) string { |
| + t.Helper() |
| + root := t.TempDir() |
| + for _, f := range []string{"cgroup.procs", "cgroup.subtree_control"} { |
| + if err := os.WriteFile(filepath.Join(root, f), nil, 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + return root |
| +} |
| + |
| +// TestSetupAtRefusesUndelegatedRoot: without Delegate= the control files are |
| +// root-owned (here: absent) — SetupAt must error so Setup degrades to the |
| +// nil manager instead of half-arming. |
| +func TestSetupAtRefusesUndelegatedRoot(t *testing.T) { |
| + if _, err := SetupAt(t.TempDir(), t.Logf); err == nil { |
| + t.Fatal("SetupAt on an undelegated root succeeded, want error") |
| + } |
| +} |
| + |
| +// TestSetupAtDance pins the dance's observable results: the agent's own PID |
| +// moved into the supervise/ leaf (cgroup v2 forbids enabling controllers on |
| +// a cgroup with member processes), then the four controllers enabled in |
| +// subtree_control, and Root reporting the subtree. |
| +func TestSetupAtDance(t *testing.T) { |
| + root := newDelegatedRoot(t) |
| + m, err := SetupAt(root, t.Logf) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if m.Root() != root { |
| + t.Errorf("Root = %q, want %q", m.Root(), root) |
| + } |
| + pid, err := os.ReadFile(filepath.Join(root, "supervise", "cgroup.procs")) |
| + if err != nil { |
| + t.Fatalf("supervise leaf not populated: %v", err) |
| + } |
| + if string(pid) != strconv.Itoa(os.Getpid()) { |
| + t.Errorf("supervise/cgroup.procs = %q, want our own pid %d", pid, os.Getpid()) |
| + } |
| + ctrl, err := os.ReadFile(filepath.Join(root, "cgroup.subtree_control")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if string(ctrl) != "+cpu +memory +io +pids" { |
| + t.Errorf("subtree_control = %q, want %q", ctrl, "+cpu +memory +io +pids") |
| + } |
| +} |
| + |
| +// TestSetupAtSweepsStaleExecDirs: a previous agent's per-host dirs are empty |
| +// after its children died with the service; the dance removes them. A |
| +// non-empty one (something still alive in it — shouldn't happen, but rmdir |
| +// would fail anyway) and a foreign name are left alone. |
| +func TestSetupAtSweepsStaleExecDirs(t *testing.T) { |
| + root := newDelegatedRoot(t) |
| + stale := filepath.Join(root, "carlos-exec@old.example.com") |
| + if err := os.Mkdir(stale, 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + occupied := filepath.Join(root, "carlos-exec@busy.example.com") |
| + if err := os.MkdirAll(filepath.Join(occupied, "child"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + foreign := filepath.Join(root, "not-ours") |
| + if err := os.Mkdir(foreign, 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + if _, err := SetupAt(root, t.Logf); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := os.Stat(stale); !os.IsNotExist(err) { |
| + t.Errorf("stale exec dir survived the sweep: %v", err) |
| + } |
| + if _, err := os.Stat(occupied); err != nil { |
| + t.Errorf("non-empty exec dir must be tolerated, not force-removed: %v", err) |
| + } |
| + if _, err := os.Stat(foreign); err != nil { |
| + t.Errorf("foreign dir must be untouched: %v", err) |
| + } |
| +} |
| + |
| +func TestNilManagerRoot(t *testing.T) { |
| + var m *Manager |
| + if got := m.Root(); got != "" { |
| + t.Fatalf("nil Root = %q, want \"\"", got) |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run tests to verify they fail** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/cgroup/ -count=1` |
| +Expected: FAIL — `SetupAt`, `Manager` undefined. |
| + |
| +- [ ] **Step 3: Write the implementation** |
| + |
| +Append to `internal/cgroup/cgroup.go` (extend the import block to |
| +`fmt`, `os`, `path/filepath`, `strconv`, `strings`, `sync/atomic`): |
| + |
| +```go |
| +// Manager owns the delegated subtree. A nil *Manager is the documented |
| +// "no delegation" state: every method is nil-safe and degrades to today's |
| +// behavior. |
| +type Manager struct { |
| + root string |
| + logf func(string, ...any) |
| + // fallback, once latched, means clone3-into-cgroup is refused here (a |
| + // seccomp filter older than the syscall, an old kernel) and children are |
| + // started normally then moved by PID. A filter is static for the life of |
| + // the process, so retrying the syscall on every spawn buys nothing. |
| + fallback atomic.Bool |
| +} |
| + |
| +// Root is the delegated subtree's path — the sampler's third scan root. |
| +// "" for a nil manager (no delegation): the sampler skips an empty root. |
| +func (m *Manager) Root() string { |
| + if m == nil { |
| + return "" |
| + } |
| + return m.root |
| +} |
| + |
| +// Setup arms exec-child accounting for the running agent: resolve our own |
| +// cgroup from /proc/self/cgroup and run the delegation dance there. Any |
| +// failure — v1-only box, old unit file without Delegate=, a test, darwin — |
| +// returns nil with one logged line, and every spawn behaves exactly as |
| +// today. Fail toward absent readings, never toward a broken spawn path. |
| +func Setup(logf func(string, ...any)) *Manager { |
| + b, err := os.ReadFile("/proc/self/cgroup") |
| + if err != nil { |
| + logf("agent: exec-child accounting disabled: %v", err) |
| + return nil |
| + } |
| + rel, ok := ParseSelfCgroup(string(b)) |
| + if !ok { |
| + logf("agent: exec-child accounting disabled: no cgroup v2 path in /proc/self/cgroup") |
| + return nil |
| + } |
| + m, err := SetupAt(filepath.Join("/sys/fs/cgroup", rel), logf) |
| + if err != nil { |
| + logf("agent: exec-child accounting disabled (no delegated subtree — unit file without Delegate=?): %v", err) |
| + return nil |
| + } |
| + logf("agent: exec-child accounting armed (delegated subtree %s)", m.Root()) |
| + return m |
| +} |
| + |
| +// SetupAt is the delegation dance against an explicit subtree root — the |
| +// testable core Setup wraps, and the constructor activator tests use over a |
| +// temp-dir fixture. Order matters: probe, move self to a leaf, THEN enable |
| +// controllers — cgroup v2 refuses subtree_control writes while the cgroup |
| +// itself still has member processes ("no internal processes"). |
| +func SetupAt(root string, logf func(string, ...any)) (*Manager, error) { |
| + // 1. Probe live delegation: both control files must be writable by us. |
| + // systemd chowns exactly these to User= when Delegate= is set; without |
| + // it this open is the cheap, side-effect-free "no" that keeps an |
| + // undelegated box on today's path. |
| + for _, name := range []string{"cgroup.procs", "cgroup.subtree_control"} { |
| + f, err := os.OpenFile(filepath.Join(root, name), os.O_WRONLY, 0) |
| + if err != nil { |
| + return nil, fmt.Errorf("probe delegation: %w", err) |
| + } |
| + f.Close() |
| + } |
| + // 2. Move ourselves into a supervise/ leaf, making step 3 legal. |
| + sup := filepath.Join(root, "supervise") |
| + if err := os.MkdirAll(sup, 0o755); err != nil { |
| + return nil, fmt.Errorf("create supervise leaf: %w", err) |
| + } |
| + if err := os.WriteFile(filepath.Join(sup, "cgroup.procs"), []byte(strconv.Itoa(os.Getpid())), 0o644); err != nil { |
| + return nil, fmt.Errorf("move self into supervise leaf: %w", err) |
| + } |
| + // 3. Enable the controllers per-child cgroups need for memory.current, |
| + // io.stat and pids accounting (cpu.stat exists regardless, but +cpu |
| + // keeps utilization attributable). One write, all four: the kernel |
| + // applies subtree_control writes atomically. |
| + if err := os.WriteFile(filepath.Join(root, "cgroup.subtree_control"), []byte("+cpu +memory +io +pids"), 0o644); err != nil { |
| + return nil, fmt.Errorf("enable controllers: %w", err) |
| + } |
| + m := &Manager{root: root, logf: logf} |
| + // 4. Sweep per-host dirs a previous agent left behind — empty after a |
| + // restart killed their members. rmdir refuses a non-empty dir, which is |
| + // exactly the tolerance we want. |
| + m.sweepStale() |
| + return m, nil |
| +} |
| + |
| +// sweepStale removes leftover carlos-exec@* dirs under the root. Failures |
| +// are logged and tolerated: a dir that won't rmdir has something alive in |
| +// it, and killing is the service manager's job, not ours. |
| +func (m *Manager) sweepStale() { |
| + entries, err := os.ReadDir(m.root) |
| + if err != nil { |
| + return |
| + } |
| + for _, e := range entries { |
| + if !e.IsDir() || !IsExecName(e.Name()) { |
| + continue |
| + } |
| + if err := os.Remove(filepath.Join(m.root, e.Name())); err != nil { |
| + m.logf("cgroup: stale %s not removed: %v", e.Name(), err) |
| + } |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 4: Run tests to verify they pass** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/cgroup/ -count=1` |
| +Expected: PASS |
| + |
| +- [ ] **Step 5: Commit** |
| + |
| +```bash |
| +git add internal/cgroup/cgroup.go internal/cgroup/cgroup_test.go |
| +git commit -m "cgroup: the delegation dance — probe, supervise leaf, controllers, sweep |
| + |
| +Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>" |
| +``` |
| + |
| +--- |
| + |
| +### Task 3: `internal/cgroup` — spawning into per-host cgroups |
| + |
| +**Files:** |
| +- Modify: `internal/cgroup/cgroup.go` (append) |
| +- Create: `internal/cgroup/cgroup_linux.go` |
| +- Create: `internal/cgroup/cgroup_other.go` |
| +- Create: `internal/cgroup/cgroup_linux_test.go` |
| +- Modify: `internal/cgroup/cgroup_test.go` (append) |
| + |
| +**Interfaces:** |
| +- Consumes: `Manager`, `ExecName` from Tasks 1–2. |
| +- Produces: |
| + `func (m *Manager) Start(host string, build func() *exec.Cmd) (*exec.Cmd, error)` |
| + — nil-safe (nil manager = plain `build().Start()`); `build` must return a |
| + fully configured, UNSTARTED Cmd and may be called twice (a Cmd is not |
| + reusable after a failed Start). On error the returned Cmd may be nil — |
| + callers must not touch it. |
| + `func (m *Manager) CleanupHost(host string)` — nil-safe, rmdir-tolerant. |
| + Task 4 uses both. |
| + |
| +- [ ] **Step 1: Write the failing tests** |
| + |
| +Append to `internal/cgroup/cgroup_test.go` (add `os/exec` and `errors` to |
| +imports): |
| + |
| +```go |
| +// fakeStart swaps the package's spawn seam for a recorder, restoring it on |
| +// cleanup. The fake never spawns anything: "success" fabricates cmd.Process |
| +// from our own PID so the PID-move path has a real number to write. |
| +func fakeStart(t *testing.T, fail func(cmd *exec.Cmd) error) *[]*exec.Cmd { |
| + t.Helper() |
| + var calls []*exec.Cmd |
| + orig := startCmd |
| + startCmd = func(cmd *exec.Cmd) error { |
| + calls = append(calls, cmd) |
| + if err := fail(cmd); err != nil { |
| + return err |
| + } |
| + cmd.Process, _ = os.FindProcess(os.Getpid()) |
| + return nil |
| + } |
| + t.Cleanup(func() { startCmd = orig }) |
| + return &calls |
| +} |
| + |
| +func buildTrue() *exec.Cmd { return exec.Command("/bin/true") } |
| + |
| +// TestStartNilManagerSpawnsPlainly: the nil manager is the no-delegation |
| +// state — one plain start, no SysProcAttr, no filesystem writes. |
| +func TestStartNilManagerSpawnsPlainly(t *testing.T) { |
| + calls := fakeStart(t, func(*exec.Cmd) error { return nil }) |
| + var m *Manager |
| + cmd, err := m.Start("h.example.com", buildTrue) |
| + if err != nil || cmd == nil { |
| + t.Fatalf("Start = (%v, %v), want started cmd", cmd, err) |
| + } |
| + if len(*calls) != 1 || (*calls)[0].SysProcAttr != nil { |
| + t.Fatalf("nil manager must start exactly once with no SysProcAttr: %d calls", len(*calls)) |
| + } |
| +} |
| + |
| +// TestStartGenuineSpawnFailureDoesNotLatch: when the retry-without-cgroup |
| +// ALSO fails, the cgroup was not the problem — the error propagates and the |
| +// fallback must not latch (the next spawn should still try clone3). |
| +func TestStartGenuineSpawnFailureDoesNotLatch(t *testing.T) { |
| + root := newDelegatedRoot(t) |
| + m, err := SetupAt(root, t.Logf) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + fakeStart(t, func(*exec.Cmd) error { return errors.New("no such binary") }) |
| + if _, err := m.Start("h.example.com", buildTrue); err == nil { |
| + t.Fatal("Start with a failing spawn succeeded, want error") |
| + } |
| + if m.fallback.Load() { |
| + t.Fatal("a genuine spawn failure latched the clone3 fallback") |
| + } |
| +} |
| + |
| +// TestCleanupHost: removes an empty per-host dir, tolerates a non-empty |
| +// one, and is nil-safe. |
| +func TestCleanupHost(t *testing.T) { |
| + root := newDelegatedRoot(t) |
| + m, err := SetupAt(root, t.Logf) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + empty := filepath.Join(root, ExecName("gone.example.com")) |
| + if err := os.Mkdir(empty, 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + m.CleanupHost("gone.example.com") |
| + if _, err := os.Stat(empty); !os.IsNotExist(err) { |
| + t.Errorf("empty host dir not removed: %v", err) |
| + } |
| + |
| + busy := filepath.Join(root, ExecName("busy.example.com")) |
| + if err := os.MkdirAll(filepath.Join(busy, "child"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + m.CleanupHost("busy.example.com") // must not panic or force-remove |
| + if _, err := os.Stat(busy); err != nil { |
| + t.Errorf("non-empty host dir must survive: %v", err) |
| + } |
| + |
| + var nilM *Manager |
| + nilM.CleanupHost("h.example.com") // must not panic |
| +} |
| +``` |
| + |
| +Create `internal/cgroup/cgroup_linux_test.go` — the clone3 paths only |
| +exist where `sysProcAttrInto` builds a real attr: |
| + |
| +```go |
| +//go:build linux |
| + |
| +package cgroup |
| + |
| +import ( |
| + "os" |
| + "os/exec" |
| + "path/filepath" |
| + "syscall" |
| + "testing" |
| +) |
| + |
| +// TestStartUsesCgroupFDFirst: with delegation live and no latch, the child |
| +// starts via clone3-into-cgroup — SysProcAttr set, host dir created, and NO |
| +// cgroup.procs write (the kernel placed it, no PID move needed). |
| +func TestStartUsesCgroupFDFirst(t *testing.T) { |
| + root := newDelegatedRoot(t) |
| + m, err := SetupAt(root, t.Logf) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + calls := fakeStart(t, func(*exec.Cmd) error { return nil }) |
| + if _, err := m.Start("h.example.com", buildTrue); err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(*calls) != 1 { |
| + t.Fatalf("started %d times, want 1", len(*calls)) |
| + } |
| + attr := (*calls)[0].SysProcAttr |
| + if attr == nil || !attr.UseCgroupFD { |
| + t.Fatalf("SysProcAttr = %+v, want UseCgroupFD", attr) |
| + } |
| + dir := filepath.Join(root, ExecName("h.example.com")) |
| + if _, err := os.Stat(dir); err != nil { |
| + t.Fatalf("host cgroup dir not created: %v", err) |
| + } |
| + if _, err := os.Stat(filepath.Join(dir, "cgroup.procs")); !os.IsNotExist(err) { |
| + t.Errorf("clone3 path must not write cgroup.procs (the kernel places the child): %v", err) |
| + } |
| +} |
| + |
| +// TestStartFallsBackAndLatches pins the refusal path: clone3 refused |
| +// (seccomp EPERM), the retry WITHOUT the cgroup fd succeeds — proving the |
| +// cgroup was the problem — the child's PID is moved by file write, and the |
| +// latch makes every later spawn skip clone3 entirely. |
| +func TestStartFallsBackAndLatches(t *testing.T) { |
| + root := newDelegatedRoot(t) |
| + m, err := SetupAt(root, t.Logf) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + calls := fakeStart(t, func(cmd *exec.Cmd) error { |
| + if cmd.SysProcAttr != nil { |
| + return syscall.EPERM // the filter refusing clone3 |
| + } |
| + return nil |
| + }) |
| + if _, err := m.Start("h.example.com", buildTrue); err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(*calls) != 2 { |
| + t.Fatalf("first spawn: %d start calls, want 2 (clone3 attempt + plain retry)", len(*calls)) |
| + } |
| + procs := filepath.Join(root, ExecName("h.example.com"), "cgroup.procs") |
| + if _, err := os.Stat(procs); err != nil { |
| + t.Fatalf("fallback did not move the PID: %v", err) |
| + } |
| + if !m.fallback.Load() { |
| + t.Fatal("refusal did not latch the fallback") |
| + } |
| + |
| + if _, err := m.Start("h2.example.com", buildTrue); err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(*calls) != 3 { |
| + t.Fatalf("latched spawn: %d total start calls, want 3 (no clone3 re-attempt)", len(*calls)) |
| + } |
| + if (*calls)[2].SysProcAttr != nil { |
| + t.Fatal("latched spawn still set SysProcAttr") |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run tests to verify they fail** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/cgroup/ -count=1` |
| +Expected: FAIL — `startCmd`, `Start`, `CleanupHost` undefined. |
| + |
| +- [ ] **Step 3: Write the implementation** |
| + |
| +Create `internal/cgroup/cgroup_linux.go`: |
| + |
| +```go |
| +//go:build linux |
| + |
| +package cgroup |
| + |
| +import "syscall" |
| + |
| +// intoCgroupSupported gates the clone3 CLONE_INTO_CGROUP attempt to |
| +// platforms whose SysProcAttr has the fields — everywhere else Start goes |
| +// straight to the PID-move fallback. |
| +const intoCgroupSupported = true |
| + |
| +func sysProcAttrInto(fd int) *syscall.SysProcAttr { |
| + return &syscall.SysProcAttr{UseCgroupFD: true, CgroupFD: fd} |
| +} |
| +``` |
| + |
| +Create `internal/cgroup/cgroup_other.go`: |
| + |
| +```go |
| +//go:build !linux |
| + |
| +package cgroup |
| + |
| +import "syscall" |
| + |
| +const intoCgroupSupported = false |
| + |
| +func sysProcAttrInto(fd int) *syscall.SysProcAttr { return nil } |
| +``` |
| + |
| +Append to `internal/cgroup/cgroup.go` (add `os/exec` to imports): |
| + |
| +```go |
| +// startCmd is Start's spawn seam — a package var so the clone3 refusal |
| +// path is testable without a seccomp filter in the loop. |
| +var startCmd = func(cmd *exec.Cmd) error { return cmd.Start() } |
| + |
| +// hostDir is the per-host cgroup: instance, sidecar and seed all share it — |
| +// a seed's first-boot cost and a sidecar's load ARE that instance's work. |
| +func (m *Manager) hostDir(host string) string { return filepath.Join(m.root, ExecName(host)) } |
| + |
| +// Start runs build() and starts the result inside host's cgroup. Nil-safe: |
| +// with no manager (or any accounting failure) the child starts exactly as |
| +// today — a spawn must never fail because accounting failed. build must |
| +// return a fully configured, unstarted Cmd; it may be called twice, because |
| +// a Cmd cannot be reused after a failed Start. On a non-nil error the |
| +// returned Cmd may be nil. |
| +// |
| +// Primary mechanism: clone3 with CLONE_INTO_CGROUP (SysProcAttr.UseCgroupFD) |
| +// — the child never spends a cycle in the wrong cgroup. If that start |
| +// fails, the cgroup fd may or may not be why (a seccomp filter older than |
| +// clone3 answers EPERM; so does a missing binary): retry WITHOUT the fd, |
| +// and only if the retry succeeds — proving the cgroup was the problem — |
| +// latch the fallback and move the child by PID. The window between start |
| +// and move charges the child's first moments to the agent: mis-accounting |
| +// in the safe direction. |
| +func (m *Manager) Start(host string, build func() *exec.Cmd) (*exec.Cmd, error) { |
| + if m == nil { |
| + cmd := build() |
| + return cmd, startCmd(cmd) |
| + } |
| + dir := m.hostDir(host) |
| + if err := os.MkdirAll(dir, 0o755); err != nil { |
| + m.logf("cgroup: %s: create cgroup: %v — spawning unaccounted", host, err) |
| + cmd := build() |
| + return cmd, startCmd(cmd) |
| + } |
| + if intoCgroupSupported && !m.fallback.Load() { |
| + f, err := os.Open(dir) |
| + if err != nil { |
| + m.logf("cgroup: %s: open cgroup dir: %v — using PID-move fallback for this spawn", host, err) |
| + return m.startAndMove(host, build) |
| + } |
| + cmd := build() |
| + cmd.SysProcAttr = sysProcAttrInto(int(f.Fd())) |
| + err = startCmd(cmd) |
| + f.Close() |
| + if err == nil { |
| + return cmd, nil |
| + } |
| + firstErr := err |
| + retried, retryErr := m.startAndMove(host, build) |
| + if retryErr != nil { |
| + return nil, retryErr // failed both ways: not the cgroup's fault |
| + } |
| + m.fallback.Store(true) |
| + m.logf("cgroup: clone3-into-cgroup refused (%v) — latching PID-move fallback for the life of this process", firstErr) |
| + return retried, nil |
| + } |
| + return m.startAndMove(host, build) |
| +} |
| + |
| +// startAndMove is the fallback spawn: plain start, then write the child's |
| +// PID into the host cgroup. A failed move is logged and tolerated — the |
| +// child runs (unaccounted), which beats not running. |
| +func (m *Manager) startAndMove(host string, build func() *exec.Cmd) (*exec.Cmd, error) { |
| + cmd := build() |
| + if err := startCmd(cmd); err != nil { |
| + return nil, err |
| + } |
| + procs := filepath.Join(m.hostDir(host), "cgroup.procs") |
| + if err := os.WriteFile(procs, []byte(strconv.Itoa(cmd.Process.Pid)), 0o644); err != nil { |
| + m.logf("cgroup: %s: move pid %d: %v — child stays unaccounted", host, cmd.Process.Pid, err) |
| + } |
| + return cmd, nil |
| +} |
| + |
| +// CleanupHost removes host's per-host cgroup dir after its children exit. |
| +// Nil-safe and rmdir-tolerant: while a sibling (instance vs sidecar) is |
| +// still alive the dir is non-empty and the remove fails — the LAST exiter's |
| +// call succeeds, and the next agent start's sweep catches anything missed. |
| +func (m *Manager) CleanupHost(host string) { |
| + if m == nil { |
| + return |
| + } |
| + _ = os.Remove(m.hostDir(host)) |
| +} |
| +``` |
| + |
| +- [ ] **Step 4: Run tests to verify they pass** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/cgroup/ -count=1` |
| +Expected: PASS |
| + |
| +- [ ] **Step 5: gofmt + vet, then commit** |
| + |
| +Run: `gofmt -l internal/cgroup/ && GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go vet ./internal/cgroup/` |
| + |
| +```bash |
| +git add internal/cgroup/ |
| +git commit -m "cgroup: spawn into per-host cgroups — clone3 first, latched PID-move fallback |
| + |
| +Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>" |
| +``` |
| + |
| +--- |
| + |
| +### Task 4: activator — spawn every exec child through the manager |
| + |
| +**Files:** |
| +- Modify: `internal/activator/backend_unit.go` (add `ExecName` next to `UnitName`, line ~66) |
| +- Modify: `internal/activator/backend_exec.go` (ExecBackend field + Start/StartSidecar/Seed paths) |
| + |
| +**Interfaces:** |
| +- Consumes: `cgroup.Manager.Start(host, build)`, `cgroup.Manager.CleanupHost(host)`, |
| + `cgroup.ExecName(host)` from Tasks 1–3. |
| +- Produces: `activator.ExecName(host string) string` (Task 7 uses it); |
| + `ExecBackend.Cgroups *cgroup.Manager` field (Task 6 wires it). |
| + |
| +- [ ] **Step 1: Add `ExecName` next to `UnitName`** |
| + |
| +In `internal/activator/backend_unit.go`, directly after the existing |
| +`UnitName` (line ~66), add (and import |
| +`github.com/carlosframework/platform/internal/cgroup`): |
| + |
| +```go |
| +// ExecName is the metrics sample key for an exec-backed tenant's children — |
| +// the delegated cgroup directory the activator spawns them into. Defined by |
| +// internal/cgroup (which creates the directories); surfaced here next to |
| +// UnitName so the console learns both of a host's legal metric keys from |
| +// the same place. |
| +func ExecName(host string) string { return cgroup.ExecName(host) } |
| +``` |
| + |
| +- [ ] **Step 2: Add the manager field to ExecBackend** |
| + |
| +In `internal/activator/backend_exec.go`, add to the `ExecBackend` struct |
| +(after the `Cache` field, line ~58), and add the `cgroup` import: |
| + |
| +```go |
| + // Cgroups, when non-nil, places every spawned child — instance, sidecar, |
| + // seed — in a per-host cgroup under the agent's delegated subtree so the |
| + // metrics sampler can account it (exec-child accounting spec). Nil — no |
| + // delegation: old unit file, tests, darwin — spawns exactly as before. |
| + Cgroups *cgroup.Manager |
| +``` |
| + |
| +- [ ] **Step 3: Route the instance spawn through the manager** |
| + |
| +In `ExecBackend.Start` (line ~440), replace this block: |
| + |
| +```go |
| + cmd := exec.Command(bin, "--socket", rt.Socket, "--db", dbPath) |
| + cmd.Env = childEnv(childVars) |
| + // The child logs through the agent's own stderr/stdout (journald under |
| + // systemd); there's no per-instance unit to own a log stream anymore. |
| + cmd.Stdout = os.Stdout |
| + cmd.Stderr = os.Stderr |
| + // Record the build we're about to exec — read the symlink immediately before |
| + // Start() so it names the version the kernel will resolve. Reading it here, |
| + // rather than back in wake() after the health wait, closes a real window: a |
| + // concurrent ship's repoint during the up-to-30s wait would otherwise make |
| + // us record the NEW target while this child runs the OLD binary, and the |
| + // sweep would then think it's already converged and never cycle it. The |
| + // residual sub-exec race (a repoint between this read and Start) is in the |
| + // safe direction — we'd record the older version, so the sweep cycles once |
| + // too often rather than never. Empty on a read error: the sweep won't cycle. |
| + spawnVer, _ := b.LiveVersion(host) |
| + if err := cmd.Start(); err != nil { |
| + return nil, "", err |
| + } |
| +``` |
| + |
| +with: |
| + |
| +```go |
| + // Record the build we're about to exec — read the symlink immediately before |
| + // the start so it names the version the kernel will resolve. Reading it here, |
| + // rather than back in wake() after the health wait, closes a real window: a |
| + // concurrent ship's repoint during the up-to-30s wait would otherwise make |
| + // us record the NEW target while this child runs the OLD binary, and the |
| + // sweep would then think it's already converged and never cycle it. The |
| + // residual sub-exec race (a repoint between this read and the start) is in |
| + // the safe direction — we'd record the older version, so the sweep cycles |
| + // once too often rather than never. Empty on a read error: the sweep won't |
| + // cycle. |
| + spawnVer, _ := b.LiveVersion(host) |
| + // Spawned through the cgroup manager (nil-tolerant: no delegation means |
| + // a plain start, exactly as before) so the child lands in |
| + // carlos-exec@<host> and the sampler can account it. build may run |
| + // twice — the manager's clone3-refusal fallback — so everything about |
| + // the Cmd is configured inside it. |
| + cmd, err := b.Cgroups.Start(host, func() *exec.Cmd { |
| + cmd := exec.Command(bin, "--socket", rt.Socket, "--db", dbPath) |
| + cmd.Env = childEnv(childVars) |
| + // The child logs through the agent's own stderr/stdout (journald |
| + // under systemd); there's no per-instance unit to own a log stream |
| + // anymore. |
| + cmd.Stdout = os.Stdout |
| + cmd.Stderr = os.Stderr |
| + return cmd |
| + }) |
| + if err != nil { |
| + return nil, "", err |
| + } |
| +``` |
| + |
| +Then in the wait goroutine a few lines below, add the cleanup: |
| + |
| +```go |
| + go func() { |
| + p.err = cmd.Wait() |
| + // Last exiter wins: rmdir refuses while the sidecar (same host |
| + // cgroup) is still alive, and that failure is tolerated. |
| + b.Cgroups.CleanupHost(host) |
| + close(p.done) |
| + }() |
| +``` |
| + |
| +- [ ] **Step 4: Route the sidecar spawn through the manager** |
| + |
| +In `StartSidecar` (line ~602), replace: |
| + |
| +```go |
| + cmd := exec.Command(b.liveLink(rt.Host), "sidecar", "run") |
| + cmd.Env = childEnv(fileEnv) |
| + // Like the instance child: logs through the agent's own streams (journald |
| + // under systemd) — no per-host unit owns a log stream anymore. |
| + cmd.Stdout = os.Stdout |
| + cmd.Stderr = os.Stderr |
| + if err := cmd.Start(); err != nil { |
| + return nil, err |
| + } |
| + p := &childProc{cmd: cmd, done: make(chan struct{})} |
| + go func() { |
| + p.err = cmd.Wait() |
| + close(p.done) |
| + }() |
| +``` |
| + |
| +with: |
| + |
| +```go |
| + // Same host cgroup as the instance: a sidecar's load is that instance's |
| + // work, and attributing it elsewhere would be a lie in the other |
| + // direction. |
| + cmd, err := b.Cgroups.Start(rt.Host, func() *exec.Cmd { |
| + cmd := exec.Command(b.liveLink(rt.Host), "sidecar", "run") |
| + cmd.Env = childEnv(fileEnv) |
| + // Like the instance child: logs through the agent's own streams |
| + // (journald under systemd) — no per-host unit owns a log stream |
| + // anymore. |
| + cmd.Stdout = os.Stdout |
| + cmd.Stderr = os.Stderr |
| + return cmd |
| + }) |
| + if err != nil { |
| + return nil, err |
| + } |
| + p := &childProc{cmd: cmd, done: make(chan struct{})} |
| + go func() { |
| + p.err = cmd.Wait() |
| + b.Cgroups.CleanupHost(rt.Host) |
| + close(p.done) |
| + }() |
| +``` |
| + |
| +- [ ] **Step 5: Route the seed through the manager** |
| + |
| +In `Seed` (line ~213), replace the block from `ctx, cancel := |
| +context.WithTimeout(ctx, seedTimeout)` through `if err := cmd.Run(); err != |
| +nil {` — keeping the whole error-handling body — with: |
| + |
| +```go |
| + ctx, cancel := context.WithTimeout(ctx, seedTimeout) |
| + defer cancel() |
| + argv := append(seedArgs, "--db", dbPath) |
| + var stderr capBuffer |
| + log.Printf("activator: seeding %s: %s %s", host, b.liveLink(host), strings.Join(argv, " ")) |
| + // The seed shares the instance's carlos-exec@<host> cgroup: first-boot |
| + // cost is that instance's work. No CleanupHost here — the instance spawn |
| + // reuses the dir moments later, and its exit path (or the next agent |
| + // start's sweep) removes it. |
| + cmd, err := b.Cgroups.Start(host, func() *exec.Cmd { |
| + cmd := exec.CommandContext(ctx, b.liveLink(host), argv...) |
| + // The purge-channel vars are agent-owned in BOTH directions here too |
| + // (Start's discipline, see withoutVars): fileEnv is tenant-supplied, so |
| + // without this a tenant's own env file could hand its seed a |
| + // CARLOS_CONTROL_SOCKET the agent never opened and a CARLOS_PURGE for a |
| + // channel that does not exist — and the seed has no serving cache to |
| + // purge in any case. This is what makes the contract above true rather |
| + // than merely intended. |
| + cmd.Env = childEnv(withoutVars(fileEnv, "CARLOS_CONTROL_SOCKET", "CARLOS_PURGE")) |
| + // Like the instance child, the seed logs through the agent's own streams |
| + // (journald under systemd) — and its stderr is ALSO teed into a bounded |
| + // buffer, because the wake error this returns is the whole diagnosis an |
| + // operator gets for a host that refuses to come up. |
| + cmd.Stdout = os.Stdout |
| + cmd.Stderr = io.MultiWriter(os.Stderr, &stderr) |
| + // WaitDelay is what makes seedTimeout real. Because Stderr is a plain |
| + // io.Writer (the tee), os/exec pipes it and Wait waits for that pipe to |
| + // close — and a seed that spawned its own grandchild holds the write end |
| + // open long after the timeout killed the seed itself. Without this, a |
| + // wedged seed would hold the lease until the wake's own 5m budget expired. |
| + cmd.WaitDelay = seedDrainGrace |
| + return cmd |
| + }) |
| + if err == nil { |
| + err = cmd.Wait() |
| + } |
| + if err != nil { |
| +``` |
| + |
| +Inside the retained error-handling body, the `ErrWaitDelay` success check |
| +must guard against a nil cmd (a spawn that never started has no |
| +ProcessState): |
| + |
| +```go |
| + if cmd != nil && errors.Is(err, exec.ErrWaitDelay) && cmd.ProcessState != nil && cmd.ProcessState.Success() { |
| +``` |
| + |
| +Everything else in the body (`DeadlineExceeded` wrap, `seedOwnsDB` cleanup, |
| +stderr tail) stays byte-identical. Note the original `log.Printf("activator: |
| +seeding %s: ...")` line moved ABOVE the spawn (it was between cmd |
| +construction and Run; keep exactly one copy). Also delete the now-orphaned |
| +comment lines that referred to `cmd.Run` if any remain outside the closure. |
| + |
| +- [ ] **Step 6: Build, run the activator suite** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && go vet ./internal/activator/` |
| +Then (sandbox disabled — the suite binds unix sockets): |
| +`GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/activator/ -count=1` |
| +Expected: PASS — every existing test runs with `Cgroups` nil, pinning that |
| +the nil manager is behavior-identical to today. |
| + |
| +- [ ] **Step 7: gofmt, commit** |
| + |
| +Run: `gofmt -l internal/activator/` |
| + |
| +```bash |
| +git add internal/activator/backend_unit.go internal/activator/backend_exec.go |
| +git commit -m "activator: instance, sidecar and seed spawn into the host's exec cgroup |
| + |
| +Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>" |
| +``` |
| + |
| +--- |
| + |
| +### Task 5: host sampler — the third scan root |
| + |
| +**Files:** |
| +- Modify: `internal/host/metrics_probes.go` (add `listExecCgroups`) |
| +- Modify: `internal/host/metrics_probes_test.go` (add discovery test) |
| +- Modify: `internal/host/metrics_loop.go` (`realUnitReadings`, |
| + `realSampleProbes`, `metricsLoop` gain the exec root) |
| +- Modify: `internal/host/agent.go:164` (call-site signature only; real wiring is Task 6) |
| + |
| +**Interfaces:** |
| +- Consumes: `cgroup.IsExecName` from Task 1. |
| +- Produces: `metricsLoop(execRoot string)` — Task 6 passes the manager's |
| + root. Sample keys `carlos-exec@<host>` flow into `metrics.Sample.Units` |
| + verbatim (additive wire change; Task 7 consumes them in the console). |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +Append to `internal/host/metrics_probes_test.go`: |
| + |
| +```go |
| +// TestListExecCgroupsFindsPerHostDirs pins the delegated subtree's scan: |
| +// carlos-exec@<host> dirs come out with their names VERBATIM as sample keys |
| +// (deliberately not dressed up as .service units — an operator who takes a |
| +// dashboard row to systemctl must not be told a unit exists that doesn't). |
| +// The agent's own supervise leaf and foreign names are invisible, and an |
| +// empty root — the no-delegation state — scans nothing. |
| +func TestListExecCgroupsFindsPerHostDirs(t *testing.T) { |
| + root := t.TempDir() |
| + mk := func(name string) string { |
| + t.Helper() |
| + dir := filepath.Join(root, name) |
| + if err := os.MkdirAll(dir, 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + return dir |
| + } |
| + execDir := mk("carlos-exec@keymail.dev") |
| + mk("supervise") |
| + mk("carlos-exec@") // no host — not one of ours |
| + mk("random-dir") |
| + |
| + got := listExecCgroups(root) |
| + if len(got) != 1 { |
| + t.Fatalf("discovered %d cgroups, want 1: %+v", len(got), got) |
| + } |
| + if got[0].name != "carlos-exec@keymail.dev" || got[0].dir != execDir { |
| + t.Fatalf("got %+v, want name carlos-exec@keymail.dev dir %s", got[0], execDir) |
| + } |
| + |
| + if out := listExecCgroups(""); out != nil { |
| + t.Fatalf("empty root must scan nothing, got %+v", out) |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run test to verify it fails** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/host/ -run TestListExecCgroups -count=1` |
| +Expected: FAIL — `listExecCgroups` undefined. |
| + |
| +- [ ] **Step 3: Implement the scan** |
| + |
| +Append to `internal/host/metrics_probes.go` (add import |
| +`github.com/carlosframework/platform/internal/cgroup`): |
| + |
| +```go |
| +// listExecCgroups lists the per-host exec-child cgroups under the agent's |
| +// delegated subtree — the third scan root, alongside listCarlosUnits' two |
| +// levels. An empty root (no delegation armed) or a read failure -> nil. The |
| +// agent's own supervise leaf fails IsExecName and is skipped: that process |
| +// is already reported as carlos-edge.service. readUnitRaw reads these dirs |
| +// unchanged — same cgroup v2 files, same fail-toward-nil fields. |
| +func listExecCgroups(root string) []unitCgroup { |
| + if root == "" { |
| + return nil |
| + } |
| + entries, err := os.ReadDir(root) |
| + if err != nil { |
| + return nil |
| + } |
| + var units []unitCgroup |
| + for _, entry := range entries { |
| + if !entry.IsDir() || !cgroup.IsExecName(entry.Name()) { |
| + continue |
| + } |
| + units = append(units, unitCgroup{name: entry.Name(), dir: filepath.Join(root, entry.Name())}) |
| + } |
| + return units |
| +} |
| +``` |
| + |
| +- [ ] **Step 4: Run test to verify it passes** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/host/ -run TestListExecCgroups -count=1` |
| +Expected: PASS |
| + |
| +- [ ] **Step 5: Thread the exec root through the loop** |
| + |
| +In `internal/host/metrics_loop.go` (all three are thin glue, untested by |
| +the package's convention — the walk itself was tested in Step 1): |
| + |
| +`realSampleProbes` (line ~621) gains the root: |
| + |
| +```go |
| +func realSampleProbes(execRoot string) sampleProbes { |
| + return sampleProbes{ |
| + procStat: readProcStat, |
| + loadavg: readLoadAvg, |
| + memFrac: readMemFrac, |
| + diskFrac: readDiskFrac, |
| + units: func() map[string]unitRaw { return realUnitReadings(execRoot) }, |
| + now: time.Now, |
| + } |
| +} |
| +``` |
| + |
| +`realUnitReadings` (line ~637) merges both discovery walks — update its doc |
| +comment's last sentence and the body: |
| + |
| +```go |
| +// realUnitReadings lists the box's currently active CARLOS units and reads |
| +// each one's cgroup usage — the production units func sampleProbes.units |
| +// wraps. Keyed by UNIT NAME (what the console matches against), read from the |
| +// unit's own cgroup DIRECTORY (which for a tenant instance is a level down; |
| +// see listCarlosUnits) — plus, when delegation is armed, the per-host |
| +// exec-child cgroups under execRoot (keyed carlos-exec@<host>; see |
| +// listExecCgroups). |
| +func realUnitReadings(execRoot string) map[string]unitRaw { |
| + units := listCarlosUnits(cgroupSliceDir) |
| + units = append(units, listExecCgroups(execRoot)...) |
| + if len(units) == 0 { |
| + return nil |
| + } |
| + out := make(map[string]unitRaw, len(units)) |
| + for _, u := range units { |
| + out[u.name] = readUnitRaw(u.dir) |
| + } |
| + return out |
| +} |
| +``` |
| + |
| +`metricsLoop` (line ~702) becomes `func metricsLoop(execRoot string)` and |
| +its `probes := realSampleProbes()` line becomes |
| +`probes := realSampleProbes(execRoot)`. |
| + |
| +In `internal/host/agent.go` line 164, change `go metricsLoop()` to |
| +`go metricsLoop("")` — the real root arrives in Task 6. |
| + |
| +- [ ] **Step 6: Build + full host suite (sandbox disabled), commit** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && go vet ./internal/host/ && go test ./internal/host/ -count=1` |
| +Expected: PASS. Then `gofmt -l internal/host/`, and: |
| + |
| +```bash |
| +git add internal/host/metrics_probes.go internal/host/metrics_probes_test.go internal/host/metrics_loop.go internal/host/agent.go |
| +git commit -m "host: the sampler scans the delegated exec subtree as a third root |
| + |
| +Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>" |
| +``` |
| + |
| +--- |
| + |
| +### Task 6: agent wiring + the unit file's delegation |
| + |
| +**Files:** |
| +- Modify: `internal/host/agent.go` (RunAgent: Setup + wire manager) |
| +- Modify: `internal/host/units/carlos-edge.service` |
| + |
| +**Interfaces:** |
| +- Consumes: `cgroup.Setup`, `Manager.Root()` (Task 2), |
| + `ExecBackend.Cgroups` (Task 4), `metricsLoop(execRoot)` (Task 5). |
| +- Produces: a fully armed production path. No new API. |
| + |
| +- [ ] **Step 1: Wire the manager in RunAgent** |
| + |
| +In `internal/host/agent.go`, add the import |
| +`github.com/carlosframework/platform/internal/cgroup`, then in `RunAgent` |
| +insert after the `cache := edge.NewCache(...)` line (~132): |
| + |
| +```go |
| + // Exec-child accounting: claim the cgroup subtree the unit file |
| + // delegates to us (Delegate= in carlos-edge.service). Nil when there is |
| + // no delegation — an old unit file, a dev run outside systemd, darwin — |
| + // in which case children spawn exactly as before and simply have no |
| + // per-instance readings. |
| + cgm := cgroup.Setup(log.Printf) |
| +``` |
| + |
| +In the `activator.ExecBackend` literal (line ~146), add the field: |
| + |
| +```go |
| + Exec: &activator.ExecBackend{ |
| + Cfg: *cfg, |
| + OptDir: release.OptDir(), |
| + DataDir: dd, |
| + Cache: cache, |
| + Cgroups: cgm, |
| + }, |
| +``` |
| + |
| +And change the Task-5 placeholder `go metricsLoop("")` (line ~164) to: |
| + |
| +```go |
| + go metricsLoop(cgm.Root()) |
| +``` |
| + |
| +- [ ] **Step 2: Delegate the subtree in the unit file** |
| + |
| +In `internal/host/units/carlos-edge.service`, two edits. |
| + |
| +First, replace the line `ProtectControlGroups=yes` with: |
| + |
| +```ini |
| +# ProtectControlGroups is deliberately ABSENT: it would mount /sys/fs/cgroup |
| +# read-only, and Delegate= (below) needs this unit's own subtree writable. |
| +# The hardening that line bought is retained by ownership instead — plain |
| +# DAC keeps everything OUTSIDE the chowned subtree read-only to this |
| +# unprivileged user. |
| +``` |
| + |
| +Second, directly after the `TasksMax=1024` line, add: |
| + |
| +```ini |
| +# Exec-backed instances run as this unit's children. Delegation hands the |
| +# unit its own cgroup subtree — chowned to User=carlos, unmanaged by systemd |
| +# below this point — so the agent can give each child its own cgroup for |
| +# per-instance accounting (docs/superpowers/specs/ |
| +# 2026-08-07-exec-child-accounting-design.md). The subtree still dies with |
| +# the service: restart semantics are unchanged. |
| +Delegate=cpu memory io pids |
| +``` |
| + |
| +- [ ] **Step 3: Full gates** |
| + |
| +Run (sandbox disabled for the test step): |
| + |
| +```bash |
| +GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && \ |
| +GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go vet ./... && \ |
| +GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/host/ ./internal/activator/ ./internal/cgroup/ -count=1 |
| +``` |
| + |
| +Expected: all PASS. (`cgroup.Setup` in a test/dev process finds |
| +`/sys/fs/cgroup/...` unwritable and returns nil — the degradation path is |
| +what makes this safe to run anywhere.) |
| + |
| +- [ ] **Step 4: Commit** |
| + |
| +```bash |
| +git add internal/host/agent.go internal/host/units/carlos-edge.service |
| +git commit -m "host: the agent claims its delegated subtree; the unit file delegates it |
| + |
| +Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>" |
| +``` |
| + |
| +--- |
| + |
| +### Task 7: console — the second allowlist key |
| + |
| +**Files:** |
| +- Modify: `internal/console/metricsview.go:656-666` (OwnerAppMetrics allowlist + doc comment; also the stale comment at line ~630) |
| +- Modify: `internal/console/metricsview_test.go` (new test) |
| + |
| +**Interfaces:** |
| +- Consumes: `activator.ExecName(host)` from Task 4. |
| +- Produces: nothing new — exec-backed instances flow through the existing |
| + row assembly (`Reporting()` turns true, sparklines render). |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +Append to `internal/console/metricsview_test.go` (it already imports |
| +`metrics` and has `newMetricsFixture`, `p`, `pi64`): |
| + |
| +```go |
| +// TestOwnerAppMetricsSeesExecBackedInstances pins the second allowlist key: |
| +// an exec-backed host reports under carlos-exec@<host> (the delegated |
| +// cgroup's verbatim name, no .service), and its readings reach the owner |
| +// row exactly like a unit-backed tenant's. Another tenant's exec cgroup |
| +// stays invisible — the allowlist boundary holds for the new key form too. |
| +func TestOwnerAppMetricsSeesExecBackedInstances(t *testing.T) { |
| + srv, rel := newMetricsFixture(t) |
| + ctx := context.Background() |
| + at := time.Now().UTC().Add(-time.Minute) |
| + if err := metrics.WriteRaw(ctx, rel, metrics.RawChunk{ |
| + V: 1, Box: "box-a", Hour: metrics.HourOf(at), |
| + Samples: []metrics.Sample{{ |
| + At: at, |
| + Units: map[string]metrics.UnitSample{ |
| + "carlos-exec@keymail.example.com": {CPUFrac: p(0.22), MemBytes: pi64(2048)}, |
| + "carlos-exec@other.example.com": {CPUFrac: p(0.44), MemBytes: pi64(4096)}, |
| + }, |
| + }}, |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + rows, err := srv.OwnerAppMetrics(ctx, []string{"keymail.example.com"}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(rows) != 1 { |
| + t.Fatalf("rows = %+v, want exactly the one allowlisted host", rows) |
| + } |
| + got := rows[0] |
| + if got.Host != "keymail.example.com" || !got.Reporting() { |
| + t.Fatalf("exec-backed host not reporting: %+v", got) |
| + } |
| + if got.CPUFrac == nil || *got.CPUFrac != 0.22 { |
| + t.Fatalf("CPU = %v, want 0.22", got.CPUFrac) |
| + } |
| + if got.MemBytes == nil || *got.MemBytes != 2048 { |
| + t.Fatalf("mem = %v, want 2048", got.MemBytes) |
| + } |
| + if got.SparkCPU == "" || got.SparkMem == "" { |
| + t.Fatalf("sparklines empty: %+v", got) |
| + } |
| + if got.CPUFrac != nil && *got.CPUFrac == 0.44 { |
| + t.Fatalf("row carries another tenant's exec reading: %+v", got) |
| + } |
| +} |
| + |
| +// TestOwnerAppMetricsExecAndUnitKeysNeverDoubleReport pins the spec's |
| +// no-double-report property: a host can't legally report under both key |
| +// forms at once (one route, one backing), but if a sweep ever carries both |
| +// — a backing flip mid-window, two boxes — the existing freshest-box rule |
| +// picks ONE deterministically. No special case, still exactly one row. |
| +func TestOwnerAppMetricsExecAndUnitKeysNeverDoubleReport(t *testing.T) { |
| + srv, rel := newMetricsFixture(t) |
| + ctx := context.Background() |
| + fresher := time.Now().UTC().Add(-time.Minute) |
| + older := fresher.Add(-2 * time.Minute) |
| + // The older box reports the host as a systemd unit, the fresher box as |
| + // an exec cgroup — the flip-mid-window shape. |
| + if err := metrics.WriteRaw(ctx, rel, metrics.RawChunk{ |
| + V: 1, Box: "box-old", Hour: metrics.HourOf(older), |
| + Samples: []metrics.Sample{{ |
| + At: older, |
| + Units: map[string]metrics.UnitSample{ |
| + "carlos-app@flip.example.com.service": {CPUFrac: p(0.99), MemBytes: pi64(9999)}, |
| + }, |
| + }}, |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := metrics.WriteRaw(ctx, rel, metrics.RawChunk{ |
| + V: 1, Box: "box-new", Hour: metrics.HourOf(fresher), |
| + Samples: []metrics.Sample{{ |
| + At: fresher, |
| + Units: map[string]metrics.UnitSample{ |
| + "carlos-exec@flip.example.com": {CPUFrac: p(0.11), MemBytes: pi64(1111)}, |
| + }, |
| + }}, |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + rows, err := srv.OwnerAppMetrics(ctx, []string{"flip.example.com"}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(rows) != 1 { |
| + t.Fatalf("rows = %+v, want exactly one row for the host, never two", rows) |
| + } |
| + got := rows[0] |
| + if got.Box != "box-new" { |
| + t.Fatalf("Box = %q, want the freshest box-new: %+v", got.Box, got) |
| + } |
| + if got.CPUFrac == nil || *got.CPUFrac != 0.11 { |
| + t.Fatalf("CPU = %v, want the freshest box's 0.11", got.CPUFrac) |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run test to verify it fails** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/console/ -run TestOwnerAppMetricsSeesExecBacked -count=1` |
| +Expected: FAIL — the row is not Reporting (key unmatched). |
| + |
| +- [ ] **Step 3: Add the second key** |
| + |
| +In `internal/console/metricsview.go`, `OwnerAppMetrics` (line ~660), |
| +replace: |
| + |
| +```go |
| + want := make(map[string]string, len(hosts)) |
| + for _, h := range hosts { |
| + if h == "" { |
| + continue |
| + } |
| + want[activator.UnitName(h)] = h |
| + } |
| +``` |
| + |
| +with: |
| + |
| +```go |
| + want := make(map[string]string, 2*len(hosts)) |
| + for _, h := range hosts { |
| + if h == "" { |
| + continue |
| + } |
| + // Two legal keys per host: the systemd unit a unit-backed tenant |
| + // reports under, and the delegated exec cgroup an exec-backed one |
| + // does. One route, one backing — a host can't legally report under |
| + // both at once; if both ever appear in a sweep, the freshest-box |
| + // rule below picks deterministically, no special case. |
| + want[activator.UnitName(h)] = h |
| + want[activator.ExecName(h)] = h |
| + } |
| +``` |
| + |
| +In the same function's row assembly (line ~709), the loop currently |
| +iterates `want`'s VALUES — which was one-per-host when each host had one |
| +key, but with two keys per host would render every host twice. Replace: |
| + |
| +```go |
| + rows := make([]InstanceMetrics, 0, len(want)) |
| + for _, host := range want { |
| + if row, ok := found[host]; ok { |
| + rows = append(rows, row) |
| + } else { |
| + rows = append(rows, InstanceMetrics{Host: host}) |
| + } |
| + } |
| +``` |
| + |
| +with: |
| + |
| +```go |
| + // Iterate the callers' hosts, not want's values: want now carries TWO |
| + // keys per host, and "every allowlisted host gets exactly one row" is a |
| + // promise about hosts, not keys. seen collapses duplicate input hosts, |
| + // which the old unique-keyed map did implicitly. |
| + rows := make([]InstanceMetrics, 0, len(hosts)) |
| + seen := make(map[string]bool, len(hosts)) |
| + for _, h := range hosts { |
| + if h == "" || seen[h] { |
| + continue |
| + } |
| + seen[h] = true |
| + if row, ok := found[h]; ok { |
| + rows = append(rows, row) |
| + } else { |
| + rows = append(rows, InstanceMetrics{Host: h}) |
| + } |
| + } |
| +``` |
| + |
| +(the `sort.Slice` line after it stays unchanged). |
| + |
| +Update the function's doc comment first sentence (line ~646) from |
| +"...EXACTLY the units carlos-app@<host>.service for the given hosts" to: |
| + |
| +```go |
| +// OwnerAppMetrics filters the cached 48h sweep down to EXACTLY the given |
| +// hosts' own per-instance readings — carlos-app@<host>.service for a |
| +// unit-backed tenant, carlos-exec@<host> for an exec-backed one. It returns |
| +// unit-level rows |
| +``` |
| + |
| +(keep the rest of the comment unchanged). |
| + |
| +Also update the stale parenthetical in the `Hibernate` field comment (line |
| +~630): change "(a bespoke unit like keymail-relay.service, or an exec child |
| +of the agent)" to "(a bespoke unit like keymail-relay.service, or an exec |
| +child on a box whose agent has no delegated subtree yet)". |
| + |
| +- [ ] **Step 4: Run tests to verify they pass** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/console/ -count=1` |
| +Expected: PASS (the whole console suite — the existing allowlist and |
| +freshness tests must not regress). |
| + |
| +- [ ] **Step 5: gofmt + full repo gates, commit** |
| + |
| +```bash |
| +gofmt -l internal/console/ |
| +GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... |
| +GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go vet ./... |
| +# full suite, sandbox disabled: |
| +GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./... -count=1 |
| +git add internal/console/metricsview.go internal/console/metricsview_test.go |
| +git commit -m "console: owner metrics match exec-backed instances by their cgroup key |
| + |
| +Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>" |
| +``` |
| + |
| +--- |
| + |
| +## After the plan: rollout (operator steps, NOT part of task execution) |
| + |
| +Per spec §6 — binary first, unit file second, verify on the dev box before |
| +flagship (`annie-welding`) / tito (`betty-fossicking`) / the Pi fleet: |
| + |
| +1. Ship the new binary fleet-wide; it tolerates the old unit file (nil |
| + manager) indefinitely. |
| +2. Per box, one-time operator step (platform unit files are not |
| + auto-converged — the follow-up gap is filed in the spec, not built |
| + here): install the updated `carlos-edge.service`, `systemctl |
| + daemon-reload`, `systemctl restart carlos-edge`. |
| +3. Verify with the binary actually built (stale-binary rule): journal line |
| + "exec-child accounting armed", then exec-backed readings on the |
| + Instances page (keymail's three flagship routes are the acceptance |
| + case). Pi fleet note: a Pi kernel booted without `cgroup_enable=memory` |
| + will fail the controller-enable write and degrade to nil — check the |
| + journal line before concluding anything about a Pi. |
diff --git a/docs/superpowers/specs/2026-08-02-host-process-monitoring-design.md b/docs/superpowers/specs/2026-08-02-host-process-monitoring-design.md| index c1ce58c14..8acc6e523 100644 |
| --- a/docs/superpowers/specs/2026-08-02-host-process-monitoring-design.md |
| +++ b/docs/superpowers/specs/2026-08-02-host-process-monitoring-design.md |
| @@ -46,6 +46,14 @@ cgroup v2 files, never `ps` scraping: |
| Discovery therefore scans the top level of `system.slice` **and** one |
| level down inside each `system-*.slice` child; a top-level-only scan |
| finds every platform unit and no tenant at all. |
| + **Amended 2026-08-08 (exec-child accounting, |
| + `2026-08-07-exec-child-accounting-design.md`):** a third scan root was |
| + added — the subtree systemd delegates to `carlos-edge.service`, where |
| + exec-backed instances (children of the agent, no unit of their own) are |
| + accounted in per-host `carlos-exec@<host>` cgroups. Those keys are |
| + deliberately not `.service` names: no such units exist. Member-less |
| + dirs are skipped (absence is the data), and a box without delegation |
| + simply has no third root. |
| - Per unit: CPU fraction (`cpu.stat` usage_usec delta over wall clock), |
| `memory.current`, `memory.peak`, and cumulative read/write bytes from |
| `io.stat`. |
| @@ -179,10 +187,13 @@ before deriving each instance's `carlos-app@<host>.service` unit name — a |
| route stamped for a different account is invisible here even if the app |
| name matches. |
| -Implementation note: a box whose newest sample is older than 5 minutes |
| -(`metricsFreshWindow`) is treated as not-reporting in this view — its |
| +Implementation note: a box whose newest sample is older than |
| +`metricsFreshWindow` is treated as not-reporting in this view — its |
| instances render "asleep — no recent readings" rather than presenting |
| -stale numbers as current. The operator dashboard, by contrast, shows the |
| +stale numbers as current. (Originally 5 minutes; re-derived 2026-08-07 as |
| +8 minutes — flush-cadence floor + the stale-while-revalidate serve |
| +ceiling + slack; the constant's doc comment in |
| +`internal/console/metricsview.go` carries the full derivation.) The operator dashboard, by contrast, shows the |
| sample's own timestamp and applies no such cutoff. |
| House rendering rules for both views: vendored assets only (Lucide-style |
diff --git a/docs/superpowers/specs/2026-08-07-exec-child-accounting-design.md b/docs/superpowers/specs/2026-08-07-exec-child-accounting-design.md| new file mode 100644 |
| index 000000000..8397325a1 |
| --- /dev/null |
| +++ b/docs/superpowers/specs/2026-08-07-exec-child-accounting-design.md |
| @@ -0,0 +1,170 @@ |
| +# Exec-child accounting — design |
| + |
| +Per-instance resource readings for exec-backed routes. Today an instance |
| +route with `backing=''` runs as an exec child of the agent, inside |
| +`carlos-edge.service`'s own cgroup, and the metrics sampler |
| +(`2026-08-02-host-process-monitoring-design.md`) — which reads only |
| +`carlos-*.service` cgroups under `system.slice` — can never see it. The |
| +owner Instances page renders "no per-instance readings" for every such |
| +route, permanently. This is the default: a plain `carlos add` produces an |
| +exec-backed route, so the blind spot covers most tenants (diagnosed |
| +2026-08-06 on keymail's three flagship routes; `home.kass.training`, |
| +unit-backed, reports fine). |
| + |
| +Approach, chosen 2026-08-07: give each exec child its **own cgroup** under a |
| +subtree systemd delegates to the agent, and extend the existing sampler to |
| +read it. The monitoring spec's convention — a process means a cgroup, never |
| +process-table scraping — is kept, the numbers are the same |
| +`cpu.stat`/`memory.current`/`io.stat` figures unit-backed tenants get, and |
| +grandchildren are covered for free. `/proc` sampling of tracked PIDs was |
| +considered and rejected: it breaks that convention, RSS is not comparable to |
| +`memory.current`, and CPU spent by grandchildren reaped between ticks is |
| +simply lost. Migrating routes to `backing=unit` was rejected as the fix |
| +because it patches one app while leaving the default path blind. |
| + |
| +The governing constraint is that the agent is **not root**: `User=carlos`, |
| +`ProtectControlGroups=yes`, seccomp-filtered. That rules out asking PID 1 |
| +for transient scopes over D-Bus (unprivileged callers can't), and it means |
| +cgroup writability must come from systemd delegation, not privilege. |
| + |
| +## 1. Unit file — delegation |
| + |
| +`internal/host/units/carlos-edge.service` changes: |
| + |
| +- Add `Delegate=cpu memory io pids`. systemd chowns the unit's own cgroup |
| + subtree to `User=carlos` and stops managing below it — the agent owns |
| + that directory tree and nothing else. |
| +- Remove `ProtectControlGroups=yes`, with a comment recording the why: |
| + delegation requires the subtree writable, and ordinary DAC keeps the rest |
| + of `/sys/fs/cgroup` read-only to an unprivileged user — the hardening |
| + this line bought is retained by ownership rather than by mount flags. |
| + |
| +Everything else — seccomp filter, `NoNewPrivileges`, capability set, |
| +`TasksMax` — stays exactly as it is. A child still runs inside the service's |
| +subtree, so `systemctl restart carlos-edge` still kills every instance: |
| +today's lifecycle, unchanged. |
| + |
| +## 2. Agent startup — the delegation dance |
| + |
| +A new small package, `internal/cgroup`, wired up by the agent |
| +(`internal/host/agent.go`). On start: |
| + |
| +1. Read the agent's own cgroup path from `/proc/self/cgroup` and resolve it |
| + under `/sys/fs/cgroup`. |
| +2. Probe for live delegation: the subtree's `cgroup.procs` and |
| + `cgroup.subtree_control` must be writable. If not — old unit file, a |
| + test, darwin — the manager is **nil**, one line is logged, and every |
| + spawn behaves exactly as today. Fail toward absent readings, never |
| + toward a broken spawn path. |
| +3. Create a `supervise/` leaf and move the agent's own process into it. |
| + cgroup v2 forbids enabling controllers on a cgroup that has member |
| + processes ("no internal processes"); the move is what makes step 4 |
| + legal. |
| +4. Enable `+cpu +memory +io +pids` in the subtree root's |
| + `cgroup.subtree_control`, so per-child cgroups get `memory.current`, |
| + `io.stat`, and `pids` accounting (`cpu.stat` exists regardless). |
| +5. Sweep stale `carlos-exec@*` directories left by a previous agent |
| + (post-restart they are empty; rmdir, failures tolerated). |
| + |
| +Any failure mid-dance degrades to the nil manager — never a fatal error. |
| + |
| +## 3. Spawning into per-host cgroups |
| + |
| +`activator.ExecBackend` gains the manager (nil-tolerant). Instance, |
| +sidecar, and **seed** processes for a host all spawn into one cgroup, |
| +`<subtree>/carlos-exec@<host>`, created on the host's first spawn of a wake |
| +— a seed's first-boot cost and a sidecar's load are that instance's work, |
| +and attributing them elsewhere would be a lie in the other direction. |
| + |
| +- Primary mechanism: Go's `SysProcAttr{UseCgroupFD, CgroupFD}` — clone3 |
| + with `CLONE_INTO_CGROUP`, so the child never spends a cycle in the wrong |
| + cgroup. |
| +- Fallback: if clone3 is refused (a seccomp filter older than the syscall; |
| + `SystemCallErrorNumber=EPERM` makes this an EPERM at spawn), start the |
| + child normally and write its PID into the cgroup's `cgroup.procs`. The |
| + window between start and move charges the child's first moments to the |
| + agent — mis-accounting in the safe direction. The first clone3 refusal |
| + latches the fallback for the life of the process: a seccomp filter is |
| + static, so retrying the syscall on every spawn buys nothing. |
| +- Cleanup: after the instance exits, rmdir the (empty) cgroup dir; a |
| + failure is tolerated and the startup sweep (§2.5) catches leftovers. |
| + |
| +## 4. Sampler — a third scan root |
| + |
| +`listCarlosUnits` (`internal/host/metrics_probes.go`) currently scans |
| +`system.slice` flat plus one level inside each `system-*.slice`. It gains a |
| +third root, passed in by the agent: the delegated subtree, matching |
| +directories named `carlos-exec@<host>` (the `supervise` leaf is skipped — |
| +it is the agent itself, already reported as `carlos-edge.service`). |
| + |
| +Sample keys are the directory name, `carlos-exec@<host>`, verbatim. The |
| +name is deliberately **not** a fake `.service`: an operator who takes a |
| +dashboard row to `systemctl status` must not be told a unit exists that |
| +doesn't. `readUnitRaw` is reused unchanged. |
| + |
| +`carlos-edge.service`'s own row keeps its current meaning: the hierarchical |
| +total, children included — which is what it already reports today, since |
| +the children have always lived inside it. |
| + |
| +## 5. Console — two allowlist keys per host |
| + |
| +`OwnerAppMetrics` (`internal/console/metricsview.go`) builds its allowlist |
| +as `want[activator.UnitName(h)] = h`. It gains a second entry per host: |
| +`want[activator.ExecName(h)] = h`, where `ExecName(host)` returns |
| +`"carlos-exec@" + host` (defined next to `UnitName`, the same one-place |
| +naming discipline). Nothing else changes: exec-backed instances flow |
| +through the existing row assembly, `Reporting()` turns true, sparklines |
| +render, the freshest-box tie-break already handles a host moving between |
| +backings. The operator dashboard needs zero changes — rows appear by key. |
| + |
| +A host can never legally report under both names at once (one route, one |
| +backing), but if both ever appear in a sweep the existing freshest-box |
| +logic picks deterministically; no special case is added. |
| + |
| +## 6. Compatibility and rollout |
| + |
| +Wire shape is additive: `Sample.Units` gains new keys, and every chunk and |
| +manifest already in a production bucket still parses (wire-shape rule). |
| +An old console rendering a new box's chunk shows the new keys on the |
| +operator dashboard and ignores them in owner views — harmless both ways. |
| + |
| +Rollout order, per box: |
| + |
| +1. Ship the new binary fleet-wide. It tolerates the old unit file (nil |
| + manager) indefinitely. |
| +2. Update `carlos-edge.service`, `systemctl daemon-reload`, restart the |
| + unit. This is a **one-time operator step**: platform unit files are not |
| + auto-converged today (the root adopt pass converges tenant drop-ins |
| + only). Teaching the root adopt pass to converge platform units is a |
| + real gap this work exposes but does not build; it is filed as a |
| + follow-up, not smuggled in. |
| + |
| +Boxes: flagship (`annie-welding`), tito (`betty-fossicking`), the Pi fleet. |
| +Verify on the dev box first, against the binary actually built (stale-binary |
| +rule). |
| + |
| +## 7. Testing |
| + |
| +- The delegation dance and directory lifecycle run against temp-dir |
| + fixtures — the same seam `listCarlosUnits` already uses; tests never |
| + touch the live cgroup tree. |
| +- The clone3 fallback is behind an injectable spawn error, so both paths |
| + are exercised without a seccomp filter in the loop. |
| +- Sampler discovery: fixture gains the third root; asserts `supervise` is |
| + skipped and `carlos-exec@<host>` keys come out verbatim. |
| +- Console: allowlist test covering both key forms for one host, and the |
| + no-double-report property of §5. |
| +- End-to-end on the dev box before flagship/tito: spawn keymail-shaped |
| + exec routes, watch readings appear on the Instances page. |
| + |
| +## Out of scope |
| + |
| +- Bespoke units (`keymail-relay.service`): invisible for a different |
| + reason — the `carlos-` name filter — and wanting a different, |
| + naming-convention fix. |
| +- `days/` rollups (still a later task's view). |
| +- Per-instance resource **limits**: out of scope here, but this design |
| + unlocks it — with each child in its own cgroup the agent could set |
| + `memory.max` per instance, which the unit file's "No MemoryMax" comment |
| + today rules out because a cap would catch every child at once. |
| +- Auto-convergence of platform unit files (filed as follow-up, §6). |
diff --git a/docs/superpowers/specs/2026-08-08-exec-instance-metrics-design.md b/docs/superpowers/specs/2026-08-08-exec-instance-metrics-design.md| index d52cbbc25..10a3d71f4 100644 |
| --- a/docs/superpowers/specs/2026-08-08-exec-instance-metrics-design.md |
| +++ b/docs/superpowers/specs/2026-08-08-exec-instance-metrics-design.md |
| @@ -1,5 +1,16 @@ |
| # Exec-backed instance metrics (issue #118) |
| +> **SUPERSEDED (2026-08-08, same day):** the pid-sampling this spec chose |
| +> shipped in PR #121 and ran live briefly, then was retired by the |
| +> delegated-cgroup design in |
| +> `2026-08-07-exec-child-accounting-design.md` (PR #91), which restores |
| +> this document's own option 2 — per-child cgroups — once `Delegate=` |
| +> landed on carlos-edge.service and made it cheap. Exec children now |
| +> report under honest `carlos-exec@<host>` keys with hierarchical cgroup |
| +> numbers; the `carlos-app@<host>.service` keys this design published |
| +> survive only in raw chunks for their 48h retention. Kept for the |
| +> problem statement and the options analysis. |
| + |
| **Date:** 2026-08-08 |
| **Issue:** carlosframework/platform#118 — exec-backed instances report no |
| CPU/memory: metrics sample unit cgroups only. |
diff --git a/internal/activator/activator.go b/internal/activator/activator.go| index 5500cee25..9014bfe37 100644 |
| --- a/internal/activator/activator.go |
| +++ b/internal/activator/activator.go |
| @@ -710,35 +710,6 @@ func (a *Service) beginStopLocked(e *actEntry) { |
| go a.stop(e) |
| } |
| -// AwakeExecPids reports the pid of every awake exec-backed child, keyed by |
| -// route host — the metrics sampler's seam (issue #118): an exec child runs |
| -// inside the agent's own cgroup, invisible to the per-unit cgroup walk, so |
| -// its per-instance attribution has to read /proc/<pid> instead, and only |
| -// the activator knows which pids those are. Included entries are awake, |
| -// not yet exited, and expose a pid: waking has no stable process yet, |
| -// stopping is on its way out, and a unit-backed tenant's proc (systemd- |
| -// driven, no Pid method) already reports through its own unit cgroup. Nil |
| -// when nothing qualifies — no readings, not an empty set of them. |
| -func (a *Service) AwakeExecPids() map[string]int { |
| - a.mu.Lock() |
| - defer a.mu.Unlock() |
| - var out map[string]int |
| - for _, e := range a.entries { |
| - if e.state != actAwake || e.proc.Exited() { |
| - continue |
| - } |
| - p, ok := e.proc.(interface{ Pid() int }) |
| - if !ok { |
| - continue |
| - } |
| - if out == nil { |
| - out = make(map[string]int) |
| - } |
| - out[e.host] = p.Pid() |
| - } |
| - return out |
| -} |
| - |
| // Owns reports whether host has a live entry in ANY state — waking, |
| // awake, or stopping. This is the external-socket advisory's predicate |
| // (issue #17): while an entry exists, whatever is serving the host's |
diff --git a/internal/activator/activator_test.go b/internal/activator/activator_test.go| index 6afbbfa45..b0f0b1d9e 100644 |
| --- a/internal/activator/activator_test.go |
| +++ b/internal/activator/activator_test.go |
| @@ -160,11 +160,6 @@ type fakeBackend struct { |
| // target). |
| execVerFn func() string |
| - // pidByHost, when a host is present, makes Start() return a pid-BEARING |
| - // proc (pidProc) for it — modeling ExecBackend's childProc. Hosts absent |
| - // from the map get the plain pid-less fakeProc, modeling unitProc. |
| - pidByHost map[string]int |
| - |
| // Sidecar co-spawn. sidecarStaged is HasSidecar's answer (false by |
| // default, so every pre-co-spawn test doubles as the "host without a |
| // sidecar spawns none" case); sidecarErr injects spawn failures. |
| @@ -229,7 +224,6 @@ func (b *fakeBackend) Start(_ context.Context, rt registry.Route) (Proc, string, |
| startErr := b.startErr |
| proc := &fakeProc{label: "instance", rec: b} |
| b.procs = append(b.procs, proc) |
| - pid, hasPid := b.pidByHost[rt.Host] |
| b.mu.Unlock() |
| if startErr != nil { |
| return nil, "", startErr |
| @@ -241,22 +235,9 @@ func (b *fakeBackend) Start(_ context.Context, rt registry.Route) (Proc, string, |
| if fn != nil { |
| ver = fn() |
| } |
| - if hasPid { |
| - return &pidProc{fakeProc: proc, pid: pid}, ver, nil |
| - } |
| return proc, ver, nil |
| } |
| -// pidProc is fakeProc plus a pid — the shape ExecBackend's childProc has, |
| -// versus unitProc's pid-less one. AwakeExecPids distinguishes the two by |
| -// the optional Pid() interface, so the fakes must too. |
| -type pidProc struct { |
| - *fakeProc |
| - pid int |
| -} |
| - |
| -func (p *pidProc) Pid() int { return p.pid } |
| - |
| func (b *fakeBackend) NextDue(context.Context, registry.Route) (int64, error) { |
| b.mu.Lock() |
| defer b.mu.Unlock() |
| @@ -3267,57 +3248,3 @@ func TestExecBackendDoesNotNormalizeSquatterSocket(t *testing.T) { |
| t.Fatalf("squatter socket mode after Start = %o, want its own 600 — a socket our child did not bind is never ours to widen", fi.Mode().Perm()) |
| } |
| } |
| - |
| -// AwakeExecPids is the metrics sampler's seam (issue #118): the host→pid map |
| -// of awake exec children. Pid-less procs (unit-backed tenants — they report |
| -// via their own unit cgroup), dead children, and stopping entries must all |
| -// be excluded — a pid the sampler reads must belong to a process the |
| -// activator still vouches for. |
| -func TestAwakeExecPids(t *testing.T) { |
| - a, b, _, _ := testActivator(t, time.Hour) |
| - b.pidByHost = map[string]int{ |
| - "one.example.com": 4242, |
| - "three.example.com": 4343, |
| - } |
| - |
| - if got := a.AwakeExecPids(); got != nil { |
| - t.Fatalf("nothing awake: got %v, want nil", got) |
| - } |
| - |
| - for _, host := range []string{"one.example.com", "two.example.com", "three.example.com"} { |
| - release, err := a.beginRequest(context.Background(), testRoute(host)) |
| - if err != nil { |
| - t.Fatalf("beginRequest %s: %v", host, err) |
| - } |
| - release() |
| - } |
| - |
| - // two (pid-less, modeling a unit tenant) is excluded; one and three report. |
| - got := a.AwakeExecPids() |
| - if len(got) != 2 || got["one.example.com"] != 4242 || got["three.example.com"] != 4343 { |
| - t.Fatalf("got %v, want one=4242 three=4343 only", got) |
| - } |
| - |
| - // A stopping entry is excluded the moment it flips, not once the stop |
| - // finishes: hold three's stop open on its gate and look while it drains. |
| - pThree := b.procs[2] |
| - pThree.mu.Lock() |
| - pThree.stopGate = make(chan struct{}) |
| - pThree.mu.Unlock() |
| - if !a.Bounce("three.example.com") { |
| - t.Fatal("Bounce three: not awake?") |
| - } |
| - if got := a.AwakeExecPids(); len(got) != 1 || got["one.example.com"] != 4242 { |
| - t.Fatalf("with three stopping: got %v, want one=4242 only", got) |
| - } |
| - close(pThree.stopGate) |
| - |
| - // A dead-but-unreaped child is excluded even before a sweep notices it. |
| - pOne := b.procs[0] |
| - pOne.mu.Lock() |
| - pOne.dead = true |
| - pOne.mu.Unlock() |
| - if got := a.AwakeExecPids(); got != nil { |
| - t.Fatalf("with one dead: got %v, want nil", got) |
| - } |
| -} |
diff --git a/internal/activator/backend_exec.go b/internal/activator/backend_exec.go| index e8c2a7e66..57775d560 100644 |
| --- a/internal/activator/backend_exec.go |
| +++ b/internal/activator/backend_exec.go |
| @@ -20,6 +20,7 @@ import ( |
| "syscall" |
| "time" |
| + "github.com/carlosframework/platform/internal/cgroup" |
| "github.com/carlosframework/platform/internal/edge" |
| "github.com/carlosframework/platform/internal/lease" |
| "github.com/carlosframework/platform/internal/registry" |
| @@ -56,6 +57,11 @@ type ExecBackend struct { |
| // instance so it can invalidate its own cached responses right after a |
| // write. Nil disables this — no purge listener, no error. |
| Cache *edge.Cache |
| + // Cgroups, when non-nil, places every spawned child — instance, sidecar, |
| + // seed — in a per-host cgroup under the agent's delegated subtree so the |
| + // metrics sampler can account it (exec-child accounting spec). Nil — no |
| + // delegation: old unit file, tests, darwin — spawns exactly as before. |
| + Cgroups *cgroup.Manager |
| } |
| var _ Backend = (*ExecBackend)(nil) |
| @@ -213,39 +219,49 @@ func (b *ExecBackend) Seed(ctx context.Context, rt registry.Route) error { |
| ctx, cancel := context.WithTimeout(ctx, seedTimeout) |
| defer cancel() |
| argv := append(seedArgs, "--db", dbPath) |
| - cmd := exec.CommandContext(ctx, b.liveLink(host), argv...) |
| - // The purge-channel vars are agent-owned in BOTH directions here too |
| - // (Start's discipline, see withoutVars): fileEnv is tenant-supplied, so |
| - // without this a tenant's own env file could hand its seed a |
| - // CARLOS_CONTROL_SOCKET the agent never opened and a CARLOS_PURGE for a |
| - // channel that does not exist — and the seed has no serving cache to |
| - // purge in any case. This is what makes the contract above true rather |
| - // than merely intended. |
| - cmd.Env = childEnv(withoutVars(fileEnv, "CARLOS_CONTROL_SOCKET", "CARLOS_PURGE")) |
| - // Like the instance child, the seed logs through the agent's own streams |
| - // (journald under systemd) — and its stderr is ALSO teed into a bounded |
| - // buffer, because the wake error this returns is the whole diagnosis an |
| - // operator gets for a host that refuses to come up. |
| var stderr capBuffer |
| - cmd.Stdout = os.Stdout |
| - cmd.Stderr = io.MultiWriter(os.Stderr, &stderr) |
| - // WaitDelay is what makes seedTimeout real. Because Stderr is a plain |
| - // io.Writer (the tee), os/exec pipes it and Run waits for that pipe to |
| - // close — and a seed that spawned its own grandchild holds the write end |
| - // open long after the timeout killed the seed itself. Without this, a |
| - // wedged seed would hold the lease until the wake's own 5m budget expired. |
| - cmd.WaitDelay = seedDrainGrace |
| log.Printf("activator: seeding %s: %s %s", host, b.liveLink(host), strings.Join(argv, " ")) |
| - if err := cmd.Run(); err != nil { |
| + // The seed shares the instance's carlos-exec@<host> cgroup: first-boot |
| + // cost is that instance's work. No CleanupHost here — the instance spawn |
| + // reuses the dir moments later, and its exit path (or the next agent |
| + // start's sweep) removes it. |
| + cmd, err := b.Cgroups.Start(host, func() *exec.Cmd { |
| + cmd := exec.CommandContext(ctx, b.liveLink(host), argv...) |
| + // The purge-channel vars are agent-owned in BOTH directions here too |
| + // (Start's discipline, see withoutVars): fileEnv is tenant-supplied, so |
| + // without this a tenant's own env file could hand its seed a |
| + // CARLOS_CONTROL_SOCKET the agent never opened and a CARLOS_PURGE for a |
| + // channel that does not exist — and the seed has no serving cache to |
| + // purge in any case. This is what makes the contract above true rather |
| + // than merely intended. |
| + cmd.Env = childEnv(withoutVars(fileEnv, "CARLOS_CONTROL_SOCKET", "CARLOS_PURGE")) |
| + // Like the instance child, the seed logs through the agent's own streams |
| + // (journald under systemd) — and its stderr is ALSO teed into a bounded |
| + // buffer, because the wake error this returns is the whole diagnosis an |
| + // operator gets for a host that refuses to come up. |
| + cmd.Stdout = os.Stdout |
| + cmd.Stderr = io.MultiWriter(os.Stderr, &stderr) |
| + // WaitDelay is what makes seedTimeout real. Because Stderr is a plain |
| + // io.Writer (the tee), os/exec pipes it and Wait waits for that pipe to |
| + // close — and a seed that spawned its own grandchild holds the write end |
| + // open long after the timeout killed the seed itself. Without this, a |
| + // wedged seed would hold the lease until the wake's own 5m budget expired. |
| + cmd.WaitDelay = seedDrainGrace |
| + return cmd |
| + }) |
| + if err == nil { |
| + err = cmd.Wait() |
| + } |
| + if err != nil { |
| // WaitDelay reports on the SUCCESS path too: a seed that exits 0 after |
| // backgrounding something (a helper, a stray `&`) leaves the inherited |
| - // stderr pipe open, and Run then returns ErrWaitDelay INSTEAD of nil |
| + // stderr pipe open, and Wait then returns ErrWaitDelay INSTEAD of nil |
| // even though the process exited 0. Treating that as a failure would |
| // fail the wake AND delete the database the seed had just correctly |
| // built — leaving the host fresh again next wake, i.e. permanently |
| // un-wakeable. The exit status is the verdict; the leaked pipe is a |
| // log line. |
| - if errors.Is(err, exec.ErrWaitDelay) && cmd.ProcessState != nil && cmd.ProcessState.Success() { |
| + if cmd != nil && errors.Is(err, exec.ErrWaitDelay) && cmd.ProcessState != nil && cmd.ProcessState.Success() { |
| log.Printf("activator: seeded %s (it exited 0 but left a child holding its output pipe open for more than %s)", host, seedDrainGrace) |
| return nil |
| } |
| @@ -285,7 +301,7 @@ func seedCreatedTheDB(statErr error) bool { return os.IsNotExist(statErr) } |
| // seedTimeout bounds a first-boot seed run — a wedged seed must fail the wake |
| // (which releases the lease) rather than sit on it. seedDrainGrace is how long |
| -// Run then waits for the killed seed's output pipes to close before giving up |
| +// Wait then waits for the killed seed's output pipes to close before giving up |
| // on them (cmd.WaitDelay). Both vars so tests can shrink them. |
| var ( |
| seedTimeout = 2 * time.Minute |
| @@ -295,9 +311,9 @@ var ( |
| // capBuffer keeps at most capBufferMax bytes of what's written to it and |
| // discards the rest, so quoting a child's stderr in an error can never turn a |
| // chatty seed into an unbounded string. Locked because it is read after |
| -// cmd.Run returns while os/exec's own copy goroutine may still be writing: on |
| -// a WaitDelay expiry (the wedged-seed path) Run returns without waiting for |
| -// that copy to finish. |
| +// the seed's Wait returns while os/exec's own copy goroutine may still be |
| +// writing: on a WaitDelay expiry (the wedged-seed path) Wait returns without |
| +// waiting for that copy to finish. |
| type capBuffer struct { |
| mu sync.Mutex |
| b []byte |
| @@ -437,23 +453,33 @@ func (b *ExecBackend) Start(ctx context.Context, rt registry.Route) (Proc, strin |
| } else { |
| childVars = withoutVars(childVars, "CARLOS_CONTROL_SOCKET", "CARLOS_PURGE") |
| } |
| - cmd := exec.Command(bin, "--socket", rt.Socket, "--db", dbPath) |
| - cmd.Env = childEnv(childVars) |
| - // The child logs through the agent's own stderr/stdout (journald under |
| - // systemd); there's no per-instance unit to own a log stream anymore. |
| - cmd.Stdout = os.Stdout |
| - cmd.Stderr = os.Stderr |
| // Record the build we're about to exec — read the symlink immediately before |
| - // Start() so it names the version the kernel will resolve. Reading it here, |
| + // the start so it names the version the kernel will resolve. Reading it here, |
| // rather than back in wake() after the health wait, closes a real window: a |
| // concurrent ship's repoint during the up-to-30s wait would otherwise make |
| // us record the NEW target while this child runs the OLD binary, and the |
| // sweep would then think it's already converged and never cycle it. The |
| - // residual sub-exec race (a repoint between this read and Start) is in the |
| - // safe direction — we'd record the older version, so the sweep cycles once |
| - // too often rather than never. Empty on a read error: the sweep won't cycle. |
| + // residual sub-exec race (a repoint between this read and the start) is in |
| + // the safe direction — we'd record the older version, so the sweep cycles |
| + // once too often rather than never. Empty on a read error: the sweep won't |
| + // cycle. |
| spawnVer, _ := b.LiveVersion(host) |
| - if err := cmd.Start(); err != nil { |
| + // Spawned through the cgroup manager (nil-tolerant: no delegation means |
| + // a plain start, exactly as before) so the child lands in |
| + // carlos-exec@<host> and the sampler can account it. build may run |
| + // twice — the manager's clone3-refusal fallback — so everything about |
| + // the Cmd is configured inside it. |
| + cmd, err := b.Cgroups.Start(host, func() *exec.Cmd { |
| + cmd := exec.Command(bin, "--socket", rt.Socket, "--db", dbPath) |
| + cmd.Env = childEnv(childVars) |
| + // The child logs through the agent's own stderr/stdout (journald |
| + // under systemd); there's no per-instance unit to own a log stream |
| + // anymore. |
| + cmd.Stdout = os.Stdout |
| + cmd.Stderr = os.Stderr |
| + return cmd |
| + }) |
| + if err != nil { |
| return nil, "", err |
| } |
| @@ -479,6 +505,9 @@ func (b *ExecBackend) Start(ctx context.Context, rt registry.Route) (Proc, strin |
| } |
| go func() { |
| p.err = cmd.Wait() |
| + // Last exiter wins: rmdir refuses while the sidecar (same host |
| + // cgroup) is still alive, and that failure is tolerated. |
| + b.Cgroups.CleanupHost(host) |
| close(p.done) |
| }() |
| @@ -632,18 +661,26 @@ func (b *ExecBackend) StartSidecar(_ context.Context, rt registry.Route) (Proc, |
| if err != nil { |
| return nil, fmt.Errorf("read sidecar env: %w", err) |
| } |
| - cmd := exec.Command(b.liveLink(rt.Host), "sidecar", "run") |
| - cmd.Env = childEnv(fileEnv) |
| - // Like the instance child: logs through the agent's own streams (journald |
| - // under systemd) — no per-host unit owns a log stream anymore. |
| - cmd.Stdout = os.Stdout |
| - cmd.Stderr = os.Stderr |
| - if err := cmd.Start(); err != nil { |
| + // Same host cgroup as the instance: a sidecar's load is that instance's |
| + // work, and attributing it elsewhere would be a lie in the other |
| + // direction. |
| + cmd, err := b.Cgroups.Start(rt.Host, func() *exec.Cmd { |
| + cmd := exec.Command(b.liveLink(rt.Host), "sidecar", "run") |
| + cmd.Env = childEnv(fileEnv) |
| + // Like the instance child: logs through the agent's own streams |
| + // (journald under systemd) — no per-host unit owns a log stream |
| + // anymore. |
| + cmd.Stdout = os.Stdout |
| + cmd.Stderr = os.Stderr |
| + return cmd |
| + }) |
| + if err != nil { |
| return nil, err |
| } |
| p := &childProc{cmd: cmd, done: make(chan struct{})} |
| go func() { |
| p.err = cmd.Wait() |
| + b.Cgroups.CleanupHost(rt.Host) |
| close(p.done) |
| }() |
| return p, nil |
| @@ -730,14 +767,6 @@ type childProc struct { |
| purge *edge.PurgeListener // nil if b.Cache was nil or ListenPurge failed |
| } |
| -// Pid reports the spawned child's OS pid — the seam AwakeExecPids reads so |
| -// the metrics sampler can find this child under /proc (issue #118: an exec |
| -// child lives in the agent's own cgroup, so per-pid reads are its only |
| -// per-instance attribution). cmd.Process is set once Start succeeded, which |
| -// is the only way a childProc is ever constructed, and stays valid for the |
| -// struct's life. |
| -func (p *childProc) Pid() int { return p.cmd.Process.Pid } |
| - |
| func (p *childProc) Exited() bool { |
| select { |
| case <-p.done: |
diff --git a/internal/activator/backend_unit.go b/internal/activator/backend_unit.go| index 55caeda87..992e5423f 100644 |
| --- a/internal/activator/backend_unit.go |
| +++ b/internal/activator/backend_unit.go |
| @@ -56,6 +56,7 @@ import ( |
| "sync" |
| "time" |
| + "github.com/carlosframework/platform/internal/cgroup" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| @@ -65,6 +66,13 @@ import ( |
| // try-restarts for non-hibernating tenants. |
| func UnitName(host string) string { return "carlos-app@" + host + ".service" } |
| +// ExecName is the metrics sample key for an exec-backed tenant's children — |
| +// the delegated cgroup directory the activator spawns them into. Defined by |
| +// internal/cgroup (which creates the directories); surfaced here next to |
| +// UnitName so the console learns both of a host's legal metric keys from |
| +// the same place. |
| +func ExecName(host string) string { return cgroup.ExecName(host) } |
| + |
| // UnitBackend is the Backend for routes with Backing == registry.BackingUnit. |
| type UnitBackend struct { |
| // Systemctl runs one systemctl verb against a unit — the Proc-style seam |
diff --git a/internal/cgroup/cgroup.go b/internal/cgroup/cgroup.go| new file mode 100644 |
| index 000000000..9752a0cf0 |
| --- /dev/null |
| +++ b/internal/cgroup/cgroup.go |
| @@ -0,0 +1,274 @@ |
| +// Package cgroup gives each exec-backed child its own cgroup under the |
| +// subtree systemd delegates to carlos-edge.service (Delegate= in the unit |
| +// file), so the metrics sampler can report per-instance figures for routes |
| +// that run as exec children of the agent — today those live inside the |
| +// agent's own cgroup and can never show readings. Design: |
| +// docs/superpowers/specs/2026-08-07-exec-child-accounting-design.md. |
| +// |
| +// The agent is NOT root: everything here works through ownership of the |
| +// delegated subtree, never privilege. When delegation is absent (old unit |
| +// file, a test, darwin) Setup returns a nil *Manager and every method on it |
| +// degrades to today's behavior — fail toward absent readings, never toward |
| +// a broken spawn path. |
| +package cgroup |
| + |
| +import ( |
| + "errors" |
| + "fmt" |
| + "os" |
| + "os/exec" |
| + "path/filepath" |
| + "strconv" |
| + "strings" |
| + "sync/atomic" |
| +) |
| + |
| +// execPrefix is the ONE definition of the per-host exec cgroup naming — |
| +// activator.ExecName and the sampler's directory match both come through |
| +// this package. |
| +const execPrefix = "carlos-exec@" |
| + |
| +// ExecName is the cgroup directory name — and therefore the metrics sample |
| +// key — for an exec-backed host's children. Deliberately NOT dressed up as |
| +// a fake ".service": an operator who takes a dashboard row to `systemctl |
| +// status` must not be told a unit exists that doesn't. |
| +func ExecName(host string) string { return execPrefix + host } |
| + |
| +// IsExecName reports whether a cgroup directory name is one of our per-host |
| +// exec cgroups: the carlos-exec@ prefix with a non-empty host after it. |
| +func IsExecName(name string) bool { |
| + return strings.HasPrefix(name, execPrefix) && len(name) > len(execPrefix) |
| +} |
| + |
| +// ParseSelfCgroup extracts the cgroup v2 path from /proc/self/cgroup |
| +// content — the "0::" unified-hierarchy line — relative (no leading slash), |
| +// ready to join under /sys/fs/cgroup. Not-ok when there is no v2 line (a |
| +// v1-only box) or the process sits in the root cgroup: neither is a subtree |
| +// systemd could have delegated to us. |
| +func ParseSelfCgroup(data string) (string, bool) { |
| + for _, line := range strings.Split(data, "\n") { |
| + rest, found := strings.CutPrefix(line, "0::") |
| + if !found { |
| + continue |
| + } |
| + p := strings.TrimPrefix(rest, "/") |
| + if p == "" { |
| + return "", false |
| + } |
| + return p, true |
| + } |
| + return "", false |
| +} |
| + |
| +// Manager owns the delegated subtree. A nil *Manager is the documented |
| +// "no delegation" state: every method is nil-safe and degrades to today's |
| +// behavior. |
| +type Manager struct { |
| + root string |
| + logf func(string, ...any) |
| + // fallback, once latched, means clone3-into-cgroup is refused here (a |
| + // seccomp filter older than the syscall, an old kernel) and children are |
| + // started normally then moved by PID. A filter is static for the life of |
| + // the process, so retrying the syscall on every spawn buys nothing. |
| + fallback atomic.Bool |
| +} |
| + |
| +// Root is the delegated subtree's path — the sampler's third scan root. |
| +// "" for a nil manager (no delegation): the sampler skips an empty root. |
| +func (m *Manager) Root() string { |
| + if m == nil { |
| + return "" |
| + } |
| + return m.root |
| +} |
| + |
| +// Setup arms exec-child accounting for the running agent: resolve our own |
| +// cgroup from /proc/self/cgroup and run the delegation dance there. Any |
| +// failure — v1-only box, old unit file without Delegate=, a test, darwin — |
| +// returns nil with one logged line, and every spawn behaves exactly as |
| +// today. Fail toward absent readings, never toward a broken spawn path. |
| +func Setup(logf func(string, ...any)) *Manager { |
| + b, err := os.ReadFile("/proc/self/cgroup") |
| + if err != nil { |
| + logf("agent: exec-child accounting disabled: %v", err) |
| + return nil |
| + } |
| + rel, ok := ParseSelfCgroup(string(b)) |
| + if !ok { |
| + logf("agent: exec-child accounting disabled: no cgroup v2 path in /proc/self/cgroup") |
| + return nil |
| + } |
| + m, err := SetupAt(filepath.Join("/sys/fs/cgroup", rel), logf) |
| + if err != nil { |
| + logf("agent: exec-child accounting disabled (no delegated subtree — unit file without Delegate=?): %v", err) |
| + return nil |
| + } |
| + logf("agent: exec-child accounting armed (delegated subtree %s)", m.Root()) |
| + return m |
| +} |
| + |
| +// SetupAt is the delegation dance against an explicit subtree root — the |
| +// testable core Setup wraps. Order matters: probe, move self to a leaf, THEN |
| +// enable controllers — cgroup v2 refuses subtree_control writes while the |
| +// cgroup itself still has member processes ("no internal processes"). |
| +func SetupAt(root string, logf func(string, ...any)) (*Manager, error) { |
| + // 1. Probe live delegation: both control files must be writable by us. |
| + // systemd chowns exactly these to User= when Delegate= is set; without |
| + // it this open is the cheap, side-effect-free "no" that keeps an |
| + // undelegated box on today's path. |
| + for _, name := range []string{"cgroup.procs", "cgroup.subtree_control"} { |
| + f, err := os.OpenFile(filepath.Join(root, name), os.O_WRONLY, 0) |
| + if err != nil { |
| + return nil, fmt.Errorf("probe delegation: %w", err) |
| + } |
| + f.Close() |
| + } |
| + // 2. Move ourselves into a supervise/ leaf, making step 3 legal. |
| + sup := filepath.Join(root, "supervise") |
| + if err := os.MkdirAll(sup, 0o755); err != nil { |
| + return nil, fmt.Errorf("create supervise leaf: %w", err) |
| + } |
| + if err := os.WriteFile(filepath.Join(sup, "cgroup.procs"), []byte(strconv.Itoa(os.Getpid())), 0o644); err != nil { |
| + return nil, fmt.Errorf("move self into supervise leaf: %w", err) |
| + } |
| + // 3. Enable the controllers per-child cgroups need for memory.current, |
| + // io.stat and pids accounting (cpu.stat exists regardless, but +cpu |
| + // keeps utilization attributable). One write, all four: the kernel |
| + // applies subtree_control writes atomically. |
| + if err := os.WriteFile(filepath.Join(root, "cgroup.subtree_control"), []byte("+cpu +memory +io +pids"), 0o644); err != nil { |
| + return nil, fmt.Errorf("enable controllers: %w", err) |
| + } |
| + m := &Manager{root: root, logf: logf} |
| + // 4. Sweep per-host dirs a previous agent left behind — empty after a |
| + // restart killed their members. rmdir refuses a non-empty dir, which is |
| + // exactly the tolerance we want. |
| + m.sweepStale() |
| + return m, nil |
| +} |
| + |
| +// sweepStale removes leftover carlos-exec@* dirs under the root. Failures |
| +// are logged and tolerated: a dir that won't rmdir has something alive in |
| +// it, and killing is the service manager's job, not ours. |
| +func (m *Manager) sweepStale() { |
| + entries, err := os.ReadDir(m.root) |
| + if err != nil { |
| + return |
| + } |
| + for _, e := range entries { |
| + if !e.IsDir() || !IsExecName(e.Name()) { |
| + continue |
| + } |
| + if err := os.Remove(filepath.Join(m.root, e.Name())); err != nil { |
| + m.logf("cgroup: stale %s not removed: %v", e.Name(), err) |
| + } |
| + } |
| +} |
| + |
| +// startCmd is Start's spawn seam — a package var so the clone3 refusal |
| +// path is testable without a seccomp filter in the loop. |
| +var startCmd = func(cmd *exec.Cmd) error { return cmd.Start() } |
| + |
| +// hostDir is the per-host cgroup: instance, sidecar and seed all share it — |
| +// a seed's first-boot cost and a sidecar's load ARE that instance's work. |
| +func (m *Manager) hostDir(host string) string { return filepath.Join(m.root, ExecName(host)) } |
| + |
| +// Start runs build() and starts the result inside host's cgroup. Nil-safe: |
| +// with no manager (or any accounting failure) the child starts exactly as |
| +// today — a spawn must never fail because accounting failed. build must |
| +// return a fully configured, unstarted Cmd; it may be called twice, because |
| +// a Cmd cannot be reused after a failed Start. On a non-nil error the |
| +// returned Cmd may be nil. |
| +// |
| +// Primary mechanism: clone3 with CLONE_INTO_CGROUP (SysProcAttr.UseCgroupFD) |
| +// — the child never spends a cycle in the wrong cgroup. If that start |
| +// fails, the cgroup fd may or may not be why (a seccomp filter older than |
| +// clone3 answers EPERM; so does a missing binary): retry WITHOUT the fd, |
| +// and only if the retry succeeds — proving the cgroup was the problem — and |
| +// only if the failure actually means the mechanism is gone (shouldLatch), |
| +// latch the fallback and move the child by PID. A transient, spawn-specific |
| +// error (e.g. the cgroup dir vanishing between os.Open and start) falls |
| +// back for this spawn only; the next Start still tries clone3. The window |
| +// between start and move charges the child's first moments to the agent: |
| +// mis-accounting in the safe direction. Note the PID write migrates only |
| +// the child process itself — any grandchildren it has already forked by |
| +// then stay charged to the agent's cgroup, not the child's. |
| +func (m *Manager) Start(host string, build func() *exec.Cmd) (*exec.Cmd, error) { |
| + if m == nil { |
| + cmd := build() |
| + return cmd, startCmd(cmd) |
| + } |
| + dir := m.hostDir(host) |
| + if err := os.MkdirAll(dir, 0o755); err != nil { |
| + m.logf("cgroup: %s: create cgroup: %v — spawning unaccounted", host, err) |
| + cmd := build() |
| + return cmd, startCmd(cmd) |
| + } |
| + if intoCgroupSupported && !m.fallback.Load() { |
| + f, err := os.Open(dir) |
| + if err != nil { |
| + m.logf("cgroup: %s: open cgroup dir: %v — using PID-move fallback for this spawn", host, err) |
| + return m.startAndMove(host, build) |
| + } |
| + cmd := build() |
| + setInto(cmd, int(f.Fd())) |
| + err = startCmd(cmd) |
| + f.Close() |
| + if err == nil { |
| + return cmd, nil |
| + } |
| + firstErr := err |
| + retried, retryErr := m.startAndMove(host, build) |
| + if retryErr != nil { |
| + // startAndMove already dropped the ghost host dir on its own |
| + // failure; join both errors so the operator can see a clone3 |
| + // attempt happened at all, not just that the retry failed. |
| + return nil, errors.Join(firstErr, retryErr) |
| + } |
| + if shouldLatch(firstErr) { |
| + m.fallback.Store(true) |
| + m.logf("cgroup: clone3-into-cgroup refused (%v) — latching PID-move fallback for the life of this process", firstErr) |
| + } else { |
| + m.logf("cgroup: clone3-into-cgroup failed for this spawn (%v) — not latching", firstErr) |
| + } |
| + return retried, nil |
| + } |
| + return m.startAndMove(host, build) |
| +} |
| + |
| +// startAndMove is the fallback spawn: plain start, then write the child's |
| +// PID into the host cgroup. A failed move is logged and tolerated — the |
| +// child runs (unaccounted), which beats not running. |
| +func (m *Manager) startAndMove(host string, build func() *exec.Cmd) (*exec.Cmd, error) { |
| + cmd := build() |
| + if err := startCmd(cmd); err != nil { |
| + // The spawn never happened: drop the now-ownerless host dir so a |
| + // failed wake doesn't leave the sampler reporting permanent zeros |
| + // for a host with nothing running. rmdir-safe — os.Remove refuses a |
| + // non-empty dir, so this can never take a live sibling's dir. |
| + _ = os.Remove(m.hostDir(host)) |
| + return nil, err |
| + } |
| + if cmd.Process == nil { |
| + // A broken startCmd seam reporting success with no process: this |
| + // package must never panic the agent over it. Log and skip the |
| + // move — the child, if any, simply stays unaccounted. |
| + m.logf("cgroup: %s: start reported success with no process — skipping pid move", host) |
| + return cmd, nil |
| + } |
| + procs := filepath.Join(m.hostDir(host), "cgroup.procs") |
| + if err := os.WriteFile(procs, []byte(strconv.Itoa(cmd.Process.Pid)), 0o644); err != nil { |
| + m.logf("cgroup: %s: move pid %d: %v — child stays unaccounted", host, cmd.Process.Pid, err) |
| + } |
| + return cmd, nil |
| +} |
| + |
| +// CleanupHost removes host's per-host cgroup dir after its children exit. |
| +// Nil-safe and rmdir-tolerant: while a sibling (instance vs sidecar) is |
| +// still alive the dir is non-empty and the remove fails — the LAST exiter's |
| +// call succeeds, and the next agent start's sweep catches anything missed. |
| +func (m *Manager) CleanupHost(host string) { |
| + if m == nil { |
| + return |
| + } |
| + _ = os.Remove(m.hostDir(host)) |
| +} |
diff --git a/internal/cgroup/cgroup_linux.go b/internal/cgroup/cgroup_linux.go| new file mode 100644 |
| index 000000000..5b362aab9 |
| --- /dev/null |
| +++ b/internal/cgroup/cgroup_linux.go |
| @@ -0,0 +1,38 @@ |
| +//go:build linux |
| + |
| +package cgroup |
| + |
| +import ( |
| + "errors" |
| + "os/exec" |
| + "syscall" |
| +) |
| + |
| +// intoCgroupSupported gates the clone3 CLONE_INTO_CGROUP attempt to |
| +// platforms whose SysProcAttr has the fields — everywhere else Start goes |
| +// straight to the PID-move fallback. |
| +const intoCgroupSupported = true |
| + |
| +// setInto arms cmd for clone3-into-cgroup without clobbering any |
| +// SysProcAttr build() already configured (Setpgid, Pdeathsig, ...) — build() |
| +// owns all Cmd configuration; Start only adds to it, never replaces it. |
| +func setInto(cmd *exec.Cmd, fd int) { |
| + if cmd.SysProcAttr == nil { |
| + cmd.SysProcAttr = &syscall.SysProcAttr{} |
| + } |
| + cmd.SysProcAttr.UseCgroupFD = true |
| + cmd.SysProcAttr.CgroupFD = fd |
| +} |
| + |
| +// shouldLatch reports whether err proves clone3-into-cgroup is unavailable |
| +// for the life of this process — EPERM (a seccomp filter predating clone3, |
| +// or one that forbids it outright) and ENOSYS (a kernel predating clone3) |
| +// mean the mechanism itself is gone; EINVAL means the kernel understood |
| +// clone3 but rejected CLONE_INTO_CGROUP. Anything else (e.g. ENODEV from the |
| +// cgroup dir disappearing between os.Open and start, raced by a concurrent |
| +// CleanupHost) is spawn-specific and says nothing about the mechanism — |
| +// latching on it would permanently downgrade the process over a fluke and |
| +// log a lie ("refused"). |
| +func shouldLatch(err error) bool { |
| + return errors.Is(err, syscall.EPERM) || errors.Is(err, syscall.ENOSYS) || errors.Is(err, syscall.EINVAL) |
| +} |
diff --git a/internal/cgroup/cgroup_linux_test.go b/internal/cgroup/cgroup_linux_test.go| new file mode 100644 |
| index 000000000..05d902b58 |
| --- /dev/null |
| +++ b/internal/cgroup/cgroup_linux_test.go |
| @@ -0,0 +1,185 @@ |
| +//go:build linux |
| + |
| +package cgroup |
| + |
| +import ( |
| + "errors" |
| + "os" |
| + "os/exec" |
| + "path/filepath" |
| + "syscall" |
| + "testing" |
| +) |
| + |
| +// TestStartUsesCgroupFDFirst: with delegation live and no latch, the child |
| +// starts via clone3-into-cgroup — SysProcAttr set, host dir created, and NO |
| +// cgroup.procs write (the kernel placed it, no PID move needed). |
| +func TestStartUsesCgroupFDFirst(t *testing.T) { |
| + root := newDelegatedRoot(t) |
| + m, err := SetupAt(root, t.Logf) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + calls := fakeStart(t, func(*exec.Cmd) error { return nil }) |
| + if _, err := m.Start("h.example.com", buildTrue); err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(*calls) != 1 { |
| + t.Fatalf("started %d times, want 1", len(*calls)) |
| + } |
| + attr := (*calls)[0].SysProcAttr |
| + if attr == nil || !attr.UseCgroupFD { |
| + t.Fatalf("SysProcAttr = %+v, want UseCgroupFD", attr) |
| + } |
| + dir := filepath.Join(root, ExecName("h.example.com")) |
| + if _, err := os.Stat(dir); err != nil { |
| + t.Fatalf("host cgroup dir not created: %v", err) |
| + } |
| + if _, err := os.Stat(filepath.Join(dir, "cgroup.procs")); !os.IsNotExist(err) { |
| + t.Errorf("clone3 path must not write cgroup.procs (the kernel places the child): %v", err) |
| + } |
| +} |
| + |
| +// TestStartFallsBackAndLatches pins the refusal path: clone3 refused |
| +// (seccomp EPERM), the retry WITHOUT the cgroup fd succeeds — proving the |
| +// cgroup was the problem — the child's PID is moved by file write, and the |
| +// latch makes every later spawn skip clone3 entirely. |
| +func TestStartFallsBackAndLatches(t *testing.T) { |
| + root := newDelegatedRoot(t) |
| + m, err := SetupAt(root, t.Logf) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + calls := fakeStart(t, func(cmd *exec.Cmd) error { |
| + if cmd.SysProcAttr != nil { |
| + return syscall.EPERM // the filter refusing clone3 |
| + } |
| + return nil |
| + }) |
| + if _, err := m.Start("h.example.com", buildTrue); err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(*calls) != 2 { |
| + t.Fatalf("first spawn: %d start calls, want 2 (clone3 attempt + plain retry)", len(*calls)) |
| + } |
| + procs := filepath.Join(root, ExecName("h.example.com"), "cgroup.procs") |
| + if _, err := os.Stat(procs); err != nil { |
| + t.Fatalf("fallback did not move the PID: %v", err) |
| + } |
| + if !m.fallback.Load() { |
| + t.Fatal("refusal did not latch the fallback") |
| + } |
| + |
| + if _, err := m.Start("h2.example.com", buildTrue); err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(*calls) != 3 { |
| + t.Fatalf("latched spawn: %d total start calls, want 3 (no clone3 re-attempt)", len(*calls)) |
| + } |
| + if (*calls)[2].SysProcAttr != nil { |
| + t.Fatal("latched spawn still set SysProcAttr") |
| + } |
| +} |
| + |
| +// TestStartMergesIntoBuilderSysProcAttr: build() owns all Cmd configuration |
| +// — Start must ADD UseCgroupFD/CgroupFD to whatever SysProcAttr build() |
| +// already set, never replace it. A builder setting Setpgid or Pdeathsig |
| +// must keep that field on the clone3 attempt. |
| +func TestStartMergesIntoBuilderSysProcAttr(t *testing.T) { |
| + root := newDelegatedRoot(t) |
| + m, err := SetupAt(root, t.Logf) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + calls := fakeStart(t, func(*exec.Cmd) error { return nil }) |
| + build := func() *exec.Cmd { |
| + cmd := exec.Command("/bin/true") |
| + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} |
| + return cmd |
| + } |
| + if _, err := m.Start("h.example.com", build); err != nil { |
| + t.Fatal(err) |
| + } |
| + attr := (*calls)[0].SysProcAttr |
| + if attr == nil || !attr.Setpgid { |
| + t.Fatalf("SysProcAttr = %+v, want Setpgid preserved from build()", attr) |
| + } |
| + if !attr.UseCgroupFD { |
| + t.Fatalf("SysProcAttr = %+v, want UseCgroupFD added by Start", attr) |
| + } |
| +} |
| + |
| +// TestStartTransientClone3FailureDoesNotLatch: ENODEV (e.g. the cgroup dir |
| +// disappearing between os.Open and start, raced by a concurrent |
| +// CleanupHost) says nothing about whether clone3-into-cgroup works at all — |
| +// the spawn falls back for THIS call only, and the NEXT Start still |
| +// attempts clone3. |
| +func TestStartTransientClone3FailureDoesNotLatch(t *testing.T) { |
| + root := newDelegatedRoot(t) |
| + m, err := SetupAt(root, t.Logf) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + calls := fakeStart(t, func(cmd *exec.Cmd) error { |
| + if cmd.SysProcAttr != nil { |
| + return syscall.ENODEV |
| + } |
| + return nil |
| + }) |
| + if _, err := m.Start("h.example.com", buildTrue); err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(*calls) != 2 { |
| + t.Fatalf("first spawn: %d start calls, want 2 (clone3 attempt + plain retry)", len(*calls)) |
| + } |
| + if m.fallback.Load() { |
| + t.Fatal("a transient clone3 failure latched the fallback") |
| + } |
| + |
| + if _, err := m.Start("h2.example.com", buildTrue); err != nil { |
| + t.Fatal(err) |
| + } |
| + // The fake keeps refusing clone3 with ENODEV for h2 too, so the second |
| + // spawn again pays for a clone3 attempt (fails) plus a plain retry |
| + // (succeeds): two more calls. What matters is call index 2 — the second |
| + // spawn's FIRST attempt — still carries SysProcAttr: proof clone3 was |
| + // retried rather than skipped by a latched fallback. |
| + if len(*calls) != 4 { |
| + t.Fatalf("second spawn: %d total start calls, want 4 (clone3 attempted again + its own retry)", len(*calls)) |
| + } |
| + if (*calls)[2].SysProcAttr == nil || !(*calls)[2].SysProcAttr.UseCgroupFD { |
| + t.Fatal("second spawn did not attempt clone3 again — fallback must not have latched") |
| + } |
| +} |
| + |
| +// TestStartJoinsBothErrorsOnTotalFailure: when the clone3 attempt AND the |
| +// PID-move retry both fail, the operator needs to see that a clone3 attempt |
| +// happened at all — the returned error must reference both, and the ghost |
| +// host dir must not survive. |
| +func TestStartJoinsBothErrorsOnTotalFailure(t *testing.T) { |
| + root := newDelegatedRoot(t) |
| + m, err := SetupAt(root, t.Logf) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + errClone3 := errors.New("clone3 boom") |
| + errRetry := errors.New("retry boom") |
| + first := true |
| + fakeStart(t, func(*exec.Cmd) error { |
| + if first { |
| + first = false |
| + return errClone3 |
| + } |
| + return errRetry |
| + }) |
| + _, err = m.Start("h.example.com", buildTrue) |
| + if err == nil { |
| + t.Fatal("Start with two failing spawns succeeded, want error") |
| + } |
| + if !errors.Is(err, errClone3) || !errors.Is(err, errRetry) { |
| + t.Fatalf("Start error = %v, want it to wrap both %v and %v", err, errClone3, errRetry) |
| + } |
| + if _, statErr := os.Stat(filepath.Join(root, ExecName("h.example.com"))); !os.IsNotExist(statErr) { |
| + t.Errorf("ghost host dir survived total spawn failure: %v", statErr) |
| + } |
| +} |
diff --git a/internal/cgroup/cgroup_other.go b/internal/cgroup/cgroup_other.go| new file mode 100644 |
| index 000000000..04e8dbcbb |
| --- /dev/null |
| +++ b/internal/cgroup/cgroup_other.go |
| @@ -0,0 +1,14 @@ |
| +//go:build !linux |
| + |
| +package cgroup |
| + |
| +import "os/exec" |
| + |
| +// intoCgroupSupported gates the clone3 CLONE_INTO_CGROUP attempt to |
| +// platforms whose SysProcAttr has the fields — everywhere else Start goes |
| +// straight to the PID-move fallback. |
| +const intoCgroupSupported = false |
| + |
| +func setInto(cmd *exec.Cmd, fd int) {} |
| + |
| +func shouldLatch(error) bool { return false } |
diff --git a/internal/cgroup/cgroup_test.go b/internal/cgroup/cgroup_test.go| new file mode 100644 |
| index 000000000..ee4ffba26 |
| --- /dev/null |
| +++ b/internal/cgroup/cgroup_test.go |
| @@ -0,0 +1,269 @@ |
| +package cgroup |
| + |
| +import ( |
| + "errors" |
| + "os" |
| + "os/exec" |
| + "path/filepath" |
| + "strconv" |
| + "testing" |
| +) |
| + |
| +func TestExecNameAndIsExecName(t *testing.T) { |
| + if got := ExecName("keymail.dev"); got != "carlos-exec@keymail.dev" { |
| + t.Fatalf("ExecName = %q, want %q", got, "carlos-exec@keymail.dev") |
| + } |
| + for name, want := range map[string]bool{ |
| + "carlos-exec@keymail.dev": true, |
| + "carlos-exec@": false, // no host — not one of ours |
| + "supervise": false, // the agent's own leaf |
| + "carlos-app@x.service": false, |
| + "cgroup.procs": false, |
| + } { |
| + if got := IsExecName(name); got != want { |
| + t.Errorf("IsExecName(%q) = %v, want %v", name, got, want) |
| + } |
| + } |
| +} |
| + |
| +// TestParseSelfCgroup pins the /proc/self/cgroup v2 line format: the "0::" |
| +// line carries the unified-hierarchy path. v1-only content (no 0:: line) |
| +// and the root cgroup ("0::/") both report not-ok — neither is a subtree |
| +// systemd could have delegated to us. |
| +func TestParseSelfCgroup(t *testing.T) { |
| + cases := []struct { |
| + name, data, want string |
| + ok bool |
| + }{ |
| + {"systemd service", "0::/system.slice/carlos-edge.service\n", "system.slice/carlos-edge.service", true}, |
| + {"hybrid box, v2 line among v1", "12:pids:/system.slice\n0::/system.slice/carlos-edge.service\n", "system.slice/carlos-edge.service", true}, |
| + {"v1 only", "12:pids:/system.slice\n11:cpu:/system.slice\n", "", false}, |
| + {"root cgroup", "0::/\n", "", false}, |
| + {"empty", "", "", false}, |
| + } |
| + for _, c := range cases { |
| + got, ok := ParseSelfCgroup(c.data) |
| + if got != c.want || ok != c.ok { |
| + t.Errorf("%s: ParseSelfCgroup = (%q, %v), want (%q, %v)", c.name, got, ok, c.want, c.ok) |
| + } |
| + } |
| +} |
| + |
| +// newDelegatedRoot builds the temp-dir shape of a delegated cgroup: the two |
| +// control files systemd chowns to the unit's user when Delegate= is set. |
| +// Everything the dance does is plain file I/O, so a temp dir is a faithful |
| +// fixture — the same seam listCarlosUnits uses in internal/host. |
| +func newDelegatedRoot(t *testing.T) string { |
| + t.Helper() |
| + root := t.TempDir() |
| + for _, f := range []string{"cgroup.procs", "cgroup.subtree_control"} { |
| + if err := os.WriteFile(filepath.Join(root, f), nil, 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + return root |
| +} |
| + |
| +// TestSetupAtRefusesUndelegatedRoot: without Delegate= the control files are |
| +// root-owned (here: absent) — SetupAt must error so Setup degrades to the |
| +// nil manager instead of half-arming. |
| +func TestSetupAtRefusesUndelegatedRoot(t *testing.T) { |
| + if _, err := SetupAt(t.TempDir(), t.Logf); err == nil { |
| + t.Fatal("SetupAt on an undelegated root succeeded, want error") |
| + } |
| +} |
| + |
| +// TestSetupAtDance pins the dance's observable results: the agent's own PID |
| +// moved into the supervise/ leaf (cgroup v2 forbids enabling controllers on |
| +// a cgroup with member processes), then the four controllers enabled in |
| +// subtree_control, and Root reporting the subtree. |
| +func TestSetupAtDance(t *testing.T) { |
| + root := newDelegatedRoot(t) |
| + m, err := SetupAt(root, t.Logf) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if m.Root() != root { |
| + t.Errorf("Root = %q, want %q", m.Root(), root) |
| + } |
| + pid, err := os.ReadFile(filepath.Join(root, "supervise", "cgroup.procs")) |
| + if err != nil { |
| + t.Fatalf("supervise leaf not populated: %v", err) |
| + } |
| + if string(pid) != strconv.Itoa(os.Getpid()) { |
| + t.Errorf("supervise/cgroup.procs = %q, want our own pid %d", pid, os.Getpid()) |
| + } |
| + ctrl, err := os.ReadFile(filepath.Join(root, "cgroup.subtree_control")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if string(ctrl) != "+cpu +memory +io +pids" { |
| + t.Errorf("subtree_control = %q, want %q", ctrl, "+cpu +memory +io +pids") |
| + } |
| +} |
| + |
| +// TestSetupAtSweepsStaleExecDirs: a previous agent's per-host dirs are empty |
| +// after its children died with the service; the dance removes them. A |
| +// non-empty one (something still alive in it — shouldn't happen, but rmdir |
| +// would fail anyway) and a foreign name are left alone. |
| +func TestSetupAtSweepsStaleExecDirs(t *testing.T) { |
| + root := newDelegatedRoot(t) |
| + stale := filepath.Join(root, "carlos-exec@old.example.com") |
| + if err := os.Mkdir(stale, 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + occupied := filepath.Join(root, "carlos-exec@busy.example.com") |
| + if err := os.MkdirAll(filepath.Join(occupied, "child"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + foreign := filepath.Join(root, "not-ours") |
| + if err := os.Mkdir(foreign, 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + if _, err := SetupAt(root, t.Logf); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := os.Stat(stale); !os.IsNotExist(err) { |
| + t.Errorf("stale exec dir survived the sweep: %v", err) |
| + } |
| + if _, err := os.Stat(occupied); err != nil { |
| + t.Errorf("non-empty exec dir must be tolerated, not force-removed: %v", err) |
| + } |
| + if _, err := os.Stat(foreign); err != nil { |
| + t.Errorf("foreign dir must be untouched: %v", err) |
| + } |
| +} |
| + |
| +func TestNilManagerRoot(t *testing.T) { |
| + var m *Manager |
| + if got := m.Root(); got != "" { |
| + t.Fatalf("nil Root = %q, want \"\"", got) |
| + } |
| +} |
| + |
| +// fakeStart swaps the package's spawn seam for a recorder, restoring it on |
| +// cleanup. The fake never spawns anything: "success" fabricates cmd.Process |
| +// from our own PID so the PID-move path has a real number to write. |
| +func fakeStart(t *testing.T, fail func(cmd *exec.Cmd) error) *[]*exec.Cmd { |
| + t.Helper() |
| + var calls []*exec.Cmd |
| + orig := startCmd |
| + startCmd = func(cmd *exec.Cmd) error { |
| + calls = append(calls, cmd) |
| + if err := fail(cmd); err != nil { |
| + return err |
| + } |
| + cmd.Process, _ = os.FindProcess(os.Getpid()) |
| + return nil |
| + } |
| + t.Cleanup(func() { startCmd = orig }) |
| + return &calls |
| +} |
| + |
| +func buildTrue() *exec.Cmd { return exec.Command("/bin/true") } |
| + |
| +// TestStartNilManagerSpawnsPlainly: the nil manager is the no-delegation |
| +// state — one plain start, no SysProcAttr, no filesystem writes. |
| +func TestStartNilManagerSpawnsPlainly(t *testing.T) { |
| + calls := fakeStart(t, func(*exec.Cmd) error { return nil }) |
| + var m *Manager |
| + cmd, err := m.Start("h.example.com", buildTrue) |
| + if err != nil || cmd == nil { |
| + t.Fatalf("Start = (%v, %v), want started cmd", cmd, err) |
| + } |
| + if len(*calls) != 1 || (*calls)[0].SysProcAttr != nil { |
| + t.Fatalf("nil manager must start exactly once with no SysProcAttr: %d calls", len(*calls)) |
| + } |
| +} |
| + |
| +// TestStartGenuineSpawnFailureDoesNotLatch: when the retry-without-cgroup |
| +// ALSO fails, the cgroup was not the problem — the error propagates and the |
| +// fallback must not latch (the next spawn should still try clone3). |
| +func TestStartGenuineSpawnFailureDoesNotLatch(t *testing.T) { |
| + root := newDelegatedRoot(t) |
| + m, err := SetupAt(root, t.Logf) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + fakeStart(t, func(*exec.Cmd) error { return errors.New("no such binary") }) |
| + if _, err := m.Start("h.example.com", buildTrue); err == nil { |
| + t.Fatal("Start with a failing spawn succeeded, want error") |
| + } |
| + if m.fallback.Load() { |
| + t.Fatal("a genuine spawn failure latched the clone3 fallback") |
| + } |
| +} |
| + |
| +// TestCleanupHost: removes an empty per-host dir, tolerates a non-empty |
| +// one, and is nil-safe. |
| +func TestCleanupHost(t *testing.T) { |
| + root := newDelegatedRoot(t) |
| + m, err := SetupAt(root, t.Logf) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + empty := filepath.Join(root, ExecName("gone.example.com")) |
| + if err := os.Mkdir(empty, 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + m.CleanupHost("gone.example.com") |
| + if _, err := os.Stat(empty); !os.IsNotExist(err) { |
| + t.Errorf("empty host dir not removed: %v", err) |
| + } |
| + |
| + busy := filepath.Join(root, ExecName("busy.example.com")) |
| + if err := os.MkdirAll(filepath.Join(busy, "child"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + m.CleanupHost("busy.example.com") // must not panic or force-remove |
| + if _, err := os.Stat(busy); err != nil { |
| + t.Errorf("non-empty host dir must survive: %v", err) |
| + } |
| + |
| + var nilM *Manager |
| + nilM.CleanupHost("h.example.com") // must not panic |
| +} |
| + |
| +// TestStartAndMoveRemovesGhostDirOnSpawnFailure: when the spawn itself |
| +// never happens, the host cgroup dir must not survive it — a ghost dir |
| +// would make the sampler report permanent zeros for a host with nothing |
| +// running. |
| +func TestStartAndMoveRemovesGhostDirOnSpawnFailure(t *testing.T) { |
| + root := newDelegatedRoot(t) |
| + m, err := SetupAt(root, t.Logf) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + dir := m.hostDir("h.example.com") |
| + if err := os.MkdirAll(dir, 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + fakeStart(t, func(*exec.Cmd) error { return errors.New("no such binary") }) |
| + if _, err := m.startAndMove("h.example.com", buildTrue); err == nil { |
| + t.Fatal("startAndMove with a failing spawn succeeded, want error") |
| + } |
| + if _, err := os.Stat(dir); !os.IsNotExist(err) { |
| + t.Errorf("ghost host dir survived a failed spawn: %v", err) |
| + } |
| +} |
| + |
| +// TestStartAndMoveToleratesBrokenSeam: a startCmd seam that reports success |
| +// without ever setting cmd.Process must not panic while writing the PID — |
| +// this package must never panic the agent over a broken seam. |
| +func TestStartAndMoveToleratesBrokenSeam(t *testing.T) { |
| + root := newDelegatedRoot(t) |
| + m, err := SetupAt(root, t.Logf) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.MkdirAll(m.hostDir("h.example.com"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + orig := startCmd |
| + startCmd = func(cmd *exec.Cmd) error { return nil } // never sets cmd.Process |
| + t.Cleanup(func() { startCmd = orig }) |
| + if _, err := m.startAndMove("h.example.com", buildTrue); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
diff --git a/internal/console/metricsview.go b/internal/console/metricsview.go| index 4dd763e2a..2aae2b16f 100644 |
| --- a/internal/console/metricsview.go |
| +++ b/internal/console/metricsview.go |
| @@ -13,6 +13,7 @@ import ( |
| "encoding/json" |
| "errors" |
| "fmt" |
| + "log" |
| "os" |
| "sort" |
| "strconv" |
| @@ -26,13 +27,15 @@ import ( |
| // UnitRow is one per-instance reading, ready to render, plus its own 48h |
| // sparklines (the owner app-metrics view renders a unit on its own, with no |
| -// host row to borrow a shape from). Keyed by unit name, but the name is the |
| -// reading's IDENTITY, not a promise that systemd knows it: an exec-backed |
| -// instance has no unit at all — it is a child of `carlos agent`, sampled by |
| -// pid — and publishes under the carlos-app@<host>.service name it WOULD |
| -// have (issue #118), so `systemctl status` on a box can come up empty for a |
| -// row that is live here. The route host inside the name is still the thing |
| -// to grep the agent's journal for. |
| +// host row to borrow a shape from). Keyed by sample name, which is the |
| +// reading's IDENTITY, not always a systemd unit: an exec-backed instance has |
| +// no unit at all — it is a child of `carlos agent`, accounted in a delegated |
| +// cgroup — and reports under carlos-exec@<host> (no .service; exec-child |
| +// accounting spec). Boxes running the superseded pid sampler (issue #118, |
| +// retired by that spec) published exec children under the |
| +// carlos-app@<host>.service name they WOULD have had, and those keys survive |
| +// in raw chunks for the 48h retention window. Either way the route host |
| +// inside the name is the thing to grep the agent's journal for. |
| type UnitRow struct { |
| Unit string // full unit name |
| CPUFrac *float64 |
| @@ -59,27 +62,77 @@ type AlertRow struct { |
| FiredAt time.Time |
| } |
| -// metricsCacheTTL bounds how long LoadMetrics reuses its last successful |
| -// sweep before touching the bucket again. All callers within the window |
| -// share the one sweep — the operator dashboard and every owner's app-metrics |
| -// page render off the same read. |
| +// metricsCacheTTL is how soon after a successful sweep LoadMetrics considers |
| +// the cache FRESH and skips even trying to refresh it. It does NOT bound how |
| +// long a result may be served overall — see metricsStaleServeCeiling for |
| +// that outer bound. Past metricsCacheTTL the cache becomes STALE (still |
| +// served immediately, but a background refresh is triggered) rather than |
| +// unusable. All callers within the FRESH window share the one sweep — the |
| +// operator dashboard and every owner's app-metrics page render off the same |
| +// read. |
| const metricsCacheTTL = 60 * time.Second |
| +// metricsStaleServeCeiling bounds how old the cache may be and still be |
| +// handed out immediately while a background refresh runs (LoadMetrics's |
| +// STALE-WITHIN-CEILING state). It exists because nothing keeps the cache |
| +// warm between requests — its age tracks the gap since the last REQUEST, |
| +// not a timer — so without a ceiling, the first request after a quiet |
| +// stretch would serve data that is arbitrarily old (a healthy box's actual |
| +// reporting gap, PLUS however long nobody happened to ask), and a |
| +// persistent bucket outage would let LoadMetrics return stale data with a |
| +// nil error forever, since a background sweep's failure is only logged (see |
| +// startBackgroundSweep) and nothing else ever revisits it. Past this |
| +// ceiling, LoadMetrics falls through to the same blocking single-flight |
| +// path the COLD case uses — costing on the order of a second now that |
| +// sweepBox parallelises its GETs, rather than the several seconds a |
| +// sequential sweep used to cost — in exchange for two things a stale answer |
| +// can't give: data no older than one fresh sweep, and a sweep error |
| +// actually reaching a caller instead of vanishing into a log line. No |
| +// separate failure backoff is needed on top of this: a sweep that keeps |
| +// failing never advances c.at, so the cache's age crosses the ceiling on its |
| +// own within a couple of minutes regardless of how long the outage lasts. |
| +const metricsStaleServeCeiling = 2 * time.Minute |
| + |
| // metricsSweepHours is how far back LoadMetrics reads raw chunks: the last |
| // 48 UTC hours, days/ rollups untouched (a later task's job). |
| const metricsSweepHours = 48 |
| // metricsFreshWindow is how old a box's newest sample may be before its |
| -// readings stop being anyone's CURRENT figure. The agent samples once a |
| -// minute, so five minutes is five consecutively missed samples — past any |
| -// normal hiccup, and the point at which a box has plainly stopped |
| -// publishing. It matters because the 48h sweep keeps serving a dead box's |
| -// final sample for two days: without this bound an instance that vanished |
| -// on Monday still reads "11% / 1.0 KB, current" on Wednesday. Owners get the |
| -// no-recent-readings state instead (they have no timestamp on the page to |
| -// judge staleness for themselves; the operator dashboard prints "Latest |
| -// <time>" and so is deliberately left unbounded). |
| -const metricsFreshWindow = 5 * time.Minute |
| +// readings stop being anyone's CURRENT figure. The value is derived, not |
| +// chosen freehand, from three terms: |
| +// |
| +// - Flush cadence, 5 minutes — a FLOOR, not an exact figure: metricsLoop |
| +// sleeps 60s per tick and does per-tick work on top of that (alertTick |
| +// every tick, metricsFlush's own bucket writes every 5th), so the actual |
| +// gap between flushes only ever drifts upward from 5:00, never down. |
| +// - metricsStaleServeCeiling, 2 minutes — the oldest a cache |
| +// LoadMetrics hands back under stale-while-revalidate may be, i.e. how |
| +// far behind the true "latest flush" a box.At a caller sees can sit even |
| +// with a store that is never slow. |
| +// - One more minute of slack, covering both the flush-cadence drift above |
| +// and the ≤metricsSweepTimeout (15s) a background refresh can still be |
| +// mid-flight for when its result gets read. |
| +// |
| +// 5 + 2 + 1 = 8 minutes. This window must exceed that sum or a perfectly |
| +// healthy, perfectly reachable box flaps between reporting and |
| +// no-recent-readings for the tail of every cycle. |
| +// |
| +// It still has to do the job it always did on top of that: the 48h sweep |
| +// keeps serving a dead box's final sample for two days, so without SOME |
| +// bound an instance that vanished on Monday still reads "11% / 1.0 KB, |
| +// current" on Wednesday. Owners get the no-recent-readings state instead |
| +// (they have no timestamp on the page to judge staleness for themselves; the |
| +// operator dashboard prints "Latest <time>" and so is deliberately left |
| +// unbounded). |
| +// |
| +// What this window does NOT promise: it does not survive even ONE missed |
| +// flush — miss a single 5-minute flush and the gap to the NEXT successful |
| +// one is already ~10 minutes, past this window, and the box drops out |
| +// exactly as it would have before this fix. That was true of the old 5-minute window too; widening |
| +// far enough to also cover a missed flush would mean a truly dead box's |
| +// stale reading survives even longer before it stops being anyone's |
| +// "current" figure — not a trade worth making for one skipped beat. |
| +const metricsFreshWindow = 8 * time.Minute |
| // metricsBoxesPrefix is where a box publishes its raw chunks and day |
| // rollups (internal/metrics.RawKey / DayKey); box discovery lists this |
| @@ -108,15 +161,29 @@ const ( |
| const metricsSweepTimeout = 15 * time.Second |
| // metricsCache holds the result of the last successful bucket sweep. A |
| -// failed sweep never reaches here — LoadMetrics returns the error to its |
| -// caller and leaves the previous (possibly stale-but-valid, possibly zero) |
| -// cache exactly as it was. |
| +// failed sweep never reaches here — a failed BLOCKING sweep (c.at still |
| +// zero, OR the cache is stale past metricsStaleServeCeiling) returns the |
| +// error to its caller and leaves the cache exactly as it was; a failed |
| +// BACKGROUND sweep (c.at set and still within the ceiling) is only logged, |
| +// since nobody is waiting on it and the stale cache it leaves behind is |
| +// still valid data, just not fresher. |
| +// |
| +// No separate failure backoff sits on top of the ceiling: a sweep that keeps |
| +// failing never advances c.at, so the cache's age crosses |
| +// metricsStaleServeCeiling on its own within a couple of minutes regardless |
| +// of how long an outage lasts, and every caller after that lands back on the |
| +// blocking path where the failure is visible again — see |
| +// metricsStaleServeCeiling's own comment. |
| // |
| // mu guards the fields and is deliberately NOT held across the sweep's bucket |
| // IO; sweeping/done are what serialise the IO instead, so that a cold sweep |
| // delays only the callers that actually need its result rather than every |
| -// caller that touches the cache. sweeping is true while one goroutine is out |
| -// at the bucket, and done is the channel it closes when it comes back. |
| +// caller that touches the cache. sweeping is true while one goroutine (either |
| +// a blocking caller's own, or the detached refresh goroutine LoadMetrics |
| +// launches for stale-while-revalidate) is out at the bucket, and done is the |
| +// channel it closes when it comes back — the SAME channel and flag serve |
| +// both shapes of sweep, so a caller arriving mid-refresh, whichever kind |
| +// started it, coalesces onto it rather than starting a second one. |
| type metricsCache struct { |
| mu sync.Mutex |
| at time.Time |
| @@ -128,36 +195,95 @@ type metricsCache struct { |
| // LoadMetrics reads every box under metrics/boxes/, the raw chunks of the |
| // last 48 hours (missing hours skipped silently), and alerts/boxes/*.json, |
| -// caching the result in-process for 60s so concurrent callers — the |
| -// dashboard page and every owner's app-metrics page — share one sweep. |
| +// caching the result in-process so concurrent callers — the dashboard page |
| +// and every owner's app-metrics page — usually share one sweep. |
| +// |
| +// Three cache states: |
| // |
| -// Two properties matter beyond the cache itself, both learned from the shape |
| -// this replaced (mutex held across the whole sweep, on the request's own |
| -// context): |
| +// - FRESH (c.at within metricsCacheTTL): the cached answer, immediately, |
| +// no bucket touched at all. |
| +// - STALE-WITHIN-CEILING (c.at is set, older than metricsCacheTTL, but no |
| +// older than metricsStaleServeCeiling): the cached answer is STILL |
| +// returned immediately — stale-while-revalidate — and, if no sweep is |
| +// already in flight, a fresh one is kicked off on a detached goroutine |
| +// (startBackgroundSweep) for the NEXT caller to benefit from. This is |
| +// the fix for the sweep's own cost: sweepBox's 48 hourly GETs for a |
| +// single box were measured at ~2.5s run sequentially, and that cost |
| +// used to land inline on whichever request's bad luck it was to arrive |
| +// right after a cache expiry — the dashboard page and every tenant's |
| +// app page alike. |
| +// - COLD (c.at is zero — nothing has ever swept successfully) or STALE |
| +// PAST THE CEILING (c.at is set but older than |
| +// metricsStaleServeCeiling — nothing has kept the cache warm for a |
| +// while, e.g. a quiet console between requests, or every sweep since |
| +// has been failing): the ORIGINAL blocking behaviour. There is either no |
| +// servable stale answer (COLD) or the one that exists is too old to |
| +// hand out one more time (STALE-PAST-CEILING) — either way this caller |
| +// (or whichever caller's sweep is already in flight) waits for a real |
| +// sweep and gets ITS error if the sweep fails. The ceiling is what keeps |
| +// a persistent bucket outage visible: without it, stale-while-revalidate |
| +// alone would let LoadMetrics return stale data with a nil error |
| +// forever regardless of how long the store stays down, because a |
| +// background sweep's failure is only logged (see startBackgroundSweep) |
| +// — crossing the ceiling forces the next caller back onto the path that |
| +// actually surfaces the error. Blocking here now costs on the order of |
| +// a second for a fleet this size rather than the several seconds it |
| +// cost before sweepBox parallelised its GETs, which is what makes |
| +// paying that cost here — instead of serving indefinitely-stale data — |
| +// an acceptable trade. |
| +// |
| +// Two properties matter beyond the cache states themselves, both learned |
| +// from the shape this replaced (mutex held across the whole sweep, on the |
| +// request's own context): |
| // |
| // - The cache mutex is never held across bucket IO. Only the goroutine that |
| // actually sweeps is slow; everyone else either takes the cached answer |
| -// immediately or waits on the in-flight sweep's channel, and neither |
| -// blocks a caller whose cache is fresh. handleApp calls this on every |
| -// tenant's app page, so a cold sweep must not become a queue every page |
| -// on the console stands in. |
| -// - The sweep runs on its own context — context.WithoutCancel of the |
| -// caller's, plus metricsSweepTimeout — never the request's. A request |
| -// cancelled mid-sweep (a reload, a navigate-away) would otherwise abort |
| -// the shared sweep AND, since a failed sweep is never cached, leave the |
| -// next request to start from scratch: reload impatiently enough and the |
| -// cache never populates at all. The caller's own context still governs |
| -// WAITING (a cancelled waiter gives up rather than hanging), it just no |
| -// longer governs the sweep. |
| +// immediately or waits on the in-flight sweep's channel (only the |
| +// blocking case waits at all), and neither blocks a caller whose cache |
| +// is fresh or stale-but-servable. handleApp calls this on every tenant's |
| +// app page, so a cold sweep must not become a queue every page on the |
| +// console stands in. |
| +// - Every sweep — blocking or background — runs on a context detached from |
| +// any particular caller's: context.WithoutCancel of whatever was handed |
| +// in (context.Background() for the background case, since there is no |
| +// caller to derive from), plus metricsSweepTimeout. A request cancelled |
| +// mid-sweep (a reload, a navigate-away) would otherwise abort a sweep |
| +// other callers are waiting on AND, since a failed blocking sweep is |
| +// never cached, leave the next request to start from scratch: reload |
| +// impatiently enough and the cache never populates at all. The caller's |
| +// own context still governs WAITING in the blocking case (a cancelled |
| +// waiter gives up rather than hanging), it just never governs the sweep |
| +// itself. |
| func (s *Server) LoadMetrics(ctx context.Context) ([]BoxMetricsView, []AlertRow, error) { |
| c := &s.metricsCache |
| for { |
| c.mu.Lock() |
| if !c.at.IsZero() && time.Since(c.at) < metricsCacheTTL { |
| + // FRESH. |
| boxes, alerts := c.boxes, c.alerts |
| c.mu.Unlock() |
| return boxes, alerts, nil |
| } |
| + if !c.at.IsZero() && time.Since(c.at) <= metricsStaleServeCeiling { |
| + // STALE-WITHIN-CEILING: hand back what we have right now — it |
| + // is still a real sweep's result, just older than |
| + // metricsCacheTTL — and let a refresh happen off to the side |
| + // rather than making this caller pay for it. If one is already |
| + // running (background OR, in principle, another blocking |
| + // caller's), don't start a second; this caller just doesn't |
| + // wait for it either way. |
| + boxes, alerts := c.boxes, c.alerts |
| + if !c.sweeping { |
| + s.startBackgroundSweep(c) |
| + } |
| + c.mu.Unlock() |
| + return boxes, alerts, nil |
| + } |
| + // COLD, or STALE-PAST-THE-CEILING: no servable stale answer exists |
| + // (COLD) or the one that does is too old to hand out again |
| + // (past metricsStaleServeCeiling) — this caller must wait for real |
| + // data and see its error if the sweep fails, exactly as the |
| + // original blocking behaviour did. |
| if c.sweeping { |
| // Someone is already at the bucket: wait for their result |
| // rather than duplicating the sweep, then re-check (their |
| @@ -211,6 +337,59 @@ func (s *Server) LoadMetrics(ctx context.Context) ([]BoxMetricsView, []AlertRow, |
| } |
| } |
| +// startBackgroundSweep launches sweepAll on its own goroutine to refresh a |
| +// cache that has swept successfully at least once and is still within |
| +// metricsStaleServeCeiling, without making any caller wait on it — see |
| +// LoadMetrics's STALE-WITHIN-CEILING case. "Swept successfully at least |
| +// once" is exactly c.at being set: an empty-bucket console (c.at set, |
| +// c.boxes nil) takes this path just like a populated one, since nil boxes |
| +// is itself a complete, valid swept result, not an absent one. Must be |
| +// called with c.mu held: it sets c.sweeping and installs c.done itself, the |
| +// very same fields the blocking path uses, so a caller arriving while this |
| +// is in flight (whether via the STALE-WITHIN-CEILING branch or the blocking |
| +// one) coalesces onto it instead of starting a second sweep. sweepAll is |
| +// handed context.Background() rather than any request's |
| +// context because there IS no request behind this goroutine — it already |
| +// detaches internally via context.WithoutCancel and bounds itself with |
| +// metricsSweepTimeout, so Background() costs nothing extra. |
| +// |
| +// Unlike the blocking path, this goroutine has no net/http per-connection |
| +// recover sitting above it — an unrecovered panic here would take the whole |
| +// process down, not just one request — so it recovers explicitly. A failed |
| +// or panicked background sweep is only logged: no caller is waiting on its |
| +// error, and the stale cache it leaves untouched is still valid data. |
| +func (s *Server) startBackgroundSweep(c *metricsCache) { |
| + c.sweeping = true |
| + c.done = make(chan struct{}) |
| + done := c.done |
| + go func() { |
| + var ( |
| + boxes []BoxMetricsView |
| + alerts []AlertRow |
| + err = errors.New("console: background metrics sweep did not complete") |
| + ) |
| + defer func() { |
| + rec := recover() |
| + c.mu.Lock() |
| + if err == nil { |
| + c.at = time.Now() |
| + c.boxes = boxes |
| + c.alerts = alerts |
| + } |
| + c.sweeping = false |
| + close(done) |
| + c.mu.Unlock() |
| + switch { |
| + case rec != nil: |
| + log.Printf("console: background metrics sweep panic, cache left at its last good sweep: %v", rec) |
| + case err != nil: |
| + log.Printf("console: background metrics sweep: %v", err) |
| + } |
| + }() |
| + boxes, alerts, err = s.sweepAll(context.Background()) |
| + }() |
| +} |
| + |
| // sweepAll is the bucket half of LoadMetrics: discovery, every box's 48h raw |
| // sweep (metricsSweepBoxes of them at a time), then the alerts pass, on a |
| // context detached from the caller's (WithoutCancel) and bounded by |
| @@ -501,8 +680,9 @@ type InstanceMetrics struct { |
| // is false, where it decides what the absence of readings may claim: a |
| // hibernating instance is presumed asleep, anything else is NOT — it may |
| // be serving right now from outside per-instance accounting (a bespoke |
| - // unit like keymail-relay.service, or an exec child of the agent), and |
| - // the template must not assert a sleep state nobody observed. |
| + // unit like keymail-relay.service, or an exec child on a box whose agent |
| + // has no delegated subtree yet), and the template must not assert a |
| + // sleep state nobody observed. |
| Hibernate bool |
| } |
| @@ -515,26 +695,33 @@ type InstanceMetrics struct { |
| // was unreadable, which renders as the usual em-dash.) |
| func (m InstanceMetrics) Reporting() bool { return m.Box != "" } |
| -// OwnerAppMetrics filters the cached 48h sweep down to EXACTLY the units |
| -// carlos-app@<host>.service for the given hosts. It returns unit-level rows |
| -// only — no host totals, no other units, no alert data. The allowlist is the |
| -// hosts slice the caller derived from the app's OWN routes; nothing else can |
| -// enter the view. |
| +// OwnerAppMetrics filters the cached 48h sweep down to EXACTLY the given |
| +// hosts' own per-instance readings — carlos-app@<host>.service for a |
| +// unit-backed tenant, carlos-exec@<host> for an exec-backed one. It returns |
| +// unit-level rows only — no host totals, no other units, no alert data. The |
| +// allowlist is the hosts slice the caller derived from the app's OWN routes; |
| +// nothing else can enter the view. |
| // |
| // Every allowlisted host gets exactly one row, in host order, whether or not |
| // the sweep saw it: a host with no unit data is an UNKNOWN row (nil |
| // readings, Reporting false), never zeros and never silently dropped — an |
| // owner whose instance is asleep must still see it listed. |
| func (s *Server) OwnerAppMetrics(ctx context.Context, hosts []string) ([]InstanceMetrics, error) { |
| - // The allowlist, keyed by the one unit name each host can legally match. |
| + // The allowlist, keyed by the unit names each host can legally match. |
| // Matching is exact-string on this map: a unit the caller did not ask |
| // for cannot be reached by any path below. |
| - want := make(map[string]string, len(hosts)) |
| + want := make(map[string]string, 2*len(hosts)) |
| for _, h := range hosts { |
| if h == "" { |
| continue |
| } |
| + // Two legal keys per host: the systemd unit a unit-backed tenant |
| + // reports under, and the delegated exec cgroup an exec-backed one |
| + // does. One route, one backing — a host can't legally report under |
| + // both at once; if both ever appear in a sweep, the freshest-box |
| + // rule below picks deterministically, no special case. |
| want[activator.UnitName(h)] = h |
| + want[activator.ExecName(h)] = h |
| } |
| if len(want) == 0 { |
| return nil, nil |
| @@ -578,12 +765,21 @@ func (s *Server) OwnerAppMetrics(ctx context.Context, hosts []string) ([]Instanc |
| } |
| } |
| - rows := make([]InstanceMetrics, 0, len(want)) |
| - for _, host := range want { |
| - if row, ok := found[host]; ok { |
| + // Iterate the callers' hosts, not want's values: want now carries TWO |
| + // keys per host, and "every allowlisted host gets exactly one row" is a |
| + // promise about hosts, not keys. seen collapses duplicate input hosts, |
| + // which the old unique-keyed map did implicitly. |
| + rows := make([]InstanceMetrics, 0, len(hosts)) |
| + seen := make(map[string]bool, len(hosts)) |
| + for _, h := range hosts { |
| + if h == "" || seen[h] { |
| + continue |
| + } |
| + seen[h] = true |
| + if row, ok := found[h]; ok { |
| rows = append(rows, row) |
| } else { |
| - rows = append(rows, InstanceMetrics{Host: host}) |
| + rows = append(rows, InstanceMetrics{Host: h}) |
| } |
| } |
| sort.Slice(rows, func(i, j int) bool { return rows[i].Host < rows[j].Host }) |
diff --git a/internal/console/metricsview_test.go b/internal/console/metricsview_test.go| index 380b74eba..b2e260795 100644 |
| --- a/internal/console/metricsview_test.go |
| +++ b/internal/console/metricsview_test.go |
| @@ -8,6 +8,7 @@ import ( |
| "strconv" |
| "strings" |
| "sync" |
| + "sync/atomic" |
| "testing" |
| "time" |
| @@ -303,6 +304,551 @@ func TestLoadMetricsConcurrentColdCallers(t *testing.T) { |
| } |
| } |
| +// writeBoxSample (re)publishes box's current-hour raw chunk with a single |
| +// host CPU reading — metrics.WriteRaw's own "replace whatever is there" |
| +// semantics (see chunk.go) let a later call simulate the agent's next flush |
| +// landing with a different reading, at the same key. |
| +func writeBoxSample(t *testing.T, rel release.Store, box string, cpu float64) { |
| + t.Helper() |
| + at := time.Now().UTC().Add(-time.Minute) |
| + if err := metrics.WriteRaw(context.Background(), rel, metrics.RawChunk{ |
| + V: 1, Box: box, Hour: metrics.HourOf(at), |
| + Samples: []metrics.Sample{{At: at, Host: metrics.HostSample{CPUFrac: p(cpu)}}}, |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +// slowCountingStore wraps a release.Store, counting List/Get calls and |
| +// optionally sleeping getDelay before each Get. It is the fixture the |
| +// stale-while-revalidate tests use to (a) prove a background sweep never |
| +// makes its trigger's caller wait for it and (b) count how many sweeps |
| +// actually ran. |
| +type slowCountingStore struct { |
| + release.Store |
| + getDelay time.Duration |
| + lists atomic.Int64 |
| + gets atomic.Int64 |
| + // listGate, when non-nil, is received from (blocking) before every List |
| + // call proceeds to the underlying store. discoverBoxes's List is the |
| + // very FIRST store operation any sweep makes (before any Get), so |
| + // gating it deterministically freezes a background sweep before it |
| + // touches the store at all — letting a test assert "the synchronous |
| + // caller performed zero store reads" without racing against how fast |
| + // the background goroutine happens to get scheduled. Set only from the |
| + // test goroutine strictly before the call that triggers the sweep, so |
| + // there is a happens-before edge (via the goroutine-creation rule) to |
| + // whichever goroutine reads it — never written concurrently with a read. |
| + // A CLOSED gate never blocks again (a receive on a closed channel |
| + // returns immediately), so once released it stays released for any |
| + // later List call in the same test, gated or not. |
| + listGate chan struct{} |
| + // listErr, when set, is what every List call returns (after clearing |
| + // listGate, if any) instead of touching the underlying store — the |
| + // fixture for simulating an outage that starts exactly when a gated |
| + // sweep is released, so both that sweep AND whatever sweep a coalesced |
| + // waiter runs on its own afterward see the same failure. |
| + listErr error |
| +} |
| + |
| +func (s *slowCountingStore) List(ctx context.Context, prefix string) ([]string, error) { |
| + s.lists.Add(1) |
| + if s.listGate != nil { |
| + <-s.listGate |
| + } |
| + if s.listErr != nil { |
| + return nil, s.listErr |
| + } |
| + return s.Store.List(ctx, prefix) |
| +} |
| + |
| +func (s *slowCountingStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| + s.gets.Add(1) |
| + if s.getDelay > 0 { |
| + time.Sleep(s.getDelay) |
| + } |
| + return s.Store.Get(ctx, key) |
| +} |
| + |
| +// newSlowMetricsFixture is newMetricsFixture with the server reading through |
| +// a slowCountingStore, for tests that need to observe how many sweeps ran or |
| +// need a sweep slow enough to prove a caller didn't wait on it. |
| +func newSlowMetricsFixture(t *testing.T, getDelay time.Duration) (*Server, *slowCountingStore) { |
| + t.Helper() |
| + st, err := Open(filepath.Join(t.TempDir(), "console.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { st.Close() }) |
| + dir := release.NewDirStore(filepath.Join(t.TempDir(), "bucket")) |
| + slow := &slowCountingStore{Store: dir, getDelay: getDelay} |
| + srv, err := NewServer(Config{Releases: slow}, st) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return srv, slow |
| +} |
| + |
| +// waitUntilNotSweeping polls the cache's sweeping flag directly (same |
| +// package, no exported seam needed) until a background sweep finishes or |
| +// the deadline passes. |
| +func waitUntilNotSweeping(t *testing.T, srv *Server, timeout time.Duration) { |
| + t.Helper() |
| + deadline := time.Now().Add(timeout) |
| + for { |
| + srv.metricsCache.mu.Lock() |
| + sweeping := srv.metricsCache.sweeping |
| + srv.metricsCache.mu.Unlock() |
| + if !sweeping { |
| + return |
| + } |
| + if time.Now().After(deadline) { |
| + t.Fatalf("background sweep did not finish within %v", timeout) |
| + } |
| + time.Sleep(10 * time.Millisecond) |
| + } |
| +} |
| + |
| +// metricsStaleTestBackdate is how far the tests below wind c.at back to |
| +// simulate an expired-but-servable cache: comfortably past metricsCacheTTL |
| +// (60s) so the cache is definitely STALE, and comfortably short of |
| +// metricsStaleServeCeiling (2min) so it's just as definitely still within |
| +// serving range — 30s of margin on each side, generous enough that neither |
| +// boundary is at risk from scheduling jitter. |
| +const metricsStaleTestBackdate = metricsCacheTTL + 30*time.Second |
| + |
| +// TestLoadMetricsStaleWhileRevalidate is fix B's core behaviour change: an |
| +// expired-but-still-within-ceiling cache is no longer a reason to make the |
| +// caller wait. The first call after expiry gets the OLD data back |
| +// immediately — proven by a zero Get-count delta on the store, not a wall- |
| +// clock guess — while a sweep runs in the background; once that sweep |
| +// lands, a LATER call sees the NEW data, still without ever blocking. |
| +func TestLoadMetricsStaleWhileRevalidate(t *testing.T) { |
| + const getDelay = 80 * time.Millisecond |
| + srv, store := newSlowMetricsFixture(t, getDelay) |
| + ctx := context.Background() |
| + |
| + writeBoxSample(t, store, "box-a", 0.5) |
| + boxes, _, err := srv.LoadMetrics(ctx) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(boxes) != 1 || boxes[0].CPU == nil || *boxes[0].CPU != 0.5 { |
| + t.Fatalf("initial cold sweep = %+v, want box-a at 0.5", boxes) |
| + } |
| + |
| + // Expire the cache (within the ceiling) and change what the store |
| + // holds. From here on a FRESH sweep would see 0.9 — the immediate call |
| + // below must still answer with the cache's last-known 0.5, proving it |
| + // didn't wait for a fresh read. |
| + srv.metricsCache.mu.Lock() |
| + srv.metricsCache.at = time.Now().Add(-metricsStaleTestBackdate) |
| + srv.metricsCache.mu.Unlock() |
| + writeBoxSample(t, store, "box-a", 0.9) |
| + |
| + // Freeze the background sweep this call is about to launch at its very |
| + // first store operation (discoverBoxes's List, before any Get) — this |
| + // makes the zero-Gets assertion below deterministic rather than a race |
| + // against the background goroutine's scheduling. |
| + store.listGate = make(chan struct{}) |
| + getsBefore := store.gets.Load() |
| + boxes, _, err = srv.LoadMetrics(ctx) |
| + getsAfter := store.gets.Load() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(boxes) != 1 || boxes[0].CPU == nil || *boxes[0].CPU != 0.5 { |
| + t.Fatalf("stale call = %+v, want the OLD 0.5 (a background refresh must not be awaited)", boxes) |
| + } |
| + if getsAfter != getsBefore { |
| + t.Fatalf("stale call performed %d store Gets, want 0 — it must return the cache without touching the store at all", getsAfter-getsBefore) |
| + } |
| + close(store.listGate) // release the frozen background sweep to finish |
| + |
| + waitUntilNotSweeping(t, srv, 5*time.Second) |
| + |
| + boxes, _, err = srv.LoadMetrics(ctx) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(boxes) != 1 || boxes[0].CPU == nil || *boxes[0].CPU != 0.9 { |
| + t.Fatalf("after the background sweep landed = %+v, want the NEW 0.9", boxes) |
| + } |
| +} |
| + |
| +// TestLoadMetricsStaleCacheSingleFlight: several callers arriving on an |
| +// EXPIRED-BUT-WITHIN-CEILING (not cold) cache at once must trigger exactly |
| +// one background sweep between them, never one each and never zero. The |
| +// baseline — however many List calls one full sweepAll makes (discoverBoxes |
| +// plus the alerts pass) — is measured from the initial cold sweep rather |
| +// than hard-coded, so this test doesn't have to know sweepAll's internals to |
| +// prove "exactly one more sweep happened." |
| +func TestLoadMetricsStaleCacheSingleFlight(t *testing.T) { |
| + srv, store := newSlowMetricsFixture(t, 30*time.Millisecond) |
| + ctx := context.Background() |
| + writeBoxSample(t, store, "box-a", 0.5) |
| + |
| + // Measured as a delta across just this call (not the cumulative total), |
| + // so any List traffic from fixture/server construction before this |
| + // point doesn't get folded into the "one sweep" baseline below. |
| + beforeCold := store.lists.Load() |
| + if _, _, err := srv.LoadMetrics(ctx); err != nil { |
| + t.Fatal(err) |
| + } |
| + oneSweepLists := store.lists.Load() - beforeCold // however many List calls one sweepAll makes |
| + |
| + srv.metricsCache.mu.Lock() |
| + srv.metricsCache.at = time.Now().Add(-metricsStaleTestBackdate) |
| + srv.metricsCache.mu.Unlock() |
| + before := store.lists.Load() |
| + |
| + const callers = 8 |
| + var wg sync.WaitGroup |
| + for i := 0; i < callers; i++ { |
| + wg.Add(1) |
| + go func() { |
| + defer wg.Done() |
| + if _, _, err := srv.LoadMetrics(ctx); err != nil { |
| + t.Error(err) |
| + } |
| + }() |
| + } |
| + wg.Wait() |
| + |
| + waitUntilNotSweeping(t, srv, 5*time.Second) |
| + |
| + if got := store.lists.Load() - before; got != oneSweepLists { |
| + t.Fatalf("List calls triggered by %d concurrent callers on a stale cache = %d, want exactly %d (one sweep, shared)", callers, got, oneSweepLists) |
| + } |
| +} |
| + |
| +// panicListStore panics on every List call — the fixture for proving a |
| +// panicking BACKGROUND sweep is recovered (startBackgroundSweep's explicit |
| +// recover — a detached goroutine has no net/http per-connection recover |
| +// sitting above it the way a request's own goroutine does) rather than |
| +// taking the whole process down, and that the cache is left usable |
| +// afterward rather than wedged. |
| +type panicListStore struct { |
| + release.Store |
| +} |
| + |
| +func (panicListStore) List(context.Context, string) ([]string, error) { |
| + panic("simulated bucket panic") |
| +} |
| + |
| +// TestLoadMetricsBackgroundSweepPanicRecovers: a background sweep |
| +// (triggered by a stale-but-within-ceiling cache) that panics must not crash |
| +// the test process, must leave the stale cache intact for its trigger's |
| +// caller (no error), must clear sweeping so nothing is wedged, and must not |
| +// prevent a later, healthy sweep from succeeding. |
| +func TestLoadMetricsBackgroundSweepPanicRecovers(t *testing.T) { |
| + srv, rel := newMetricsFixture(t) |
| + ctx := context.Background() |
| + writeBoxSample(t, rel, "box-a", 0.5) |
| + |
| + if _, _, err := srv.LoadMetrics(ctx); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + srv.metricsCache.mu.Lock() |
| + srv.metricsCache.at = time.Now().Add(-metricsStaleTestBackdate) |
| + srv.metricsCache.mu.Unlock() |
| + srv.cfg.Releases = panicListStore{Store: rel} |
| + |
| + boxes, _, err := srv.LoadMetrics(ctx) |
| + if err != nil { |
| + t.Fatalf("stale call with a panicking background sweep returned an error: %v", err) |
| + } |
| + if len(boxes) != 1 || boxes[0].CPU == nil || *boxes[0].CPU != 0.5 { |
| + t.Fatalf("stale call = %+v, want the OLD cached data despite the panic", boxes) |
| + } |
| + |
| + waitUntilNotSweeping(t, srv, 5*time.Second) |
| + |
| + srv.metricsCache.mu.Lock() |
| + sweeping := srv.metricsCache.sweeping |
| + srv.metricsCache.mu.Unlock() |
| + if sweeping { |
| + t.Fatal("cache left wedged (sweeping still true) after a panicking background sweep") |
| + } |
| + |
| + // Nothing is permanently wedged: restore a healthy store and force the |
| + // COLD path (c.at zeroed) for a clean, unambiguous assertion that a |
| + // later sweep still works. |
| + srv.cfg.Releases = rel |
| + srv.metricsCache.mu.Lock() |
| + srv.metricsCache.at = time.Time{} |
| + srv.metricsCache.mu.Unlock() |
| + |
| + boxes, _, err = srv.LoadMetrics(ctx) |
| + if err != nil { |
| + t.Fatalf("sweep after a prior background panic failed: %v", err) |
| + } |
| + if len(boxes) != 1 || boxes[0].CPU == nil || *boxes[0].CPU != 0.5 { |
| + t.Fatalf("recovered sweep = %+v, want box-a at 0.5", boxes) |
| + } |
| +} |
| + |
| +// errListStore fails every List call with a fixed error — the fixture for |
| +// proving a background sweep's failure is only logged while its trigger's |
| +// caller sees no error and the stale cache survives (within the ceiling), |
| +// and that the SAME failure surfaces to a blocking caller once the cache |
| +// ages past metricsStaleServeCeiling — restoring outage visibility rather |
| +// than serving indefinitely-stale data with no error forever. |
| +type errListStore struct { |
| + release.Store |
| + err error |
| +} |
| + |
| +func (e errListStore) List(context.Context, string) ([]string, error) { |
| + return nil, e.err |
| +} |
| + |
| +// TestLoadMetricsBackgroundSweepFailureThenCeilingSurfacesError covers both |
| +// halves of that contract against the one failing store. |
| +func TestLoadMetricsBackgroundSweepFailureThenCeilingSurfacesError(t *testing.T) { |
| + srv, rel := newMetricsFixture(t) |
| + ctx := context.Background() |
| + writeBoxSample(t, rel, "box-a", 0.5) |
| + |
| + if _, _, err := srv.LoadMetrics(ctx); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + wantErr := errors.New("bucket had a bad day") |
| + srv.cfg.Releases = errListStore{Store: rel, err: wantErr} |
| + |
| + // Within the ceiling: the stale cache survives, the failing background |
| + // sweep is only logged, and this caller sees no error at all. |
| + srv.metricsCache.mu.Lock() |
| + srv.metricsCache.at = time.Now().Add(-metricsStaleTestBackdate) |
| + srv.metricsCache.mu.Unlock() |
| + |
| + boxes, _, err := srv.LoadMetrics(ctx) |
| + if err != nil { |
| + t.Fatalf("stale call with a failing background sweep returned an error: %v", err) |
| + } |
| + if len(boxes) != 1 || boxes[0].CPU == nil || *boxes[0].CPU != 0.5 { |
| + t.Fatalf("stale call = %+v, want the OLD cached data", boxes) |
| + } |
| + |
| + waitUntilNotSweeping(t, srv, 5*time.Second) |
| + srv.metricsCache.mu.Lock() |
| + sweeping := srv.metricsCache.sweeping |
| + srv.metricsCache.mu.Unlock() |
| + if sweeping { |
| + t.Fatal("cache left wedged after a failing background sweep") |
| + } |
| + |
| + // Past the ceiling: the SAME failing store must now surface its error |
| + // to a blocking caller instead of handing back stale data forever. |
| + srv.metricsCache.mu.Lock() |
| + srv.metricsCache.at = time.Now().Add(-(metricsStaleServeCeiling + time.Second)) |
| + srv.metricsCache.mu.Unlock() |
| + |
| + if _, _, err := srv.LoadMetrics(ctx); err == nil { |
| + t.Fatal("call past the ceiling with a failing store returned no error") |
| + } else if !errors.Is(err, wantErr) { |
| + t.Fatalf("error = %v, want it to wrap %v", err, wantErr) |
| + } |
| +} |
| + |
| +// TestLoadMetricsPastCeilingBlocksForFreshData: a cache older than |
| +// metricsStaleServeCeiling is no longer eligible for stale-while-revalidate |
| +// even with a perfectly healthy store — the caller blocks on a synchronous |
| +// sweep and gets genuinely fresh data, proven by a non-zero Get-count delta |
| +// (the sweep ran INSIDE this call, not off to the side) rather than a |
| +// timing guess. |
| +func TestLoadMetricsPastCeilingBlocksForFreshData(t *testing.T) { |
| + srv, store := newSlowMetricsFixture(t, 0) |
| + ctx := context.Background() |
| + writeBoxSample(t, store, "box-a", 0.5) |
| + |
| + if _, _, err := srv.LoadMetrics(ctx); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + writeBoxSample(t, store, "box-a", 0.9) |
| + srv.metricsCache.mu.Lock() |
| + srv.metricsCache.at = time.Now().Add(-(metricsStaleServeCeiling + time.Second)) |
| + srv.metricsCache.mu.Unlock() |
| + |
| + getsBefore := store.gets.Load() |
| + boxes, _, err := srv.LoadMetrics(ctx) |
| + getsAfter := store.gets.Load() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if getsAfter == getsBefore { |
| + t.Fatal("call past the ceiling performed no store Gets — it must sweep synchronously, not serve stale data") |
| + } |
| + if len(boxes) != 1 || boxes[0].CPU == nil || *boxes[0].CPU != 0.9 { |
| + t.Fatalf("boxes = %+v, want the FRESH 0.9 — this call must have swept, not served the stale 0.5", boxes) |
| + } |
| +} |
| + |
| +// TestLoadMetricsPastCeilingWaiterCoalescesOntoBackgroundSweep pins the one |
| +// state-machine transition the other tests only exercise as a side effect: |
| +// a caller landing in the blocking COLD-or-STALE-PAST-CEILING branch while a |
| +// BACKGROUND sweep (started earlier by a within-ceiling stale caller) is |
| +// already in flight. LoadMetrics's blocking branch waits on c.sweeping/ |
| +// c.done regardless of which kind of sweep set them — this proves that |
| +// holds for a background sweep specifically, not just another blocking |
| +// caller's, by gating the background sweep at its very first store call so |
| +// the two callers' timing is deterministic rather than a race. |
| +func TestLoadMetricsPastCeilingWaiterCoalescesOntoBackgroundSweep(t *testing.T) { |
| + srv, store := newSlowMetricsFixture(t, 0) |
| + ctx := context.Background() |
| + writeBoxSample(t, store, "box-a", 0.5) |
| + |
| + // Baseline: however many List calls one full sweepAll makes, measured |
| + // the same way the single-flight test does. |
| + beforeCold := store.lists.Load() |
| + if _, _, err := srv.LoadMetrics(ctx); err != nil { |
| + t.Fatal(err) |
| + } |
| + oneSweepLists := store.lists.Load() - beforeCold |
| + |
| + listsBeforeTrigger := store.lists.Load() |
| + |
| + // Gate the NEXT sweep at its first store call, then trigger it via a |
| + // within-ceiling stale call — this is startBackgroundSweep's path, not |
| + // the blocking one. |
| + store.listGate = make(chan struct{}) |
| + srv.metricsCache.mu.Lock() |
| + srv.metricsCache.at = time.Now().Add(-metricsStaleTestBackdate) |
| + srv.metricsCache.mu.Unlock() |
| + writeBoxSample(t, store, "box-a", 0.9) // what the gated sweep will see once released |
| + |
| + if boxes, _, err := srv.LoadMetrics(ctx); err != nil { |
| + t.Fatal(err) |
| + } else if len(boxes) != 1 || boxes[0].CPU == nil || *boxes[0].CPU != 0.5 { |
| + t.Fatalf("triggering call = %+v, want the OLD 0.5 (background sweep just launched, now gated)", boxes) |
| + } |
| + |
| + // Age the SAME cache past the ceiling while the background sweep is |
| + // still stuck at the gate — exactly the state the waiter below lands in. |
| + srv.metricsCache.mu.Lock() |
| + srv.metricsCache.at = time.Now().Add(-(metricsStaleServeCeiling + time.Second)) |
| + srv.metricsCache.mu.Unlock() |
| + |
| + waiterDone := make(chan struct{}) |
| + var waiterBoxes []BoxMetricsView |
| + var waiterErr error |
| + go func() { |
| + defer close(waiterDone) |
| + waiterBoxes, _, waiterErr = srv.LoadMetrics(ctx) |
| + }() |
| + |
| + select { |
| + case <-waiterDone: |
| + t.Fatal("past-ceiling waiter returned before the gated background sweep was released") |
| + case <-time.After(200 * time.Millisecond): |
| + // still blocked, as it must be — coalesced onto the background |
| + // sweep's c.done rather than racing off to start its own. |
| + } |
| + |
| + close(store.listGate) |
| + |
| + select { |
| + case <-waiterDone: |
| + case <-time.After(5 * time.Second): |
| + t.Fatal("past-ceiling waiter never returned after the gate was released") |
| + } |
| + if waiterErr != nil { |
| + t.Fatalf("waiter error = %v, want nil", waiterErr) |
| + } |
| + if len(waiterBoxes) != 1 || waiterBoxes[0].CPU == nil || *waiterBoxes[0].CPU != 0.9 { |
| + t.Fatalf("waiter boxes = %+v, want the FRESH 0.9 the background sweep it coalesced onto produced", waiterBoxes) |
| + } |
| + if got := store.lists.Load() - listsBeforeTrigger; got != oneSweepLists { |
| + t.Fatalf("List calls across the whole gated-sweep-plus-waiter span = %d, want exactly %d (one sweep, not duplicated by the waiter)", got, oneSweepLists) |
| + } |
| + srv.metricsCache.mu.Lock() |
| + sweeping := srv.metricsCache.sweeping |
| + srv.metricsCache.mu.Unlock() |
| + if sweeping { |
| + t.Fatal("cache left wedged (sweeping still true) after the waiter returned") |
| + } |
| +} |
| + |
| +// TestLoadMetricsPastCeilingWaiterTakesOverFailedBackgroundSweep is the |
| +// failure half of the same coalescing transition: the background sweep the |
| +// waiter coalesced onto fails (rather than lands), so the cache stays past |
| +// the ceiling — the waiter must then take the sweep over ITSELF (the |
| +// c.sweeping check it started blocked on now reads false) rather than |
| +// staying blocked or silently giving up, and surface that second attempt's |
| +// error like any ordinary blocking caller would. Nothing may wedge: the |
| +// flag clears either way, and a later call against a healthy store must |
| +// still succeed. |
| +func TestLoadMetricsPastCeilingWaiterTakesOverFailedBackgroundSweep(t *testing.T) { |
| + srv, store := newSlowMetricsFixture(t, 0) |
| + ctx := context.Background() |
| + writeBoxSample(t, store, "box-a", 0.5) |
| + |
| + if _, _, err := srv.LoadMetrics(ctx); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + store.listGate = make(chan struct{}) |
| + srv.metricsCache.mu.Lock() |
| + srv.metricsCache.at = time.Now().Add(-metricsStaleTestBackdate) |
| + srv.metricsCache.mu.Unlock() |
| + |
| + if _, _, err := srv.LoadMetrics(ctx); err != nil { |
| + t.Fatal(err) |
| + } // stale 0.5 returned, background sweep launched and now gated |
| + |
| + srv.metricsCache.mu.Lock() |
| + srv.metricsCache.at = time.Now().Add(-(metricsStaleServeCeiling + time.Second)) |
| + srv.metricsCache.mu.Unlock() |
| + |
| + waiterDone := make(chan struct{}) |
| + var waiterErr error |
| + go func() { |
| + defer close(waiterDone) |
| + _, _, waiterErr = srv.LoadMetrics(ctx) |
| + }() |
| + |
| + select { |
| + case <-waiterDone: |
| + t.Fatal("past-ceiling waiter returned before the gated background sweep was released") |
| + case <-time.After(200 * time.Millisecond): |
| + } |
| + |
| + // The outage starts exactly when the gate opens: the background |
| + // sweep's blocked List call, and any later List call (including the |
| + // waiter's own follow-up sweep), all see the same error from here on. |
| + wantErr := errors.New("bucket had a bad day") |
| + store.listErr = wantErr |
| + close(store.listGate) |
| + |
| + select { |
| + case <-waiterDone: |
| + case <-time.After(5 * time.Second): |
| + t.Fatal("past-ceiling waiter never returned after the gate was released") |
| + } |
| + if waiterErr == nil { |
| + t.Fatal("waiter that took over a failed background sweep returned no error") |
| + } |
| + if !errors.Is(waiterErr, wantErr) { |
| + t.Fatalf("waiter error = %v, want it to wrap %v", waiterErr, wantErr) |
| + } |
| + |
| + waitUntilNotSweeping(t, srv, 5*time.Second) |
| + |
| + // Nothing is permanently wedged: clear the outage and confirm a later |
| + // call against the same (now healthy) store still succeeds. |
| + store.listErr = nil |
| + boxes, _, err := srv.LoadMetrics(ctx) |
| + if err != nil { |
| + t.Fatalf("sweep after the prior failures still failed: %v", err) |
| + } |
| + if len(boxes) != 1 || boxes[0].CPU == nil || *boxes[0].CPU != 0.5 { |
| + t.Fatalf("recovered sweep = %+v, want box-a at 0.5", boxes) |
| + } |
| +} |
| + |
| // errOnKeyStore fails one specific Get with a non-NotExist error and passes |
| // everything else through — the "bucket is answering, but this object will |
| // not read" case, which must fail the whole sweep rather than being skipped |
| @@ -556,6 +1102,63 @@ func TestOwnerAppMetricsFiltersToOwnUnits(t *testing.T) { |
| } |
| } |
| +// TestOwnerAppMetricsSurvivesHealthyFlushCycle pins metricsFreshWindow's |
| +// derivation (flush cadence floor 5min + stale-serve ceiling 2min + 1min |
| +// slack = 8 minutes): a healthy agent's newest VISIBLE sample can be as old |
| +// as flush-cadence-plus-ceiling and still be current, but a sample old |
| +// enough that NOTHING short of a dead agent explains it must still flap to |
| +// no-recent-readings. Three ages, three different hosts/boxes (so the |
| +// "freshest box wins" rule in OwnerAppMetrics can't let one case's fresh |
| +// reading paper over another's staleness): |
| +// |
| +// - 5m30s: the original healthy-worst-case under the OLD (5min-flush-only) |
| +// math — comfortably inside both the old and new window. |
| +// - 7m0s: the NEW healthy worst case this fix widens the window to cover |
| +// — flush cadence's floor (5min) plus the full stale-serve ceiling |
| +// (2min) a caller's cache may be behind by. |
| +// - 8m30s: comfortably past the 8-minute window regardless of derivation |
| +// — must NOT read as current. |
| +func TestOwnerAppMetricsSurvivesHealthyFlushCycle(t *testing.T) { |
| + srv, rel := newMetricsFixture(t) |
| + ctx := context.Background() |
| + |
| + write := func(box, host string, age time.Duration) { |
| + at := time.Now().UTC().Add(-age) |
| + if err := metrics.WriteRaw(ctx, rel, metrics.RawChunk{ |
| + V: 1, Box: box, Hour: metrics.HourOf(at), |
| + Samples: []metrics.Sample{{ |
| + At: at, |
| + Units: map[string]metrics.UnitSample{ |
| + "carlos-app@" + host + ".service": {CPUFrac: p(0.42), MemBytes: pi64(2048)}, |
| + }, |
| + }}, |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + write("box-healthy-1", "flush1.example.com", 5*time.Minute+30*time.Second) |
| + write("box-healthy-2", "flush2.example.com", 7*time.Minute) |
| + write("box-dead", "dead.example.com", 8*time.Minute+30*time.Second) |
| + |
| + rows, err := srv.OwnerAppMetrics(ctx, []string{"flush1.example.com", "flush2.example.com", "dead.example.com"}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + byHost := map[string]InstanceMetrics{} |
| + for _, r := range rows { |
| + byHost[r.Host] = r |
| + } |
| + if r := byHost["flush1.example.com"]; !r.Reporting() { |
| + t.Fatalf("a 5m30s-old sample flapped to no-recent-readings: %+v", r) |
| + } |
| + if r := byHost["flush2.example.com"]; !r.Reporting() { |
| + t.Fatalf("a 7m0s-old sample (flush floor + stale-serve ceiling) flapped to no-recent-readings: %+v", r) |
| + } |
| + if r := byHost["dead.example.com"]; r.Reporting() { |
| + t.Fatalf("an 8m30s-old sample — past the fresh window — still read as current: %+v", r) |
| + } |
| +} |
| + |
| // TestSparkPointsInvertsYAndSkipsNil pins the coordinate math the brief |
| // calls out explicitly: a nil sample contributes no pair at all (not a |
| // zero), and a higher fraction plots at a SMALLER y (SVG's y axis points |
| @@ -666,3 +1269,98 @@ func TestSparkPointsDownsampleKeepsNilUnknown(t *testing.T) { |
| } |
| } |
| } |
| + |
| +// TestOwnerAppMetricsSeesExecBackedInstances pins the second allowlist key: |
| +// an exec-backed host reports under carlos-exec@<host> (the delegated |
| +// cgroup's verbatim name, no .service), and its readings reach the owner |
| +// row exactly like a unit-backed tenant's. Another tenant's exec cgroup |
| +// stays invisible — the allowlist boundary holds for the new key form too. |
| +func TestOwnerAppMetricsSeesExecBackedInstances(t *testing.T) { |
| + srv, rel := newMetricsFixture(t) |
| + ctx := context.Background() |
| + at := time.Now().UTC().Add(-time.Minute) |
| + if err := metrics.WriteRaw(ctx, rel, metrics.RawChunk{ |
| + V: 1, Box: "box-a", Hour: metrics.HourOf(at), |
| + Samples: []metrics.Sample{{ |
| + At: at, |
| + Units: map[string]metrics.UnitSample{ |
| + "carlos-exec@keymail.example.com": {CPUFrac: p(0.22), MemBytes: pi64(2048)}, |
| + "carlos-exec@other.example.com": {CPUFrac: p(0.44), MemBytes: pi64(4096)}, |
| + }, |
| + }}, |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + rows, err := srv.OwnerAppMetrics(ctx, []string{"keymail.example.com"}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(rows) != 1 { |
| + t.Fatalf("rows = %+v, want exactly the one allowlisted host", rows) |
| + } |
| + got := rows[0] |
| + if got.Host != "keymail.example.com" || !got.Reporting() { |
| + t.Fatalf("exec-backed host not reporting: %+v", got) |
| + } |
| + if got.CPUFrac == nil || *got.CPUFrac != 0.22 { |
| + t.Fatalf("CPU = %v, want 0.22", got.CPUFrac) |
| + } |
| + if got.MemBytes == nil || *got.MemBytes != 2048 { |
| + t.Fatalf("mem = %v, want 2048", got.MemBytes) |
| + } |
| + if got.SparkCPU == "" || got.SparkMem == "" { |
| + t.Fatalf("sparklines empty: %+v", got) |
| + } |
| +} |
| + |
| +// TestOwnerAppMetricsExecAndUnitKeysNeverDoubleReport pins the spec's |
| +// no-double-report property: a host can't legally report under both key |
| +// forms at once (one route, one backing), but if a sweep ever carries both |
| +// — a backing flip mid-window, two boxes — the existing freshest-box rule |
| +// picks ONE deterministically. No special case, still exactly one row. |
| +func TestOwnerAppMetricsExecAndUnitKeysNeverDoubleReport(t *testing.T) { |
| + srv, rel := newMetricsFixture(t) |
| + ctx := context.Background() |
| + fresher := time.Now().UTC().Add(-time.Minute) |
| + older := fresher.Add(-2 * time.Minute) |
| + // The older box reports the host as a systemd unit, the fresher box as |
| + // an exec cgroup — the flip-mid-window shape. |
| + if err := metrics.WriteRaw(ctx, rel, metrics.RawChunk{ |
| + V: 1, Box: "box-old", Hour: metrics.HourOf(older), |
| + Samples: []metrics.Sample{{ |
| + At: older, |
| + Units: map[string]metrics.UnitSample{ |
| + "carlos-app@flip.example.com.service": {CPUFrac: p(0.99), MemBytes: pi64(9999)}, |
| + }, |
| + }}, |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := metrics.WriteRaw(ctx, rel, metrics.RawChunk{ |
| + V: 1, Box: "box-new", Hour: metrics.HourOf(fresher), |
| + Samples: []metrics.Sample{{ |
| + At: fresher, |
| + Units: map[string]metrics.UnitSample{ |
| + "carlos-exec@flip.example.com": {CPUFrac: p(0.11), MemBytes: pi64(1111)}, |
| + }, |
| + }}, |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + rows, err := srv.OwnerAppMetrics(ctx, []string{"flip.example.com"}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(rows) != 1 { |
| + t.Fatalf("rows = %+v, want exactly one row for the host, never two", rows) |
| + } |
| + got := rows[0] |
| + if got.Box != "box-new" { |
| + t.Fatalf("Box = %q, want the freshest box-new: %+v", got.Box, got) |
| + } |
| + if got.CPUFrac == nil || *got.CPUFrac != 0.11 { |
| + t.Fatalf("CPU = %v, want the freshest box's 0.11", got.CPUFrac) |
| + } |
| +} |
diff --git a/internal/console/server.go b/internal/console/server.go| index 814be44a1..121523cbb 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -209,9 +209,11 @@ type Server struct { |
| // direct call so an alertPass assertion needs no keymail server. |
| sendApp func(ctx context.Context, token, to, subject, body string) (string, error) |
| // metricsCache holds the result of LoadMetrics's last successful bucket |
| - // sweep (metricsview.go), shared across callers for metricsCacheTTL so |
| - // the operator dashboard and every owner's app-metrics page don't each |
| - // re-sweep 48h of chunks per request. |
| + // sweep (metricsview.go). Concurrent callers within metricsCacheTTL share |
| + // it outright; callers arriving later still get it immediately (stale- |
| + // while-revalidate, up to metricsStaleServeCeiling) so the operator |
| + // dashboard and every owner's app-metrics page don't each re-sweep 48h of |
| + // chunks per request. |
| metricsCache metricsCache |
| // shipSem bounds how many `carlos ship` uploads (apicli.go, |
| // handleAPIShip) may run AT ONCE, as opposed to s.cli.ship, which |
diff --git a/internal/host/agent.go b/internal/host/agent.go| index 41cecde9d..94a784712 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -92,6 +92,7 @@ import ( |
| "github.com/carlosframework/platform/internal/activator" |
| "github.com/carlosframework/platform/internal/appconfig" |
| "github.com/carlosframework/platform/internal/caps" |
| + "github.com/carlosframework/platform/internal/cgroup" |
| "github.com/carlosframework/platform/internal/domains" |
| "github.com/carlosframework/platform/internal/edge" |
| "github.com/carlosframework/platform/internal/lease" |
| @@ -131,6 +132,13 @@ func RunAgent(args []string) { |
| // hibernation branch. |
| cache := edge.NewCache(edge.DefaultCacheMaxEntries, edge.DefaultCacheMaxBodyBytes) |
| + // Exec-child accounting: claim the cgroup subtree the unit file |
| + // delegates to us (Delegate= in carlos-edge.service). Nil when there is |
| + // no delegation — an old unit file, a dev run outside systemd, darwin — |
| + // in which case children spawn exactly as before and simply have no |
| + // per-instance readings. |
| + cgm := cgroup.Setup(log.Printf) |
| + |
| cfg, err := activator.ConfigFromEnv() |
| if err != nil { |
| log.Fatalf("agent: %v", err) |
| @@ -149,6 +157,7 @@ func RunAgent(args []string) { |
| DataDir: dd, |
| Cache: cache, |
| Materialize: materializeHook(reg, dd), |
| + Cgroups: cgm, |
| }, |
| Unit: &activator.UnitBackend{OptDir: release.OptDir()}, |
| }, reg) // the registry satisfies activator.DueStore directly |
| @@ -162,7 +171,7 @@ func RunAgent(args []string) { |
| go statusLoop(reg) |
| go domainsLoop(reg, peekAcmeCache(args)) |
| go instancesLoop(reg) |
| - go metricsLoop(svc) |
| + go metricsLoop(cgm.Root()) |
| go configSyncLoop(reg, svc, dd) |
| go configGenLoop(reg, svc, dd) |
| go socketAdvisoryLoop(reg, svc) |
diff --git a/internal/host/host_test.go b/internal/host/host_test.go| index 52c7d1a4e..f7b844a10 100644 |
| --- a/internal/host/host_test.go |
| +++ b/internal/host/host_test.go |
| @@ -116,11 +116,24 @@ func TestBootstrapWritesUnitsToStagingDir(t *testing.T) { |
| "SystemCallFilter=@system-service", |
| "User=carlos", |
| "Restart=always", |
| + // Delegation is the SOLE mechanism behind exec-instance readings |
| + // fleet-wide (exec-child accounting spec): without it the agent's |
| + // cgroup manager degrades to nil and every exec-backed tenant goes |
| + // blind on the Instances page. |
| + "Delegate=cpu memory io pids", |
| } { |
| if !strings.Contains(string(edge), want) { |
| t.Errorf("carlos-edge.service: missing %q", want) |
| } |
| } |
| + // The counterpart of Delegate=: ProtectControlGroups would mount the |
| + // cgroup tree read-only and silently un-arm delegation. Its hardening is |
| + // retained by ownership (DAC) instead — see the unit file's comment. A |
| + // future "hardening cleanup" that re-adds it must fail here, not on the |
| + // fleet. |
| + if strings.Contains(string(edge), "ProtectControlGroups=") { |
| + t.Error("carlos-edge.service: the ProtectControlGroups= directive is deliberately absent (Delegate= needs the subtree writable); do not re-add it") |
| + } |
| // The tenant socket unit: systemd owns the listener as carlos so the |
| // edge can connect and tenants can't reach each other (the whole point). |
diff --git a/internal/host/metrics_loop.go b/internal/host/metrics_loop.go| index 4d0b3753d..4c7045cec 100644 |
| --- a/internal/host/metrics_loop.go |
| +++ b/internal/host/metrics_loop.go |
| @@ -20,7 +20,6 @@ import ( |
| "path/filepath" |
| "time" |
| - "github.com/carlosframework/platform/internal/activator" |
| "github.com/carlosframework/platform/internal/metrics" |
| "github.com/carlosframework/platform/internal/release" |
| "github.com/carlosframework/platform/internal/status" |
| @@ -618,24 +617,14 @@ func httpPostWebhook(url string, body []byte) error { |
| // realSampleProbes wires sampleProbes to the actual OS/cgroup readers in |
| // metrics_probes.go and probes.go — the production seam metricsLoop uses, |
| // never called from a test (host_test.go's convention: tests inject |
| -// fixtures, never touch /proc or a syscall). svc feeds the units probe the |
| -// activator's awake exec children (AwakeExecPids); nil — no activator |
| -// armed, e.g. plain `carlos edge` — keeps the units probe cgroup-only, |
| -// exactly the pre-#118 behavior, the same nilable-svc convention every |
| -// other activator-fed loop in this package uses. |
| -func realSampleProbes(svc *activator.Service) sampleProbes { |
| - units := realUnitReadings |
| - if svc != nil { |
| - units = func() map[string]unitRaw { |
| - return mergeUnitReadings(realUnitReadings(), execInstanceReadings(svc.AwakeExecPids(), readExecInstanceRaw)) |
| - } |
| - } |
| +// fixtures, never touch /proc or a syscall). |
| +func realSampleProbes(execRoot string) sampleProbes { |
| return sampleProbes{ |
| procStat: readProcStat, |
| loadavg: readLoadAvg, |
| memFrac: readMemFrac, |
| diskFrac: readDiskFrac, |
| - units: units, |
| + units: func() map[string]unitRaw { return realUnitReadings(execRoot) }, |
| now: time.Now, |
| } |
| } |
| @@ -644,10 +633,13 @@ func realSampleProbes(svc *activator.Service) sampleProbes { |
| // each one's cgroup usage — the production units func sampleProbes.units |
| // wraps. Keyed by UNIT NAME (what the console matches against), read from the |
| // unit's own cgroup DIRECTORY (which for a tenant instance is a level down; |
| -// see listCarlosUnits). |
| -func realUnitReadings() map[string]unitRaw { |
| +// see listCarlosUnits) — plus, when delegation is armed, the per-host |
| +// exec-child cgroups under execRoot (keyed carlos-exec@<host>; see |
| +// listExecCgroups). |
| +func realUnitReadings(execRoot string) map[string]unitRaw { |
| units := listCarlosUnits(cgroupSliceDir) |
| - if units == nil { |
| + units = append(units, listExecCgroups(execRoot)...) |
| + if len(units) == 0 { |
| return nil |
| } |
| out := make(map[string]unitRaw, len(units)) |
| @@ -709,10 +701,8 @@ func loadAlertState(path, box string) metrics.AlertState { |
| // still a valid deployment" shape every other optional loop in this package |
| // uses. Flush runs every 5th tick (5 minutes at the 60s tick cadence); |
| // alertTick runs every tick, since a threshold breach deserves faster |
| -// detection than a metrics flush does. svc feeds the units probe the |
| -// activator's awake exec children (nil when no activator is armed) — see |
| -// realSampleProbes. |
| -func metricsLoop(svc *activator.Service) { |
| +// detection than a metrics flush does. |
| +func metricsLoop(execRoot string) { |
| // This loop is always on and shares a process with the agent that serves |
| // this box's traffic, so a panic in it — an unreadable cgroup shape, a |
| // nil no one expected — must not take the box down with it. Monitoring |
| @@ -745,7 +735,7 @@ func metricsLoop(svc *activator.Service) { |
| log.Printf("agent: metrics loop armed (60s ticks, 5min flush), publishing to bucket %s", bucket) |
| sm := &sampler{} |
| - probes := realSampleProbes(svc) |
| + probes := realSampleProbes(execRoot) |
| tick := 0 |
| for { |
| time.Sleep(60 * time.Second) |
diff --git a/internal/host/metrics_probes.go b/internal/host/metrics_probes.go| index ef0ffc744..30ec0a3bb 100644 |
| --- a/internal/host/metrics_probes.go |
| +++ b/internal/host/metrics_probes.go |
| @@ -3,22 +3,20 @@ |
| // a guessed zero. This file is thin OS glue — os.ReadFile and os.ReadDir |
| // wrapping the pure parsers in internal/metrics — and the readers are |
| // deliberately untested by the same convention as probes.go readers; |
| -// internal/metrics tests exercise the parsers with fixtures instead. The one |
| -// exceptions are the functions with decisions in them: listCarlosUnits is a |
| -// directory WALK, not a single read, and which directories it walks has |
| -// already been wrong once (see its doc comment), so it takes its root as an |
| -// argument and is pinned by metrics_probes_test.go against a temp-dir |
| -// fixture; execInstanceReadings/mergeUnitReadings are pure (keying and |
| -// precedence for exec-child readings, issue #118) and are pinned there too. |
| +// internal/metrics tests exercise the parsers with fixtures instead. The |
| +// exceptions are listCarlosUnits and listExecCgroups: they are directory |
| +// WALKs, not single reads, and which directories they walk is a decision that |
| +// has already been wrong once (see listCarlosUnits' doc comment), so they take |
| +// their roots as arguments and are pinned by metrics_probes_test.go against |
| +// temp-dir fixtures. |
| package host |
| import ( |
| "os" |
| "path/filepath" |
| - "strconv" |
| "strings" |
| - "github.com/carlosframework/platform/internal/activator" |
| + "github.com/carlosframework/platform/internal/cgroup" |
| "github.com/carlosframework/platform/internal/metrics" |
| ) |
| @@ -137,73 +135,6 @@ type unitRaw struct { |
| writeBytes *int64 |
| } |
| -// readExecInstanceRaw reads one awake exec child's resource usage from |
| -// /proc/<pid>/{stat,status,io} — the per-pid counterpart of readUnitRaw for |
| -// instances that have no unit cgroup to read (issue #118: an exec-backed |
| -// instance is a direct child of `carlos agent`, inside the agent's own |
| -// cgroup, so the unit walk above can never see it). Same contract as |
| -// readUnitRaw: each field reads and fails independently, toward nil — a pid |
| -// that vanished mid-read (raced its own exit) contributes nils, never zeros. |
| -// Known limit, documented in the design: a pid reading covers ONE process, |
| -// not a subtree — an app that forks workers under-reports here where a |
| -// cgroup would not. |
| -func readExecInstanceRaw(pid int) unitRaw { |
| - dir := filepath.Join("/proc", strconv.Itoa(pid)) |
| - raw := unitRaw{} |
| - if b, err := os.ReadFile(filepath.Join(dir, "stat")); err == nil { |
| - raw.cpuUsec = metrics.ParsePidStatCPUUsec(string(b)) |
| - } |
| - if b, err := os.ReadFile(filepath.Join(dir, "status")); err == nil { |
| - raw.memBytes, raw.memPeakBytes = metrics.ParsePidStatusMem(string(b)) |
| - } |
| - if b, err := os.ReadFile(filepath.Join(dir, "io")); err == nil { |
| - raw.readBytes, raw.writeBytes = metrics.ParsePidIO(string(b)) |
| - } |
| - return raw |
| -} |
| - |
| -// execInstanceReadings turns the activator's awake host→pid map into unit |
| -// readings keyed by the SAME carlos-app@<host>.service name the console's |
| -// owner view matches (activator.UnitName) — which is what makes the whole |
| -// fix additive: the wire shape (Sample.Units, a string-keyed map) and the |
| -// console's allowlist both take the new entries as-is. An exec-backed host |
| -// has no such unit by construction (units are for the edge; instances are |
| -// the agent's children), so the key cannot collide with a real cgroup |
| -// reading on this box; mergeUnitReadings still defines the winner in case |
| -// that ever changes. read is a parameter (readExecInstanceRaw in |
| -// production) so the keying is testable without /proc. |
| -func execInstanceReadings(pids map[string]int, read func(pid int) unitRaw) map[string]unitRaw { |
| - if len(pids) == 0 { |
| - return nil |
| - } |
| - out := make(map[string]unitRaw, len(pids)) |
| - for host, pid := range pids { |
| - out[activator.UnitName(host)] = read(pid) |
| - } |
| - return out |
| -} |
| - |
| -// mergeUnitReadings composes the units probe's two sources: the cgroup walk |
| -// (every systemd-backed CARLOS unit) plus the per-pid readings for awake |
| -// exec children. On a key collision the cgroup reading wins — it accounts a |
| -// whole subtree where the pid reading covers one process, so it can only be |
| -// more complete. Both-empty stays nil (no readings, not an empty set), and |
| -// a nil cgroup map with live exec children still reports the children — a |
| -// box whose cgroup walk failed has not stopped running instances. |
| -func mergeUnitReadings(cgroup, exec map[string]unitRaw) map[string]unitRaw { |
| - if len(exec) == 0 { |
| - return cgroup |
| - } |
| - out := make(map[string]unitRaw, len(cgroup)+len(exec)) |
| - for name, u := range exec { |
| - out[name] = u |
| - } |
| - for name, u := range cgroup { |
| - out[name] = u |
| - } |
| - return out |
| -} |
| - |
| // readUnitRaw reads a unit's resource usage from the cgroup v2 files in |
| // unitPath — the directory listCarlosUnits reported for that unit, which for |
| // an instantiated unit is NOT <cgroupSliceDir>/<name>. Each file read failure |
| @@ -235,3 +166,37 @@ func readUnitRaw(unitPath string) unitRaw { |
| return raw |
| } |
| + |
| +// listExecCgroups lists the per-host exec-child cgroups under the agent's |
| +// delegated subtree — the third scan root, alongside listCarlosUnits' two |
| +// levels. An empty root (no delegation armed) or a read failure -> nil. The |
| +// agent's own supervise leaf fails IsExecName and is skipped: that process |
| +// is already reported as carlos-edge.service. A dir whose cgroup.procs is |
| +// empty or unreadable is skipped too: a failed seed or an aborted wake |
| +// leaves the dir behind (until a later exit's rmdir or the next agent |
| +// start's sweep), and reporting it would render a host whose wake FAILED |
| +// as a live instance at zero — absence is the data, never a guessed zero. |
| +// readUnitRaw reads the included dirs unchanged — same cgroup v2 files, |
| +// same fail-toward-nil fields. |
| +func listExecCgroups(root string) []unitCgroup { |
| + if root == "" { |
| + return nil |
| + } |
| + entries, err := os.ReadDir(root) |
| + if err != nil { |
| + return nil |
| + } |
| + var units []unitCgroup |
| + for _, entry := range entries { |
| + if !entry.IsDir() || !cgroup.IsExecName(entry.Name()) { |
| + continue |
| + } |
| + dir := filepath.Join(root, entry.Name()) |
| + procs, err := os.ReadFile(filepath.Join(dir, "cgroup.procs")) |
| + if err != nil || len(strings.TrimSpace(string(procs))) == 0 { |
| + continue |
| + } |
| + units = append(units, unitCgroup{name: entry.Name(), dir: dir}) |
| + } |
| + return units |
| +} |
diff --git a/internal/host/metrics_probes_test.go b/internal/host/metrics_probes_test.go| index bf9d7e443..0e691d0ca 100644 |
| --- a/internal/host/metrics_probes_test.go |
| +++ b/internal/host/metrics_probes_test.go |
| @@ -89,60 +89,46 @@ func TestReadUnitRawReadsFromUnitDir(t *testing.T) { |
| } |
| } |
| -// Exec children publish under the SAME carlos-app@<host>.service key the |
| -// console's owner allowlist matches (activator.UnitName) — that keying is |
| -// the whole fix for issue #118, so it's pinned here: get it wrong and the |
| -// readings publish fine but match nothing, silently reopening the gap. |
| -func TestExecInstanceReadingsKeysByUnitName(t *testing.T) { |
| - cpu := int64(5_000_000) |
| - got := execInstanceReadings(map[string]int{"lpay.bde.oncarlos.com": 4242}, func(pid int) unitRaw { |
| - if pid != 4242 { |
| - t.Fatalf("read called with pid %d, want 4242", pid) |
| +// TestListExecCgroupsFindsPerHostDirs pins the delegated subtree's scan: |
| +// carlos-exec@<host> dirs with live members come out with their names |
| +// VERBATIM as sample keys (deliberately not dressed up as .service units — |
| +// an operator who takes a dashboard row to systemctl must not be told a |
| +// unit exists that doesn't). The agent's own supervise leaf and foreign |
| +// names are invisible; so is a MEMBER-LESS dir (empty or missing |
| +// cgroup.procs) — a failed seed's leftover must read as absent, never as a |
| +// live instance at zero. An empty root — the no-delegation state — scans |
| +// nothing. |
| +func TestListExecCgroupsFindsPerHostDirs(t *testing.T) { |
| + root := t.TempDir() |
| + mk := func(name, procs string) string { |
| + t.Helper() |
| + dir := filepath.Join(root, name) |
| + if err := os.MkdirAll(dir, 0o755); err != nil { |
| + t.Fatal(err) |
| } |
| - return unitRaw{cpuUsec: &cpu} |
| - }) |
| - raw, ok := got["carlos-app@lpay.bde.oncarlos.com.service"] |
| - if !ok || len(got) != 1 { |
| - t.Fatalf("got keys %v, want exactly carlos-app@lpay.bde.oncarlos.com.service", got) |
| - } |
| - if raw.cpuUsec == nil || *raw.cpuUsec != cpu { |
| - t.Fatalf("reading not carried through: %v", raw.cpuUsec) |
| - } |
| - |
| - if execInstanceReadings(nil, nil) != nil { |
| - t.Fatal("no pids must be nil, not an empty map") |
| - } |
| -} |
| - |
| -func TestMergeUnitReadings(t *testing.T) { |
| - v := func(n int64) unitRaw { return unitRaw{cpuUsec: &n} } |
| - deref := func(u unitRaw) int64 { return *u.cpuUsec } |
| - |
| - // Both sources present: union, cgroup wins the collision (it accounts a |
| - // whole subtree; the pid reading is one process). |
| - got := mergeUnitReadings( |
| - map[string]unitRaw{"carlos-edge.service": v(1), "carlos-app@both.example.com.service": v(2)}, |
| - map[string]unitRaw{"carlos-app@x.example.com.service": v(3), "carlos-app@both.example.com.service": v(4)}, |
| - ) |
| - if len(got) != 3 { |
| - t.Fatalf("got %d keys, want 3", len(got)) |
| - } |
| - if deref(got["carlos-app@both.example.com.service"]) != 2 { |
| - t.Fatal("collision must keep the cgroup reading") |
| - } |
| - if deref(got["carlos-edge.service"]) != 1 || deref(got["carlos-app@x.example.com.service"]) != 3 { |
| - t.Fatal("non-colliding readings must survive from both sides") |
| + if procs != "" { |
| + if err := os.WriteFile(filepath.Join(dir, "cgroup.procs"), []byte(procs), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + return dir |
| } |
| + execDir := mk("carlos-exec@keymail.dev", "1234\n") |
| + mk("supervise", "1\n") |
| + mk("carlos-exec@", "99\n") // no host — not one of ours |
| + mk("random-dir", "77\n") // foreign name |
| + mk("carlos-exec@failed.example.com", "\n") // member-less: whitespace-only procs |
| + mk("carlos-exec@aborted.example.com", "") // member-less: no procs file at all |
| - // A failed cgroup walk (nil) with live exec children still reports them. |
| - got = mergeUnitReadings(nil, map[string]unitRaw{"carlos-app@x.example.com.service": v(3)}) |
| + got := listExecCgroups(root) |
| if len(got) != 1 { |
| - t.Fatalf("nil cgroup side: got %v, want the exec reading alone", got) |
| + t.Fatalf("discovered %d cgroups, want 1: %+v", len(got), got) |
| + } |
| + if got[0].name != "carlos-exec@keymail.dev" || got[0].dir != execDir { |
| + t.Fatalf("got %+v, want name carlos-exec@keymail.dev dir %s", got[0], execDir) |
| } |
| - // No exec children: the cgroup map passes through untouched — including |
| - // nil staying nil ("no readings", not an empty set of them). |
| - if mergeUnitReadings(nil, nil) != nil { |
| - t.Fatal("both-nil must stay nil") |
| + if out := listExecCgroups(""); out != nil { |
| + t.Fatalf("empty root must scan nothing, got %+v", out) |
| } |
| } |
diff --git a/internal/host/units/carlos-edge.service b/internal/host/units/carlos-edge.service| index aa41e536a..ee57b7f40 100644 |
| --- a/internal/host/units/carlos-edge.service |
| +++ b/internal/host/units/carlos-edge.service |
| @@ -38,7 +38,11 @@ ProtectHostname=yes |
| ProtectKernelTunables=yes |
| ProtectKernelModules=yes |
| ProtectKernelLogs=yes |
| -ProtectControlGroups=yes |
| +# ProtectControlGroups is deliberately ABSENT: it would mount /sys/fs/cgroup |
| +# read-only, and Delegate= (below) needs this unit's own subtree writable. |
| +# The hardening that line bought is retained by ownership instead — plain |
| +# DAC keeps everything OUTSIDE the chowned subtree read-only to this |
| +# unprivileged user. |
| ProtectProc=invisible |
| RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX |
| RestrictNamespaces=yes |
| @@ -53,6 +57,13 @@ SystemCallArchitectures=native |
| # No MemoryMax: hibernating instances are this unit's children, so a cap here |
| # would cap the whole set of awake instances at once. |
| TasksMax=1024 |
| +# Exec-backed instances run as this unit's children. Delegation hands the |
| +# unit its own cgroup subtree — chowned to User=carlos, unmanaged by systemd |
| +# below this point — so the agent can give each child its own cgroup for |
| +# per-instance accounting (docs/superpowers/specs/ |
| +# 2026-08-07-exec-child-accounting-design.md). The subtree still dies with |
| +# the service: restart semantics are unchanged. |
| +Delegate=cpu memory io pids |
| [Install] |
| WantedBy=multi-user.target |
diff --git a/internal/metrics/pidparse.go b/internal/metrics/pidparse.go| deleted file mode 100644 |
| index 1bb70e736..000000000 |
| --- a/internal/metrics/pidparse.go |
| +++ /dev/null |
| @@ -1,101 +0,0 @@ |
| -// /proc/<pid> parsers — the per-process counterpart of the cgroup parsers in |
| -// parse.go, for instances that have no unit cgroup to read: an exec-backed |
| -// instance is a direct child of `carlos agent` inside the agent's own cgroup |
| -// (issue #118), so its readings come from /proc/<pid>/{stat,status,io} |
| -// instead. Same package contract as everything else here: parse text the |
| -// caller read, fail toward nil, never touch the OS. |
| -package metrics |
| - |
| -import ( |
| - "strconv" |
| - "strings" |
| -) |
| - |
| -// userHZ is the tick unit of /proc/<pid>/stat's utime/stime fields. The |
| -// kernel fixes the userspace-visible USER_HZ at 100 on every architecture |
| -// CARLOS runs on (it is independent of the kernel's internal CONFIG_HZ), so |
| -// a constant is the honest value — there is no portable syscall to ask for |
| -// it without cgo. |
| -const userHZ = 100 |
| - |
| -// ParsePidStatCPUUsec parses /proc/<pid>/stat and returns utime+stime as CPU |
| -// microseconds — the same unit as cpu.stat's usage_usec, so the reading |
| -// feeds UnitCPUFrac's delta machinery unchanged. The comm field (2) is the |
| -// process name in parentheses and may itself contain spaces and parens, so |
| -// fields are located from the LAST ')' — counting from the front would |
| -// misparse any binary with a space in its name. After that anchor the line |
| -// reads: state ppid pgrp session tty tpgid flags minflt cminflt majflt |
| -// cmajflt utime stime — utime and stime at offsets 11 and 12. Too few |
| -// fields or a non-numeric tick count -> nil. |
| -func ParsePidStatCPUUsec(text string) *int64 { |
| - i := strings.LastIndexByte(text, ')') |
| - if i < 0 { |
| - return nil |
| - } |
| - fields := strings.Fields(text[i+1:]) |
| - if len(fields) < 13 { |
| - return nil |
| - } |
| - utime, err1 := strconv.ParseInt(fields[11], 10, 64) |
| - stime, err2 := strconv.ParseInt(fields[12], 10, 64) |
| - if err1 != nil || err2 != nil { |
| - return nil |
| - } |
| - usec := (utime + stime) * (1e6 / userHZ) |
| - return &usec |
| -} |
| - |
| -// ParsePidStatusMem parses /proc/<pid>/status and returns VmRSS (resident |
| -// set) and VmHWM (its peak) in bytes. RSS rather than anything |
| -// cache-inclusive on purpose: a per-process reading cannot attribute page |
| -// cache the way memory.current does, and for "what your app uses" resident |
| -// memory is the honest number it CAN give. Each field fails toward nil |
| -// independently — the kernel prints these in kB always, so any other unit |
| -// suffix means the line is being misread and nils that field rather than |
| -// guessing a scale. |
| -func ParsePidStatusMem(text string) (rss, hwm *int64) { |
| - for _, line := range strings.Split(text, "\n") { |
| - fields := strings.Fields(line) |
| - if len(fields) != 3 || fields[2] != "kB" { |
| - continue |
| - } |
| - v, err := strconv.ParseInt(fields[1], 10, 64) |
| - if err != nil { |
| - continue |
| - } |
| - bytes := v * 1024 |
| - switch fields[0] { |
| - case "VmRSS:": |
| - rss = &bytes |
| - case "VmHWM:": |
| - hwm = &bytes |
| - } |
| - } |
| - return rss, hwm |
| -} |
| - |
| -// ParsePidIO parses /proc/<pid>/io and returns read_bytes / write_bytes — |
| -// actual block-device I/O, cumulative since process start, the same |
| -// semantics as the cgroup io.stat counters. rchar/wchar are deliberately |
| -// ignored: they count every read/write syscall including cache hits, a |
| -// different (and much larger) number. Each field fails toward nil |
| -// independently. |
| -func ParsePidIO(text string) (rbytes, wbytes *int64) { |
| - for _, line := range strings.Split(text, "\n") { |
| - key, val, ok := strings.Cut(line, ":") |
| - if !ok { |
| - continue |
| - } |
| - v, err := strconv.ParseInt(strings.TrimSpace(val), 10, 64) |
| - if err != nil { |
| - continue |
| - } |
| - switch key { |
| - case "read_bytes": |
| - rbytes = &v |
| - case "write_bytes": |
| - wbytes = &v |
| - } |
| - } |
| - return rbytes, wbytes |
| -} |
diff --git a/internal/metrics/pidparse_test.go b/internal/metrics/pidparse_test.go| deleted file mode 100644 |
| index 85d8dfdd0..000000000 |
| --- a/internal/metrics/pidparse_test.go |
| +++ /dev/null |
| @@ -1,83 +0,0 @@ |
| -package metrics |
| - |
| -import "testing" |
| - |
| -func TestParsePidStatCPUUsec(t *testing.T) { |
| - // pid (comm) state ppid pgrp session tty tpgid flags minflt cminflt |
| - // majflt cmajflt utime stime ... — utime=250 stime=150 ticks at USER_HZ |
| - // 100 is 4.0 CPU-seconds = 4_000_000 usec. |
| - line := "1234 (lpay) S 1 1234 1234 0 -1 4194560 900 0 0 0 250 150 0 0 20 0 8 0 12345 1000000 500 18446744073709551615 1 1 0 0 0 0 0 0 0 0 0 0 17 3 0 0 0 0 0" |
| - v := ParsePidStatCPUUsec(line) |
| - if v == nil || *v != 4_000_000 { |
| - t.Fatalf("got %v, want 4000000", v) |
| - } |
| - |
| - // comm may contain spaces AND parens; everything before the LAST ')' is |
| - // name, so the field offsets must be taken from there. |
| - tricky := "1234 (my app) (v2)) R 1 1234 1234 0 -1 4194560 900 0 0 0 100 100 0 0 20 0 8 0 12345 1000000 500 0 1 1 0 0 0 0 0 0 0 0 0 0 17 3 0 0 0 0 0" |
| - v = ParsePidStatCPUUsec(tricky) |
| - if v == nil || *v != 2_000_000 { |
| - t.Fatalf("paren-comm: got %v, want 2000000", v) |
| - } |
| - |
| - for _, bad := range []string{ |
| - "", |
| - "no parens at all", |
| - "1234 (lpay) S 1 2", // too few fields after comm |
| - "1234 (lpay) S 1 2 3 4 5 6 7 8 9 10 x 150 0", // non-numeric utime |
| - } { |
| - if ParsePidStatCPUUsec(bad) != nil { |
| - t.Fatalf("garbage %q must be nil", bad) |
| - } |
| - } |
| -} |
| - |
| -func TestParsePidStatusMem(t *testing.T) { |
| - text := "Name:\tlpay\nVmPeak:\t 20000 kB\nVmHWM:\t 9000 kB\nVmRSS:\t 8192 kB\nThreads:\t8\n" |
| - rss, hwm := ParsePidStatusMem(text) |
| - if rss == nil || *rss != 8192*1024 { |
| - t.Fatalf("rss: got %v, want %d", rss, 8192*1024) |
| - } |
| - if hwm == nil || *hwm != 9000*1024 { |
| - t.Fatalf("hwm: got %v, want %d", hwm, 9000*1024) |
| - } |
| - |
| - // Each field fails toward nil independently: a kernel thread has no VmRSS |
| - // line at all, and a garbled VmHWM must not take VmRSS down with it. |
| - rss, hwm = ParsePidStatusMem("Name:\tx\nVmRSS:\t 100 kB\nVmHWM:\tgarbage kB\n") |
| - if rss == nil || *rss != 100*1024 || hwm != nil { |
| - t.Fatalf("independent fields: got rss=%v hwm=%v", rss, hwm) |
| - } |
| - |
| - rss, hwm = ParsePidStatusMem("") |
| - if rss != nil || hwm != nil { |
| - t.Fatal("empty must be both-nil") |
| - } |
| - |
| - // A VmRSS value not in kB (the kernel never emits another unit, so an |
| - // unexpected one means we're misreading) must be nil, not a wrong scale. |
| - rss, _ = ParsePidStatusMem("VmRSS:\t 100 mB\n") |
| - if rss != nil { |
| - t.Fatal("non-kB unit must be nil") |
| - } |
| -} |
| - |
| -func TestParsePidIO(t *testing.T) { |
| - text := "rchar: 999\nwchar: 888\nsyscr: 10\nsyscw: 20\nread_bytes: 4096\nwrite_bytes: 8192\ncancelled_write_bytes: 0\n" |
| - r, w := ParsePidIO(text) |
| - if r == nil || *r != 4096 || w == nil || *w != 8192 { |
| - t.Fatalf("got r=%v w=%v, want 4096/8192", r, w) |
| - } |
| - |
| - // Independent nils: rchar/wchar (which count ALL io including cached) |
| - // must never stand in for the missing block-io counters. |
| - r, w = ParsePidIO("rchar: 999\nread_bytes: 4096\n") |
| - if r == nil || *r != 4096 || w != nil { |
| - t.Fatalf("independent fields: got r=%v w=%v", r, w) |
| - } |
| - |
| - r, w = ParsePidIO("") |
| - if r != nil || w != nil { |
| - t.Fatal("empty must be both-nil") |
| - } |
| -} |
diff --git a/internal/metrics/sample.go b/internal/metrics/sample.go| index 99dcef827..da14784a0 100644 |
| --- a/internal/metrics/sample.go |
| +++ b/internal/metrics/sample.go |
| @@ -27,8 +27,10 @@ type UnitSample struct { |
| } |
| // Sample is one point-in-time reading of a box: the host as a whole plus |
| -// every monitored unit, keyed by full unit name (e.g. |
| -// carlos-app@x.example.com.service). |
| +// every monitored unit, keyed by unit name: systemd unit names (e.g. |
| +// carlos-app@x.example.com.service) and delegated exec-child cgroup names |
| +// (e.g. carlos-exec@x.example.com — no .service suffix; written by the |
| +// sampler's exec-subtree scan since exec-child accounting). |
| type Sample struct { |
| At time.Time `json:"at"` |
| Host HostSample `json:"host"` |