//go:build linux package host import ( "context" "encoding/json" "errors" "os" "path/filepath" "testing" "time" "github.com/carlosframework/platform/internal/hostbox" "github.com/carlosframework/platform/internal/hostclaim" "github.com/carlosframework/platform/internal/hostguard" "github.com/carlosframework/platform/internal/hostops" "github.com/carlosframework/platform/internal/registry" "github.com/carlosframework/platform/internal/rootstate" ) type migrationTimerFault struct { rootstate.Store fail bool } func (s *migrationTimerFault) Write(n string, b []byte) error { if err := s.Store.Write(n, b); err != nil { return err } if s.fail { s.fail = false return os.ErrInvalid } return nil } func TestPlan14RootMigrationTimerAdoption(t *testing.T) { testRootMigrationTimerAdoption(t, func(t *testing.T) rootstate.Store { return &rollbackDisk{dir: t.TempDir()} }, nil) } func testRootMigrationTimerAdoption(t *testing.T, open func(*testing.T) rootstate.Store, prepareRegistry func(*testing.T, *ClaimRuntimeResources)) { for _, attack := range []string{"none", "intent-sync", "after-row", "after-inventory", "after-root", "registration", "contents", "foreign-owner", "erased-row", "extra-row", "missing-receipt", "missing-deny"} { t.Run(attack, func(t *testing.T) { store, r, owners, _ := fix4JournalFixture(t) if prepareRegistry != nil { prepareRegistry(t, &r) } actor, err := owners.Identity.Verify(t.Context()) if err != nil { t.Fatal(err) } target, receipt := migrationHistoricFixture(t, store, r, owners.ClaimOwnerBindings, actor, &hostops.BackfillPlan{Hosts: map[string]hostops.BackfillHost{}}) j, err := hostbox.OpenLaunchJournal(filepath.Join(t.TempDir(), "root.db")) if err != nil { t.Fatal(err) } defer j.Close() svc, err := hostbox.NewJournalService(j) if err != nil { t.Fatal(err) } if err := svc.MigrateLegacy(t.Context(), filepath.Join(r.DataDir, ".hostclaims"), actor.Box, actor.Enrollment, func(context.Context) (func(), error) { return func() {}, nil }, legacyJournalProof(store, owners.RetirementAuthority.Directory)); err != nil { t.Fatal(err) } fstate := &migrationTimerFault{Store: open(t)} defer fstate.Close() root := hostguard.NewRootAuthority(fstate) guard := hostguard.Guard{Root: root, LockDir: t.TempDir(), DataDir: r.DataDir, Claims: hostguard.StoreReader{Store: store}} item := rootMigrationItem{Installation: hostguard.RootInstallation{Host: target.Host, Incarnation: target.Incarnation, Stamp: target.Stamp, Account: "bab", App: "app", Record: "apps/bab/app/instances/" + target.Host + ".json", Box: target.Box, Enrollment: target.Enrollment, Migration: 1}, Session: target.Session, ClaimETag: target.ClaimETag} if attack != "missing-deny" { if err := root.RetainMigrationDeny(t.Context(), item.Installation, item.Session); err != nil { t.Fatal(err) } } if attack == "missing-deny" { if err := guard.EstablishRoot(t.Context(), item.Installation, func(*hostclaim.Claim) error { return nil }); err != nil { t.Fatal(err) } } if attack != "missing-receipt" { if _, err := guard.BeginFence(t.Context(), target); err != nil { t.Fatal(err) } if err := guard.CompleteFence(t.Context(), target, receipt, time.Now(), func(context.Context, hostguard.FenceTarget, []byte, time.Time) error { return nil }); err != nil { t.Fatal(err) } } state := &migrationTimerFault{Store: open(t), fail: attack == "intent-sync"} defer state.Close() m := rootMigrationTimers{state: state, registry: r.Registry, inventory: svc.Inventory, guard: guard} original, err := r.Registry.GetTimer(target.Host, "refresh") if err != nil { t.Fatal(err) } canonical := registry.Route{Host: target.Host, Account: "bab", App: "app"} if attack == "missing-receipt" || attack == "missing-deny" { if m.adopt(t.Context(), item, target, canonical) == nil { t.Fatal("adoption without stopped root authority") } got, err := r.Registry.GetTimer(target.Host, "refresh") if err != nil || *got != *original { t.Fatal("row changed before root authority", got, err) } if _, exists, err := m.load(item); err != nil || exists { t.Fatal("intent written before root authority", exists, err) } return } if attack != "none" { state.fail = true if m.adopt(t.Context(), item, target, canonical) == nil { t.Fatal("intent sync acknowledged") } got, _ := r.Registry.GetTimer(target.Host, "refresh") if *got != *original { t.Fatal("row changed before durable intent") } rec, ok, err := m.load(item) if err != nil || !ok { t.Fatal(ok, err) } owned := rec.Rows[0] owned.ClaimOwnership = m.owner(item, owned.Registration) switch attack { case "after-row", "after-inventory", "after-root": if err := r.Registry.AdoptClaimTimer(t.Context(), rec.Rows[0], owned.ClaimOwnership); err != nil { t.Fatal(err) } if attack != "after-row" { if err := svc.Inventory.WriteStopInventory(t.Context(), rec.Next); err != nil { t.Fatal(err) } } if attack == "after-root" { fstate.fail = true if err := root.CompleteMigrationInventory(t.Context(), target, rec.Revision+1, rec.Artifacts, rec.Revision); err == nil { t.Fatal("root sync acknowledged") } } case "foreign-owner": if err := r.Registry.AdoptClaimTimer(t.Context(), *original, registry.ClaimOwnership{ClaimIncarnation: "foreign", ClaimStamp: 2, ClaimOwner: "foreign", ClaimRowID: original.Registration}); err != nil { t.Fatal(err) } case "registration", "contents": changed := *original if attack == "registration" { changed.Registration = "replacement" } if attack == "contents" { changed.Path = "/changed" } if err := r.Registry.ReplaceTimers(target.Host, []registry.Timer{changed}); err != nil { t.Fatal(err) } case "erased-row": if err := r.Registry.DeleteTimer(target.Host, "refresh"); err != nil { t.Fatal(err) } case "extra-row": if err := r.Registry.UpsertTimer(registry.Timer{Host: target.Host, Name: "extra", Kind: "cron", Spec: "0 0 * * *", Path: "/extra"}); err != nil { t.Fatal(err) } } } err = m.adopt(t.Context(), item, target, canonical) refused := attack == "registration" || attack == "contents" || attack == "foreign-owner" || attack == "erased-row" || attack == "extra-row" if (err != nil) != refused { t.Fatalf("refused=%v: %v", refused, err) } if refused { return } if err = m.adopt(t.Context(), item, target, canonical); err != nil { t.Fatal("replay", err) } got, err := r.Registry.GetTimer(target.Host, "refresh") want := *original want.ClaimOwnership = m.owner(item, original.Registration) if err != nil || *got != want { t.Fatal("identity or bookkeeping changed", got, err) } in, rev, err := svc.Inventory.Snapshot(t.Context(), target.Host, hostguard.Captured{Incarnation: target.Incarnation, Stamp: target.Stamp}) if err != nil || rev != 2 { t.Fatal("inventory revision", rev, err) } e := in.Entries[1] if e.ManagedRoot != "registry" || e.RowIdentity != original.Registration || e.Ownership != want.ClaimOwner { t.Fatal("timer not inventoried", e) } retained, err := root.Installation(t.Context(), target.Host, hostguard.Captured{Incarnation: target.Incarnation, Stamp: target.Stamp}) if err != nil || retained.InventoryRevision != rev { t.Fatal("root revision", retained, err) } var decoded StopReceipt if json.Unmarshal(receipt, &decoded) != nil { t.Fatal("receipt") } saved, err := guard.FenceReceiptFor(t.Context(), target) if err != nil || string(saved.Bytes) != string(receipt) { t.Fatal("receipt changed", err) } if _, err := state.Read("absent"); !errors.Is(err, os.ErrNotExist) { t.Fatal(err) } }) } }