| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "errors" |
| 7 | "os" |
| 8 | "testing" |
| 9 | "time" |
| 10 | |
| 11 | "github.com/carlosframework/platform/internal/hostclaim" |
| 12 | "github.com/carlosframework/platform/internal/hostguard" |
| 13 | "github.com/carlosframework/platform/internal/hostops" |
| 14 | "github.com/carlosframework/platform/internal/provision" |
| 15 | "github.com/carlosframework/platform/internal/rootparticipation" |
| 16 | "github.com/carlosframework/platform/internal/rootstate" |
| 17 | ) |
| 18 | |
| 19 | func migrationFixture(t *testing.T) (rootMigration, *rootMigrationSnapshot, *int) { |
| 20 | t.Helper() |
| 21 | state := &rollbackDisk{dir: t.TempDir()} |
| 22 | exec := &rollbackDisk{dir: t.TempDir()} |
| 23 | writes := 0 |
| 24 | 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{}} |
| 25 | 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 }} |
| 26 | return m, snapshot, &writes |
| 27 | } |
| 28 | |
| 29 | func TestPlan14RootMigrationRefusesBeforeEffects(t *testing.T) { |
| 30 | 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"} { |
| 31 | t.Run(kind, func(t *testing.T) { |
| 32 | m, s, effects := migrationFixture(t) |
| 33 | switch kind { |
| 34 | case "root-journal": |
| 35 | s.MoveJournals = 1 |
| 36 | case "reservation": |
| 37 | s.MoveReservations = 1 |
| 38 | case "door": |
| 39 | s.Exclusion = "" |
| 40 | case "late-move": |
| 41 | n := 0 |
| 42 | m.observe = func(context.Context) (rootMigrationSnapshot, error) { |
| 43 | n++ |
| 44 | if n == 2 { |
| 45 | s.Claims = []hostclaim.Claim{{Op: hostclaim.Op{ID: "late"}}} |
| 46 | } |
| 47 | return *s, nil |
| 48 | } |
| 49 | case "incomplete": |
| 50 | s.Complete = false |
| 51 | case "seed-journal": |
| 52 | s.Report.Seeds.Journaled = []string{"host"} |
| 53 | case "seed-gate": |
| 54 | s.Report.Seeds.Gated = []string{"host"} |
| 55 | case "seed-unknown": |
| 56 | s.Report.Seeds = nil |
| 57 | default: |
| 58 | s.Records = []provision.Record{{Move: &provision.MoveState{State: kind, LandingPhase: kind}}} |
| 59 | } |
| 60 | before, _ := json.Marshal(s) |
| 61 | if m.run(t.Context()) == nil { |
| 62 | t.Fatal("migration accepted active work") |
| 63 | } |
| 64 | after, _ := json.Marshal(s) |
| 65 | if kind != "late-move" && string(before) != string(after) { |
| 66 | t.Fatal("migration changed input") |
| 67 | } |
| 68 | names, _ := m.state.Names() |
| 69 | for _, n := range names { |
| 70 | if n != "migration.lock" { |
| 71 | t.Fatal("write before preflight", n) |
| 72 | } |
| 73 | } |
| 74 | if *effects != 0 { |
| 75 | t.Fatal("effects before preflight", *effects) |
| 76 | } |
| 77 | s.MoveJournals = 0 |
| 78 | s.MoveReservations = 0 |
| 79 | s.Exclusion = "signed collecting reservation" |
| 80 | s.Complete = true |
| 81 | s.Records = nil |
| 82 | s.Claims = nil |
| 83 | s.Report.Seeds = &hostops.CensusSeeds{} |
| 84 | m.observe = func(context.Context) (rootMigrationSnapshot, error) { return *s, nil } |
| 85 | if err := m.run(t.Context()); err != nil { |
| 86 | t.Fatal("normal move close did not unblock", err) |
| 87 | } |
| 88 | }) |
| 89 | } |
| 90 | } |
| 91 | |
| 92 | func TestPlan14RootMigrationCrashResume(t *testing.T) { |
| 93 | for _, after := range []bool{false, true} { |
| 94 | for cut := 1; cut <= 7; cut++ { |
| 95 | t.Run(string(rune('a'+cut))+map[bool]string{true: "-after", false: "-before"}[after], func(t *testing.T) { |
| 96 | m, s, _ := migrationFixture(t) |
| 97 | 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"}} |
| 98 | n := 0 |
| 99 | original := m.state |
| 100 | m.state = &floorFaultStore{Store: m.state, n: &n, fail: cut, after: after} |
| 101 | err := m.run(t.Context()) |
| 102 | if err == nil { |
| 103 | t.Fatal("missing crash", cut, n) |
| 104 | } |
| 105 | m.state = original |
| 106 | // DataDir is deliberately absent on replay; all retained work is root state. |
| 107 | if err := m.run(t.Context()); err != nil { |
| 108 | t.Fatal("resume", err) |
| 109 | } |
| 110 | c, err := (rootparticipation.Ledger{State: m.execution}).Census() |
| 111 | if err != nil || c.Migration != 1 { |
| 112 | t.Fatal(c, err) |
| 113 | } |
| 114 | if err := m.run(t.Context()); err != nil { |
| 115 | t.Fatal("idempotence", err) |
| 116 | } |
| 117 | m.identity.Enrollment = "other" |
| 118 | if m.run(t.Context()) == nil { |
| 119 | t.Fatal("foreign enrollment reused migration") |
| 120 | } |
| 121 | }) |
| 122 | } |
| 123 | } |
| 124 | } |
| 125 | |
| 126 | func TestPlan14RootMigrationRetainsSessionDeny(t *testing.T) { |
| 127 | state := &rollbackDisk{dir: t.TempDir()} |
| 128 | root := hostguard.NewRootAuthority(state) |
| 129 | in := hostguard.RootInstallation{Host: "a.example", Incarnation: "inc", Stamp: 1, Account: "bab", App: "app", Record: "record", Box: "box", Enrollment: "en", Migration: 1} |
| 130 | if err := root.RetainMigrationDeny(t.Context(), in, 9); err != nil { |
| 131 | t.Fatal(err) |
| 132 | } |
| 133 | if err := root.RetainMigrationDeny(t.Context(), in, 8); err != nil { |
| 134 | t.Fatal(err) |
| 135 | } |
| 136 | root = hostguard.NewRootAuthority(state) |
| 137 | view, err := root.View(t.Context(), in.Host, hostguard.Captured{Incarnation: "inc", Stamp: 1}) |
| 138 | if err != nil { |
| 139 | t.Fatal(err) |
| 140 | } |
| 141 | if len(view.MigrationDenies) != 1 || view.MigrationDenies[0].Session != 9 { |
| 142 | t.Fatal("lost deny", view) |
| 143 | } |
| 144 | } |
| 145 | |
| 146 | var _ rootstate.Store = (*rollbackDisk)(nil) |
| 147 | var _ = errors.Is |
| 148 | var _ = os.ErrNotExist |
| 149 | |
| 150 | func TestPlan14RootMigrationPublishesOnlyDurableCompletion(t *testing.T) { |
| 151 | m, _, _ := migrationFixture(t) |
| 152 | m.publish = func(ctx context.Context, _ hostops.CensusReport) error { |
| 153 | raw, err := m.state.Read("migration.json") |
| 154 | if err != nil { |
| 155 | return err |
| 156 | } |
| 157 | var r rootMigrationRecord |
| 158 | if json.Unmarshal(raw, &r) != nil || r.Phase != "publishing" { |
| 159 | t.Fatal("report preceded local completion") |
| 160 | } |
| 161 | if _, err := (rootparticipation.Ledger{State: m.execution}).Census(); err != nil { |
| 162 | t.Fatal("report preceded census", err) |
| 163 | } |
| 164 | return nil |
| 165 | } |
| 166 | if err := m.run(t.Context()); err != nil { |
| 167 | t.Fatal(err) |
| 168 | } |
| 169 | } |
| 170 | |
| 171 | func TestPlan14RootMigrationPreparedBlocksGrants(t *testing.T) { |
| 172 | m, _, _ := migrationFixture(t) |
| 173 | n := 0 |
| 174 | m.state = &floorFaultStore{Store: m.state, n: &n, fail: 1, after: true} |
| 175 | if err := m.run(t.Context()); err == nil { |
| 176 | t.Fatal("prepare interruption was not reached") |
| 177 | } |
| 178 | raw, err := m.execution.Read("census.json") |
| 179 | if err != nil { |
| 180 | t.Fatal("visible prepared without root admission barrier", err) |
| 181 | } |
| 182 | var c rootparticipation.Census |
| 183 | if rootparticipation.Decode(raw, &c) != nil || c.Complete || c.Pending != "migration" { |
| 184 | t.Fatal("prepared permits root grants", c) |
| 185 | } |
| 186 | } |
| 187 | |
| 188 | func TestPlan14RootMigrationCensusCrashResume(t *testing.T) { |
| 189 | for _, after := range []bool{false, true} { |
| 190 | for _, cut := range []int{1, 2} { |
| 191 | m, _, published := migrationFixture(t) |
| 192 | execution := m.execution |
| 193 | n := 0 |
| 194 | m.execution = &floorFaultStore{Store: execution, n: &n, fail: cut, after: after} |
| 195 | if err := m.run(t.Context()); err == nil { |
| 196 | t.Fatal("census fault not reached", cut, after) |
| 197 | } |
| 198 | if *published != 0 { |
| 199 | t.Fatal("published despite failed census sync") |
| 200 | } |
| 201 | raw, err := execution.Read("census.json") |
| 202 | if err == nil { |
| 203 | var c rootparticipation.Census |
| 204 | if rootparticipation.Decode(raw, &c) != nil || c.Complete || c.Pending != "migration" { |
| 205 | t.Fatal("failed census granted", cut, after, c) |
| 206 | } |
| 207 | } else if cut != 1 || after || !errors.Is(err, os.ErrNotExist) { |
| 208 | t.Fatal(err) |
| 209 | } |
| 210 | m.execution = execution |
| 211 | if err := m.run(t.Context()); err != nil { |
| 212 | t.Fatal("census retry", cut, after, err) |
| 213 | } |
| 214 | c, err := (rootparticipation.Ledger{State: execution}).Census() |
| 215 | if err != nil || !c.Complete || *published != 1 { |
| 216 | t.Fatal("retry incomplete", c, err, *published) |
| 217 | } |
| 218 | } |
| 219 | } |
| 220 | } |
| 221 | |
| 222 | func TestPlan14RootMigrationStartupVerification(t *testing.T) { |
| 223 | m, _, _ := migrationFixture(t) |
| 224 | root := hostguard.NewRootAuthority(&rollbackDisk{dir: t.TempDir()}) |
| 225 | if err := m.run(t.Context()); err != nil { |
| 226 | t.Fatal(err) |
| 227 | } |
| 228 | if err := verifyRootMigrationState(t.Context(), m.state, m.execution, m.identity, root); err != nil { |
| 229 | t.Fatal("matching startup", err) |
| 230 | } |
| 231 | raw, err := m.state.Read("migration.json") |
| 232 | if err != nil { |
| 233 | t.Fatal(err) |
| 234 | } |
| 235 | for _, attack := range []string{"missing", "pending", "enrollment", "schema", "census"} { |
| 236 | t.Run(attack, func(t *testing.T) { |
| 237 | if err := m.state.Write("migration.json", raw); err != nil { |
| 238 | t.Fatal(err) |
| 239 | } |
| 240 | if err := m.census(true); err != nil { |
| 241 | t.Fatal(err) |
| 242 | } |
| 243 | var r rootMigrationRecord |
| 244 | if json.Unmarshal(raw, &r) != nil { |
| 245 | t.Fatal("fixture") |
| 246 | } |
| 247 | switch attack { |
| 248 | case "missing": |
| 249 | if err := m.state.Remove("migration.json"); err != nil { |
| 250 | t.Fatal(err) |
| 251 | } |
| 252 | case "pending": |
| 253 | r.Phase = "prepared" |
| 254 | case "enrollment": |
| 255 | r.Identity.Enrollment = "other" |
| 256 | case "schema": |
| 257 | r.Schema++ |
| 258 | case "census": |
| 259 | c, _ := (rootparticipation.Ledger{State: m.execution}).Census() |
| 260 | c.Complete = false |
| 261 | c.Pending = "migration" |
| 262 | b, _ := json.Marshal(c) |
| 263 | if err := m.execution.Write("census.json", b); err != nil { |
| 264 | t.Fatal(err) |
| 265 | } |
| 266 | } |
| 267 | if attack != "missing" { |
| 268 | if err := m.write("migration.json", r); err != nil { |
| 269 | t.Fatal(err) |
| 270 | } |
| 271 | } |
| 272 | if verifyRootMigrationState(t.Context(), m.state, m.execution, m.identity, root) == nil { |
| 273 | t.Fatal("unsafe startup") |
| 274 | } |
| 275 | }) |
| 276 | } |
| 277 | } |
| 278 | |
| 279 | func fixtureWithoutRootMigrationCensus(t *testing.T) { |
| 280 | t.Helper() |
| 281 | oldOpen, oldShapes := openClaimActivationState, productionClaimUnitShapes |
| 282 | t.Cleanup(func() { openClaimActivationState, productionClaimUnitShapes = oldOpen, oldShapes }) |
| 283 | openClaimActivationState = func(string, bool, bool) (rootstate.Store, error) { return nil, os.ErrNotExist } |
| 284 | productionClaimUnitShapes = func() (map[string]claimUnitShape, error) { return map[string]claimUnitShape{}, nil } |
| 285 | original := openRootMigrationParticipation |
| 286 | openRootMigrationParticipation = func() (rootparticipation.Ledger, bool, error) { return rootparticipation.Ledger{}, false, nil } |
| 287 | t.Cleanup(func() { openRootMigrationParticipation = original }) |
| 288 | } |
| 289 | |
| 290 | func TestPlan14RootMigrationCompletedPreservesRevisions(t *testing.T) { |
| 291 | m, _, effects := migrationFixture(t) |
| 292 | if err := m.run(t.Context()); err != nil { |
| 293 | t.Fatal(err) |
| 294 | } |
| 295 | c, err := (rootparticipation.Ledger{State: m.execution}).Census() |
| 296 | if err != nil { |
| 297 | t.Fatal(err) |
| 298 | } |
| 299 | c.Revision = 42 |
| 300 | c.LaunchRevision = 17 |
| 301 | raw, _ := json.Marshal(c) |
| 302 | if err := m.execution.Write("census.json", raw); err != nil { |
| 303 | t.Fatal(err) |
| 304 | } |
| 305 | before := *effects |
| 306 | m.ready = func(context.Context, rootMigrationSnapshot) error { |
| 307 | t.Fatal("completed migration re-ran readiness") |
| 308 | return nil |
| 309 | } |
| 310 | if err := m.run(t.Context()); err != nil { |
| 311 | t.Fatal(err) |
| 312 | } |
| 313 | after, _ := m.execution.Read("census.json") |
| 314 | if string(raw) != string(after) || *effects != before { |
| 315 | t.Fatal("completed migration rewrote state or republished") |
| 316 | } |
| 317 | } |
| 318 | func TestPlan14RootMigrationPreparedReobserves(t *testing.T) { |
| 319 | m, s, _ := migrationFixture(t) |
| 320 | 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"}} |
| 321 | m.recover = func(context.Context, rootMigrationItem) error { return os.ErrInvalid } |
| 322 | if m.run(t.Context()) == nil { |
| 323 | t.Fatal("missing interruption") |
| 324 | } |
| 325 | s.Items[0].Session = 10 |
| 326 | s.Items[0].ClaimETag = "second" |
| 327 | seen := map[uint64]bool{} |
| 328 | m.deny = func(_ context.Context, i rootMigrationItem) error { seen[i.Session] = true; return nil } |
| 329 | m.recover = func(context.Context, rootMigrationItem) error { return nil } |
| 330 | if err := m.run(t.Context()); err != nil { |
| 331 | t.Fatal("late claim wedged migration", err) |
| 332 | } |
| 333 | if !seen[9] || !seen[10] { |
| 334 | t.Fatal("deny coverage shrank", seen) |
| 335 | } |
| 336 | } |
| 337 | func TestPlan14RootMigrationGuardBeforeCensus(t *testing.T) { |
| 338 | m, _, _ := migrationFixture(t) |
| 339 | m.guardReady = func(context.Context) error { return os.ErrPermission } |
| 340 | if m.run(t.Context()) == nil { |
| 341 | t.Fatal("unarmed migration accepted") |
| 342 | } |
| 343 | if _, err := m.execution.Read("census.json"); !os.IsNotExist(err) { |
| 344 | t.Fatal("pending census before guard", err) |
| 345 | } |
| 346 | } |
| 347 | |