//go:build linux package host import ( "context" "database/sql" "errors" "os" "path/filepath" "strings" "testing" "github.com/carlosframework/platform/internal/cgroup" "github.com/carlosframework/platform/internal/hostbox" "github.com/carlosframework/platform/internal/hostguard" "github.com/carlosframework/platform/internal/instancectl" ) type changingLaunchHosts struct { records []hostbox.LaunchInventory next []hostbox.LaunchInventory calls int } func (j *changingLaunchHosts) HostLaunches(context.Context, string) ([]hostbox.LaunchInventory, error) { j.calls++ if j.calls > 1 && j.next != nil { return j.next, nil } return j.records, nil } func TestPlan14UnjournaledStopAbsence(t *testing.T) { for _, mode := range []string{"absent", "predecessor", "journal", "changed-journal", "descriptor", "containment", "isolated", "inventory"} { t.Run(mode, func(t *testing.T) { f := newWritersKernelFixture(t) f.dropAgentHolds(t) f.target.FenceID = "fence" j := &changingLaunchHosts{} switch mode { case "predecessor": target := f.target target.Stamp-- j.records = []hostbox.LaunchInventory{{Target: target}} case "journal": j.records = []hostbox.LaunchInventory{{Target: f.target}} case "changed-journal": j.next = []hostbox.LaunchInventory{{Target: f.target}} case "descriptor": f.holdDB(t, 30, 3) case "containment", "isolated": dir := filepath.Join(f.agentCgroup, cgroup.ExecName(claimContainmentName(f.target))) if mode == "isolated" { // Use the same canonical host slice as the production observer. dir = filepath.Join(f.cgroupRoot, filepath.Dir(instancectl.ClaimUnitControlGroup(f.target.Host))) } if err := os.MkdirAll(dir, 0700); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, "cgroup.events"), []byte("populated 1\n"), 0600); err != nil { t.Fatal(err) } case "inventory": f.inv.Entries[0].Inode++ } _, err := verifyNoLaunchAt(t.Context(), f.roots(), j, f.target, f.inv) wantPass := mode == "absent" || mode == "predecessor" if (err == nil) != wantPass { t.Fatalf("%s: %v", mode, err) } }) } } type unjournaledKernel struct { stopKernelFunc calls int evidence StopKernelEvidence } func (k *unjournaledKernel) VerifyUnjournaled(context.Context, hostguard.FenceTarget, StopInventory) (StopKernelEvidence, error) { k.calls++ return k.evidence, nil } func TestPlan14UnjournaledStopRequiresRootFence(t *testing.T) { c, _, _ := claimStopFixture(t) k := &unjournaledKernel{} c.Kernel = k for _, cause := range []error{sql.ErrNoRows, os.ErrPermission, hostbox.ErrTypedInventory} { if _, err := c.verifyUnjournaled(t.Context(), hostguard.FenceTarget{}, StopInventory{}, cause); !errors.Is(err, cause) || k.calls != 0 { t.Fatal("legacy fallback", err) } } c.Guard.Root = hostguard.NewRootAuthority(&rollbackMemory{files: map[string][]byte{}}) if _, err := c.verifyUnjournaled(t.Context(), hostguard.FenceTarget{Host: "shop.example", Incarnation: "inc-1", Stamp: 11}, StopInventory{}, sql.ErrNoRows); err == nil || k.calls != 0 { t.Fatal("missing root fence accepted", err) } } func (k *unjournaledKernel) RecoverStopped(context.Context, hostguard.FenceTarget, StopInventory) (StopKernelEvidence, DrainAcknowledgement, error) { return StopKernelEvidence{}, DrainAcknowledgement{}, sql.ErrNoRows } func TestPlan14UnjournaledStopRecoveryReceipt(t *testing.T) { c, req, _ := claimStopFixture(t) c.Guard.Root = hostguard.NewRootAuthority(&rollbackMemory{files: map[string][]byte{}}) in := hostguard.RootInstallation{Host: req.Host, Incarnation: req.Incarnation, Stamp: req.InstallationStamp, Account: "bab", App: "app", Record: "record", Box: req.Box, Enrollment: req.Enrollment, Migration: 1} if err := c.Guard.Root.RetainMigrationDeny(t.Context(), in, req.WriterSession); err != nil { t.Fatal(err) } k := &unjournaledKernel{stopKernelFunc: func(context.Context, hostguard.FenceTarget, StopInventory, DrainAcknowledgement) (StopKernelEvidence, error) { return StopKernelEvidence{}, sql.ErrNoRows }} c.Kernel = k c.Inventory = exactEvidenceInventory{c.Inventory} c.Drainer = stopDrainFunc(func(context.Context, StopRequest, string) (DrainAcknowledgement, error) { t.Fatal("unjournaled stop tried to close nonexistent writers") return DrainAcknowledgement{}, sql.ErrNoRows }) // Fail the evidence check after exercising the fallback, leaving the durable fence. if _, err := c.FenceAndStop(t.Context(), req); !errors.Is(err, ErrClaimStopReceipt) || k.calls != 1 { t.Fatal("initial fallback", err, k.calls) } targets, err := c.Guard.PendingStopFences(t.Context()) if err != nil || len(targets) != 1 { t.Fatal("pending fence", err, targets) } c.NewID = func() string { return "recovered" } k.evidence = StopKernelEvidence{ObservedBootID: "boot-2", InventorySHA256: strings.Repeat("b", 64), LocalJournalSHA256: strings.Repeat("c", 64), CgroupIdentities: []string{"absent"}} if _, err = c.RecoverStopped(t.Context(), targets[0]); err != nil || k.calls != 2 { t.Fatal("recovery fallback", err, k.calls) } if pending, err := c.Guard.PendingStopFences(t.Context()); err != nil || len(pending) != 0 { t.Fatal("recovery left a pending fence", err, pending) } if _, err := c.Guard.FenceReceiptFor(t.Context(), targets[0]); err != nil { t.Fatal("recovery receipt", err) } } func TestPlan14StoppedAgentContainment(t *testing.T) { for _, tc := range []struct{ name, raw, want string }{ {"running", "Id=carlos-edge.service\nControlGroup=/carlos.slice/carlos-edge.service\nSlice=carlos.slice\nActiveState=active", "/sys/fs/cgroup/carlos.slice/carlos-edge.service"}, {"stopped", "Id=carlos-edge.service\nControlGroup=\nSlice=carlos.slice\nActiveState=inactive", "/sys/fs/cgroup/carlos.slice/carlos-edge.service"}, {"failed", "Id=carlos-edge.service\nControlGroup=\nSlice=carlos.slice\nActiveState=failed", "/sys/fs/cgroup/carlos.slice/carlos-edge.service"}, {"nested slice", "Id=carlos-edge.service\nControlGroup=\nSlice=tenant-carlos.slice\nActiveState=inactive", "/sys/fs/cgroup/tenant.slice/tenant-carlos.slice/carlos-edge.service"}, {"active without group", "Id=carlos-edge.service\nControlGroup=\nSlice=carlos.slice\nActiveState=active", ""}, {"wrong unit", "Id=other.service\nControlGroup=\nSlice=carlos.slice\nActiveState=inactive", ""}, {"missing slice", "Id=carlos-edge.service\nControlGroup=\nActiveState=inactive", ""}, {"traversal", "Id=carlos-edge.service\nControlGroup=\nSlice=../carlos.slice\nActiveState=inactive", ""}, {"duplicate", "Id=carlos-edge.service\nId=carlos-edge.service\nControlGroup=\nSlice=carlos.slice\nActiveState=inactive", ""}, } { t.Run(tc.name, func(t *testing.T) { got, err := rootStopAgentGroup([]byte(tc.raw)) if (err == nil) != (tc.want != "") || got != tc.want { t.Fatal(got, err) } }) } } func TestPlan14NeverIntendedStopProof(t *testing.T) { target := hostguard.FenceTarget{Host: "shop.example", Incarnation: "inc", Stamp: 1} for _, mode := range []string{"absent", "tombstone", "intended", "started", "legacy", "open-writers", "other-record"} { t.Run(mode, func(t *testing.T) { in := hostbox.LaunchInventory{Target: target, WritersClosed: true, Groups: []hostbox.LaunchGroup{{RelativePath: "agent/parent"}}} r := hostbox.LaunchReservation{NeverIntended: true, State: hostbox.UnitSettled, Group: hostbox.LaunchGroup{RelativePath: "host.service"}} if mode != "absent" { in.Reservations = []hostbox.LaunchReservation{r} } switch mode { case "intended": in.Reservations[0].NeverIntended = false case "started": in.Reservations[0].PID = 1 case "legacy": in.Reservations[0].Group.RelativePath = "role-child" case "open-writers": in.WritersClosed = false } records := []hostbox.LaunchInventory{in} if mode == "other-record" { other := in other.WritersClosed = false records = append(records, other) } if got := stopNeverIntended(records, target); got != (mode == "absent" || mode == "tombstone") { t.Fatal("unlaunched proof", mode, got) } }) } }