| 1 | //go:build linux |
| 2 | |
| 3 | package host |
| 4 | |
| 5 | import ( |
| 6 | "bytes" |
| 7 | "context" |
| 8 | "crypto/ed25519" |
| 9 | "errors" |
| 10 | "fmt" |
| 11 | "os" |
| 12 | "path/filepath" |
| 13 | "testing" |
| 14 | "time" |
| 15 | |
| 16 | "github.com/carlosframework/platform/internal/genselect" |
| 17 | "github.com/carlosframework/platform/internal/hostactors" |
| 18 | "github.com/carlosframework/platform/internal/hostbox" |
| 19 | "github.com/carlosframework/platform/internal/hostclaim" |
| 20 | "github.com/carlosframework/platform/internal/hostguard" |
| 21 | "github.com/carlosframework/platform/internal/hostops" |
| 22 | "github.com/carlosframework/platform/internal/hostwrite" |
| 23 | "github.com/carlosframework/platform/internal/park" |
| 24 | "github.com/carlosframework/platform/internal/provision" |
| 25 | "github.com/carlosframework/platform/internal/registry" |
| 26 | "github.com/carlosframework/platform/internal/release" |
| 27 | "github.com/carlosframework/platform/internal/rootstate" |
| 28 | ) |
| 29 | |
| 30 | // bindingAssemblyOwners opens the claim owners the way the production |
| 31 | // provider does in instance-control mode: the identity is productionClaimRoster |
| 32 | // over the signed roster, and the cleanup enrollment is the startup read |
| 33 | // verifyClaimCleanupEnrollment makes, once per open. Owners that the paths |
| 34 | // under test never call are the fix4 unused dependencies. |
| 35 | type bindingAssemblyOwners struct { |
| 36 | base ClaimOwnerBindings |
| 37 | dir hostactors.Directory |
| 38 | } |
| 39 | |
| 40 | func (o bindingAssemblyOwners) OpenClaimOwners(ctx context.Context, _ release.Store, r ClaimRuntimeResources) (ClaimOwnerBindings, error) { |
| 41 | out := o.base |
| 42 | if r.Mode != "instance-control" { |
| 43 | return out, hostwrite.ErrUnavailable |
| 44 | } |
| 45 | var err error |
| 46 | out.cleanupEnrollment, err = verifyClaimCleanupEnrollment(ctx, out.Identity, o.dir) |
| 47 | return out, err |
| 48 | } |
| 49 | |
| 50 | // assemblyWorld is one box under the real instance-control construction: |
| 51 | // OpenProductionClaimAssembly over a signed roster, with root's handler |
| 52 | // built by the production claimRootHTTP and its stop coordinator by |
| 53 | // rootStopCoordinator. Root's state stores are directories (the fix4 |
| 54 | // seams), the guard's lock directory is a temporary one, and |
| 55 | // NewProductionClaimStopHandler's process bindings (the carlos user, the |
| 56 | // edge unit's peer, the kernel observation) are fixtures: that handler |
| 57 | // needs systemd and a carlos account this test host does not have. The |
| 58 | // agent half installs through root's real commitRootInventory. |
| 59 | type assemblyWorld struct { |
| 60 | t *testing.T |
| 61 | roster *bindingRosterFixture |
| 62 | key ed25519.PrivateKey |
| 63 | store release.Store |
| 64 | reg *registry.Registry |
| 65 | r ClaimRuntimeResources |
| 66 | owners bindingAssemblyOwners |
| 67 | locks string |
| 68 | kernel *receiptKernel |
| 69 | ids int |
| 70 | assembly *ClaimAssembly |
| 71 | root ClaimRootHTTP |
| 72 | authority string |
| 73 | journal string |
| 74 | // retire is root's retirement and cold-handover journal namespace, |
| 75 | // /var/lib/carlos-claims/retire on a box (claimRetireJournalDir). |
| 76 | retire string |
| 77 | now time.Time |
| 78 | // interrupt, while set, fails root's next record write after the |
| 79 | // numbered one (a crash after the intent is durable). |
| 80 | interrupt *int |
| 81 | // migrating, while set, makes root see a completed migration |
| 82 | // (migrationRequired). |
| 83 | migrating bool |
| 84 | } |
| 85 | |
| 86 | // interruptibleDisk is a root authority directory whose writes can fail |
| 87 | // from the Nth on: root's process dying with its intent on disk. |
| 88 | type interruptibleDisk struct { |
| 89 | *rollbackDisk |
| 90 | after *int |
| 91 | } |
| 92 | |
| 93 | func (d interruptibleDisk) Write(k string, b []byte) error { |
| 94 | if *d.after == 0 { |
| 95 | return errors.New("root interrupted") |
| 96 | } |
| 97 | if *d.after > 0 { |
| 98 | *d.after-- |
| 99 | } |
| 100 | return d.rollbackDisk.Write(k, b) |
| 101 | } |
| 102 | |
| 103 | func newAssemblyWorld(t *testing.T) *assemblyWorld { |
| 104 | t.Helper() |
| 105 | fixtureWithoutRootMigrationCensus(t) |
| 106 | w := &assemblyWorld{t: t, roster: newBindingRosterFixture(t, "box-1", "ie"), key: ed25519.NewKeyFromSeed(bytes.Repeat([]byte{9}, ed25519.SeedSize)), kernel: &receiptKernel{}, now: time.Now().UTC().Truncate(time.Second)} |
| 107 | w.store = w.roster.store |
| 108 | w.roster.bindCensusKey(w.key) |
| 109 | w.reg = restartTestRegistry(t) |
| 110 | w.r = ClaimRuntimeResources{Mode: "instance-control", Registry: w.reg, DataDir: t.TempDir(), OptDir: t.TempDir(), UnitsDir: t.TempDir()} |
| 111 | for _, dir := range []string{".hostclaims", "hostguard", "instances"} { |
| 112 | if err := os.Mkdir(filepath.Join(w.r.DataDir, dir), 0700); err != nil { |
| 113 | t.Fatal(err) |
| 114 | } |
| 115 | } |
| 116 | w.locks, w.authority, w.journal, w.retire = t.TempDir(), t.TempDir(), filepath.Join(t.TempDir(), "journals.db"), t.TempDir() |
| 117 | // Root's journal namespace is trusted only when no other principal can |
| 118 | // write it (hostbox checkTrustedJournalDir). |
| 119 | if err := os.Chmod(w.retire, 0700); err != nil { |
| 120 | t.Fatal(err) |
| 121 | } |
| 122 | never := -1 |
| 123 | w.interrupt = &never |
| 124 | originalAuthority, originalJournal := openClaimRootAuthority, openClaimRootJournal |
| 125 | t.Cleanup(func() { openClaimRootAuthority, openClaimRootJournal = originalAuthority, originalJournal }) |
| 126 | openClaimRootAuthority = func() (rootstate.Store, error) { |
| 127 | return interruptibleDisk{rollbackDisk: &rollbackDisk{dir: w.authority}, after: w.interrupt}, nil |
| 128 | } |
| 129 | openClaimRootJournal = func() (*hostbox.LaunchJournal, error) { return hostbox.OpenLaunchJournal(w.journal) } |
| 130 | unused := fix4UnusedDependencies{} |
| 131 | w.owners = bindingAssemblyOwners{dir: w.roster.dir, base: ClaimOwnerBindings{ |
| 132 | Identity: w.roster.identity(), RetirementAuthority: &hostops.ProductionAuthority{Store: w.store, Directory: w.roster.dir, Now: time.Now}, EvidenceKey: w.key, |
| 133 | Local: unused, Controllers: unused, Data: unused, Inputs: unused, Evidence: unused, |
| 134 | InputPolicy: func(context.Context, *hostclaim.Claim, hostbox.Selection, hostwrite.Identity) error { return nil }, |
| 135 | MovesPolicy: func(context.Context, *hostclaim.Claim, hostbox.Selection, hostwrite.Identity) error { return nil }, |
| 136 | LeaseSet: func(context.Context, string, string) ([]string, error) { return nil, nil }, |
| 137 | View: func(context.Context, hostclaim.Observed) (park.View, error) { return park.View{}, nil }, |
| 138 | Authorize: func(context.Context, hostclaim.Observed, hostclaim.Obligation) error { return nil }, |
| 139 | AdmissionEvidence: func(context.Context, hostclaim.Observed, hostclaim.Admission, *genselect.Plan) error { return nil }, |
| 140 | PublicationEvidence: func(context.Context, hostclaim.Observed) error { return nil }, |
| 141 | Close: func(context.Context) error { return nil }, |
| 142 | }} |
| 143 | return w |
| 144 | } |
| 145 | |
| 146 | // start is root's instance-control start: OpenProductionClaimAssembly, then |
| 147 | // NewProductionRootClaimStop's construction of the stop coordinator and the |
| 148 | // root handler. It returns the construction error unchanged. |
| 149 | func (w *assemblyWorld) start() error { |
| 150 | w.t.Helper() |
| 151 | ctx := w.t.Context() |
| 152 | a, err := OpenProductionClaimAssembly(ctx, w.store, w.r, w.owners) |
| 153 | if err != nil { |
| 154 | return err |
| 155 | } |
| 156 | w.t.Cleanup(func() { a.Close(context.Background()) }) |
| 157 | a.Guard.LockDir = w.locks |
| 158 | actor, err := a.actorForRoot(ctx) |
| 159 | if err != nil { |
| 160 | return err |
| 161 | } |
| 162 | c := a.rootStopCoordinator(w.store) |
| 163 | c.CarlosUID, c.Now, c.Kernel = 1001, time.Now, w.kernel |
| 164 | c.NewID = func() string { w.ids++; return fmt.Sprintf("root-%d", w.ids) } |
| 165 | c.Peers = stopPeerFunc(func(context.Context) (StopPeer, error) { |
| 166 | return StopPeer{UID: 1001, PID: 42, ProcessStart: "start-42", BootID: "boot-1", ControllerID: "controller-1"}, nil |
| 167 | }) |
| 168 | c.Drainer = stopDrainFunc(func(_ context.Context, _ StopRequest, fence string) (DrainAcknowledgement, error) { |
| 169 | return DrainClaimAcknowledgement(fence, "controller-1", "start-42", nil), nil |
| 170 | }) |
| 171 | w.assembly, w.root = a, a.claimRootHTTP(actor, ClaimStopHTTP{Coordinator: c}) |
| 172 | w.root.Reconcile.Retirement.JournalDir = w.retire |
| 173 | return nil |
| 174 | } |
| 175 | |
| 176 | func (w *assemblyWorld) mustStart() { |
| 177 | w.t.Helper() |
| 178 | if err := w.start(); err != nil { |
| 179 | w.t.Fatal("instance-control start:", err) |
| 180 | } |
| 181 | } |
| 182 | |
| 183 | // restart closes root's assembly and starts it again over the same state. |
| 184 | func (w *assemblyWorld) restart() error { |
| 185 | w.t.Helper() |
| 186 | if err := w.assembly.Close(w.t.Context()); err != nil { |
| 187 | w.t.Fatal(err) |
| 188 | } |
| 189 | return w.start() |
| 190 | } |
| 191 | |
| 192 | func (w *assemblyWorld) fresh() hostbox.Actor { |
| 193 | w.t.Helper() |
| 194 | a, err := w.owners.base.Identity.Verify(w.t.Context()) |
| 195 | if err != nil { |
| 196 | w.t.Fatal(err) |
| 197 | } |
| 198 | return a |
| 199 | } |
| 200 | |
| 201 | // newHost creates a claimed instance (a console create completed onto this |
| 202 | // box) and its provision record. |
| 203 | func (w *assemblyWorld) newHost(name string) (provision.Record, registry.Route) { |
| 204 | w.t.Helper() |
| 205 | ctx := w.t.Context() |
| 206 | host := name + ".bab.oncarlos.com" |
| 207 | in := hostclaim.NewHolder{Family: "instance", Key: "apps/bab/jam/instances/" + host + ".json", Account: "bab", App: "jam", Host: host, Region: "ie", RecordRegion: "ie", CreatedAt: w.now} |
| 208 | body := []byte(`{"schema":1,"host":"` + host + `","account":"bab","app":"jam","region":"ie","created_at":"` + w.now.Format(time.RFC3339) + `"}`) |
| 209 | absent, err := hostclaim.Read(ctx, w.store, host) |
| 210 | if err != nil { |
| 211 | w.t.Fatal(err) |
| 212 | } |
| 213 | o, err := hostclaim.Update(ctx, w.store, absent, hostclaim.BeginCreate(in, body, "create", "console", w.now)) |
| 214 | if err != nil { |
| 215 | w.t.Fatal(err) |
| 216 | } |
| 217 | if _, err = hostclaim.Update(ctx, w.store, o, hostclaim.CompleteCreate("create", "box-1")); err != nil { |
| 218 | w.t.Fatal(err) |
| 219 | } |
| 220 | rec := provision.Record{Schema: 1, Host: host, Account: "bab", App: "jam", Channel: "stable", CreatedAt: w.now} |
| 221 | plan14PutRecord(w.t, w.store, rec) |
| 222 | return rec, registry.Route{Host: host, Account: "bab", App: "jam", Kind: "instance", DBPath: provision.NestedDBPath(w.r.DataDir, host), Socket: provision.NestedSocket(host)} |
| 223 | } |
| 224 | |
| 225 | func (w *assemblyWorld) claim(host string) hostclaim.Observed { |
| 226 | w.t.Helper() |
| 227 | o, err := hostclaim.Read(w.t.Context(), w.store, host) |
| 228 | if err != nil { |
| 229 | w.t.Fatal(err) |
| 230 | } |
| 231 | return o |
| 232 | } |
| 233 | |
| 234 | // bindingAgent is the agent half of one agent start: its pin is the |
| 235 | // binding Verify returned when it started, set into the adapter and the |
| 236 | // census exactly as ClaimAssembly.Build does, and its inventory writes go to |
| 237 | // root's real commitRootInventory. |
| 238 | type bindingAgent struct { |
| 239 | w *assemblyWorld |
| 240 | pinned hostbox.Actor |
| 241 | adapter hostbox.ReconcileAdapter |
| 242 | remote *hostbox.StopInventoryDB |
| 243 | source hostbox.WorkSource |
| 244 | guard hostguard.Guard |
| 245 | census *hostbox.EmptyInstallationCensus |
| 246 | } |
| 247 | |
| 248 | func (w *assemblyWorld) agent(pinned hostbox.Actor) *bindingAgent { |
| 249 | w.t.Helper() |
| 250 | if !hostactors.ValidBinding(pinned.Binding) { |
| 251 | w.t.Fatalf("agent pin %q", pinned.Binding) |
| 252 | } |
| 253 | root, journals := w.root, w.assembly.Journals |
| 254 | _, remote := hostbox.RemoteJournals(func(ctx context.Context, q hostbox.JournalRequest) (hostbox.JournalResponse, error) { |
| 255 | if q.Op == "inventory-write" { |
| 256 | return root.commitRootInventory(ctx, q) |
| 257 | } |
| 258 | return journals.Execute(ctx, pinned.Box+"/"+pinned.Enrollment, q) |
| 259 | }) |
| 260 | guard := hostguard.Guard{DataDir: w.r.DataDir, LockDir: w.locks, Claims: hostguard.StoreReader{Store: w.store}, FenceView: w.assembly.Guard.Root.View} |
| 261 | census := &hostbox.EmptyInstallationCensus{Inventory: journals.Inventory, Registry: w.reg, Launches: w.assembly.Launches, Box: pinned.Box, Enrollment: pinned.Enrollment, DirectoryRevision: pinned.Binding, Roots: w.assembly.ManagedRoots, SharedPaths: map[string][]string{"data": {"hostguard"}}} |
| 262 | // The production policy, unit-shape refusal included (G10). |
| 263 | policy, _ := witnessPolicy(w.t, w.reg) |
| 264 | source := hostbox.WorkSource{Guard: guard, Box: pinned.Box, Region: "ie", Now: time.Now, Policy: policy, Local: func(context.Context, *hostclaim.Claim, hostbox.Selection) error { return nil }} |
| 265 | adapter := hostbox.ReconcileAdapter{Registry: w.reg, Inventory: remote, Box: pinned.Box, Enrollment: pinned.Enrollment, DirectoryRevision: pinned.Binding, Census: census, Reconciler: hostbox.Reconciler{Claims: w.store, Guard: guard, Box: pinned.Box, Region: "ie", Now: time.Now, Writes: hostwrite.Config{Enabled: true, Selector: source}, Local: park.Local{Guard: guard, Claims: w.store, DataDir: guard.DataDir, Box: pinned.Box}, CheckRecord: func(context.Context, hostbox.RecordSelection) error { return nil }, CheckLocal: func(context.Context, hostclaim.Observed, hostbox.RecordSelection) error { return nil }}} |
| 266 | return &bindingAgent{w: w, pinned: pinned, adapter: adapter, remote: remote, source: source, guard: guard, census: census} |
| 267 | } |
| 268 | |
| 269 | // install is the agent's install of rec on this box. |
| 270 | func (a *bindingAgent) install(rec provision.Record, route registry.Route) error { |
| 271 | a.w.t.Helper() |
| 272 | holder := a.w.claim(rec.Host).Claim.Holder |
| 273 | ctx, cancel := context.WithTimeout(a.w.t.Context(), 5*time.Second) |
| 274 | defer cancel() |
| 275 | return a.adapter.Install(ctx, provision.ClaimRecord{Host: rec.Host, Key: holder.Key, Account: rec.Account, App: rec.App, Family: "instance", CreatedAt: rec.CreatedAt, Value: rec, Route: route}, func(owner registry.ClaimOwnership) error { |
| 276 | route.ClaimOwnership = owner |
| 277 | return a.w.reg.Upsert(route) |
| 278 | }) |
| 279 | } |
| 280 | |
| 281 | // registerState is the agent's second inventory write for host, state |
| 282 | // registration at wake, compared at root with actor. |
| 283 | func (a *bindingAgent) registerState(host, dbPath string, actor hostbox.Actor) error { |
| 284 | a.w.t.Helper() |
| 285 | ctx := a.w.t.Context() |
| 286 | work, err := a.source.Select(ctx, host, "spawn") |
| 287 | if err != nil { |
| 288 | return err |
| 289 | } |
| 290 | roles := &ClaimRoleAssembly{Inventory: a.remote, ManagedRoots: a.w.assembly.ManagedRoots, Guard: a.guard, Claims: a.w.store} |
| 291 | return roles.prepareStateRemote(ctx, a.w.claim(host), work, ClaimLaunchInputs{DBPath: dbPath}, actor) |
| 292 | } |
| 293 | |
| 294 | func (w *assemblyWorld) inventory(host string) (hostbox.StopInventory, uint64) { |
| 295 | w.t.Helper() |
| 296 | h := w.claim(host).Claim.Holder |
| 297 | in, revision, err := w.assembly.Journals.Inventory.Snapshot(w.t.Context(), host, hostguard.Captured{Incarnation: h.Incarnation, Stamp: h.Stamp}) |
| 298 | if err != nil { |
| 299 | w.t.Fatal(err) |
| 300 | } |
| 301 | return in, revision |
| 302 | } |
| 303 | |
| 304 | // noProcessStop is the agent's session-zero stop of host's installation, |
| 305 | // stamped with stamp and the roster counter it read. |
| 306 | func (w *assemblyWorld) noProcessStop(host, stamp string, revision uint64) (StopResult, error) { |
| 307 | w.t.Helper() |
| 308 | o := w.claim(host) |
| 309 | h := o.Claim.Holder |
| 310 | enrollment := w.roster.row().Enrollment |
| 311 | return w.root.Stop.Coordinator.FenceAndStop(w.t.Context(), StopRequest{Region: "ie", Host: host, Incarnation: h.Incarnation, InstallationStamp: h.Stamp, Box: "box-1", Enrollment: enrollment, |
| 312 | BootID: "boot-1", ControllerID: "controller-1", ExpectedClaimETag: o.ETag, ExpectedDirectoryRevision: stamp, RosterRevision: revision}) |
| 313 | } |
| 314 | |
| 315 | // L4 (roster binding R2): readmission through the real instance-control |
| 316 | // assembly, without and then with a restart. Root's InventoryActor is the |
| 317 | // fresh identity; the cleanup identity is the historical one. |
| 318 | func TestClaimBindingReadmissionWithoutRestart(t *testing.T) { |
| 319 | ctx := t.Context() |
| 320 | w := newAssemblyWorld(t) |
| 321 | w.mustStart() |
| 322 | b0 := w.fresh() |
| 323 | if startup := w.assembly.cleanupEnrollment.actor; startup.Binding != b0.Binding { |
| 324 | t.Fatalf("cleanup identity %q, fresh %q", startup.Binding, b0.Binding) |
| 325 | } |
| 326 | old := w.agent(b0) |
| 327 | recA, routeA := w.newHost("a") |
| 328 | if err := old.install(recA, routeA); err != nil { |
| 329 | t.Fatal("install at B0:", err) |
| 330 | } |
| 331 | if in, _ := w.inventory(recA.Host); in.DirectoryRevision != b0.Binding { |
| 332 | t.Fatalf("installed stamp %q", in.DirectoryRevision) |
| 333 | } |
| 334 | |
| 335 | // Quarantine: new work is refused at once, historical cleanup works. |
| 336 | w.roster.quarantine("loss_1") |
| 337 | if _, err := w.root.InventoryActor(ctx); err == nil { |
| 338 | t.Fatal("InventoryActor admitted a quarantined box") |
| 339 | } |
| 340 | recB, routeB := w.newHost("b") |
| 341 | if err := old.install(recB, routeB); err == nil { |
| 342 | t.Fatal("root accepted an install while quarantined") |
| 343 | } |
| 344 | stopped, err := w.noProcessStop(recA.Host, b0.Binding, w.roster.read().Roster.Revision) |
| 345 | if err != nil { |
| 346 | t.Fatal("stop of the B0 installation while quarantined:", err) |
| 347 | } |
| 348 | if r := decodeStopReceipt(t, stopped.Bytes); r.Binding != b0.Binding { |
| 349 | t.Fatalf("quarantined stop receipt binding %q", r.Binding) |
| 350 | } |
| 351 | signer := ClaimStopCoordinator{Now: time.Now} |
| 352 | if err := w.assembly.cleanupEnrollment.configureSigner(ctx, &signer, w.key); err != nil { |
| 353 | t.Fatal("configureSigner while quarantined:", err) |
| 354 | } |
| 355 | |
| 356 | // Readmission (direct signed write; see readmit): the fresh identity |
| 357 | // returns B1 without a restart. |
| 358 | w.roster.readmit() |
| 359 | b1, err := w.root.InventoryActor(ctx) |
| 360 | if err != nil || !hostactors.ValidBinding(b1.Binding) || b1.Binding == b0.Binding { |
| 361 | t.Fatalf("InventoryActor after readmission: %q (B0 %q) %v", b1.Binding, b0.Binding, err) |
| 362 | } |
| 363 | if err := old.install(recB, routeB); err == nil { |
| 364 | t.Fatal("root accepted the old agent's B0 install after readmission") |
| 365 | } |
| 366 | if row, err := w.reg.Get(recB.Host); err != nil || row != nil { |
| 367 | t.Fatalf("refused install left a route: %+v %v", row, err) |
| 368 | } |
| 369 | renewed := w.agent(w.fresh()) |
| 370 | if renewed.pinned.Binding != b1.Binding { |
| 371 | t.Fatal("restarted agent did not pin B1") |
| 372 | } |
| 373 | if err := renewed.install(recB, routeB); err != nil { |
| 374 | t.Fatal("install at B1:", err) |
| 375 | } |
| 376 | if in, _ := w.inventory(recB.Host); in.DirectoryRevision != b1.Binding { |
| 377 | t.Fatalf("B1 install stamp %q", in.DirectoryRevision) |
| 378 | } |
| 379 | if err := renewed.registerState(recA.Host, routeA.DBPath, b1); !errors.Is(err, hostbox.ErrTypedInventory) { |
| 380 | t.Fatalf("state registration of the B0 installation after readmission: %v", err) |
| 381 | } |
| 382 | |
| 383 | // Restart: the cleanup identity is B1 now; a fresh install passes and |
| 384 | // the B0 installation is still refused. |
| 385 | if err := w.restart(); err != nil { |
| 386 | t.Fatal(err) |
| 387 | } |
| 388 | if startup := w.assembly.cleanupEnrollment.actor; startup.Binding != b1.Binding { |
| 389 | t.Fatalf("restarted cleanup identity %q", startup.Binding) |
| 390 | } |
| 391 | agent := w.agent(w.fresh()) |
| 392 | recC, routeC := w.newHost("c") |
| 393 | if err := agent.install(recC, routeC); err != nil { |
| 394 | t.Fatal("fresh install after restart:", err) |
| 395 | } |
| 396 | if err := agent.registerState(recA.Host, routeA.DBPath, w.fresh()); !errors.Is(err, hostbox.ErrTypedInventory) { |
| 397 | t.Fatalf("B0 installation after restart: %v", err) |
| 398 | } |
| 399 | if err := agent.registerState(recB.Host, routeB.DBPath, w.fresh()); err != nil { |
| 400 | t.Fatal("B1 installation's state registration after restart:", err) |
| 401 | } |
| 402 | } |
| 403 | |
| 404 | // L2 and L3 through the real instance-control assembly: root's handler is |
| 405 | // built by OpenProductionClaimAssembly and claimRootHTTP, its InventoryActor |
| 406 | // the fresh identity; installs reach its commitRootInventory. Six real |
| 407 | // roster writes and a held create leave every comparison passing, without |
| 408 | // and after a root restart. |
| 409 | func TestClaimBindingAssemblySurvivesRosterWritesWithoutRestart(t *testing.T) { |
| 410 | testClaimBindingAssemblySurvivesRosterWrites(t, false) |
| 411 | } |
| 412 | |
| 413 | func TestClaimBindingAssemblySurvivesRosterWritesAfterRestart(t *testing.T) { |
| 414 | testClaimBindingAssemblySurvivesRosterWrites(t, true) |
| 415 | } |
| 416 | |
| 417 | func testClaimBindingAssemblySurvivesRosterWrites(t *testing.T, restart bool) { |
| 418 | ctx := t.Context() |
| 419 | w := newAssemblyWorld(t) |
| 420 | w.mustStart() |
| 421 | pinned := w.fresh() |
| 422 | agent := w.agent(pinned) |
| 423 | rec, route := w.newHost("a") |
| 424 | if err := agent.install(rec, route); err != nil { |
| 425 | t.Fatal(err) |
| 426 | } |
| 427 | before := w.roster.read().Roster.Revision |
| 428 | w.roster.sixWrites() |
| 429 | if restart { |
| 430 | if err := w.restart(); err != nil { |
| 431 | t.Fatal("restart after six roster writes:", err) |
| 432 | } |
| 433 | agent = w.agent(w.fresh()) |
| 434 | } |
| 435 | held := w.roster.reserveHold() |
| 436 | actor, err := w.root.InventoryActor(ctx) |
| 437 | if err != nil || actor.Revision != before+7 || !hostbox.SameBinding(actor, pinned) { |
| 438 | t.Fatalf("InventoryActor with a hold held: revision %d (before %d), same binding %v, %v", actor.Revision, before, hostbox.SameBinding(actor, pinned), err) |
| 439 | } |
| 440 | if agent.pinned.Binding != pinned.Binding || w.assembly.cleanupEnrollment.actor.Binding != pinned.Binding { |
| 441 | t.Fatal("a pin moved with unrelated roster writes") |
| 442 | } |
| 443 | h := w.claim(rec.Host).Claim.Holder |
| 444 | if err := agent.census.CheckLocal(ctx, w.claim(rec.Host), hostbox.RecordSelection{Host: rec.Host, Incarnation: h.Incarnation}); err != nil { |
| 445 | t.Fatal("agent census:", err) |
| 446 | } |
| 447 | if err := w.root.Reconcile.Census.CheckLocal(ctx, w.claim(rec.Host), hostbox.RecordSelection{Host: rec.Host, Incarnation: h.Incarnation}); err != nil { |
| 448 | t.Fatal("root census:", err) |
| 449 | } |
| 450 | if err := agent.registerState(rec.Host, route.DBPath, actor); err != nil { |
| 451 | t.Fatal("state registration through root after roster writes:", err) |
| 452 | } |
| 453 | if in, revision := w.inventory(rec.Host); in.DirectoryRevision != pinned.Binding || revision < 2 { |
| 454 | t.Fatalf("after state registration: stamp %q revision %d", in.DirectoryRevision, revision) |
| 455 | } |
| 456 | if err := claimBoundIdentity(w.owners.base.Identity.Verify, pinned)(ctx); err != nil { |
| 457 | t.Fatal("bound identity:", err) |
| 458 | } |
| 459 | w.roster.releaseHold(held) |
| 460 | |
| 461 | // A binding change refuses new work at root at once. |
| 462 | w.roster.quarantine("loss_1") |
| 463 | w.roster.readmit() |
| 464 | if err := agent.registerState(rec.Host, route.DBPath, w.fresh()); !errors.Is(err, hostbox.ErrTypedInventory) { |
| 465 | t.Fatalf("state registration after readmission: %v", err) |
| 466 | } |
| 467 | recB, routeB := w.newHost("b") |
| 468 | if err := agent.install(recB, routeB); err == nil { |
| 469 | t.Fatal("root accepted a B0 install after readmission") |
| 470 | } |
| 471 | } |
| 472 | |