package host import ( "context" "encoding/json" "errors" "os" "testing" "time" "github.com/carlosframework/platform/internal/hostclaim" "github.com/carlosframework/platform/internal/hostguard" "github.com/carlosframework/platform/internal/hostops" "github.com/carlosframework/platform/internal/provision" "github.com/carlosframework/platform/internal/rootparticipation" "github.com/carlosframework/platform/internal/rootstate" ) func migrationFixture(t *testing.T) (rootMigration, *rootMigrationSnapshot, *int) { t.Helper() state := &rollbackDisk{dir: t.TempDir()} exec := &rollbackDisk{dir: t.TempDir()} writes := 0 snapshot := &rootMigrationSnapshot{Complete: true, DirectoryRevision: 7, Exclusion: "signed collecting reservation", Report: hostops.CensusReport{Box: "box", Enrollment: "en", BootID: "boot", Reservation: "offline", DirectoryRevision: 7, CapturedAt: time.Now(), Complete: true, Installations: []hostops.CensusInstallation{}, Seeds: &hostops.CensusSeeds{Journaled: []string{}, Gated: []string{}}}, Items: []rootMigrationItem{}} m := rootMigration{prepareFloors: func(context.Context) error { return nil }, guardReady: func(context.Context) error { return nil }, state: state, execution: exec, identity: rootMigrationIdentity{Deployment: "deploy", Box: "box", Enrollment: "en", Generation: 1}, quiesce: func(context.Context) (func(), error) { return func() {}, nil }, observe: func(context.Context) (rootMigrationSnapshot, error) { return *snapshot, nil }, deny: func(context.Context, rootMigrationItem) error { writes++; return nil }, recover: func(context.Context, rootMigrationItem) error { writes++; return nil }, ready: func(context.Context, rootMigrationSnapshot) error { return nil }, publish: func(context.Context, hostops.CensusReport) error { writes++; return nil }} return m, snapshot, &writes } func TestPlan14RootMigrationRefusesBeforeEffects(t *testing.T) { for _, kind := range []string{"preparing", "ready", "cutting", "aborting", "reverting", "landing", "committed", "rolling-back", "stuck", "future", "admitted-before-launch", "probing", "probed", "root-journal", "reservation", "door", "late-move", "incomplete", "seed-journal", "seed-gate", "seed-unknown"} { t.Run(kind, func(t *testing.T) { m, s, effects := migrationFixture(t) switch kind { case "root-journal": s.MoveJournals = 1 case "reservation": s.MoveReservations = 1 case "door": s.Exclusion = "" case "late-move": n := 0 m.observe = func(context.Context) (rootMigrationSnapshot, error) { n++ if n == 2 { s.Claims = []hostclaim.Claim{{Op: hostclaim.Op{ID: "late"}}} } return *s, nil } case "incomplete": s.Complete = false case "seed-journal": s.Report.Seeds.Journaled = []string{"host"} case "seed-gate": s.Report.Seeds.Gated = []string{"host"} case "seed-unknown": s.Report.Seeds = nil default: s.Records = []provision.Record{{Move: &provision.MoveState{State: kind, LandingPhase: kind}}} } before, _ := json.Marshal(s) if m.run(t.Context()) == nil { t.Fatal("migration accepted active work") } after, _ := json.Marshal(s) if kind != "late-move" && string(before) != string(after) { t.Fatal("migration changed input") } names, _ := m.state.Names() for _, n := range names { if n != "migration.lock" { t.Fatal("write before preflight", n) } } if *effects != 0 { t.Fatal("effects before preflight", *effects) } s.MoveJournals = 0 s.MoveReservations = 0 s.Exclusion = "signed collecting reservation" s.Complete = true s.Records = nil s.Claims = nil s.Report.Seeds = &hostops.CensusSeeds{} m.observe = func(context.Context) (rootMigrationSnapshot, error) { return *s, nil } if err := m.run(t.Context()); err != nil { t.Fatal("normal move close did not unblock", err) } }) } } func TestPlan14RootMigrationCrashResume(t *testing.T) { for _, after := range []bool{false, true} { for cut := 1; cut <= 7; cut++ { t.Run(string(rune('a'+cut))+map[bool]string{true: "-after", false: "-before"}[after], func(t *testing.T) { m, s, _ := migrationFixture(t) s.Items = []rootMigrationItem{{Installation: hostguard.RootInstallation{Host: "a.example", Incarnation: "inc", Stamp: 1, Account: "bab", App: "app", Record: "apps/bab/app/instances/a.example.json", Box: "box", Enrollment: "en", Migration: 1}, Session: 9, ClaimETag: "claim"}} n := 0 original := m.state m.state = &floorFaultStore{Store: m.state, n: &n, fail: cut, after: after} err := m.run(t.Context()) if err == nil { t.Fatal("missing crash", cut, n) } m.state = original // DataDir is deliberately absent on replay; all retained work is root state. if err := m.run(t.Context()); err != nil { t.Fatal("resume", err) } c, err := (rootparticipation.Ledger{State: m.execution}).Census() if err != nil || c.Migration != 1 { t.Fatal(c, err) } if err := m.run(t.Context()); err != nil { t.Fatal("idempotence", err) } m.identity.Enrollment = "other" if m.run(t.Context()) == nil { t.Fatal("foreign enrollment reused migration") } }) } } } func TestPlan14RootMigrationRetainsSessionDeny(t *testing.T) { state := &rollbackDisk{dir: t.TempDir()} root := hostguard.NewRootAuthority(state) in := hostguard.RootInstallation{Host: "a.example", Incarnation: "inc", Stamp: 1, Account: "bab", App: "app", Record: "record", Box: "box", Enrollment: "en", Migration: 1} if err := root.RetainMigrationDeny(t.Context(), in, 9); err != nil { t.Fatal(err) } if err := root.RetainMigrationDeny(t.Context(), in, 8); err != nil { t.Fatal(err) } root = hostguard.NewRootAuthority(state) view, err := root.View(t.Context(), in.Host, hostguard.Captured{Incarnation: "inc", Stamp: 1}) if err != nil { t.Fatal(err) } if len(view.MigrationDenies) != 1 || view.MigrationDenies[0].Session != 9 { t.Fatal("lost deny", view) } } var _ rootstate.Store = (*rollbackDisk)(nil) var _ = errors.Is var _ = os.ErrNotExist func TestPlan14RootMigrationPublishesOnlyDurableCompletion(t *testing.T) { m, _, _ := migrationFixture(t) m.publish = func(ctx context.Context, _ hostops.CensusReport) error { raw, err := m.state.Read("migration.json") if err != nil { return err } var r rootMigrationRecord if json.Unmarshal(raw, &r) != nil || r.Phase != "publishing" { t.Fatal("report preceded local completion") } if _, err := (rootparticipation.Ledger{State: m.execution}).Census(); err != nil { t.Fatal("report preceded census", err) } return nil } if err := m.run(t.Context()); err != nil { t.Fatal(err) } } func TestPlan14RootMigrationPreparedBlocksGrants(t *testing.T) { m, _, _ := migrationFixture(t) n := 0 m.state = &floorFaultStore{Store: m.state, n: &n, fail: 1, after: true} if err := m.run(t.Context()); err == nil { t.Fatal("prepare interruption was not reached") } raw, err := m.execution.Read("census.json") if err != nil { t.Fatal("visible prepared without root admission barrier", err) } var c rootparticipation.Census if rootparticipation.Decode(raw, &c) != nil || c.Complete || c.Pending != "migration" { t.Fatal("prepared permits root grants", c) } } func TestPlan14RootMigrationCensusCrashResume(t *testing.T) { for _, after := range []bool{false, true} { for _, cut := range []int{1, 2} { m, _, published := migrationFixture(t) execution := m.execution n := 0 m.execution = &floorFaultStore{Store: execution, n: &n, fail: cut, after: after} if err := m.run(t.Context()); err == nil { t.Fatal("census fault not reached", cut, after) } if *published != 0 { t.Fatal("published despite failed census sync") } raw, err := execution.Read("census.json") if err == nil { var c rootparticipation.Census if rootparticipation.Decode(raw, &c) != nil || c.Complete || c.Pending != "migration" { t.Fatal("failed census granted", cut, after, c) } } else if cut != 1 || after || !errors.Is(err, os.ErrNotExist) { t.Fatal(err) } m.execution = execution if err := m.run(t.Context()); err != nil { t.Fatal("census retry", cut, after, err) } c, err := (rootparticipation.Ledger{State: execution}).Census() if err != nil || !c.Complete || *published != 1 { t.Fatal("retry incomplete", c, err, *published) } } } } func TestPlan14RootMigrationStartupVerification(t *testing.T) { m, _, _ := migrationFixture(t) root := hostguard.NewRootAuthority(&rollbackDisk{dir: t.TempDir()}) if err := m.run(t.Context()); err != nil { t.Fatal(err) } if err := verifyRootMigrationState(t.Context(), m.state, m.execution, m.identity, root); err != nil { t.Fatal("matching startup", err) } raw, err := m.state.Read("migration.json") if err != nil { t.Fatal(err) } for _, attack := range []string{"missing", "pending", "enrollment", "schema", "census"} { t.Run(attack, func(t *testing.T) { if err := m.state.Write("migration.json", raw); err != nil { t.Fatal(err) } if err := m.census(true); err != nil { t.Fatal(err) } var r rootMigrationRecord if json.Unmarshal(raw, &r) != nil { t.Fatal("fixture") } switch attack { case "missing": if err := m.state.Remove("migration.json"); err != nil { t.Fatal(err) } case "pending": r.Phase = "prepared" case "enrollment": r.Identity.Enrollment = "other" case "schema": r.Schema++ case "census": c, _ := (rootparticipation.Ledger{State: m.execution}).Census() c.Complete = false c.Pending = "migration" b, _ := json.Marshal(c) if err := m.execution.Write("census.json", b); err != nil { t.Fatal(err) } } if attack != "missing" { if err := m.write("migration.json", r); err != nil { t.Fatal(err) } } if verifyRootMigrationState(t.Context(), m.state, m.execution, m.identity, root) == nil { t.Fatal("unsafe startup") } }) } } func fixtureWithoutRootMigrationCensus(t *testing.T) { t.Helper() oldOpen, oldShapes := openClaimActivationState, productionClaimUnitShapes t.Cleanup(func() { openClaimActivationState, productionClaimUnitShapes = oldOpen, oldShapes }) openClaimActivationState = func(string, bool, bool) (rootstate.Store, error) { return nil, os.ErrNotExist } productionClaimUnitShapes = func() (map[string]claimUnitShape, error) { return map[string]claimUnitShape{}, nil } original := openRootMigrationParticipation openRootMigrationParticipation = func() (rootparticipation.Ledger, bool, error) { return rootparticipation.Ledger{}, false, nil } t.Cleanup(func() { openRootMigrationParticipation = original }) } func TestPlan14RootMigrationCompletedPreservesRevisions(t *testing.T) { m, _, effects := migrationFixture(t) if err := m.run(t.Context()); err != nil { t.Fatal(err) } c, err := (rootparticipation.Ledger{State: m.execution}).Census() if err != nil { t.Fatal(err) } c.Revision = 42 c.LaunchRevision = 17 raw, _ := json.Marshal(c) if err := m.execution.Write("census.json", raw); err != nil { t.Fatal(err) } before := *effects m.ready = func(context.Context, rootMigrationSnapshot) error { t.Fatal("completed migration re-ran readiness") return nil } if err := m.run(t.Context()); err != nil { t.Fatal(err) } after, _ := m.execution.Read("census.json") if string(raw) != string(after) || *effects != before { t.Fatal("completed migration rewrote state or republished") } } func TestPlan14RootMigrationPreparedReobserves(t *testing.T) { m, s, _ := migrationFixture(t) s.Items = []rootMigrationItem{{Installation: hostguard.RootInstallation{Host: "a.example", Incarnation: "inc", Stamp: 1, Account: "bab", App: "app", Record: "record", Box: "box", Enrollment: "en", Migration: 1}, Session: 9, ClaimETag: "first"}} m.recover = func(context.Context, rootMigrationItem) error { return os.ErrInvalid } if m.run(t.Context()) == nil { t.Fatal("missing interruption") } s.Items[0].Session = 10 s.Items[0].ClaimETag = "second" seen := map[uint64]bool{} m.deny = func(_ context.Context, i rootMigrationItem) error { seen[i.Session] = true; return nil } m.recover = func(context.Context, rootMigrationItem) error { return nil } if err := m.run(t.Context()); err != nil { t.Fatal("late claim wedged migration", err) } if !seen[9] || !seen[10] { t.Fatal("deny coverage shrank", seen) } } func TestPlan14RootMigrationGuardBeforeCensus(t *testing.T) { m, _, _ := migrationFixture(t) m.guardReady = func(context.Context) error { return os.ErrPermission } if m.run(t.Context()) == nil { t.Fatal("unarmed migration accepted") } if _, err := m.execution.Read("census.json"); !os.IsNotExist(err) { t.Fatal("pending census before guard", err) } }