carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1//go:build linux
2
3package host
4
5import (
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.
35type bindingAssemblyOwners struct {
36 base ClaimOwnerBindings
37 dir hostactors.Directory
38}
39
40func (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.
59type 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.
88type interruptibleDisk struct {
89 *rollbackDisk
90 after *int
91}
92
93func (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
103func 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.
149func (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
176func (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.
184func (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
192func (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.
203func (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
225func (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.
238type 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
248func (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.
270func (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.
283func (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
294func (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.
306func (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.
318func 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.
409func TestClaimBindingAssemblySurvivesRosterWritesWithoutRestart(t *testing.T) {
410 testClaimBindingAssemblySurvivesRosterWrites(t, false)
411}
412
413func TestClaimBindingAssemblySurvivesRosterWritesAfterRestart(t *testing.T) {
414 testClaimBindingAssemblySurvivesRosterWrites(t, true)
415}
416
417func 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