| 1 | //go:build linux && rootrunner |
| 2 | |
| 3 | package host |
| 4 | |
| 5 | import ( |
| 6 | "context" |
| 7 | "encoding/json" |
| 8 | "errors" |
| 9 | "os" |
| 10 | "path/filepath" |
| 11 | "strings" |
| 12 | "testing" |
| 13 | "time" |
| 14 | |
| 15 | "github.com/carlosframework/platform/internal/hostguard" |
| 16 | "github.com/carlosframework/platform/internal/hostops" |
| 17 | "github.com/carlosframework/platform/internal/rootparticipation" |
| 18 | "github.com/carlosframework/platform/internal/rootstate" |
| 19 | ) |
| 20 | |
| 21 | const migrationRebootDir = "/var/lib/plan14-p3-migration-reboot" |
| 22 | |
| 23 | func rebootMigration(t *testing.T) (rootMigration, *hostguard.RootAuthority) { |
| 24 | t.Helper() |
| 25 | open := func(name string) rootstate.Store { |
| 26 | s, e := rootstate.Open(filepath.Join(migrationRebootDir, name), true, false) |
| 27 | if e != nil { |
| 28 | t.Fatal(e) |
| 29 | } |
| 30 | t.Cleanup(func() { s.Close() }) |
| 31 | return s |
| 32 | } |
| 33 | state, execution, fences := open("migration"), open("execution"), open("fences") |
| 34 | root := hostguard.NewRootAuthority(fences) |
| 35 | m := rootMigration{prepareFloors: func(context.Context) error { return nil }, guardReady: func(context.Context) error { return nil }, state: state, execution: execution, identity: rootMigrationIdentity{"deploy", "box", "en", 1}, quiesce: func(context.Context) (func(), error) { return func() {}, nil }} |
| 36 | m.observe = func(context.Context) (rootMigrationSnapshot, error) { |
| 37 | raw, e := state.Read("snapshot.json") |
| 38 | var s rootMigrationSnapshot |
| 39 | if e == nil { |
| 40 | e = json.Unmarshal(raw, &s) |
| 41 | } |
| 42 | return s, e |
| 43 | } |
| 44 | m.deny = func(ctx context.Context, item rootMigrationItem) error { |
| 45 | return root.RetainMigrationDeny(ctx, item.Installation, item.Session) |
| 46 | } |
| 47 | m.recover = func(ctx context.Context, item rootMigrationItem) error { |
| 48 | return verifyRootMigrationRetained(ctx, root, rootMigrationSnapshot{Items: []rootMigrationItem{item}}) |
| 49 | } |
| 50 | m.ready = func(ctx context.Context, s rootMigrationSnapshot) error { |
| 51 | return verifyRootMigrationRetained(ctx, root, s) |
| 52 | } |
| 53 | m.publish = func(context.Context, hostops.CensusReport) error { return nil } |
| 54 | return m, root |
| 55 | } |
| 56 | func TestRootRebootPlan14MigrationPhase1(t *testing.T) { |
| 57 | if err := os.Mkdir(migrationRebootDir, 0700); err != nil { |
| 58 | t.Fatal(err) |
| 59 | } |
| 60 | m, root := rebootMigration(t) |
| 61 | _, s, _ := migrationFixture(t) |
| 62 | item := rootMigrationItem{Installation: hostguard.RootInstallation{Host: "a.example", Incarnation: "inc", Stamp: 1, Account: "bab", App: "app", Record: "record", Box: "box", Enrollment: "en", Migration: 1, InventoryRevision: 1, Artifacts: hostguard.InstallationArtifacts{Schema: 1}}, Session: 9, ClaimETag: "claim"} |
| 63 | s.Items = []rootMigrationItem{item} |
| 64 | if err := m.write("snapshot.json", s); err != nil { |
| 65 | t.Fatal(err) |
| 66 | } |
| 67 | // This phase-engine witness starts with a root-verified completed stop. The |
| 68 | // separate historic runner exercises its signed import and physical census. |
| 69 | if err := root.RetainMigrationDeny(t.Context(), item.Installation, item.Session); err != nil { |
| 70 | t.Fatal(err) |
| 71 | } |
| 72 | target := hostguard.FenceTarget{Host: "a.example", Incarnation: "inc", Stamp: 1, Session: 9, Box: "box", Enrollment: "en", BootID: "old", ControllerID: "controller", ClaimETag: "claim", DirectoryRevision: "7", FenceID: "fence"} |
| 73 | fences, err := rootstate.Open(filepath.Join(migrationRebootDir, "fences"), false, false) |
| 74 | if err != nil { |
| 75 | t.Fatal(err) |
| 76 | } |
| 77 | defer fences.Close() |
| 78 | raw, err := fences.Read("a.example.json") |
| 79 | if err != nil { |
| 80 | t.Fatal(err) |
| 81 | } |
| 82 | var record map[string]any |
| 83 | if json.Unmarshal(raw, &record) != nil { |
| 84 | t.Fatal("record") |
| 85 | } |
| 86 | // Encode the typed completed receipt as pre-existing authority for this crash fixture. |
| 87 | var state map[string]any |
| 88 | encoded, _ := json.Marshal(record["State"]) |
| 89 | if json.Unmarshal(encoded, &state) != nil { |
| 90 | t.Fatal("state") |
| 91 | } |
| 92 | state["fence_journal"] = []hostguard.FenceReceipt{{Target: target, Bytes: []byte("root verified receipt"), EvidenceAt: time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)}} |
| 93 | record["State"] = state |
| 94 | raw, _ = json.Marshal(record) |
| 95 | if err := fences.Write("a.example.json", raw); err != nil { |
| 96 | t.Fatal(err) |
| 97 | } |
| 98 | boot, err := os.ReadFile("/proc/sys/kernel/random/boot_id") |
| 99 | if err != nil { |
| 100 | t.Fatal(err) |
| 101 | } |
| 102 | if err := m.state.Write("boot", boot); err != nil { |
| 103 | t.Fatal(err) |
| 104 | } |
| 105 | cuts := 0 |
| 106 | interrupted := errors.New("completion parent sync interrupted") |
| 107 | m.state = rootstate.WithWriteFault(m.state, func(name, stage string, op func() error) error { |
| 108 | if filepath.Base(name) == "migration.json" && stage == "parent-sync" { |
| 109 | cuts++ |
| 110 | if cuts == 4 { |
| 111 | return interrupted |
| 112 | } |
| 113 | } |
| 114 | return op() |
| 115 | }) |
| 116 | if err := m.run(t.Context()); !errors.Is(err, interrupted) { |
| 117 | t.Fatal("crash boundary", cuts, err) |
| 118 | } |
| 119 | if _, err := (rootparticipation.Ledger{State: m.execution}).Census(); err == nil { |
| 120 | t.Fatal("unsynced completion allowed grants") |
| 121 | } |
| 122 | } |
| 123 | func TestRootRebootPlan14MigrationPhase2(t *testing.T) { |
| 124 | if _, err := os.Stat(migrationRebootDir); err != nil { |
| 125 | t.Fatal(err) |
| 126 | } |
| 127 | defer os.RemoveAll(migrationRebootDir) |
| 128 | m, root := rebootMigration(t) |
| 129 | before, err := m.state.Read("boot") |
| 130 | if err != nil { |
| 131 | t.Fatal(err) |
| 132 | } |
| 133 | now, err := os.ReadFile("/proc/sys/kernel/random/boot_id") |
| 134 | if err != nil || strings.TrimSpace(string(before)) == strings.TrimSpace(string(now)) { |
| 135 | t.Fatal("no real reboot", err) |
| 136 | } |
| 137 | if err := m.run(t.Context()); err != nil { |
| 138 | t.Fatal("reboot resume", err) |
| 139 | } |
| 140 | if err := verifyRootMigrationState(t.Context(), m.state, m.execution, m.identity, root); err != nil { |
| 141 | t.Fatal("startup verification", err) |
| 142 | } |
| 143 | v, err := root.View(t.Context(), "a.example", hostguard.Captured{Incarnation: "inc", Stamp: 1}) |
| 144 | if err != nil || len(v.MigrationDenies) != 1 || v.MigrationDenies[0].Session != 9 || len(v.Receipts) != 1 || string(v.Receipts[0].Bytes) != "root verified receipt" { |
| 145 | t.Fatal("reboot lost evidence", v, err) |
| 146 | } |
| 147 | if err := m.run(t.Context()); err != nil { |
| 148 | t.Fatal("completed reboot retry", err) |
| 149 | } |
| 150 | } |
| 151 | |