| 1 | //go:build linux |
| 2 | |
| 3 | package host |
| 4 | |
| 5 | import ( |
| 6 | "context" |
| 7 | "database/sql" |
| 8 | "errors" |
| 9 | "os" |
| 10 | "path/filepath" |
| 11 | "strings" |
| 12 | "testing" |
| 13 | |
| 14 | "github.com/carlosframework/platform/internal/cgroup" |
| 15 | "github.com/carlosframework/platform/internal/hostbox" |
| 16 | "github.com/carlosframework/platform/internal/hostguard" |
| 17 | "github.com/carlosframework/platform/internal/instancectl" |
| 18 | ) |
| 19 | |
| 20 | type changingLaunchHosts struct { |
| 21 | records []hostbox.LaunchInventory |
| 22 | next []hostbox.LaunchInventory |
| 23 | calls int |
| 24 | } |
| 25 | |
| 26 | func (j *changingLaunchHosts) HostLaunches(context.Context, string) ([]hostbox.LaunchInventory, error) { |
| 27 | j.calls++ |
| 28 | if j.calls > 1 && j.next != nil { |
| 29 | return j.next, nil |
| 30 | } |
| 31 | return j.records, nil |
| 32 | } |
| 33 | |
| 34 | func TestPlan14UnjournaledStopAbsence(t *testing.T) { |
| 35 | for _, mode := range []string{"absent", "predecessor", "journal", "changed-journal", "descriptor", "containment", "isolated", "inventory"} { |
| 36 | t.Run(mode, func(t *testing.T) { |
| 37 | f := newWritersKernelFixture(t) |
| 38 | f.dropAgentHolds(t) |
| 39 | f.target.FenceID = "fence" |
| 40 | j := &changingLaunchHosts{} |
| 41 | switch mode { |
| 42 | case "predecessor": |
| 43 | target := f.target |
| 44 | target.Stamp-- |
| 45 | j.records = []hostbox.LaunchInventory{{Target: target}} |
| 46 | case "journal": |
| 47 | j.records = []hostbox.LaunchInventory{{Target: f.target}} |
| 48 | case "changed-journal": |
| 49 | j.next = []hostbox.LaunchInventory{{Target: f.target}} |
| 50 | case "descriptor": |
| 51 | f.holdDB(t, 30, 3) |
| 52 | case "containment", "isolated": |
| 53 | dir := filepath.Join(f.agentCgroup, cgroup.ExecName(claimContainmentName(f.target))) |
| 54 | if mode == "isolated" { |
| 55 | // Use the same canonical host slice as the production observer. |
| 56 | dir = filepath.Join(f.cgroupRoot, filepath.Dir(instancectl.ClaimUnitControlGroup(f.target.Host))) |
| 57 | } |
| 58 | if err := os.MkdirAll(dir, 0700); err != nil { |
| 59 | t.Fatal(err) |
| 60 | } |
| 61 | if err := os.WriteFile(filepath.Join(dir, "cgroup.events"), []byte("populated 1\n"), 0600); err != nil { |
| 62 | t.Fatal(err) |
| 63 | } |
| 64 | case "inventory": |
| 65 | f.inv.Entries[0].Inode++ |
| 66 | } |
| 67 | _, err := verifyNoLaunchAt(t.Context(), f.roots(), j, f.target, f.inv) |
| 68 | wantPass := mode == "absent" || mode == "predecessor" |
| 69 | if (err == nil) != wantPass { |
| 70 | t.Fatalf("%s: %v", mode, err) |
| 71 | } |
| 72 | }) |
| 73 | } |
| 74 | } |
| 75 | |
| 76 | type unjournaledKernel struct { |
| 77 | stopKernelFunc |
| 78 | calls int |
| 79 | evidence StopKernelEvidence |
| 80 | } |
| 81 | |
| 82 | func (k *unjournaledKernel) VerifyUnjournaled(context.Context, hostguard.FenceTarget, StopInventory) (StopKernelEvidence, error) { |
| 83 | k.calls++ |
| 84 | return k.evidence, nil |
| 85 | } |
| 86 | func TestPlan14UnjournaledStopRequiresRootFence(t *testing.T) { |
| 87 | c, _, _ := claimStopFixture(t) |
| 88 | k := &unjournaledKernel{} |
| 89 | c.Kernel = k |
| 90 | for _, cause := range []error{sql.ErrNoRows, os.ErrPermission, hostbox.ErrTypedInventory} { |
| 91 | if _, err := c.verifyUnjournaled(t.Context(), hostguard.FenceTarget{}, StopInventory{}, cause); !errors.Is(err, cause) || k.calls != 0 { |
| 92 | t.Fatal("legacy fallback", err) |
| 93 | } |
| 94 | } |
| 95 | c.Guard.Root = hostguard.NewRootAuthority(&rollbackMemory{files: map[string][]byte{}}) |
| 96 | if _, err := c.verifyUnjournaled(t.Context(), hostguard.FenceTarget{Host: "shop.example", Incarnation: "inc-1", Stamp: 11}, StopInventory{}, sql.ErrNoRows); err == nil || k.calls != 0 { |
| 97 | t.Fatal("missing root fence accepted", err) |
| 98 | } |
| 99 | } |
| 100 | |
| 101 | func (k *unjournaledKernel) RecoverStopped(context.Context, hostguard.FenceTarget, StopInventory) (StopKernelEvidence, DrainAcknowledgement, error) { |
| 102 | return StopKernelEvidence{}, DrainAcknowledgement{}, sql.ErrNoRows |
| 103 | } |
| 104 | |
| 105 | func TestPlan14UnjournaledStopRecoveryReceipt(t *testing.T) { |
| 106 | c, req, _ := claimStopFixture(t) |
| 107 | c.Guard.Root = hostguard.NewRootAuthority(&rollbackMemory{files: map[string][]byte{}}) |
| 108 | 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} |
| 109 | if err := c.Guard.Root.RetainMigrationDeny(t.Context(), in, req.WriterSession); err != nil { |
| 110 | t.Fatal(err) |
| 111 | } |
| 112 | k := &unjournaledKernel{stopKernelFunc: func(context.Context, hostguard.FenceTarget, StopInventory, DrainAcknowledgement) (StopKernelEvidence, error) { |
| 113 | return StopKernelEvidence{}, sql.ErrNoRows |
| 114 | }} |
| 115 | c.Kernel = k |
| 116 | c.Inventory = exactEvidenceInventory{c.Inventory} |
| 117 | c.Drainer = stopDrainFunc(func(context.Context, StopRequest, string) (DrainAcknowledgement, error) { |
| 118 | t.Fatal("unjournaled stop tried to close nonexistent writers") |
| 119 | return DrainAcknowledgement{}, sql.ErrNoRows |
| 120 | }) |
| 121 | // Fail the evidence check after exercising the fallback, leaving the durable fence. |
| 122 | if _, err := c.FenceAndStop(t.Context(), req); !errors.Is(err, ErrClaimStopReceipt) || k.calls != 1 { |
| 123 | t.Fatal("initial fallback", err, k.calls) |
| 124 | } |
| 125 | targets, err := c.Guard.PendingStopFences(t.Context()) |
| 126 | if err != nil || len(targets) != 1 { |
| 127 | t.Fatal("pending fence", err, targets) |
| 128 | } |
| 129 | c.NewID = func() string { return "recovered" } |
| 130 | k.evidence = StopKernelEvidence{ObservedBootID: "boot-2", InventorySHA256: strings.Repeat("b", 64), LocalJournalSHA256: strings.Repeat("c", 64), CgroupIdentities: []string{"absent"}} |
| 131 | if _, err = c.RecoverStopped(t.Context(), targets[0]); err != nil || k.calls != 2 { |
| 132 | t.Fatal("recovery fallback", err, k.calls) |
| 133 | } |
| 134 | if pending, err := c.Guard.PendingStopFences(t.Context()); err != nil || len(pending) != 0 { |
| 135 | t.Fatal("recovery left a pending fence", err, pending) |
| 136 | } |
| 137 | if _, err := c.Guard.FenceReceiptFor(t.Context(), targets[0]); err != nil { |
| 138 | t.Fatal("recovery receipt", err) |
| 139 | } |
| 140 | } |
| 141 | |
| 142 | func TestPlan14StoppedAgentContainment(t *testing.T) { |
| 143 | for _, tc := range []struct{ name, raw, want string }{ |
| 144 | {"running", "Id=carlos-edge.service\nControlGroup=/carlos.slice/carlos-edge.service\nSlice=carlos.slice\nActiveState=active", "/sys/fs/cgroup/carlos.slice/carlos-edge.service"}, |
| 145 | {"stopped", "Id=carlos-edge.service\nControlGroup=\nSlice=carlos.slice\nActiveState=inactive", "/sys/fs/cgroup/carlos.slice/carlos-edge.service"}, |
| 146 | {"failed", "Id=carlos-edge.service\nControlGroup=\nSlice=carlos.slice\nActiveState=failed", "/sys/fs/cgroup/carlos.slice/carlos-edge.service"}, |
| 147 | {"nested slice", "Id=carlos-edge.service\nControlGroup=\nSlice=tenant-carlos.slice\nActiveState=inactive", "/sys/fs/cgroup/tenant.slice/tenant-carlos.slice/carlos-edge.service"}, |
| 148 | {"active without group", "Id=carlos-edge.service\nControlGroup=\nSlice=carlos.slice\nActiveState=active", ""}, |
| 149 | {"wrong unit", "Id=other.service\nControlGroup=\nSlice=carlos.slice\nActiveState=inactive", ""}, |
| 150 | {"missing slice", "Id=carlos-edge.service\nControlGroup=\nActiveState=inactive", ""}, |
| 151 | {"traversal", "Id=carlos-edge.service\nControlGroup=\nSlice=../carlos.slice\nActiveState=inactive", ""}, |
| 152 | {"duplicate", "Id=carlos-edge.service\nId=carlos-edge.service\nControlGroup=\nSlice=carlos.slice\nActiveState=inactive", ""}, |
| 153 | } { |
| 154 | t.Run(tc.name, func(t *testing.T) { |
| 155 | got, err := rootStopAgentGroup([]byte(tc.raw)) |
| 156 | if (err == nil) != (tc.want != "") || got != tc.want { |
| 157 | t.Fatal(got, err) |
| 158 | } |
| 159 | }) |
| 160 | } |
| 161 | } |
| 162 | |
| 163 | func TestPlan14NeverIntendedStopProof(t *testing.T) { |
| 164 | target := hostguard.FenceTarget{Host: "shop.example", Incarnation: "inc", Stamp: 1} |
| 165 | for _, mode := range []string{"absent", "tombstone", "intended", "started", "legacy", "open-writers", "other-record"} { |
| 166 | t.Run(mode, func(t *testing.T) { |
| 167 | in := hostbox.LaunchInventory{Target: target, WritersClosed: true, Groups: []hostbox.LaunchGroup{{RelativePath: "agent/parent"}}} |
| 168 | r := hostbox.LaunchReservation{NeverIntended: true, State: hostbox.UnitSettled, Group: hostbox.LaunchGroup{RelativePath: "host.service"}} |
| 169 | if mode != "absent" { |
| 170 | in.Reservations = []hostbox.LaunchReservation{r} |
| 171 | } |
| 172 | switch mode { |
| 173 | case "intended": |
| 174 | in.Reservations[0].NeverIntended = false |
| 175 | case "started": |
| 176 | in.Reservations[0].PID = 1 |
| 177 | case "legacy": |
| 178 | in.Reservations[0].Group.RelativePath = "role-child" |
| 179 | case "open-writers": |
| 180 | in.WritersClosed = false |
| 181 | } |
| 182 | records := []hostbox.LaunchInventory{in} |
| 183 | if mode == "other-record" { |
| 184 | other := in |
| 185 | other.WritersClosed = false |
| 186 | records = append(records, other) |
| 187 | } |
| 188 | if got := stopNeverIntended(records, target); got != (mode == "absent" || mode == "tombstone") { |
| 189 | t.Fatal("unlaunched proof", mode, got) |
| 190 | } |
| 191 | }) |
| 192 | } |
| 193 | } |
| 194 | |