| 1 | //go:build linux |
| 2 | |
| 3 | package host |
| 4 | |
| 5 | import ( |
| 6 | "encoding/json" |
| 7 | "errors" |
| 8 | "strings" |
| 9 | "testing" |
| 10 | "time" |
| 11 | |
| 12 | "github.com/carlosframework/platform/internal/hostbox" |
| 13 | "github.com/carlosframework/platform/internal/hostclaim" |
| 14 | "github.com/carlosframework/platform/internal/hostguard" |
| 15 | "github.com/carlosframework/platform/internal/hostops" |
| 16 | "github.com/carlosframework/platform/internal/provision" |
| 17 | "github.com/carlosframework/platform/internal/registry" |
| 18 | "github.com/carlosframework/platform/internal/rootparticipation" |
| 19 | "github.com/carlosframework/platform/internal/rootstate" |
| 20 | ) |
| 21 | |
| 22 | var errRootInterrupted = errors.New("root interrupted before its commit") |
| 23 | |
| 24 | func (w *assemblyWorld) pending() int { |
| 25 | w.t.Helper() |
| 26 | pending, err := w.assembly.Guard.Root.PendingInstallations() |
| 27 | if err != nil { |
| 28 | w.t.Fatal(err) |
| 29 | } |
| 30 | return len(pending) |
| 31 | } |
| 32 | |
| 33 | // interruptedRegistration is the agent's state registration for rec through |
| 34 | // root's commitRootInventory, with root dying after its intent is durable |
| 35 | // and before it records the commit. |
| 36 | func (w *assemblyWorld) interruptedRegistration(agent *bindingAgent, rec provision.Record, route registry.Route, actor hostbox.Actor) { |
| 37 | w.t.Helper() |
| 38 | before := w.pending() |
| 39 | *w.interrupt = 1 |
| 40 | err := agent.registerState(rec.Host, route.DBPath, actor) |
| 41 | *w.interrupt = -1 |
| 42 | if err == nil || w.pending() != before+1 { |
| 43 | w.t.Fatalf("registration was not interrupted after its intent: %v, pending %d", err, w.pending()) |
| 44 | } |
| 45 | } |
| 46 | |
| 47 | // interruptedRebind leaves a recovery rebind intent for rec's installation |
| 48 | // stamped with stamp: the claim moves to a recovered successor stamp, and the |
| 49 | // guard's RebindRootInventory (the write rebindRecoveryInventory makes) |
| 50 | // persists the intent and is interrupted before its commit. |
| 51 | func (w *assemblyWorld) interruptedRebind(rec provision.Record, stamp string) recoveryRebindRequest { |
| 52 | w.t.Helper() |
| 53 | ctx := w.t.Context() |
| 54 | o := w.claim(rec.Host) |
| 55 | prior := hostguard.Captured{Incarnation: o.Claim.Holder.Incarnation, Stamp: o.Claim.Holder.Stamp} |
| 56 | _, revision := w.inventory(rec.Host) |
| 57 | next, err := hostclaim.Update(ctx, w.store, o, func(c *hostclaim.Claim) error { |
| 58 | c.Holder.Stamp, c.Holder.Session, c.Holder.Generation, c.Holder.WriterBox = prior.Stamp+1, prior.Stamp+1, "recovered", "box-1" |
| 59 | c.Holder.Admissions = []hostclaim.AdmissionRecord{{Region: "ie", Session: prior.Stamp + 1, Box: "box-1", Generation: "recovered", Mode: "cold"}} |
| 60 | return nil |
| 61 | }) |
| 62 | if err != nil { |
| 63 | w.t.Fatal(err) |
| 64 | } |
| 65 | h := next.Claim.Holder |
| 66 | _, recordETag, err := w.store.Get(ctx, h.Key) |
| 67 | if err != nil { |
| 68 | w.t.Fatal(err) |
| 69 | } |
| 70 | q := recoveryRebindRequest{V: 1, Prior: prior, Revision: revision, RecordKey: h.Key, RecordETag: recordETag, Next: hostbox.ColdSuccessor{Host: rec.Host, Incarnation: h.Incarnation, Stamp: h.Stamp, Session: h.Session, Generation: h.Generation, Box: "box-1", Enrollment: w.roster.row().Enrollment, DirectoryRevision: stamp, ETag: next.ETag}} |
| 71 | raw, err := json.Marshal(recoveryRebindIntent{Recovery: &q}) |
| 72 | if err != nil { |
| 73 | w.t.Fatal(err) |
| 74 | } |
| 75 | installation := hostguard.RootInstallation{Host: rec.Host, Incarnation: h.Incarnation, Stamp: h.Stamp, Account: h.Account, App: h.App, Record: h.Key, Box: "box-1", Enrollment: q.Next.Enrollment, Migration: 1, InventoryRevision: 1} |
| 76 | before := w.pending() |
| 77 | err = w.assembly.Guard.RebindRootInventory(ctx, prior, revision, installation, raw, func(*hostclaim.Claim) error { return nil }, func() error { return errRootInterrupted }) |
| 78 | if !errors.Is(err, errRootInterrupted) || w.pending() != before+1 { |
| 79 | w.t.Fatalf("rebind intent: %v, pending %d", err, w.pending()) |
| 80 | } |
| 81 | return q |
| 82 | } |
| 83 | |
| 84 | // migrationRequired gives root a completed Plan 14 migration for actor, |
| 85 | // seen while w.migrating is set, so OpenProductionClaimAssembly runs its |
| 86 | // startup inventory recovery and its migration census |
| 87 | // (verifyRootMigrationStartup) with participation required. The |
| 88 | // participation ledger and the migration record are root-owned directories |
| 89 | // on a box; here they are test directories. The census's last step, the |
| 90 | // installed floor binaries' check, needs root-owned binaries this host does |
| 91 | // not have and is the one step stood in for. |
| 92 | func (w *assemblyWorld) migrationRequired(actor hostbox.Actor) { |
| 93 | w.t.Helper() |
| 94 | census := rootparticipation.Census{Schema: 1, Box: actor.Box, Enrollment: actor.Enrollment, Migration: 1, Revision: 1, LaunchRevision: 1, Complete: true} |
| 95 | participation := &rollbackDisk{dir: w.t.TempDir()} |
| 96 | raw, _ := json.Marshal(census) |
| 97 | if err := participation.Write("census.json", raw); err != nil { |
| 98 | w.t.Fatal(err) |
| 99 | } |
| 100 | now := time.Now().UTC() |
| 101 | record := rootMigrationRecord{Schema: 1, Identity: rootMigrationIdentity{Deployment: actor.ControlBucket, Box: actor.Box, Enrollment: actor.Enrollment, Generation: 1}, Phase: "complete", Snapshot: rootMigrationSnapshot{Complete: true, DirectoryRevision: 1, Exclusion: "exclusion", Items: []rootMigrationItem{}, |
| 102 | Report: hostops.CensusReport{Box: actor.Box, Enrollment: actor.Enrollment, BootID: "boot", Reservation: "migration", DirectoryRevision: 1, CapturedAt: now, Complete: true, Installations: []hostops.CensusInstallation{}, Seeds: &hostops.CensusSeeds{Journaled: []string{}, Gated: []string{}}}}} |
| 103 | state := &rollbackDisk{dir: w.t.TempDir()} |
| 104 | raw, _ = json.Marshal(record) |
| 105 | if err := state.Write("migration.json", raw); err != nil { |
| 106 | w.t.Fatal(err) |
| 107 | } |
| 108 | original, originalState, originalFloors := openRootMigrationParticipation, openRootMigrationState, rootMigrationStartupFloors |
| 109 | w.t.Cleanup(func() { |
| 110 | openRootMigrationParticipation, openRootMigrationState, rootMigrationStartupFloors = original, originalState, originalFloors |
| 111 | }) |
| 112 | openRootMigrationParticipation = func() (rootparticipation.Ledger, bool, error) { |
| 113 | if !w.migrating { |
| 114 | return rootparticipation.Ledger{}, false, nil |
| 115 | } |
| 116 | return rootparticipation.Ledger{State: participation}, true, nil |
| 117 | } |
| 118 | openRootMigrationState = func() (rootstate.Store, error) { return state, nil } |
| 119 | rootMigrationStartupFloors = func(string, rootFloorIdentity) error { return nil } |
| 120 | } |
| 121 | |
| 122 | // plantInventoryIntent leaves q pending at root for installation, as a root |
| 123 | // that died after persisting the intent and before its commit leaves it: |
| 124 | // the guard's real CommitRootInventoryArtifacts persists the intent, and the |
| 125 | // commit fails before any inventory is written. |
| 126 | func (w *assemblyWorld) plantInventoryIntent(q hostbox.JournalRequest, installation hostguard.RootInstallation) { |
| 127 | w.t.Helper() |
| 128 | raw, err := json.Marshal(q) |
| 129 | if err != nil { |
| 130 | w.t.Fatal(err) |
| 131 | } |
| 132 | before := w.pending() |
| 133 | err = w.assembly.Guard.CommitRootInventoryArtifacts(w.t.Context(), installation, raw, func(*hostclaim.Claim) error { return nil }, nil, func(hostguard.RootInstallation) error { return errRootInterrupted }, nil) |
| 134 | if !errors.Is(err, errRootInterrupted) || w.pending() != before+1 { |
| 135 | w.t.Fatalf("planted intent: %v, pending %d", err, w.pending()) |
| 136 | } |
| 137 | } |
| 138 | |
| 139 | // inventoryRequest is an inventory write for rec's current installation as |
| 140 | // the agent would send it: inventory in, at revision, against the claim and |
| 141 | // record as they are now. |
| 142 | func (w *assemblyWorld) inventoryRequest(rec provision.Record, in hostbox.StopInventory, revision uint64, id string) hostbox.JournalRequest { |
| 143 | w.t.Helper() |
| 144 | o := w.claim(rec.Host) |
| 145 | h := o.Claim.Holder |
| 146 | _, recordETag, err := w.store.Get(w.t.Context(), h.Key) |
| 147 | if err != nil { |
| 148 | w.t.Fatal(err) |
| 149 | } |
| 150 | return hostbox.JournalRequest{V: 3, ID: id, Op: "inventory-write", Revision: revision, RecordKey: h.Key, RecordETag: recordETag, Inventory: in, |
| 151 | Target: hostguard.FenceTarget{Host: rec.Host, Incarnation: h.Incarnation, Stamp: h.Stamp, Session: h.Session, Box: "box-1", Enrollment: w.roster.row().Enrollment, ClaimETag: o.ETag, Op: o.Claim.Op.ID, Phase: o.Claim.Op.Phase}} |
| 152 | } |
| 153 | |
| 154 | // plantedFirstInstall leaves rec's first inventory write pending at root, |
| 155 | // stamped stamp, with no inventory written. |
| 156 | func (w *assemblyWorld) plantedFirstInstall(rec provision.Record, stamp string) { |
| 157 | w.t.Helper() |
| 158 | h := w.claim(rec.Host).Claim.Holder |
| 159 | enrollment := w.roster.row().Enrollment |
| 160 | q := w.inventoryRequest(rec, hostbox.StopInventory{Complete: true, Host: rec.Host, Incarnation: h.Incarnation, InstallationStamp: h.Stamp, Box: "box-1", Enrollment: enrollment, DirectoryRevision: stamp}, 0, "planted-"+rec.Host) |
| 161 | w.plantInventoryIntent(q, hostguard.RootInstallation{Empty: true, Host: rec.Host, Incarnation: h.Incarnation, Stamp: h.Stamp, Account: h.Account, App: h.App, Record: h.Key, Box: "box-1", Enrollment: enrollment, Migration: 1, InventoryRevision: 1}) |
| 162 | } |
| 163 | |
| 164 | // targetStampedWrite is agent's inventory write of rec's current inventory, |
| 165 | // unchanged, through the agent's own journal client, with its target's |
| 166 | // DirectoryRevision set to stamp: an agent-chosen request field that root |
| 167 | // must not take on trust. The request is prepared (nonce, issue time, ID) |
| 168 | // by the client exactly as any agent write is. |
| 169 | func (w *assemblyWorld) targetStampedWrite(agent *bindingAgent, rec provision.Record, stamp string) error { |
| 170 | w.t.Helper() |
| 171 | ctx := w.t.Context() |
| 172 | h := w.claim(rec.Host).Claim.Holder |
| 173 | _, etag, err := w.store.Get(ctx, h.Key) |
| 174 | if err != nil { |
| 175 | w.t.Fatal(err) |
| 176 | } |
| 177 | captured := hostguard.Captured{Incarnation: h.Incarnation, Stamp: h.Stamp} |
| 178 | in, revision, err := agent.remote.Snapshot(ctx, rec.Host, captured) |
| 179 | if err != nil { |
| 180 | w.t.Fatal(err) |
| 181 | } |
| 182 | client := hostbox.ReconcileAdapter{Reconciler: hostbox.Reconciler{Guard: agent.guard, Claims: w.store}, Box: agent.pinned.Box, Enrollment: agent.pinned.Enrollment} |
| 183 | expected, err := client.CaptureInventory(ctx, hostbox.RecordSelection{Host: rec.Host, Key: h.Key, ETag: etag, Incarnation: h.Incarnation, Family: h.Family}, captured, revision) |
| 184 | if err != nil { |
| 185 | w.t.Fatal(err) |
| 186 | } |
| 187 | expected.Target.DirectoryRevision = stamp |
| 188 | _, err = agent.remote.WriteExpected(ctx, in, expected) |
| 189 | return err |
| 190 | } |
| 191 | |
| 192 | // N3 (roster binding task 5b, fix round 1 finding 1): interrupted intents |
| 193 | // are left pending at B0 (an inventory write whose commit landed before the |
| 194 | // record write was lost, a first install likewise, a first install whose |
| 195 | // journal never committed, and a recovery rebind); the box is quarantined |
| 196 | // and root restarts. Each intent is settled and logged, root comes up (never |
| 197 | // errRootMigrationBusy), and its stop service answers a stop of the B0 |
| 198 | // installation. After readmission and another restart root is still up and |
| 199 | // the B0 installation is refused at wake. The control: an interrupted intent |
| 200 | // whose binding did not change replays and completes. |
| 201 | // |
| 202 | // Two restarts settle pending intents in production: the startup inventory |
| 203 | // recovery inside OpenProductionClaimAssembly, followed there by the |
| 204 | // migration census (verifyRootMigrationStartup), either of which aborts the |
| 205 | // assembly; and the service's RecoverPending. "startup" restarts the real |
| 206 | // assembly with migration participation required through the whole |
| 207 | // construction, so the census inspects every settled installation; |
| 208 | // "service" restarts without participation and settles through the root |
| 209 | // handler's RecoverPending. |
| 210 | func TestRootStartupSettlesObsoleteIntents(t *testing.T) { |
| 211 | for _, path := range []string{"startup", "service"} { |
| 212 | t.Run(path, func(t *testing.T) { |
| 213 | ctx := t.Context() |
| 214 | w := newAssemblyWorld(t) |
| 215 | b0 := w.fresh() |
| 216 | if path == "startup" { |
| 217 | w.migrationRequired(b0) |
| 218 | } |
| 219 | w.mustStart() |
| 220 | agent := w.agent(b0) |
| 221 | recA, routeA := w.newHost("a") |
| 222 | recD, routeD := w.newHost("d") |
| 223 | recE, routeE := w.newHost("e") |
| 224 | for _, h := range []struct { |
| 225 | rec provision.Record |
| 226 | route registry.Route |
| 227 | }{{recA, routeA}, {recD, routeD}, {recE, routeE}} { |
| 228 | if err := agent.install(h.rec, h.route); err != nil { |
| 229 | t.Fatal(err) |
| 230 | } |
| 231 | } |
| 232 | recover := func() error { |
| 233 | if path == "startup" { |
| 234 | w.migrating = true |
| 235 | return w.restart() |
| 236 | } |
| 237 | if err := w.restart(); err != nil { |
| 238 | return err |
| 239 | } |
| 240 | return w.root.RecoverPending(ctx) |
| 241 | } |
| 242 | |
| 243 | // Control: same binding, interrupted, replays and completes. (The |
| 244 | // startup inventory recovery replays through root's creation |
| 245 | // history, a root-owned directory; the control runs through the |
| 246 | // service, before participation is required.) |
| 247 | w.roster.holdChurn() |
| 248 | _, before := w.inventory(recE.Host) |
| 249 | w.interruptedRegistration(agent, recE, routeE, w.fresh()) |
| 250 | if err := w.restart(); err != nil { |
| 251 | t.Fatal("restart with a same-binding intent:", err) |
| 252 | } |
| 253 | if err := w.root.RecoverPending(ctx); err != nil { |
| 254 | t.Fatal("recovery of a same-binding intent:", err) |
| 255 | } |
| 256 | if _, after := w.inventory(recE.Host); w.pending() != 0 || after != before+1 { |
| 257 | t.Fatalf("same-binding intent: pending %d, inventory revision %d -> %d", w.pending(), before, after) |
| 258 | } |
| 259 | |
| 260 | // Obsolete intents at B0, then quarantine and restart. |
| 261 | agent = w.agent(b0) |
| 262 | _, beforeA := w.inventory(recA.Host) |
| 263 | w.interruptedRegistration(agent, recA, routeA, w.fresh()) |
| 264 | if _, after := w.inventory(recA.Host); after != beforeA+1 { |
| 265 | t.Fatalf("interrupted registration did not land in the inventory: %d -> %d", beforeA, after) |
| 266 | } |
| 267 | recF, routeF := w.newHost("f") |
| 268 | *w.interrupt = 1 |
| 269 | err := agent.install(recF, routeF) |
| 270 | *w.interrupt = -1 |
| 271 | if err == nil || w.pending() != 2 { |
| 272 | t.Fatalf("first install was not interrupted after its intent: %v, pending %d", err, w.pending()) |
| 273 | } |
| 274 | recG, _ := w.newHost("g") |
| 275 | w.plantedFirstInstall(recG, b0.Binding) |
| 276 | w.interruptedRebind(recD, b0.Binding) |
| 277 | w.roster.quarantine("loss_1") |
| 278 | logs := captureLog(t) |
| 279 | if err := recover(); err != nil { |
| 280 | t.Fatal("quarantined restart with obsolete intents:", err) |
| 281 | } |
| 282 | if w.pending() != 0 { |
| 283 | t.Fatalf("obsolete intents left pending: %d", w.pending()) |
| 284 | } |
| 285 | for _, kind := range []string{"inventory", "rebind"} { |
| 286 | if !strings.Contains(logs(), "obsolete pending "+kind+" intent: stamped "+b0.Binding) { |
| 287 | t.Fatalf("no settlement log for the %s intent: %s", kind, logs()) |
| 288 | } |
| 289 | } |
| 290 | if strings.Contains(logs(), "not settled") { |
| 291 | t.Fatalf("a settlement failed: %s", logs()) |
| 292 | } |
| 293 | // Guard and inventory agree for every settled installation, and |
| 294 | // each still refuses execution. |
| 295 | views, err := w.assembly.Guard.Root.MigrationInstallations(ctx) |
| 296 | if err != nil { |
| 297 | t.Fatal("migration census after settlement:", err) |
| 298 | } |
| 299 | settled := map[string]hostguard.FenceView{} |
| 300 | for _, v := range views { |
| 301 | if v.Settled { |
| 302 | settled[v.Host] = v |
| 303 | } |
| 304 | } |
| 305 | for _, rec := range []provision.Record{recA, recD, recF, recG} { |
| 306 | v, ok := settled[rec.Host] |
| 307 | if !ok { |
| 308 | t.Fatalf("%s not settled: %+v", rec.Host, views) |
| 309 | } |
| 310 | _, revision, err := w.assembly.Journals.Inventory.Snapshot(ctx, rec.Host, v.Captured) |
| 311 | if err != nil || revision != v.Installation.InventoryRevision { |
| 312 | t.Fatalf("%s: guard revision %d, inventory %d %v", rec.Host, v.Installation.InventoryRevision, revision, err) |
| 313 | } |
| 314 | if _, err := w.assembly.Guard.Root.View(ctx, rec.Host, v.Captured); !errors.Is(err, hostguard.ErrFenced) { |
| 315 | t.Fatalf("%s: settled installation's execution view: %v", rec.Host, err) |
| 316 | } |
| 317 | } |
| 318 | if v := settled[recA.Host]; v.Installation.InventoryRevision != beforeA+1 { |
| 319 | t.Fatalf("landed registration not recorded: %d", v.Installation.InventoryRevision) |
| 320 | } |
| 321 | if v := settled[recF.Host]; v.Installation.InventoryRevision != 1 { |
| 322 | t.Fatalf("landed first install not recorded: %d", v.Installation.InventoryRevision) |
| 323 | } |
| 324 | if v := settled[recG.Host]; v.Installation.InventoryRevision != 0 { |
| 325 | t.Fatalf("unlanded first install: %d", v.Installation.InventoryRevision) |
| 326 | } |
| 327 | if _, err := w.root.InventoryActor(ctx); err == nil { |
| 328 | t.Fatal("quarantined root admitted new work") |
| 329 | } |
| 330 | if _, err := w.noProcessStop(recA.Host, b0.Binding, w.roster.read().Roster.Revision); err != nil { |
| 331 | t.Fatal("stop service after settlement:", err) |
| 332 | } |
| 333 | |
| 334 | // Readmission and another restart: up, the B0 installation refused. |
| 335 | w.roster.readmit() |
| 336 | if err := recover(); err != nil { |
| 337 | t.Fatal("readmitted restart:", err) |
| 338 | } |
| 339 | b1 := w.fresh() |
| 340 | if err := w.agent(b1).registerState(recE.Host, routeE.DBPath, b1); !errors.Is(err, hostbox.ErrTypedInventory) { |
| 341 | t.Fatalf("the B0 installation at wake after readmission: %v", err) |
| 342 | } |
| 343 | }) |
| 344 | } |
| 345 | } |
| 346 | |
| 347 | // Fix round 1, finding 2: root validates an agent-sent target stamp against |
| 348 | // the inventory's validated binding before it persists any intent, and |
| 349 | // startup decides obsolescence on validated binding evidence only. |
| 350 | // Contradictory: a request whose inventory names B0 (valid) and whose |
| 351 | // target names another binding is refused before any intent, and the |
| 352 | // installation keeps working. Control: the same request with the target |
| 353 | // naming B0, interrupted after its intent, replays and completes at the |
| 354 | // next start. Legacy: an intent persisted with a contradictory target (as |
| 355 | // the unchecked root accepted one) is not settled as obsolete at startup. |
| 356 | func TestRootInventoryRefusesContradictoryTarget(t *testing.T) { |
| 357 | ctx := t.Context() |
| 358 | w := newAssemblyWorld(t) |
| 359 | w.mustStart() |
| 360 | b0 := w.fresh() |
| 361 | recA, routeA := w.newHost("a") |
| 362 | recB, routeB := w.newHost("b") |
| 363 | for _, h := range []struct { |
| 364 | rec provision.Record |
| 365 | route registry.Route |
| 366 | }{{recA, routeA}, {recB, routeB}} { |
| 367 | if err := w.agent(b0).install(h.rec, h.route); err != nil { |
| 368 | t.Fatal(err) |
| 369 | } |
| 370 | } |
| 371 | other := strings.Repeat("ab", 32) |
| 372 | if other == b0.Binding { |
| 373 | t.Fatal("fixture binding collision") |
| 374 | } |
| 375 | restart := func(what string) string { |
| 376 | t.Helper() |
| 377 | logs := captureLog(t) |
| 378 | if err := w.restart(); err != nil { |
| 379 | t.Fatal(what, err) |
| 380 | } |
| 381 | if err := w.root.RecoverPending(ctx); err != nil { |
| 382 | t.Fatal(what, err) |
| 383 | } |
| 384 | return logs() |
| 385 | } |
| 386 | |
| 387 | // Contradictory target: refused before the intent. |
| 388 | _, before := w.inventory(recA.Host) |
| 389 | *w.interrupt = 1 |
| 390 | err := w.targetStampedWrite(w.agent(b0), recA, other) |
| 391 | consumed := *w.interrupt == 0 |
| 392 | *w.interrupt = -1 |
| 393 | if err == nil || w.pending() != 0 || consumed { |
| 394 | t.Fatalf("contradictory target: %v, pending %d, root wrote %v", err, w.pending(), consumed) |
| 395 | } |
| 396 | if _, after := w.inventory(recA.Host); after != before { |
| 397 | t.Fatalf("contradictory target changed the inventory: %d -> %d", before, after) |
| 398 | } |
| 399 | if logs := restart("restart after a contradictory target:"); strings.Contains(logs, "obsolete pending") { |
| 400 | t.Fatalf("an unchanged binding was settled as obsolete: %s", logs) |
| 401 | } |
| 402 | if err := w.agent(b0).registerState(recA.Host, routeA.DBPath, b0); err != nil { |
| 403 | t.Fatal("the installation after a refused contradictory target:", err) |
| 404 | } |
| 405 | |
| 406 | // Control: the target names the validated binding; interrupted, then |
| 407 | // replayed at the next start. |
| 408 | _, before = w.inventory(recB.Host) |
| 409 | *w.interrupt = 1 |
| 410 | err = w.targetStampedWrite(w.agent(b0), recB, b0.Binding) |
| 411 | *w.interrupt = -1 |
| 412 | if err == nil || w.pending() != 1 { |
| 413 | t.Fatalf("same-binding target was not interrupted after its intent: %v, pending %d", err, w.pending()) |
| 414 | } |
| 415 | if logs := restart("restart with a same-binding target:"); strings.Contains(logs, "obsolete pending") { |
| 416 | t.Fatalf("a same-binding intent was settled: %s", logs) |
| 417 | } |
| 418 | if _, after := w.inventory(recB.Host); w.pending() != 0 || after != before+1 { |
| 419 | t.Fatalf("same-binding target: pending %d, inventory revision %d -> %d", w.pending(), before, after) |
| 420 | } |
| 421 | |
| 422 | // Legacy: an intent persisted with a contradictory target. Startup does |
| 423 | // not take its target as obsolescence evidence; the replay is refused |
| 424 | // and the intent stays pending for the ordinary recovery. |
| 425 | in, revision := w.inventory(recA.Host) |
| 426 | q := w.inventoryRequest(recA, in, revision, "legacy-"+recA.Host) |
| 427 | q.Target.DirectoryRevision = other |
| 428 | h := w.claim(recA.Host).Claim.Holder |
| 429 | installed, err := w.assembly.Guard.Root.Installation(ctx, recA.Host, hostguard.Captured{Incarnation: h.Incarnation, Stamp: h.Stamp}) |
| 430 | if err != nil { |
| 431 | t.Fatal(err) |
| 432 | } |
| 433 | installed.InventoryRevision = revision + 1 |
| 434 | w.plantInventoryIntent(q, installed) |
| 435 | if logs := restart("restart with a legacy contradictory intent:"); strings.Contains(logs, "obsolete pending") { |
| 436 | t.Fatalf("a contradictory target was taken as obsolescence evidence: %s", logs) |
| 437 | } |
| 438 | if w.pending() != 1 { |
| 439 | t.Fatalf("legacy contradictory intent: pending %d", w.pending()) |
| 440 | } |
| 441 | } |
| 442 | |
| 443 | // Fix round 2: an intent whose claim advanced before recovery is abandoned |
| 444 | // (the guard's exact-intent and fresh-claim checks), and the abandoned |
| 445 | // installation ends settled like an obsolete one, so it never blocks |
| 446 | // cleanup startup. The binding never changes here. A landed inventory write |
| 447 | // whose claim then moved to an operation session, and a rebind intent whose |
| 448 | // claim then advanced, are abandoned by the service's RecoverPending. Root |
| 449 | // then restarts twice with migration participation required through the |
| 450 | // whole OpenProductionClaimAssembly: its startup census inspects both |
| 451 | // abandoned installations, and the terminal state persists across restarts. |
| 452 | // (Abandonment runs in the service, as it can in production: the startup |
| 453 | // recovery's replays need root's creation history and the guard's lock |
| 454 | // directory, root-owned paths this test host does not have.) |
| 455 | func TestRootStartupInspectsSupersededIntents(t *testing.T) { |
| 456 | ctx := t.Context() |
| 457 | w := newAssemblyWorld(t) |
| 458 | b0 := w.fresh() |
| 459 | w.migrationRequired(b0) |
| 460 | w.mustStart() |
| 461 | agent := w.agent(b0) |
| 462 | recA, routeA := w.newHost("a") |
| 463 | recD, routeD := w.newHost("d") |
| 464 | if err := agent.install(recA, routeA); err != nil { |
| 465 | t.Fatal(err) |
| 466 | } |
| 467 | if err := agent.install(recD, routeD); err != nil { |
| 468 | t.Fatal(err) |
| 469 | } |
| 470 | |
| 471 | // A: a landed inventory write, then the claim moves on. |
| 472 | _, beforeA := w.inventory(recA.Host) |
| 473 | w.interruptedRegistration(agent, recA, routeA, b0) |
| 474 | moved := w.operationSession(recA.Host) |
| 475 | // D: a rebind intent, then the claim moves on. |
| 476 | w.interruptedRebind(recD, b0.Binding) |
| 477 | if _, err := hostclaim.Update(ctx, w.store, w.claim(recD.Host), func(c *hostclaim.Claim) error { |
| 478 | c.Holder.Generation = "advanced" |
| 479 | return nil |
| 480 | }); err != nil { |
| 481 | t.Fatal(err) |
| 482 | } |
| 483 | if err := w.restart(); err != nil { |
| 484 | t.Fatal(err) |
| 485 | } |
| 486 | if err := w.root.RecoverPending(ctx); err != nil { |
| 487 | t.Fatal("service recovery of superseded intents:", err) |
| 488 | } |
| 489 | if w.pending() != 0 { |
| 490 | t.Fatalf("superseded intents left pending: %d", w.pending()) |
| 491 | } |
| 492 | for _, round := range []string{"first restart", "second restart"} { |
| 493 | w.migrating = true |
| 494 | if err := w.restart(); err != nil { |
| 495 | t.Fatalf("%s with migration participation required: %v", round, err) |
| 496 | } |
| 497 | if w.fresh().Binding != b0.Binding { |
| 498 | t.Fatal("the binding changed") |
| 499 | } |
| 500 | } |
| 501 | views, err := w.assembly.Guard.Root.MigrationInstallations(ctx) |
| 502 | if err != nil { |
| 503 | t.Fatal("migration census:", err) |
| 504 | } |
| 505 | settled := map[string]hostguard.FenceView{} |
| 506 | for _, v := range views { |
| 507 | if v.Settled { |
| 508 | settled[v.Host] = v |
| 509 | } |
| 510 | } |
| 511 | for _, rec := range []provision.Record{recA, recD} { |
| 512 | v, ok := settled[rec.Host] |
| 513 | if !ok { |
| 514 | t.Fatalf("%s not settled: %+v", rec.Host, views) |
| 515 | } |
| 516 | _, revision, err := w.assembly.Journals.Inventory.Snapshot(ctx, rec.Host, v.Captured) |
| 517 | if err != nil || revision != v.Installation.InventoryRevision { |
| 518 | t.Fatalf("%s: guard revision %d, inventory %d %v", rec.Host, v.Installation.InventoryRevision, revision, err) |
| 519 | } |
| 520 | if _, err := w.assembly.Guard.Root.View(ctx, rec.Host, v.Captured); !errors.Is(err, hostguard.ErrFenced) { |
| 521 | t.Fatalf("%s: abandoned installation's execution view: %v", rec.Host, err) |
| 522 | } |
| 523 | } |
| 524 | if v := settled[recA.Host]; v.Installation.InventoryRevision != beforeA+1 { |
| 525 | t.Fatalf("landed write not recorded: %d", v.Installation.InventoryRevision) |
| 526 | } |
| 527 | // Startup proceeded over these views; journal-migrate's collector, the |
| 528 | // census's other consumer, still refuses them (census condition 1). |
| 529 | if err := refuseSettledInstallations(views); !errors.Is(err, hostguard.ErrFenced) { |
| 530 | t.Fatal("the collector would admit settled installations:", err) |
| 531 | } |
| 532 | // The stop service is up and stops A's moved-on operation session. |
| 533 | if _, err := w.root.Stop.Coordinator.FenceAndStop(ctx, w.priorStop(moved, b0.Binding)); err != nil { |
| 534 | t.Fatal("stop service after abandonment:", err) |
| 535 | } |
| 536 | } |
| 537 | |