//go:build linux package activator import ( "context" "errors" "fmt" "os" "os/exec" "path/filepath" "slices" "strconv" "strings" "testing" "time" "github.com/carlosframework/platform/internal/cgroup" "github.com/carlosframework/platform/internal/provision" "github.com/carlosframework/platform/internal/registry" ) // Amadan #272, real-unit check (d): on the dev box (arm64, kernel 6.12, // systemd 257) a generation started under a carlos-exec@ that still // held a process directly ran with no memory limit, silently, while the // strict path refused. This runs the same shape against the real cgroup // filesystem: a flat start (the older agent's layout) puts a process // directly in the parent, and a generation start beside it must be refused // before it creates a g* child or execs anything. The unit-level cases // (internal/cgroup gen_test.go, containment_test.go) use a fake cgroupfs; // only this one proves the kernel's side. Same gate as the claim-spawn // kernel test: a disposable delegated user service, and the synthetic // child TestClaimSpawnKernelChild. func TestGenerationStartRefusedBesideADirectParentMember(t *testing.T) { if os.Getenv("CARLOS_CLAIM_SPAWN_KERNEL") != "1" { t.Skip("requires a disposable delegated user service") } manager := cgroup.Setup(t.Logf) if manager == nil { t.Fatal("missing delegated cgroup") } const host = "genparent.example" parent := filepath.Join(manager.Root(), cgroup.ExecName(host)) t.Cleanup(func() { manager.CleanupHost(host) }) legacy, err := manager.Start(host, cgroup.TierDefault, func() *exec.Cmd { cmd := exec.Command(os.Args[0], "-test.run=^TestClaimSpawnKernelChild$", "-test.count=1") cmd.Env = []string{"CARLOS_CLAIM_SPAWN_CHILD=1"} cmd.Dir = t.TempDir() return cmd }) if err != nil || legacy == nil || legacy.Process == nil { t.Fatalf("flat start: %v, %v", legacy, err) } t.Cleanup(func() { _ = legacy.Process.Kill() _ = legacy.Wait() }) procs, err := os.ReadFile(filepath.Join(parent, "cgroup.procs")) if err != nil || !strings.Contains(string(procs), "\n") { t.Fatalf("the flat start's process is not a direct member of %s: %q, %v", parent, procs, err) } b := testExecBackend(t) b.Cgroups = manager b.GenContainment = true ran := false cmd, _, err := b.spawnInCgroupAt(registry.Route{Host: host}, cgroup.GenChild(1), true, func() *exec.Cmd { ran = true return exec.Command("/bin/true") }) if !errors.Is(err, cgroup.ErrLegacyParentPopulated) || cmd != nil { t.Fatalf("generation start beside a direct member = %v, %v: want ErrLegacyParentPopulated and no command", cmd, err) } if ran { t.Error("the generation's command was built for exec") } ents, err := os.ReadDir(parent) if err != nil { t.Fatal(err) } for _, e := range ents { if e.IsDir() { t.Errorf("the refused start left %s under the parent", e.Name()) } } // Positive controls (zdd readiness round 2, Task 7): the refusal above // says nothing unless the same start succeeds, capped, where the parent // holds no process directly. The sibling control runs on another host; // the recovery control is this host once its direct member is gone. t.Run("sibling control", func(t *testing.T) { genParentSiblingControl(t, manager) }) t.Run("recovery control", func(t *testing.T) { genParentRecoveryControl(t, b, host, parent, legacy) }) } // genParentKernelChild is the sleeper TestClaimSpawnKernelChild runs as. func genParentKernelChild(t *testing.T) func() *exec.Cmd { dir := t.TempDir() return func() *exec.Cmd { cmd := exec.Command(os.Args[0], "-test.run=^TestClaimSpawnKernelChild$", "-test.count=1") cmd.Env = []string{"CARLOS_CLAIM_SPAWN_CHILD=1"} cmd.Dir = dir return cmd } } // readCgroupFile reads one cgroupfs file, trimmed. func readCgroupFile(t *testing.T, path string) string { t.Helper() b, err := os.ReadFile(path) if err != nil { t.Fatalf("read %s: %v", path, err) } return strings.TrimSpace(string(b)) } // expectGenParent checks a generation parent the layout made: no process // of its own, memory delegated to its children, memory.oom.group=0. func expectGenParent(t *testing.T, parent string) { t.Helper() if got := readCgroupFile(t, filepath.Join(parent, "cgroup.procs")); got != "" { t.Errorf("the parent holds processes directly: %q", got) } if got := readCgroupFile(t, filepath.Join(parent, "cgroup.subtree_control")); !slices.Contains(strings.Fields(got), "memory") { t.Errorf("the parent's subtree_control is %q, want memory delegated", got) } if got := readCgroupFile(t, filepath.Join(parent, "memory.oom.group")); got != "0" { t.Errorf("the parent's memory.oom.group = %q, want 0", got) } } // expectCappedGen checks that cmd runs in host's parent/ and that the // child carries the default tier. func expectCappedGen(t *testing.T, host, parent, child string, cmd *exec.Cmd) { t.Helper() tier := strconv.FormatInt(cgroup.TierDefault.MemoryMax, 10) if got := readCgroupFile(t, filepath.Join(parent, child, "memory.max")); got != tier { t.Errorf("%s memory.max = %q, want the tier %s", child, got, tier) } own, ok := cgroup.ParseSelfCgroup(readCgroupFile(t, filepath.Join("/proc", strconv.Itoa(cmd.Process.Pid), "cgroup"))) if want := "/" + cgroup.ExecName(host) + "/" + child; !ok || !strings.HasSuffix(own, want) { t.Errorf("pid %d runs in %q, want .../%s", cmd.Process.Pid, own, want[1:]) } } // genParentSiblingControl: on a host whose parent holds no process, g1 // starts capped, and g2 starts capped beside the live g1 once the parent is // raised, as a swap's overlap does. func genParentSiblingControl(t *testing.T, manager *cgroup.Manager) { const host = "genparent-ok.example" b := &ExecBackend{Cgroups: manager, GenContainment: true, DataDir: t.TempDir(), OptDir: t.TempDir()} rt := registry.Route{Host: host, Kind: "instance", DBPath: provision.DefaultDBPath(b.DataDir, host), Socket: filepath.Join(provision.SocketDir, host+".sock")} parent := filepath.Join(manager.Root(), cgroup.ExecName(host)) t.Cleanup(func() { manager.CleanupHost(host) }) // registered first, so it runs last build := genParentKernelChild(t) g1, _, err := b.spawnInCgroupAt(rt, cgroup.GenChild(1), true, build) if err != nil || g1 == nil { t.Fatalf("g1 under an empty parent: %v, %v", g1, err) } t.Cleanup(func() { _ = g1.Process.Kill() }) g1Done := spawnStaysUp(t, "g1", g1) expectGenParent(t, parent) expectCappedGen(t, host, parent, cgroup.GenChild(1), g1) if err := manager.SetParentLimit(host, cgroup.OverlapLimit(cgroup.TierDefault, cgroup.TierDefault)); err != nil { t.Fatal(err) } g2, _, err := b.spawnInCgroupAt(rt, cgroup.GenChild(2), true, build) if err != nil || g2 == nil { t.Fatalf("g2 beside a live g1: %v, %v", g2, err) } t.Cleanup(func() { _ = g2.Process.Kill() }) g2Done := spawnStaysUp(t, "g2", g2) expectGenParent(t, parent) expectCappedGen(t, host, parent, cgroup.GenChild(2), g2) select { case err := <-g1Done: t.Fatalf("g1 died while g2 started beside it: %v", err) default: } for _, g := range []struct { n uint64 done <-chan error }{{2, g2Done}, {1, g1Done}} { ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second) err := b.KillGen(ctx, rt, g.n) cancel() if err != nil { t.Fatalf("KillGen %d: %v", g.n, err) } reaped(t, fmt.Sprintf("g%d", g.n), g.done) } } // genParentRecoveryControl: once the direct member the refusal named has // exited, the same host starts a capped g1 under a parent the layout made. // This is the manual recovery amadan #272's ruling relies on. func genParentRecoveryControl(t *testing.T, b *ExecBackend, host, parent string, legacy *exec.Cmd) { _ = legacy.Process.Kill() _ = legacy.Wait() deadline := time.Now().Add(5 * time.Second) for readCgroupFile(t, filepath.Join(parent, "cgroup.procs")) != "" { if time.Now().After(deadline) { t.Fatal("the parent still holds the killed legacy process") } time.Sleep(20 * time.Millisecond) } g1, _, err := b.spawnInCgroupAt(registry.Route{Host: host}, cgroup.GenChild(1), true, genParentKernelChild(t)) if err != nil || g1 == nil { t.Fatalf("g1 after the legacy process exited: %v, %v", g1, err) } t.Cleanup(func() { _ = g1.Process.Kill() }) // a failed check below must not leave g1 running done := spawnStaysUp(t, "recovered g1", g1) expectGenParent(t, parent) expectCappedGen(t, host, parent, cgroup.GenChild(1), g1) _ = g1.Process.Kill() reaped(t, "recovered g1", done) b.Cgroups.RemoveChild(host, cgroup.GenChild(1)) }