| 1 | //go:build linux |
| 2 | |
| 3 | package host |
| 4 | |
| 5 | import ( |
| 6 | "bytes" |
| 7 | "context" |
| 8 | "crypto/ed25519" |
| 9 | "crypto/sha256" |
| 10 | "database/sql" |
| 11 | "encoding/binary" |
| 12 | "encoding/hex" |
| 13 | "encoding/json" |
| 14 | "errors" |
| 15 | "fmt" |
| 16 | "os" |
| 17 | "os/exec" |
| 18 | "path/filepath" |
| 19 | "reflect" |
| 20 | "strconv" |
| 21 | "strings" |
| 22 | "testing" |
| 23 | "time" |
| 24 | |
| 25 | "github.com/carlosframework/platform/internal/activator" |
| 26 | "github.com/carlosframework/platform/internal/cgroup" |
| 27 | "github.com/carlosframework/platform/internal/hostactors" |
| 28 | "github.com/carlosframework/platform/internal/hostbox" |
| 29 | "github.com/carlosframework/platform/internal/hostclaim" |
| 30 | "github.com/carlosframework/platform/internal/hostfence" |
| 31 | "github.com/carlosframework/platform/internal/hostguard" |
| 32 | "github.com/carlosframework/platform/internal/hostops" |
| 33 | "github.com/carlosframework/platform/internal/hostwrite" |
| 34 | "github.com/carlosframework/platform/internal/moves" |
| 35 | "github.com/carlosframework/platform/internal/park" |
| 36 | "github.com/carlosframework/platform/internal/provision" |
| 37 | "github.com/carlosframework/platform/internal/registry" |
| 38 | "github.com/carlosframework/platform/internal/release" |
| 39 | "github.com/superfly/ltx" |
| 40 | ) |
| 41 | |
| 42 | // Plan 5 Task 9b's integrated acceptance tests (ruling Q12): claim Plan 12 |
| 43 | // proved its half with claim fixtures (TestPlan12RecoveryAdmissionAfter |
| 44 | // TargetSession, TestPlan12RecoveryRestoreSourceDatabase); these run the |
| 45 | // same paths with the moves owners the registered wake source opens: the |
| 46 | // claim's wake runtime, the cross-box handover, the claim's writer with |
| 47 | // the moves owners' evidence and loss owner, and, where the database is |
| 48 | // restored, ColdRestore and the source installation's retirement. |
| 49 | |
| 50 | // mwAccess is the claim's move read access over a poFixture: its route and |
| 51 | // launch as they are when asked, and the held works the fixture selects. |
| 52 | type mwAccess struct{ f *poFixture } |
| 53 | |
| 54 | func (a mwAccess) HeldWork(ctx context.Context, host, action string, captured hostguard.Captured) (hostwrite.Work, error) { |
| 55 | return a.f.owners.Works(ctx, host, action, captured) |
| 56 | } |
| 57 | func (a mwAccess) Route(_ context.Context, host string) (*registry.Route, error) { |
| 58 | if host != a.f.route.Host { |
| 59 | return nil, hostbox.ErrChanged |
| 60 | } |
| 61 | rt := a.f.route |
| 62 | return &rt, nil |
| 63 | } |
| 64 | func (a mwAccess) Launch(_ context.Context, host string) (ClaimServingLaunch, moves.EnvReport, error) { |
| 65 | if host != a.f.route.Host { |
| 66 | return ClaimServingLaunch{}, moves.EnvReport{}, hostbox.ErrChanged |
| 67 | } |
| 68 | // The env report of the same launch env, as claimMoveLaunchSource makes |
| 69 | // it: its digest is the launch's move-portable pin. |
| 70 | key, err := moves.ReportKeyFromSecrets(mwSecretsKey) |
| 71 | if err != nil { |
| 72 | return ClaimServingLaunch{}, moves.EnvReport{}, err |
| 73 | } |
| 74 | return a.f.launch, moves.ReportLaunch(key, launchVars(a.f.launch.Env), nil), nil |
| 75 | } |
| 76 | |
| 77 | // mwWorld is one box's registered moves wake source over a poFixture's |
| 78 | // world, and the claim's wake runtime that box builds around it. |
| 79 | type mwWorld struct { |
| 80 | t *testing.T |
| 81 | f *poFixture |
| 82 | box, region string |
| 83 | local claimStopLocal |
| 84 | owners ClaimMovesOwners |
| 85 | m *movesProbeOwners |
| 86 | files ClaimFilesManifest |
| 87 | guard hostguard.Guard |
| 88 | journal *hostbox.LaunchJournal |
| 89 | handover claimCrossBoxHandover |
| 90 | wake *ClaimWakeAssembly |
| 91 | runtime *activator.ClaimRuntime |
| 92 | } |
| 93 | |
| 94 | // mwRegister registers the moves wake source as the agent does (the parks, |
| 95 | // then the wake source, both switches on), opens it as the claim's |
| 96 | // OpenClaimOwners does for box in region (the source's Open, then the |
| 97 | // claim's C8 provider through openClaimFilesManifest), and repins the |
| 98 | // fixture's recorded environment reports under the registered source's key |
| 99 | // (CARLOS_SECRETS_KEY's). Only the records and the marker store are the |
| 100 | // fixture's: the fixture keeps its record in memory and its bucket in a |
| 101 | // fake. |
| 102 | func mwRegister(t *testing.T, f *poFixture, box, region string, files ClaimFilesManifest) *mwWorld { |
| 103 | t.Helper() |
| 104 | ctx := t.Context() |
| 105 | withMovesWakeSource(t, false) |
| 106 | withClaimFilesSource(t, nil) |
| 107 | parks, err := newMovesParkSources(f.owners.Records, f.bucket, "data", fixedRegion(region), movesRetirementEvidence{Records: f.owners.Records, Now: time.Now}) |
| 108 | if err != nil { |
| 109 | t.Fatal(err) |
| 110 | } |
| 111 | if err := registerMovesParks(parks); err != nil { |
| 112 | t.Fatal(err) |
| 113 | } |
| 114 | env := mwEnv(true, true) |
| 115 | if err := registerProductionMovesWakeSource(env, parks, f.dataDir); err != nil { |
| 116 | t.Fatal(err) |
| 117 | } |
| 118 | if err := movesWakeSourceRegistered(); err != nil { |
| 119 | t.Fatal(err) |
| 120 | } |
| 121 | // The app's manifest, which the registered owners read for the |
| 122 | // readiness path, and the files directory where production keeps it |
| 123 | // (movesFilesDirFor: <data>/<host>); a rename keeps its identity. |
| 124 | if err := provision.PutManifest(ctx, f.claims, "bab", "jam", provision.Manifest{Moves: &provision.MovesManifest{ReadyPath: "/healthz"}}); err != nil { |
| 125 | t.Fatal(err) |
| 126 | } |
| 127 | if dir, _ := movesFilesDirFor(f.dataDir)(poHost); f.files != dir { |
| 128 | if err := os.Rename(f.files, dir); err != nil { |
| 129 | t.Fatal(err) |
| 130 | } |
| 131 | f.files = dir |
| 132 | } |
| 133 | src := claimMoveSources.Source.(*movesWakeSourceRegistration).ClaimProductionMoveSources.(*movesWakeSources) |
| 134 | records := f.owners.Records |
| 135 | src.records = func(release.Store) MovesRecordReader { return records } |
| 136 | src.markers = func(context.Context, string, string) (ClaimMarkerStore, error) { return plan9D5Markers{f.bucket}, nil } |
| 137 | src.now, src.newID = f.owners.Now, f.owners.NewID |
| 138 | w := &mwWorld{t: t, f: f, box: box, region: region} |
| 139 | // The box's local guard and parked area under the fixture's data |
| 140 | // directory. |
| 141 | for _, dir := range []string{"hostguard", ".parked"} { |
| 142 | if err := os.MkdirAll(filepath.Join(f.dataDir, dir), 0o770); err != nil { |
| 143 | t.Fatal(err) |
| 144 | } |
| 145 | } |
| 146 | w.guard = hostguard.Guard{DataDir: f.dataDir, LockDir: t.TempDir(), Claims: hostguard.StoreReader{Store: f.claims}} |
| 147 | w.local = claimStopLocal{Actor: hostbox.Actor{Box: box, Enrollment: "enrollment-1", Revision: 7, Binding: "7", Region: region}, BootID: "boot-1", ControllerID: "controller-1", PID: 4101, ProcessStart: "start-1"} |
| 148 | actor := hostbox.Actor{Box: box, Region: region, Bucket: "data", Enrollment: "enrollment-1", Cloud: hostbox.CloudIdentity{Region: "eu-west-1"}} |
| 149 | out := ClaimOwnerBindings{Data: f.bucket} |
| 150 | var opened ClaimFilesManifest |
| 151 | // The claim hands Open its bucket through the request store |
| 152 | // (openProductionClaimMoveWake), with the box's identity check. |
| 153 | data := hostbox.RequestStore{Store: f.bucket, Check: func(context.Context) error { return nil }} |
| 154 | out.Moves, opened, err = claimMoveSources.Source.Open(ctx, f.claims, data, actor, mwAccess{f}) |
| 155 | if err != nil || opened != nil { |
| 156 | t.Fatalf("the registered source's Open: %v (files %v)", err, opened) |
| 157 | } |
| 158 | if _, err := openClaimFilesManifest(ctx, env, &fakeFilesSource{files: files}, f.claims, f.bucket, actor, &out, func(context.Context) error { return nil }); err != nil { |
| 159 | t.Fatal(err) |
| 160 | } |
| 161 | w.owners, w.files = out.Moves, out.Serving.Files |
| 162 | w.m = out.Moves.Inputs.(*movesProbeOwners) |
| 163 | if w.m.Files != files || w.files != files { |
| 164 | t.Fatalf("the recovery owners' C8 provider %v is not the claim's %v", w.m.Files, w.files) |
| 165 | } |
| 166 | key, err := moves.ReportKeyFromSecrets(mwSecretsKey) |
| 167 | if err != nil { |
| 168 | t.Fatal(err) |
| 169 | } |
| 170 | if mv := f.rec.Move; mv != nil { |
| 171 | if mv.Inputs != nil { |
| 172 | mv.Inputs.EnvReport = envDigest(key, f.launch.Env) |
| 173 | } |
| 174 | if mv.RecoveryInputs != nil { |
| 175 | mv.RecoveryInputs.EnvReport = localEnvDigest(key, f.launch.Env) |
| 176 | } |
| 177 | if mv.Rollback != nil && mv.Rollback.RecoveryInputs != nil { |
| 178 | mv.Rollback.RecoveryInputs.EnvReport = localEnvDigest(key, f.launch.Env) |
| 179 | } |
| 180 | } |
| 181 | return w |
| 182 | } |
| 183 | |
| 184 | // build is the claim's wake runtime on this box (claimconstruction.go's |
| 185 | // shape): the moves owners behind ClaimOwnerDispatch, the claim's writer |
| 186 | // with their evidence and the moves loss owner (pub is the loss records' |
| 187 | // root key), the cross-box handover over real registry rows and stop |
| 188 | // inventory, and the source installation's retirement through park.Local. |
| 189 | func (w *mwWorld) build(pub ed25519.PublicKey, inventory *hostbox.StopInventoryDB, reg *registry.Registry) { |
| 190 | t, f := w.t, w.f |
| 191 | journal, err := hostbox.OpenLaunchJournal(filepath.Join(t.TempDir(), "launches.db")) |
| 192 | if err != nil { |
| 193 | t.Fatal(err) |
| 194 | } |
| 195 | t.Cleanup(func() { journal.Close() }) |
| 196 | w.journal = journal |
| 197 | dispatch := ClaimOwnerDispatch{Moves: &w.owners.ClaimKindOwners} |
| 198 | local, now := w.local, f.owners.Now |
| 199 | producers := &ClaimProducerSet{} |
| 200 | retirement := claimArtifactRetirement{Local: park.Local{Guard: w.guard, Claims: f.claims, DataDir: f.dataDir, Box: w.box}, Continue: hostclaim.SessionContinues} |
| 201 | wk := &ClaimWakeAssembly{LeaseRegion: "eu-west-1", Inputs: dispatch, Manager: new(cgroup.Manager), Operations: w.owners.Operations, Continue: hostclaim.SessionContinues} |
| 202 | wk.ProbeReady = func(context.Context, *exec.Cmd, registry.Route, ClaimLaunchInputs, string) error { return nil } |
| 203 | wk.AfterAdmission = func(context.Context, hostclaim.Observed, hostclaim.Observed) error { return nil } |
| 204 | // Every predecessor here is a remote one (the cross-box handover) or |
| 205 | // none; root's local stop is never asked. |
| 206 | wk.Stop = func(context.Context, hostclaim.Observed) error { return hostbox.ErrChanged } |
| 207 | wk.PrepareState = func(ctx context.Context, _ hostclaim.Observed, work hostwrite.Work, in ClaimLaunchInputs) error { |
| 208 | return work.With(ctx, in.Identity, func() error { return os.MkdirAll(filepath.Dir(in.DBPath), 0o700) }) |
| 209 | } |
| 210 | wk.RetireArtifacts, wk.RecoverArtifacts = retirement.Retire, retirement.Recover |
| 211 | wk.Retain = func(hostclaim.Observed, activator.Proc, activator.Rep, error) {} |
| 212 | wk.ObserveLoss = func(hostclaim.Observed, error) {} |
| 213 | wk.Producers = func(_ context.Context, o hostclaim.Observed) (*ClaimProducers, error) { |
| 214 | return producers.Ensure(recoveryTuple(o, local), local.ProcessStart, journal.RecordProducer) |
| 215 | } |
| 216 | wk.Launch = func(context.Context, hostclaim.Observed, hostwrite.Work, ClaimLaunchInputs, *activator.RoleLaunch) (*activator.RoleLaunch, error) { |
| 217 | return nil, hostwrite.ErrUnavailable |
| 218 | } |
| 219 | wk.Prelaunch = func(ctx context.Context, o hostclaim.Observed, work hostwrite.Work, in ClaimLaunchInputs) (*activator.RoleLaunch, error) { |
| 220 | target := recoveryTuple(o, local) |
| 221 | err := work.With(ctx, in.Identity, func() error { |
| 222 | return journal.Prelaunch(ctx, hostbox.LaunchInventory{ControllerPID: local.PID, ProcessStart: local.ProcessStart, Target: target, Groups: []hostbox.LaunchGroup{{RelativePath: "carlos/" + claimContainmentName(target), Device: 1, Inode: target.Session}}, NoState: true}) |
| 223 | }) |
| 224 | if err != nil { |
| 225 | return nil, err |
| 226 | } |
| 227 | return &activator.RoleLaunch{Target: target, Journal: journal}, nil |
| 228 | } |
| 229 | wk.Retries = activator.ServingRetries{Claims: f.claims, Box: w.box, Enrollment: local.Actor.Enrollment, BootID: local.BootID, ControllerID: local.ControllerID, Now: now, NewID: f.owners.NewID, |
| 230 | Inputs: func(context.Context, registry.Route) (string, error) { return "inputs", nil }, |
| 231 | StopAttempt: func(context.Context, hostclaim.Observed, hostclaim.WakeRetryAttempt) error { return hostbox.ErrChanged }} |
| 232 | w.handover = claimCrossBoxHandover{Guard: w.guard, Evidence: f.claims, Box: w.box, PublicKey: pub, FenceAuthority: plan12FenceAuthority{}, Identity: handoverRecoveryIdentity(t, local.Actor), Inventory: inventory, Registry: reg} |
| 233 | wireClaimAdmission(wk, w.box, wk.AfterAdmission, w.handover, nil, nil) |
| 234 | source := hostbox.WorkSource{Guard: w.guard, Box: w.box, Region: w.region, Now: now, |
| 235 | Policy: func(context.Context, *hostclaim.Claim, hostbox.Selection, hostwrite.Identity) error { return nil }, |
| 236 | Local: func(context.Context, *hostclaim.Claim, hostbox.Selection) error { return nil }} |
| 237 | writer := hostbox.Writer{Data: f.bucket, Bucket: "data", Claims: f.claims, Box: w.box, Region: w.region, AdmissionEvidence: dispatch.AdmissionEvidence, |
| 238 | Loss: &hostbox.LossAdmission{PublicKey: pub, Owner: w.owners.LossRecovery, Sources: w.owners.LossSources}} |
| 239 | runtime, err := wk.Build(t.Context(), source, writer) |
| 240 | if err != nil { |
| 241 | t.Fatal(err) |
| 242 | } |
| 243 | w.wake, w.runtime = wk, runtime |
| 244 | } |
| 245 | |
| 246 | // admit stands in for Prerequisites' S3 lease, then admits through the |
| 247 | // runtime: the moves owners' Select, the handover's recovery intent, the |
| 248 | // claim's writer and the moves owners' evidence, the handover after the |
| 249 | // CAS, the guard's activation and the pre-launch record. |
| 250 | func (w *mwWorld) admit(o hostclaim.Observed, p activator.StartPolicy) (hostclaim.Observed, error) { |
| 251 | w.t.Helper() |
| 252 | live, cancel := context.WithCancel(context.Background()) |
| 253 | w.t.Cleanup(cancel) |
| 254 | done := make(chan struct{}) |
| 255 | go func() { <-live.Done(); close(done) }() |
| 256 | w.wake.mu.Lock() |
| 257 | w.wake.pending[o.Host] = &claimWakeAttempt{observed: o, held: recoveryHeld{}, ctx: live, cancel: cancel, done: done} |
| 258 | w.wake.mu.Unlock() |
| 259 | return w.runtime.Admit(w.t.Context(), w.f.route, o, p) |
| 260 | } |
| 261 | |
| 262 | // mwSnapshot is a real Litestream snapshot (one level-9 LTX file, TXIDs 1 |
| 263 | // to maxTXID) of a SQLite database holding value. |
| 264 | func mwSnapshot(t *testing.T, maxTXID uint64, value string) []byte { |
| 265 | t.Helper() |
| 266 | path := filepath.Join(t.TempDir(), "snapshot.db") |
| 267 | db, err := sql.Open("sqlite", path) |
| 268 | if err != nil { |
| 269 | t.Fatal(err) |
| 270 | } |
| 271 | if _, err := db.Exec("CREATE TABLE fixture(value TEXT); INSERT INTO fixture VALUES (?)", value); err != nil { |
| 272 | t.Fatal(err) |
| 273 | } |
| 274 | if err := db.Close(); err != nil { |
| 275 | t.Fatal(err) |
| 276 | } |
| 277 | raw, err := os.ReadFile(path) |
| 278 | if err != nil { |
| 279 | t.Fatal(err) |
| 280 | } |
| 281 | size := int(binary.BigEndian.Uint16(raw[16:18])) |
| 282 | if size == 1 { |
| 283 | size = 65536 |
| 284 | } |
| 285 | spec := ltx.FileSpec{Header: ltx.Header{Version: ltx.Version, Flags: ltx.HeaderFlagNoChecksum, PageSize: uint32(size), Commit: uint32(len(raw) / size), MinTXID: 1, MaxTXID: ltx.TXID(maxTXID)}} |
| 286 | for off := 0; off < len(raw); off += size { |
| 287 | pg := uint32(off/size + 1) |
| 288 | if pg != ltx.LockPgno(uint32(size)) { |
| 289 | spec.Pages = append(spec.Pages, ltx.PageSpec{Header: ltx.PageHeader{Pgno: pg}, Data: raw[off : off+size]}) |
| 290 | } |
| 291 | } |
| 292 | var out bytes.Buffer |
| 293 | if _, err := spec.WriteTo(&out); err != nil { |
| 294 | t.Fatal(err) |
| 295 | } |
| 296 | return out.Bytes() |
| 297 | } |
| 298 | |
| 299 | // mwReadDB reads the fixture value of the database at path. |
| 300 | func mwReadDB(t *testing.T, path string) string { |
| 301 | t.Helper() |
| 302 | db, err := sql.Open("sqlite", "file:"+path+"?mode=ro") |
| 303 | if err != nil { |
| 304 | t.Fatal(err) |
| 305 | } |
| 306 | defer db.Close() |
| 307 | var value string |
| 308 | if err := db.QueryRow("SELECT value FROM fixture").Scan(&value); err != nil { |
| 309 | t.Fatalf("read %s: %v", path, err) |
| 310 | } |
| 311 | return value |
| 312 | } |
| 313 | |
| 314 | // mwSourceInstallation is the source's installation at S on ie-box as the |
| 315 | // cut and the return leave it, for the handover and the retirement: the |
| 316 | // source's stopped report (the cut's evidence), the guard's local record at |
| 317 | // S, the database and its marker on disk tagged S in the claim's inventory, |
| 318 | // and the registry row (with a timer) and complete stop inventory at S. |
| 319 | type mwSourceInstallation struct { |
| 320 | inventory *hostbox.StopInventoryDB |
| 321 | registry *registry.Registry |
| 322 | dbRel string |
| 323 | } |
| 324 | |
| 325 | func (w *mwWorld) sourceInstallation() mwSourceInstallation { return w.sourceInstallationRow(rcS) } |
| 326 | |
| 327 | // sourceInstallationRow is sourceInstallation with the registry's route row |
| 328 | // owned by the installation at rowStamp. |
| 329 | func (w *mwWorld) sourceInstallationRow(rowStamp uint64) mwSourceInstallation { |
| 330 | t, f := w.t, w.f |
| 331 | ctx := t.Context() |
| 332 | o := f.read() |
| 333 | h := o.Claim.Holder |
| 334 | report := moves.StoppedReport{V: 1, Role: "source", Box: w.box, Host: poHost, OpID: poOp, Incarnation: h.Incarnation, ClaimGen: o.Claim.Gen, CheckedAt: time.Now().UTC(), |
| 335 | Steps: map[string]moves.Check{moves.StepTree: {OK: true}, moves.StepStray: {OK: true}, moves.StepFDs: {OK: true}, moves.StepLease: {OK: true}, moves.StepReplica: {OK: true}}} |
| 336 | raw, _ := json.Marshal(report) |
| 337 | if _, err := f.claims.Put(ctx, moves.StoppedKey(moves.ReportPrefix("bab", "jam", poHost, poOp), "source"), raw, ""); err != nil { |
| 338 | t.Fatal(err) |
| 339 | } |
| 340 | if err := w.guard.Activate(ctx, poHost, w.box, nil); err != nil { |
| 341 | t.Fatal(err) |
| 342 | } |
| 343 | if err := os.WriteFile(f.route.DBPath, []byte("source database"), 0o600); err != nil { |
| 344 | t.Fatal(err) |
| 345 | } |
| 346 | dbRel, err := filepath.Rel(f.dataDir, f.route.DBPath) |
| 347 | if err != nil { |
| 348 | t.Fatal(err) |
| 349 | } |
| 350 | local := park.Local{Guard: w.guard, Claims: f.claims, DataDir: f.dataDir, Box: w.box} |
| 351 | if err := local.RecordInventory(ctx, poHost, hostguard.Captured{Incarnation: h.Incarnation, Stamp: rcS}, "flat", []string{dbRel, dbRel + ".gen"}, func(*hostclaim.Claim) error { return nil }); err != nil { |
| 352 | t.Fatal(err) |
| 353 | } |
| 354 | inv, err := openLegacyInventory(t, filepath.Join(t.TempDir(), "inventory.db")) |
| 355 | if err != nil { |
| 356 | t.Fatal(err) |
| 357 | } |
| 358 | t.Cleanup(func() { inv.Close() }) |
| 359 | reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) |
| 360 | if err != nil { |
| 361 | t.Fatal(err) |
| 362 | } |
| 363 | t.Cleanup(func() { reg.Close() }) |
| 364 | row := registry.Route{Host: poHost, Kind: "instance", DBPath: f.route.DBPath, ClaimOwnership: registry.ClaimOwnership{ClaimIncarnation: h.Incarnation, ClaimStamp: rowStamp, ClaimOwner: "old-owner", ClaimRowID: "old-row"}} |
| 365 | if err := reg.Upsert(row); err != nil { |
| 366 | t.Fatal(err) |
| 367 | } |
| 368 | // A timer takes its route's ownership, under a row id of its own. |
| 369 | timer, err := reg.UpsertTimerRegistration(registry.Timer{Host: poHost, Name: "nightly", Kind: "cron", Spec: "0 3 * * *"}, "") |
| 370 | if err != nil { |
| 371 | t.Fatal(err) |
| 372 | } |
| 373 | if err := inv.WriteStopInventory(ctx, hostbox.StopInventory{Complete: true, Host: poHost, Incarnation: h.Incarnation, InstallationStamp: rcS, Box: w.box, Enrollment: w.local.Actor.Enrollment, DirectoryRevision: "7", |
| 374 | Entries: []hostbox.StopInventoryEntry{ |
| 375 | {Kind: "route", ArtifactID: "route", ManagedRoot: "registry", RelativePath: poHost, RowIdentity: "old-row", Ownership: "old-owner", Materialization: "materialized"}, |
| 376 | {Kind: "timer", ArtifactID: "timer", ManagedRoot: "registry", RelativePath: poHost + "/nightly", RowIdentity: timer.ClaimRowID, Ownership: "old-owner", Materialization: "materialized"}, |
| 377 | }}); err != nil { |
| 378 | t.Fatal(err) |
| 379 | } |
| 380 | return mwSourceInstallation{inventory: inv, registry: reg, dbRel: dbRel} |
| 381 | } |
| 382 | |
| 383 | // targetStopped journals the target's stop evidence for its last session |
| 384 | // (the claim's target journal, J3): the session, the generation, and the |
| 385 | // target's stopped report. |
| 386 | func (w *mwWorld) targetStopped() { |
| 387 | t, f := w.t, w.f |
| 388 | o := f.read() |
| 389 | h := o.Claim.Holder |
| 390 | last := h.Admissions[len(h.Admissions)-1] |
| 391 | key := moves.StoppedKey(moves.ReportPrefix("bab", "jam", poHost, poOp), "target") |
| 392 | report := moves.StoppedReport{V: 1, Role: "target", Box: last.Box, Host: poHost, OpID: poOp, Incarnation: h.Incarnation, ClaimGen: o.Claim.Gen, CheckedAt: time.Now().UTC(), |
| 393 | Steps: map[string]moves.Check{moves.StepTree: {OK: true}, moves.StepStray: {OK: true}, moves.StepFDs: {OK: true}, moves.StepLease: {OK: true}, moves.StepReplica: {OK: true}}} |
| 394 | raw, _ := json.Marshal(report) |
| 395 | if _, err := f.claims.Put(t.Context(), key, raw, ""); err != nil { |
| 396 | t.Fatal(err) |
| 397 | } |
| 398 | c := *o.Claim |
| 399 | c.Op.Journal[hostclaim.JournalEvidenceTarget] = key |
| 400 | c.Op.Journal[hostclaim.JournalTargetSession] = strconv.FormatUint(last.Session, 10) |
| 401 | c.Op.Journal[hostclaim.JournalTargetGeneration] = last.Generation |
| 402 | f.putClaim(&c) |
| 403 | } |
| 404 | |
| 405 | // srcSnapshot replaces src_gen's root with a real snapshot holding value at |
| 406 | // the source's maximum (30), the clean return's restore point. |
| 407 | func (w *mwWorld) srcSnapshot(value string) { |
| 408 | w.f.bucket.Put("data", "instances/"+poHost+"/g-src/0009/"+ltx.FormatFilename(1, 30), mwSnapshot(w.t, 30, value)) |
| 409 | } |
| 410 | |
| 411 | // restore prepares the admitted start and runs its restore. |
| 412 | func (w *mwWorld) restore(admitted hostclaim.Observed, p activator.StartPolicy) activator.ClaimStart { |
| 413 | w.t.Helper() |
| 414 | s, err := w.runtime.Prepare(w.t.Context(), w.f.route, admitted, p) |
| 415 | if err != nil { |
| 416 | w.t.Fatal(err) |
| 417 | } |
| 418 | if err := s.Restore(w.t.Context()); err != nil { |
| 419 | w.t.Fatal(err) |
| 420 | } |
| 421 | return s |
| 422 | } |
| 423 | |
| 424 | // TestRecoveryAdmissionAfterTargetSession (Q12; claim Plan 12's |
| 425 | // TestPlan12RecoveryAdmissionAfterTargetSession with the registered moves |
| 426 | // owners): the source's first recovery admission after a target session, |
| 427 | // on each of the three authorities a remote predecessor can have, the |
| 428 | // target's journalled stop, a declared loss (J4) and a consumed |
| 429 | // termination fence, goes through the handover, the claim's writer under |
| 430 | // the moves owners' evidence and loss owner, and the restore: the source |
| 431 | // executes a new session over the restored source baseline. |
| 432 | func TestRecoveryAdmissionAfterTargetSession(t *testing.T) { |
| 433 | for _, mode := range []string{"journal", "loss", "fence"} { |
| 434 | t.Run(mode, func(t *testing.T) { |
| 435 | f := newRCFixture(t, rcClean) |
| 436 | var pub ed25519.PublicKey |
| 437 | switch mode { |
| 438 | case "loss": |
| 439 | pub = f.declare(false, []hostclaim.WriteSource{rcTargetSource(poOp, 20), rcCutSource()}, nil) |
| 440 | case "fence": |
| 441 | f.fence() |
| 442 | } |
| 443 | w := mwRegister(t, f.poFixture, "ie-box", "ie", f.c8) |
| 444 | w.srcSnapshot("the source baseline") |
| 445 | src := w.sourceInstallation() |
| 446 | w.build(pub, src.inventory, src.registry) |
| 447 | if mode == "journal" { |
| 448 | w.targetStopped() |
| 449 | } |
| 450 | o := f.read() |
| 451 | before := o.Claim.Holder |
| 452 | if !claimRemotePredecessor(o, "ie-box") || before.WriterBox != "uk-box" || before.Session != 20 { |
| 453 | t.Fatalf("the predecessor is not the target's session: %+v", before) |
| 454 | } |
| 455 | if err := w.wake.stopPredecessor(t.Context(), o); err != nil { |
| 456 | t.Fatalf("the predecessor's authority: %v", err) |
| 457 | } |
| 458 | admitted, err := w.admit(o, f.policy()) |
| 459 | if err != nil { |
| 460 | t.Fatal(err) |
| 461 | } |
| 462 | h := admitted.Claim.Holder |
| 463 | a := h.Admissions[len(h.Admissions)-1] |
| 464 | if h.WriterBox != "ie-box" || h.Session <= 20 || h.Stamp != h.Session || h.Generation != "fresh1" || a.Box != "ie-box" || a.Region != "ie" || a.Mode != "cold" || a.Predecessor != poOp || a.PredecessorRegion != "uk" { |
| 465 | t.Fatalf("the admission: holder %+v, last %+v", h, a) |
| 466 | } |
| 467 | switch mode { |
| 468 | case "journal": |
| 469 | if a.Coverage != recoveryCoverageClean || a.LossID != "" || a.WritesLost != nil { |
| 470 | t.Fatalf("a stopped target's clean return: %+v", a) |
| 471 | } |
| 472 | case "loss": |
| 473 | if a.Coverage != "" || a.LossID != rcLossID || a.WritesLost == nil || a.WritesLost.Box != "uk-box" || a.WritesLost.Session != 20 || !hostclaim.OperationLossConsumed(admitted.Claim, rcLossID) { |
| 474 | t.Fatalf("a declared loss's admission: %+v", a) |
| 475 | } |
| 476 | case "fence": |
| 477 | if a.Coverage != "" || a.WritesLost == nil || *a.WritesLost != (hostclaim.WritesLost{Box: "uk-box", Generation: poOp, Session: 20, RemoteMaxTXID: 0}) { |
| 478 | t.Fatalf("a fenced target's admission: %+v", a) |
| 479 | } |
| 480 | } |
| 481 | w.restore(admitted, f.policy()) |
| 482 | if got, err := os.ReadFile(f.route.DBPath + ".gen"); err != nil || string(got) != h.Generation+"\n" { |
| 483 | t.Fatalf("the restored marker: %q %v", got, err) |
| 484 | } |
| 485 | if got := mwReadDB(t, f.route.DBPath); got != "the source baseline" { |
| 486 | t.Fatalf("restored %q", got) |
| 487 | } |
| 488 | }) |
| 489 | } |
| 490 | } |
| 491 | |
| 492 | // TestCleanReturnAdmissionRetiresTheSTaggedDatabase (Q12): the clean |
| 493 | // return's first admission restores src_gen over the source's own |
| 494 | // installation, whose database and marker the claim's inventory tags with |
| 495 | // the source stamp S. The restore retires them, on the cut's stop |
| 496 | // evidence, into the superseded installation's parked artifacts, and the |
| 497 | // claim's inventory then tags the paths with the admitted stamp; a restart |
| 498 | // restores the same pinned baseline and retires nothing further. |
| 499 | func TestCleanReturnAdmissionRetiresTheSTaggedDatabase(t *testing.T) { |
| 500 | f := newRCFixture(t, rcClean) |
| 501 | w := mwRegister(t, f.poFixture, "ie-box", "ie", f.c8) |
| 502 | w.srcSnapshot("the source baseline") |
| 503 | src := w.sourceInstallation() |
| 504 | w.build(nil, src.inventory, src.registry) |
| 505 | w.targetStopped() |
| 506 | o := f.read() |
| 507 | if tag := o.Claim.Inventory["ie-box"].Paths[src.dbRel]; tag != rcS { |
| 508 | t.Fatalf("the source database is tagged %d, not S", tag) |
| 509 | } |
| 510 | if err := w.wake.stopPredecessor(t.Context(), o); err != nil { |
| 511 | t.Fatal(err) |
| 512 | } |
| 513 | admitted, err := w.admit(o, f.policy()) |
| 514 | if err != nil { |
| 515 | t.Fatal(err) |
| 516 | } |
| 517 | h := admitted.Claim.Holder |
| 518 | w.restore(admitted, f.policy()) |
| 519 | retired := filepath.Join(f.dataDir, ".parked", h.Incarnation, poHost, fmt.Sprintf("superseded-%d-%d", h.Session, rcS), "artifacts", src.dbRel) |
| 520 | if got, err := os.ReadFile(retired); err != nil || string(got) != "source database" { |
| 521 | t.Fatalf("the S-tagged database was not retired: %q %v", got, err) |
| 522 | } |
| 523 | if got, err := os.ReadFile(retired + ".gen"); err != nil || string(got) != "src\n" { |
| 524 | t.Fatalf("the S-tagged marker was not retired: %q %v", got, err) |
| 525 | } |
| 526 | if got := mwReadDB(t, f.route.DBPath); got != "the source baseline" { |
| 527 | t.Fatalf("restored %q", got) |
| 528 | } |
| 529 | after := f.read() |
| 530 | for _, p := range []string{src.dbRel, src.dbRel + ".gen"} { |
| 531 | if tag := after.Claim.Inventory["ie-box"].Paths[p]; tag != h.Stamp { |
| 532 | t.Fatalf("%s is tagged %d after the restore, want the admitted stamp %d", p, tag, h.Stamp) |
| 533 | } |
| 534 | } |
| 535 | // The recorded source baseline pins the restore: a newer version of |
| 536 | // src_gen's root is refused, and nothing more is retired. |
| 537 | w.f.bucket.Put("data", "instances/"+poHost+"/g-src/0009/"+ltx.FormatFilename(1, 30), mwSnapshot(t, 30, "a newer write")) |
| 538 | if _, err := w.m.Select(t.Context(), f.route, after, f.policy()); !errors.Is(err, hostbox.ErrChanged) { |
| 539 | t.Fatalf("a restart over another version of src_gen: %v", err) |
| 540 | } |
| 541 | } |
| 542 | |
| 543 | // TestDirtyReturnThroughAfterRecoveryOnRealRows (Q12, the rev 3 recheck |
| 544 | // §2.3): a dirty return's first admission, into the return generation, |
| 545 | // goes through the handover's afterRecovery over the box's real registry |
| 546 | // rows (the route and a timer) and its stop inventory at S: the rows and |
| 547 | // the inventory move to the admitted installation's ownership, the |
| 548 | // source's inventory stays for retirement and replay, and a row another |
| 549 | // installation owns refuses the admission's handover. |
| 550 | func TestDirtyReturnThroughAfterRecoveryOnRealRows(t *testing.T) { |
| 551 | t.Run("the rows move to the admitted installation", func(t *testing.T) { |
| 552 | f := newRCFixture(t, rcDirty) |
| 553 | w := mwRegister(t, f.poFixture, "ie-box", "ie", f.c8) |
| 554 | src := w.sourceInstallation() |
| 555 | w.build(nil, src.inventory, src.registry) |
| 556 | w.targetStopped() |
| 557 | o := f.read() |
| 558 | if err := w.wake.stopPredecessor(t.Context(), o); err != nil { |
| 559 | t.Fatal(err) |
| 560 | } |
| 561 | admitted, err := w.admit(o, f.policy()) |
| 562 | if err != nil { |
| 563 | t.Fatal(err) |
| 564 | } |
| 565 | h := admitted.Claim.Holder |
| 566 | a := h.Admissions[len(h.Admissions)-1] |
| 567 | if h.Generation != rpRB || h.Stamp == rcS || h.Stamp != h.Session || a.Coverage != "sealed" || a.Predecessor != poOp { |
| 568 | t.Fatalf("the dirty return's admission: holder %+v, last %+v", h, a) |
| 569 | } |
| 570 | sum := sha256.Sum256([]byte(fmt.Sprintf("%s\x00%s\x00%d\x00%s", poHost, h.Incarnation, h.Stamp, "ie-box"))) |
| 571 | owner := hex.EncodeToString(sum[:]) |
| 572 | row, err := src.registry.Get(poHost) |
| 573 | if err != nil || row.ClaimStamp != h.Stamp || row.ClaimOwner != owner || row.ClaimIncarnation != h.Incarnation || row.ClaimRowID == "old-row" { |
| 574 | t.Fatalf("the route row: %+v %v", row, err) |
| 575 | } |
| 576 | timers, err := src.registry.TimersFor(poHost) |
| 577 | if err != nil || len(timers) != 1 || timers[0].ClaimStamp != h.Stamp || timers[0].ClaimOwner != owner || timers[0].ClaimRowID == "" || timers[0].ClaimRowID == row.ClaimRowID { |
| 578 | t.Fatalf("the timer row: %+v %v", timers, err) |
| 579 | } |
| 580 | inv, err := src.inventory.ReadInstallation(t.Context(), poHost, h.Incarnation, h.Stamp) |
| 581 | if err != nil || !inv.Complete || inv.Box != "ie-box" || len(inv.Entries) != 2 { |
| 582 | t.Fatalf("the admitted installation's inventory: %+v %v", inv, err) |
| 583 | } |
| 584 | for _, e := range inv.Entries { |
| 585 | if e.Ownership != owner || (e.Kind == "route" && e.RowIdentity != row.ClaimRowID) || (e.Kind == "timer" && e.RowIdentity != timers[0].ClaimRowID) { |
| 586 | t.Fatalf("an inventory entry: %+v", e) |
| 587 | } |
| 588 | } |
| 589 | if old, err := src.inventory.ReadInstallation(t.Context(), poHost, h.Incarnation, rcS); err != nil || !old.Complete { |
| 590 | t.Fatalf("the source's inventory at S is gone: %v", err) |
| 591 | } |
| 592 | }) |
| 593 | t.Run("a row another installation owns refuses the handover", func(t *testing.T) { |
| 594 | f := newRCFixture(t, rcDirty) |
| 595 | w := mwRegister(t, f.poFixture, "ie-box", "ie", f.c8) |
| 596 | src := w.sourceInstallationRow(99) |
| 597 | w.build(nil, src.inventory, src.registry) |
| 598 | w.targetStopped() |
| 599 | o := f.read() |
| 600 | if err := w.wake.stopPredecessor(t.Context(), o); err != nil { |
| 601 | t.Fatal(err) |
| 602 | } |
| 603 | if _, err := w.admit(o, f.policy()); !errors.Is(err, hostbox.ErrTypedInventory) { |
| 604 | t.Fatalf("a foreign row: %v", err) |
| 605 | } |
| 606 | if got, err := src.registry.Get(poHost); err != nil || got.ClaimStamp != 99 || got.ClaimRowID != "old-row" { |
| 607 | t.Fatalf("the foreign row moved: %+v %v", got, err) |
| 608 | } |
| 609 | }) |
| 610 | } |
| 611 | |
| 612 | // TestCommittedColdWakeThroughTheRegisteredSource (Q12): after the |
| 613 | // landing's probe passed and the move committed, a wake on the target is a |
| 614 | // cold admission into a fresh generation from the certified remote, through |
| 615 | // the registered source's owners and the claim's runtime: the moves |
| 616 | // owners' evidence admits it, and the start runs the committed wake's |
| 617 | // ordinary launch, no probe variable, bound to the moves attempt journal. |
| 618 | func TestCommittedColdWakeThroughTheRegisteredSource(t *testing.T) { |
| 619 | f, o := committedAfterProbe(t) |
| 620 | w := mwRegister(t, f, "uk-box", "uk", &rcFiles{f: f}) |
| 621 | w.build(nil, nil, nil) |
| 622 | if err := w.guard.Activate(t.Context(), poHost, "uk-box", nil); err != nil { |
| 623 | t.Fatal(err) |
| 624 | } |
| 625 | h := o.Claim.Holder |
| 626 | if h.WriterBox != "uk-box" || h.Generation != poOp { |
| 627 | t.Fatalf("not the committed landing: %+v", h) |
| 628 | } |
| 629 | admitted, err := w.admit(o, poWake) |
| 630 | if err != nil { |
| 631 | t.Fatal(err) |
| 632 | } |
| 633 | ah := admitted.Claim.Holder |
| 634 | a := ah.Admissions[len(ah.Admissions)-1] |
| 635 | if ah.Generation == poOp || ah.Generation != a.Generation || a.Mode != "cold" || a.Coverage != "" || a.Predecessor != poOp || a.Region != "uk" || a.PredecessorRegion != "uk" || ah.WriterBox != "uk-box" || ah.Session <= h.Session { |
| 636 | t.Fatalf("the committed admission: holder %+v, last %+v", ah, a) |
| 637 | } |
| 638 | s, err := w.runtime.Prepare(t.Context(), f.route, admitted, poWake) |
| 639 | if err != nil { |
| 640 | t.Fatal(err) |
| 641 | } |
| 642 | if s.OpenProbe != nil || s.Identity != committedPinsIdentity(f.rec.Move.Inputs) { |
| 643 | t.Fatalf("the committed start: probe %v, identity %+v", s.OpenProbe != nil, s.Identity) |
| 644 | } |
| 645 | cmd := s.Build() |
| 646 | for _, kv := range cmd.Env { |
| 647 | if len(kv) >= len("CARLOS_MOVE_PROBE=") && kv[:len("CARLOS_MOVE_PROBE=")] == "CARLOS_MOVE_PROBE=" { |
| 648 | t.Fatalf("the committed wake carries %s", kv) |
| 649 | } |
| 650 | } |
| 651 | if err := s.Reserve(t.Context(), 0); err != nil { |
| 652 | t.Fatal(err) |
| 653 | } |
| 654 | j, err := moves.ReadJournal(f.dataDir, poHost, poOp) |
| 655 | if err != nil { |
| 656 | t.Fatal(err) |
| 657 | } |
| 658 | if _, ok := j.Steps[probeStepStart+"committed-"+strconv.FormatUint(ah.Session, 10)]; !ok { |
| 659 | t.Fatalf("the committed start is not in the moves attempt journal: %v", j.Steps) |
| 660 | } |
| 661 | } |
| 662 | |
| 663 | // mwDeclare turns f into the landing whose target uk-box is lost, and |
| 664 | // declares it through the console's real path: the record and the claim in |
| 665 | // the deployment store (the target admitted session 20 into the move id |
| 666 | // after the cut at 15, whose seal did not cover the source's replica), a |
| 667 | // signed roster naming both boxes, the move's admission registration, then |
| 668 | // hostops' InspectMoveLoss and DeclareMoveLoss, which run hostresolve's |
| 669 | // declaration with the real moves loss owner. It returns the root's public |
| 670 | // key, the declaration's id and the rollback id the declaration chose. |
| 671 | func mwDeclare(t *testing.T, f *rcFixture) (ed25519.PublicKey, string, string) { |
| 672 | t.Helper() |
| 673 | ctx := t.Context() |
| 674 | pub, priv, err := ed25519.GenerateKey(nil) |
| 675 | if err != nil { |
| 676 | t.Fatal(err) |
| 677 | } |
| 678 | signer := release.RootSigner(priv) |
| 679 | clock := time.Now().UTC().Truncate(time.Second) |
| 680 | control, err := hostops.NewControlWriter(f.claims, pub, func() time.Time { return clock }) |
| 681 | if err != nil { |
| 682 | t.Fatal(err) |
| 683 | } |
| 684 | const bucket = "control" |
| 685 | d := hostactors.Directory{Store: f.claims, PublicKey: pub, Bucket: bucket} |
| 686 | if err := d.Initialize(ctx, signer); err != nil { |
| 687 | t.Fatal(err) |
| 688 | } |
| 689 | ro, err := d.Read(ctx) |
| 690 | if err != nil { |
| 691 | t.Fatal(err) |
| 692 | } |
| 693 | r := hostactors.Clone(ro.Roster) |
| 694 | r.Boxes["ie-box"] = hostactors.Box{Enrollment: strings.Repeat("a", 32), Namespace: "platform", BoxID: "ie-box", Label: "ie-label", Region: "ie", Pool: "default", Cloud: hostactors.Cloud{Provider: "aws", Account: "123", Region: "eu-west-1", InstanceID: "i-ie"}, EvidenceSHA256: strings.Repeat("c", 64), State: "active"} |
| 695 | r.Boxes["uk-box"] = hostactors.Box{Enrollment: strings.Repeat("b", 32), Namespace: "platform", BoxID: "uk-box", Label: "uk-label", Region: "uk", Pool: "default", Cloud: hostactors.Cloud{Provider: "aws", Account: "123", Region: "eu-west-2", InstanceID: "i-uk"}, EvidenceSHA256: strings.Repeat("d", 64), State: "active"} |
| 696 | if _, err := d.Replace(ctx, signer, ro, r); err != nil { |
| 697 | t.Fatal(err) |
| 698 | } |
| 699 | at := clock.Add(-time.Hour) |
| 700 | seal := rcCutSeal(false) |
| 701 | c := f.recoveryClaim() |
| 702 | c.Holder.Acquisition = "acq" |
| 703 | c.Holder.Stamp = 18 |
| 704 | c.Op = hostclaim.Op{Kind: hostclaim.KindLanding, ID: poOp, Phase: hostclaim.PhaseLandingProbing, Exec: "uk-box", Participants: []string{"ie-box", "uk-box"}, |
| 705 | Intent: map[string]string{"to": "uk", "source": "ie-box", "target": "uk-box", "app_id": "canonical", "admission_id": poOp, "source_enrollment": strings.Repeat("a", 32), "target_enrollment": strings.Repeat("b", 32)}, |
| 706 | Journal: map[string]string{hostclaim.JournalSeal: seal.Encode(), hostclaim.JournalCutSession: "15", hostclaim.JournalSourceStamp: "15", hostclaim.JournalSrcGen: "src", |
| 707 | hostclaim.JournalEvidenceSource: moves.StoppedKey(moves.ReportPrefix("bab", "jam", poHost, poOp), "source")}} |
| 708 | if err := c.Validate(); err != nil { |
| 709 | t.Fatal(err) |
| 710 | } |
| 711 | f.putClaim(c) |
| 712 | flipped, deadline := at, at.Add(2*time.Hour) |
| 713 | rec := provision.Record{Schema: 1, Host: poHost, Slug: "jam", Account: "bab", App: "jam", Acquisition: "acq", Channel: "edge", Region: "uk", CreatedAt: c.Holder.CreatedAt, |
| 714 | Move: &provision.MoveState{ID: poOp, Incarnation: f.inc, From: "ie", To: "uk", Cut: "auto", State: provision.MoveLanding, CutPhase: provision.CutFlipped, |
| 715 | FlippedAt: &flipped, LandingDeadline: &deadline, LandingPhase: provision.LandProbing, Inputs: mpPins(), Source: f.rec.Move.Source}} |
| 716 | raw, _ := json.Marshal(rec) |
| 717 | if _, err := f.claims.Put(ctx, provision.RecordKey("bab", "jam", poHost), raw, ""); err != nil { |
| 718 | t.Fatal(err) |
| 719 | } |
| 720 | owner := hostops.RegistrationOwner{Console: "console", Enrollment: strings.Repeat("e", 32), Controller: "coordinator"} |
| 721 | adm := hostops.AppAdmission{V: 1, AppID: "canonical", Epoch: 1, Revision: 1, State: "open", Effects: map[string]hostops.Effect{}, |
| 722 | Registrations: map[string]hostops.Registration{poOp: {Kind: hostops.RegistrationMove, Host: poHost, Owner: owner, AppID: "canonical", Epoch: 1, Claim: hostops.SavedClaim{ETag: "serving", Gen: 1}, Expires: clock.Add(hostops.RegistrationTTL), CreatedAt: clock, Incarnation: f.inc}}} |
| 723 | key, err := hostops.AdmissionKey("bab", "jam") |
| 724 | if err != nil { |
| 725 | t.Fatal(err) |
| 726 | } |
| 727 | raw, _ = json.Marshal(adm) |
| 728 | if _, err := f.claims.Put(ctx, key, raw, ""); err != nil { |
| 729 | t.Fatal(err) |
| 730 | } |
| 731 | in, err := control.InspectMoveLoss(ctx, signer, bucket, "operator", "bab", "jam", poHost, "uk-label") |
| 732 | if err != nil { |
| 733 | t.Fatal(err) |
| 734 | } |
| 735 | clock = clock.Add(2 * time.Second) |
| 736 | sub := hostfence.Submission{Challenge: in.Challenge, Confirmation: in.Details.ID, |
| 737 | Observation: hostfence.CloudObservation{ObservedAt: in.Details.IssuedAt.Add(time.Second), Account: "123", Region: "eu-west-2", InstanceID: "i-uk", State: "stopped", RequestID: "request"}, |
| 738 | Attestation: hostfence.OperatorAttestation{MaintainComputeFence: true, ColdQuarantineStartOnly: true, Disks: in.Details.Consequences.DestroyedStorageAssertion}} |
| 739 | declared, err := control.DeclareMoveLoss(ctx, signer, bucket, "operator", "bab", "jam", poHost, "uk-box", sub) |
| 740 | if err != nil { |
| 741 | t.Fatal(err) |
| 742 | } |
| 743 | return pub, in.Details.ID, declared.Claim.Op.Intent["rollback"] |
| 744 | } |
| 745 | |
| 746 | // TestLostTargetRecoveryRunsAgainstARealDeclaration (Q12; Plan 4 Task |
| 747 | // 10): the source's recovery runs against the claim and the signed loss |
| 748 | // record a real declaration produced (hostresolve with the real moves loss |
| 749 | // owner, through the console's typed writer), never a hand-written one. |
| 750 | // The source's return then takes the claim back (ReturnToSource, probing) |
| 751 | // and its record to probing with the recorded inputs, as the source pass |
| 752 | // does, and the registered source's owners, reading the record from the |
| 753 | // store, admit through the claim's writer with the moves loss owner: the |
| 754 | // declaration is consumed, the target's session is declared lost at 0 (the |
| 755 | // restore does not contain the move id's generation) and the uncovered cut |
| 756 | // at the seal's maximum; the source restores its own baseline. |
| 757 | func TestLostTargetRecoveryRunsAgainstARealDeclaration(t *testing.T) { |
| 758 | ctx := t.Context() |
| 759 | f := newRCFixture(t, rcClean) |
| 760 | pub, lossID, rb := mwDeclare(t, f) |
| 761 | o := f.read() |
| 762 | if o.Claim.Op.Kind != hostclaim.KindRestoring || o.Claim.Op.Phase != hostclaim.PhaseReleased || !provision.ValidRollbackID(rb) || !hostclaim.LostTargetReleased(o.Claim, lossID) { |
| 763 | t.Fatalf("the declaration's claim: %+v", o.Claim.Op) |
| 764 | } |
| 765 | loss, err := hostclaim.ReadLossRecord(ctx, f.claims, pub, lossID) |
| 766 | if err != nil || loss.Record.State != "committed" { |
| 767 | t.Fatalf("the loss record: %+v %v", loss.Record, err) |
| 768 | } |
| 769 | wantSources := []hostclaim.WriteSource{ |
| 770 | {Reason: "target", Box: "uk-box", Generation: poOp, Session: 20, Region: "uk", Baseline: "replica:uk/" + poOp}, |
| 771 | {Reason: "source-cut", Box: "ie-box", Generation: "src", Session: rcS, Region: "ie", Baseline: "seal:" + poOp}, |
| 772 | } |
| 773 | if !reflect.DeepEqual(loss.Record.WriteSources, wantSources) { |
| 774 | t.Fatalf("the declaration froze %+v", loss.Record.WriteSources) |
| 775 | } |
| 776 | |
| 777 | // The source's pass: the claim back to this box at probing, the record |
| 778 | // through the clean branch to probing, the inputs it renders recorded. |
| 779 | if _, err := hostclaim.Update(ctx, f.claims, o, hostclaim.ReturnToSource(poOp, "ie-box", true, hostclaim.ReturnFence{})); err != nil { |
| 780 | t.Fatal(err) |
| 781 | } |
| 782 | if _, err := hostclaim.Update(ctx, f.claims, f.read(), hostclaim.SetRestoringPhase(poOp, hostclaim.PhaseClaimed, hostclaim.PhaseProbing)); err != nil { |
| 783 | t.Fatal(err) |
| 784 | } |
| 785 | key, err := moves.ReportKeyFromSecrets(mwSecretsKey) |
| 786 | if err != nil { |
| 787 | t.Fatal(err) |
| 788 | } |
| 789 | inputs := *f.inputs |
| 790 | inputs.EnvReport = localEnvDigest(key, f.launch.Env) |
| 791 | *f.inputs = inputs |
| 792 | g := provision.MoveGuard{ID: poOp, Incarnation: f.inc} |
| 793 | for _, step := range [][2]string{{provision.RbSourceWaiting, provision.RbSourceClaimed}, {provision.RbSourceClaimed, provision.RbSourceRestoring}, {provision.RbSourceRestoring, provision.RbSourceVerified}, {provision.RbSourceVerified, provision.RbSourceProbing}} { |
| 794 | if _, err := provision.SetRecordMove(ctx, f.claims, "bab", "jam", poHost, provision.MoveRollbackSourcePhase(g, step[0], step[1], nil)); err != nil { |
| 795 | t.Fatalf("%s to %s: %v", step[0], step[1], err) |
| 796 | } |
| 797 | } |
| 798 | if _, err := provision.SetRecordMove(ctx, f.claims, "bab", "jam", poHost, provision.MoveRecordRecoveryInputs(g, rb, inputs)); err != nil { |
| 799 | t.Fatal(err) |
| 800 | } |
| 801 | f.route.HoldOp = rb |
| 802 | |
| 803 | // The registered source over the store's records. |
| 804 | f.owners.Records = MovesRecordsFrom(f.claims) |
| 805 | w := mwRegister(t, f.poFixture, "ie-box", "ie", f.c8) |
| 806 | w.srcSnapshot("the source baseline") |
| 807 | src := w.sourceInstallation() |
| 808 | w.build(pub, src.inventory, src.registry) |
| 809 | o = f.read() |
| 810 | if err := w.wake.stopPredecessor(ctx, o); err != nil { |
| 811 | t.Fatalf("the declaration as the predecessor's authority: %v", err) |
| 812 | } |
| 813 | admitted, err := w.admit(o, f.policy()) |
| 814 | if err != nil { |
| 815 | t.Fatal(err) |
| 816 | } |
| 817 | h := admitted.Claim.Holder |
| 818 | a := h.Admissions[len(h.Admissions)-1] |
| 819 | if h.WriterBox != "ie-box" || a.Coverage != "" || a.LossID != lossID || !hostclaim.OperationLossConsumed(admitted.Claim, lossID) { |
| 820 | t.Fatalf("the loss admission: holder %+v, last %+v", h, a) |
| 821 | } |
| 822 | if a.WritesLost == nil || *a.WritesLost != (hostclaim.WritesLost{Box: "uk-box", Generation: poOp, Session: 20, RemoteMaxTXID: 0}) || |
| 823 | !reflect.DeepEqual(a.AdditionalWritesLost, []hostclaim.WritesLost{{Box: "ie-box", Generation: "src", Session: rcS, RemoteMaxTXID: rcSealN}}) { |
| 824 | t.Fatalf("the declared writes: %+v, additional %+v", a.WritesLost, a.AdditionalWritesLost) |
| 825 | } |
| 826 | w.restore(admitted, f.policy()) |
| 827 | if got := mwReadDB(t, f.route.DBPath); got != "the source baseline" { |
| 828 | t.Fatalf("restored %q", got) |
| 829 | } |
| 830 | } |
| 831 | |