| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "crypto/ed25519" |
| 6 | "encoding/json" |
| 7 | "errors" |
| 8 | "os" |
| 9 | "path/filepath" |
| 10 | "strings" |
| 11 | "testing" |
| 12 | "time" |
| 13 | |
| 14 | "github.com/carlosframework/platform/internal/hostactors" |
| 15 | "github.com/carlosframework/platform/internal/hostbox" |
| 16 | "github.com/carlosframework/platform/internal/hostclaim" |
| 17 | execlease "github.com/carlosframework/platform/internal/hostexec" |
| 18 | "github.com/carlosframework/platform/internal/hostfloor" |
| 19 | "github.com/carlosframework/platform/internal/hostguard" |
| 20 | "github.com/carlosframework/platform/internal/hostwrite" |
| 21 | "github.com/carlosframework/platform/internal/release" |
| 22 | ) |
| 23 | |
| 24 | func TestPlan9ProductionRosterRequiresPublishedMatchingFloor(t *testing.T) { |
| 25 | key := ed25519.NewKeyFromSeed(make([]byte, 32)) |
| 26 | s := release.NewDirStore(t.TempDir()) |
| 27 | bucket := "deployment" |
| 28 | floor := hostfloor.Floor{Min: 1, MinRelease: "v0.1.0"} |
| 29 | if err := hostfloor.Publish(t.Context(), s, release.RootSigner(key), bucket, floor); err != nil { |
| 30 | t.Fatal(err) |
| 31 | } |
| 32 | raw, _, _ := s.Get(t.Context(), hostfloor.Key) |
| 33 | auth, _, _ := s.Get(t.Context(), hostfloor.AuthKey) |
| 34 | dir := t.TempDir() |
| 35 | policy := hostfloor.Policy{Schema: 1, DeploymentBucket: bucket, Mode: "enforced", Floor: raw, Authorization: auth} |
| 36 | body, _ := json.Marshal(policy) |
| 37 | if err := os.WriteFile(filepath.Join(dir, "policy.json"), body, 0600); err != nil { |
| 38 | t.Fatal(err) |
| 39 | } |
| 40 | row := hostactors.Box{BoxID: "box", Enrollment: strings.Repeat("a", 32), Namespace: "platform", Label: "box", Region: "ie", Pool: "default", Cloud: hostactors.Cloud{Provider: "aws", Account: "123", Region: "eu-west-1", InstanceID: "i-box"}, EvidenceSHA256: strings.Repeat("c", 64), State: "active"} |
| 41 | roster := hostactors.Roster{V: 1, DeploymentBucket: bucket, Revision: 1, RequestLimitNS: int64(hostbox.RequestLimit), Publication: "published", FloorSHA256: hostfloor.Digest(raw), Boxes: map[string]hostactors.Box{"box": row}, Consoles: map[string]hostactors.Console{}, Holds: map[string]hostactors.Reservation{}, Activations: map[string]hostactors.Reservation{}} |
| 42 | write := func() { |
| 43 | wire, err := release.SignRootEnvelope(release.RootSigner(key), roster) |
| 44 | if err != nil { |
| 45 | t.Fatal(err) |
| 46 | } |
| 47 | _, etag, _ := s.Get(t.Context(), hostactors.Key) |
| 48 | if _, err = s.Put(t.Context(), hostactors.Key, wire, etag); err != nil { |
| 49 | t.Fatal(err) |
| 50 | } |
| 51 | } |
| 52 | write() |
| 53 | provider := productionClaimRoster{Directory: hostactors.Directory{Store: s, PublicKey: key.Public().(ed25519.PublicKey), Bucket: bucket}, DataBucket: "data", FloorDir: dir} |
| 54 | actor, err := provider.Actor(t.Context(), "box") |
| 55 | if err != nil { |
| 56 | t.Fatal(err) |
| 57 | } |
| 58 | if actor.Bucket != "data" || actor.ControlBucket != bucket { |
| 59 | t.Fatal("mixed bucket authority") |
| 60 | } |
| 61 | roster.Publication = "preparing" |
| 62 | write() |
| 63 | if _, err = provider.Actor(t.Context(), "box"); err == nil { |
| 64 | t.Fatal("accepted unpublished floor") |
| 65 | } |
| 66 | roster.Publication = "published" |
| 67 | row.State = "quarantined" |
| 68 | row.QuarantineRevision = 1 |
| 69 | row.LossIDs = []string{"loss"} |
| 70 | roster.Boxes["box"] = row |
| 71 | write() |
| 72 | if _, err = provider.Actor(t.Context(), "box"); err == nil { |
| 73 | t.Fatal("accepted quarantined actor") |
| 74 | } |
| 75 | } |
| 76 | func TestPlan9ProductionLocalServingDoesNotCreateLease(t *testing.T) { |
| 77 | dir := t.TempDir() |
| 78 | if err := os.Mkdir(filepath.Join(dir, ".hostexec"), 0700); err != nil { |
| 79 | t.Fatal(err) |
| 80 | } |
| 81 | p := productionClaimLocal{legacyParticipation: true, Resources: ClaimRuntimeResources{Mode: "agent", DataDir: dir}, slots: map[string]*claimParticipation{}} |
| 82 | c := &hostclaim.Claim{Host: "x.example", Holder: &hostclaim.Holder{Incarnation: "inc"}, Op: hostclaim.Op{Kind: hostclaim.KindServing}} |
| 83 | actor := hostbox.Actor{Box: "box", Region: "ie"} |
| 84 | selection := hostbox.Selection{Box: "box", Region: "ie", Captured: hostguard.Captured{Incarnation: "inc"}} |
| 85 | if err := p.Check(t.Context(), actor, c, selection); err != nil { |
| 86 | t.Fatal(err) |
| 87 | } |
| 88 | entries, err := os.ReadDir(filepath.Join(dir, ".hostexec")) |
| 89 | if err != nil || len(entries) != 0 { |
| 90 | t.Fatalf("serving created lease: %v %v", entries, err) |
| 91 | } |
| 92 | c.Op.Kind = hostclaim.KindLanding |
| 93 | c.Op.ID = "operation" |
| 94 | if err := p.Check(t.Context(), actor, c, selection); err == nil { |
| 95 | t.Fatal("active operation used no controller") |
| 96 | } |
| 97 | p.closed = true |
| 98 | if err := p.Check(t.Context(), actor, c, selection); !errors.Is(err, execlease.ErrLost) { |
| 99 | t.Fatalf("closed=%v", err) |
| 100 | } |
| 101 | } |
| 102 | func TestPlan9ProductionMissingMovesSourcesAreTypedRefusals(t *testing.T) { |
| 103 | p := productionClaimRegional{} |
| 104 | if _, err := p.View(context.Background(), hostclaim.Observed{}); err == nil { |
| 105 | t.Fatal("missing view granted") |
| 106 | } |
| 107 | if _, err := p.LeaseSet(context.Background(), "bucket", "scope"); err == nil { |
| 108 | t.Fatal("missing staging lease inventory granted") |
| 109 | } |
| 110 | if _, _, err := p.VerifyRetirement(context.Background(), hostclaim.Observed{}, nil); err == nil { |
| 111 | t.Fatal("missing archived evidence granted") |
| 112 | } |
| 113 | } |
| 114 | |
| 115 | func TestPlan9ParticipationCleanupDoesNotWaitForItsOpeningSlot(t *testing.T) { |
| 116 | f := newRecoveryFixture(t) |
| 117 | controller := f.controller("boot-1", "controller-1", 4101, "start-1") |
| 118 | o := controller.admit(t, f) |
| 119 | controller.assembly.Reconcile = &hostbox.ReconcileAdapter{Census: &hostbox.EmptyInstallationCensus{}} |
| 120 | pending := &claimParticipation{Ready: make(chan struct{})} |
| 121 | p := &productionClaimLocal{legacyParticipation: true, assembly: &controller.assembly, slots: map[string]*claimParticipation{recoveryHost: pending}} |
| 122 | ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second) |
| 123 | defer cancel() |
| 124 | if err := p.cleanup(ctx, o, execlease.Binding{}); err != nil { |
| 125 | t.Fatal(err) |
| 126 | } |
| 127 | select { |
| 128 | case <-pending.Ready: |
| 129 | t.Fatal("cleanup altered the opening slot") |
| 130 | default: |
| 131 | } |
| 132 | if len(f.receipts()) != 1 { |
| 133 | t.Fatal("cleanup omitted root stop evidence") |
| 134 | } |
| 135 | } |
| 136 | |
| 137 | func TestPlan9ParticipationFailedOpenCanDischargeAfterPositiveCleanup(t *testing.T) { |
| 138 | t.Parallel() |
| 139 | f := newRecoveryFixture(t) |
| 140 | o := f.read() |
| 141 | if err := os.Mkdir(filepath.Join(f.guard.DataDir, ".hostexec"), 0700); err != nil { |
| 142 | t.Fatal(err) |
| 143 | } |
| 144 | identity := handoverRecoveryIdentity(t, hostbox.Actor{Box: "box-1", Enrollment: "enrollment-1", Revision: 1, Binding: "1", Region: "ie"}) |
| 145 | ready := make(chan struct{}) |
| 146 | close(ready) |
| 147 | slot := &claimParticipation{Ready: ready, Err: execlease.ErrLost, CleanupSucceeded: true, Captured: o, |
| 148 | Config: execlease.Config{Binding: execlease.Binding{Host: recoveryHost, Box: "box-1", Enrollment: "enrollment-1", Op: "old-op", Incarnation: o.Claim.Holder.Incarnation}, Guard: f.guard, LockDir: t.TempDir()}} |
| 149 | p := &productionClaimLocal{legacyParticipation: true, Resources: ClaimRuntimeResources{Mode: "agent", DataDir: f.guard.DataDir}, Identity: identity, assembly: &ClaimAssembly{}, lifetime: t.Context(), slots: map[string]*claimParticipation{recoveryHost: slot}} |
| 150 | if err := p.Prepare(t.Context(), o); err != nil { |
| 151 | t.Fatal(err) |
| 152 | } |
| 153 | if p.slots[recoveryHost] != nil { |
| 154 | t.Fatal("historical Open error prevented CC9 discharge") |
| 155 | } |
| 156 | } |
| 157 | |
| 158 | func TestPlan9ParticipationCleanupSurvivesRosterWithdrawal(t *testing.T) { |
| 159 | f := newRecoveryFixture(t) |
| 160 | controller := f.controller("boot-1", "controller-1", 4101, "start-1") |
| 161 | o := controller.admit(t, f) |
| 162 | controller.assembly.Reconcile = &hostbox.ReconcileAdapter{Census: &hostbox.EmptyInstallationCensus{}} |
| 163 | p := &productionClaimLocal{legacyParticipation: true, slots: map[string]*claimParticipation{}} |
| 164 | if err := p.bind(t.Context(), &controller.assembly); err != nil { |
| 165 | t.Fatal(err) |
| 166 | } |
| 167 | // New authority fails after withdrawal. Cleanup retains only this already |
| 168 | // verified process identity; root still checks the exact captured claim. |
| 169 | controller.assembly.stopLocal = nil |
| 170 | controller.assembly.Identity = hostbox.IdentityProvider{} |
| 171 | ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second) |
| 172 | defer cancel() |
| 173 | if err := p.cleanup(ctx, o, execlease.Binding{}); err != nil { |
| 174 | t.Fatalf("withdrawal prevented cleanup: %v", err) |
| 175 | } |
| 176 | if len(f.receipts()) != 1 { |
| 177 | t.Fatal("cleanup omitted root evidence") |
| 178 | } |
| 179 | } |
| 180 | |
| 181 | func TestPlan9RootAndAdoptLeaveStaleParticipationForAgent(t *testing.T) { |
| 182 | for _, mode := range []string{"adopt", "instance-control"} { |
| 183 | t.Run(mode, func(t *testing.T) { |
| 184 | f := newRecoveryFixture(t) |
| 185 | o := f.read() |
| 186 | directory := filepath.Join(f.guard.DataDir, ".hostexec") |
| 187 | if err := os.Mkdir(directory, 0700); err != nil { |
| 188 | t.Fatal(err) |
| 189 | } |
| 190 | state := execlease.State{V: 1, Binding: execlease.Binding{Host: o.Host, Box: "box-1", Enrollment: "enrollment-1", Op: "old-op", Incarnation: o.Claim.Holder.Incarnation, BootID: "boot", ControllerID: "old"}, State: "lost", SafeUntil: 1, Expires: time.Now()} |
| 191 | raw, err := json.Marshal(state) |
| 192 | if err != nil { |
| 193 | t.Fatal(err) |
| 194 | } |
| 195 | path := filepath.Join(directory, o.Host+".json") |
| 196 | if err = os.WriteFile(path, raw, 0600); err != nil { |
| 197 | t.Fatal(err) |
| 198 | } |
| 199 | actor := hostbox.Actor{Box: "box-1", Enrollment: "enrollment-1", Revision: 1, Binding: "1", Region: "ie"} |
| 200 | // No cleanup dependencies: a root observer must refuse before touching |
| 201 | // the journal, root socket or lifetime locks. |
| 202 | p := productionClaimLocal{legacyParticipation: true, Resources: ClaimRuntimeResources{Mode: mode, DataDir: f.guard.DataDir}, assembly: &ClaimAssembly{}, lifetime: t.Context(), slots: map[string]*claimParticipation{}} |
| 203 | if err = p.prepareWith(t.Context(), actor, o); err == nil { |
| 204 | t.Fatal("root admitted without root participation") |
| 205 | } |
| 206 | after, err := os.ReadFile(path) |
| 207 | if err != nil { |
| 208 | t.Fatal(err) |
| 209 | } |
| 210 | if string(raw) != string(after) { |
| 211 | t.Fatal("root rewrote stale participation") |
| 212 | } |
| 213 | }) |
| 214 | } |
| 215 | } |
| 216 | |
| 217 | func TestPlan9ParticipationDoesNotSkipPreviousAgentState(t *testing.T) { |
| 218 | t.Parallel() |
| 219 | for _, serving := range []bool{true, false} { |
| 220 | f := newRecoveryFixture(t) |
| 221 | o := f.read() |
| 222 | if !serving { |
| 223 | o.Claim.Op = hostclaim.Op{Kind: hostclaim.KindDeleting, ID: "elsewhere", Phase: "stop", Exec: "box-2", Participants: []string{"box-2"}} |
| 224 | body, _ := json.Marshal(o.Claim) |
| 225 | key, _ := hostclaim.Key(o.Host) |
| 226 | if _, err := f.claims.Put(t.Context(), key, body, o.ETag); err != nil { |
| 227 | t.Fatal(err) |
| 228 | } |
| 229 | } |
| 230 | directory := filepath.Join(f.guard.DataDir, ".hostexec") |
| 231 | if err := os.Mkdir(directory, 0700); err != nil { |
| 232 | t.Fatal(err) |
| 233 | } |
| 234 | state := execlease.State{V: 1, Binding: execlease.Binding{Host: o.Host, Box: "box-1", Enrollment: "enrollment-1", Op: "previous", Incarnation: o.Claim.Holder.Incarnation, BootID: "boot", ControllerID: "previous"}, State: "lost", SafeUntil: 1, Expires: time.Now()} |
| 235 | body, _ := json.Marshal(state) |
| 236 | if err := os.WriteFile(filepath.Join(directory, o.Host+".json"), body, 0600); err != nil { |
| 237 | t.Fatal(err) |
| 238 | } |
| 239 | actor := hostbox.Actor{Box: "box-1", Enrollment: "enrollment-1", Revision: 1, Binding: "1", Region: "ie"} |
| 240 | p := productionClaimLocal{legacyParticipation: true, Resources: ClaimRuntimeResources{Mode: "agent", DataDir: f.guard.DataDir}, Identity: handoverRecoveryIdentity(t, actor), Store: f.claims, assembly: &ClaimAssembly{}, lifetime: t.Context(), slots: map[string]*claimParticipation{}} |
| 241 | // Missing cleanup proof must surface. Success would mean the scan silently |
| 242 | // skipped a surviving prior controller just because no slot exists in RAM. |
| 243 | if err := p.participate(t.Context()); !errors.Is(err, hostwrite.ErrUnavailable) { |
| 244 | t.Fatalf("serving=%v skipped previous controller: %v", serving, err) |
| 245 | } |
| 246 | } |
| 247 | } |
| 248 | |
| 249 | type plan9DeadlineStore struct { |
| 250 | release.Store |
| 251 | deadline time.Time |
| 252 | } |
| 253 | |
| 254 | func (s *plan9DeadlineStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| 255 | if key == "control/hosts/"+recoveryHost+".json" { |
| 256 | s.deadline, _ = ctx.Deadline() |
| 257 | } |
| 258 | return s.Store.Get(ctx, key) |
| 259 | } |
| 260 | func TestPlan9ParticipationBoundsEachClaimReadIndependently(t *testing.T) { |
| 261 | t.Parallel() |
| 262 | f := newRecoveryFixture(t) |
| 263 | actor := hostbox.Actor{Box: "box-1", Enrollment: "enrollment-1", Revision: 1, Binding: "1", Region: "ie"} |
| 264 | store := &plan9DeadlineStore{Store: f.claims} |
| 265 | p := productionClaimLocal{legacyParticipation: true, Resources: ClaimRuntimeResources{Mode: "agent", DataDir: f.guard.DataDir}, Identity: handoverRecoveryIdentity(t, actor), Store: store, slots: map[string]*claimParticipation{}} |
| 266 | before := time.Now() |
| 267 | if err := p.participate(t.Context()); err != nil { |
| 268 | t.Fatal(err) |
| 269 | } |
| 270 | if store.deadline.IsZero() || store.deadline.Before(before.Add(time.Minute)) || store.deadline.After(time.Now().Add(hostbox.RequestLimit)) { |
| 271 | t.Fatalf("claim read lacks independent request budget: %v", store.deadline) |
| 272 | } |
| 273 | } |
| 274 | |