| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "errors" |
| 7 | "fmt" |
| 8 | "os/exec" |
| 9 | "reflect" |
| 10 | "strings" |
| 11 | "testing" |
| 12 | "time" |
| 13 | |
| 14 | "github.com/carlosframework/platform/internal/activator" |
| 15 | "github.com/carlosframework/platform/internal/hostbox" |
| 16 | "github.com/carlosframework/platform/internal/hostclaim" |
| 17 | "github.com/carlosframework/platform/internal/hostguard" |
| 18 | "github.com/carlosframework/platform/internal/hostwrite" |
| 19 | "github.com/carlosframework/platform/internal/lease" |
| 20 | "github.com/carlosframework/platform/internal/moves" |
| 21 | "github.com/carlosframework/platform/internal/provision" |
| 22 | "github.com/carlosframework/platform/internal/registry" |
| 23 | "github.com/carlosframework/platform/internal/release" |
| 24 | ) |
| 25 | |
| 26 | // plan10J2AdapterInputs are what the source would render now. |
| 27 | func plan10J2AdapterInputs() provision.MoveInputs { |
| 28 | return provision.MoveInputs{BundleETags: map[string]string{"default": "b1"}, OverlayETag: "o1", Release: "v7", EnvReport: "env"} |
| 29 | } |
| 30 | |
| 31 | // plan10J2AdapterRecord is the rolling_back record of the recovery fixture's |
| 32 | // claim: rollback plan10J2Rollback with the source at probing. |
| 33 | func plan10J2AdapterRecord(incarnation string, dirty bool) provision.Record { |
| 34 | return provision.Record{Account: "bab", App: "jam", Host: recoveryHost, Move: &provision.MoveState{ID: "m1", Incarnation: incarnation, From: "ie", To: "uk", State: provision.MoveRollingBack, |
| 35 | Rollback: &provision.MoveRollback{ID: plan10J2Rollback, Dirty: dirty, SourcePhase: provision.RbSourceProbing}}} |
| 36 | } |
| 37 | |
| 38 | // plan10J2AdapterRequest is the request movesrecover.go's probing builds, |
| 39 | // recording into rec through the real provision transition. |
| 40 | func plan10J2AdapterRequest(rec *provision.Record, srcGen string, events *[]string) recoveryProbeRequest { |
| 41 | g := provision.MoveGuard{ID: rec.Move.ID, Incarnation: rec.Move.Incarnation} |
| 42 | req := recoveryProbeRequest{RollbackID: rec.Move.Rollback.ID, Clean: !rec.Move.Rollback.Dirty, Attempts: recoveryProbeAttempts, SourceGeneration: srcGen, |
| 43 | Record: func(_ context.Context, in provision.MoveInputs) error { |
| 44 | *events = append(*events, "record") |
| 45 | return provision.MoveRecordRecoveryInputs(g, rec.Move.Rollback.ID, in)(rec) |
| 46 | }} |
| 47 | if rec.Move.Rollback.Dirty { |
| 48 | req.Coverage, req.Generation = recoveryCoverageSealed, rec.Move.Rollback.ID |
| 49 | } else { |
| 50 | req.Coverage = recoveryCoverageClean |
| 51 | } |
| 52 | return req |
| 53 | } |
| 54 | |
| 55 | // plan10J2AdapterClaim puts the fixture's claim at restoring/probing with |
| 56 | // box-1 executing and returns the incarnation. |
| 57 | func plan10J2AdapterClaim(t *testing.T, f *recoveryFixture, phase, srcGen string) string { |
| 58 | t.Helper() |
| 59 | o, err := hostclaim.Update(t.Context(), f.claims, f.read(), func(c *hostclaim.Claim) error { |
| 60 | c.Op = plan10J2Op(hostclaim.KindRestoring, phase) |
| 61 | c.Op.Journal = map[string]string{hostclaim.JournalSrcGen: srcGen} |
| 62 | return nil |
| 63 | }) |
| 64 | if err != nil { |
| 65 | t.Fatal(err) |
| 66 | } |
| 67 | return o.Claim.Holder.Incarnation |
| 68 | } |
| 69 | |
| 70 | // The adapter records the recovery inputs at probing before anything is |
| 71 | // rendered, then asks the activator for a probe-shaped wake carrying exactly |
| 72 | // those inputs. That wake runs the real Group D path: it is admitted at |
| 73 | // probing as a recovery probe, and Prepare binds the operation attempts and |
| 74 | // probe readiness to the admitted session. The held render (Materialize) |
| 75 | // follows the record. |
| 76 | func TestPlan10J2AdapterRecordsInputsBeforeTheGroupDProbe(t *testing.T) { |
| 77 | for _, tc := range []struct{ dirty, revert bool }{{}, {dirty: true}, {revert: true}} { |
| 78 | dirty := tc.dirty |
| 79 | t.Run(fmt.Sprintf("dirty=%v/revert=%v", dirty, tc.revert), func(t *testing.T) { |
| 80 | f := newRecoveryFixture(t) |
| 81 | inc := plan10J2AdapterClaim(t, f, hostclaim.PhaseProbing, "src-gen") |
| 82 | c := f.controller("boot-1", "controller-1", 4101, "start-1") |
| 83 | ops := &plan9D2Attempts{} |
| 84 | c.wake.Inputs, c.wake.Continue, c.wake.Operations = plan9D2Inputs{recoveryInputs{f}}, hostclaim.SessionContinues, ops |
| 85 | c.wake.ProbeReady = func(context.Context, *exec.Cmd, registry.Route, ClaimLaunchInputs, string) error { return nil } |
| 86 | rec := plan10J2AdapterRecord(inc, dirty) |
| 87 | var events []string |
| 88 | req := plan10J2AdapterRequest(&rec, "src-gen", &events) |
| 89 | kind := hostclaim.KindRestoring |
| 90 | if tc.revert { |
| 91 | kind = hostclaim.KindReverting |
| 92 | o, err := hostclaim.Update(t.Context(), f.claims, f.read(), func(c *hostclaim.Claim) error { |
| 93 | c.Op = plan10J2Op(hostclaim.KindReverting, hostclaim.PhaseProbing) |
| 94 | c.Op.Journal = map[string]string{hostclaim.JournalRevPhase: hostclaim.PhaseProbing} |
| 95 | return nil |
| 96 | }) |
| 97 | if err != nil { |
| 98 | t.Fatal(err) |
| 99 | } |
| 100 | rec.Move.State, rec.Move.Rollback = provision.MoveReverting, nil |
| 101 | req = recoveryProbeRequest{Clean: true, SourceGeneration: o.Claim.Holder.Generation, Attempts: recoveryProbeAttempts, Record: func(_ context.Context, in provision.MoveInputs) error { |
| 102 | events = append(events, "record") |
| 103 | return provision.MoveRecordRevertInputs(provision.MoveGuard{ID: rec.Move.ID, Incarnation: inc}, in)(&rec) |
| 104 | }} |
| 105 | } |
| 106 | recorded := func() *provision.MoveInputs { |
| 107 | if tc.revert { |
| 108 | return rec.Move.RecoveryInputs |
| 109 | } |
| 110 | return rec.Move.Rollback.RecoveryInputs |
| 111 | } |
| 112 | f.materialize = func(context.Context, hostclaim.Observed) error { |
| 113 | events = append(events, "render") |
| 114 | if recorded() == nil { |
| 115 | t.Error("rendered before the inputs were recorded") |
| 116 | } |
| 117 | return nil |
| 118 | } |
| 119 | var policies []activator.StartPolicy |
| 120 | wake := func(ctx context.Context, rt registry.Route, p activator.StartPolicy) error { |
| 121 | events = append(events, "wake") |
| 122 | policies = append(policies, p) |
| 123 | if recorded() == nil || p.Inputs == nil || !reflect.DeepEqual(*p.Inputs, *recorded()) { |
| 124 | t.Errorf("the wake does not carry the recorded inputs: %+v / %+v", p.Inputs, recorded()) |
| 125 | } |
| 126 | if err := c.wake.admitsOperation(f.read(), p, "box-1"); err != nil { |
| 127 | return err |
| 128 | } |
| 129 | // Prerequisites' S3 lease stands in, as plan10J2Recovery does. |
| 130 | live, cancel := context.WithCancel(context.Background()) |
| 131 | t.Cleanup(cancel) |
| 132 | done := make(chan struct{}) |
| 133 | go func() { <-live.Done(); close(done) }() |
| 134 | o := f.read() |
| 135 | c.wake.mu.Lock() |
| 136 | c.wake.pending[rt.Host] = &claimWakeAttempt{observed: o, held: recoveryHeld{}, ctx: live, cancel: cancel, done: done} |
| 137 | c.wake.mu.Unlock() |
| 138 | admitted, err := c.runtime.Admit(ctx, rt, o, p) |
| 139 | if err != nil { |
| 140 | return err |
| 141 | } |
| 142 | events = append(events, "admit") |
| 143 | s, err := c.runtime.Prepare(ctx, rt, admitted, p) |
| 144 | if err != nil { |
| 145 | return err |
| 146 | } |
| 147 | if _, err := s.OpenProbe(ctx, time.Now()); err != nil { |
| 148 | return err |
| 149 | } |
| 150 | if err := s.Reserve(ctx, 0); err != nil { |
| 151 | return err |
| 152 | } |
| 153 | return s.Materialize(ctx) |
| 154 | } |
| 155 | a := recoveryProbeAdapter{Box: "box-1", Claims: true, Wake: wake, |
| 156 | Route: func(host string) (*registry.Route, error) { return ®istry.Route{Host: host}, nil }, |
| 157 | Current: func(context.Context, release.Store, provision.Record) (provision.MoveInputs, error) { |
| 158 | return plan10J2AdapterInputs(), nil |
| 159 | }} |
| 160 | out, err := a.probe(t.Context(), f.claims, rec, req) |
| 161 | if err != nil || out != recoveryProbeAnswered { |
| 162 | t.Fatalf("probe: %v %v", out, err) |
| 163 | } |
| 164 | if got := strings.Join(events, ","); got != "record,wake,admit,render" { |
| 165 | t.Fatalf("order: %s", got) |
| 166 | } |
| 167 | if len(policies) != 1 || !policies[0].Probe || policies[0].MaxStarts != recoveryProbeStartsPerWake || policies[0].Window != 30*time.Second || policies[0].RecoveryFence != moveFenceGeneration(rec.Move) { |
| 168 | t.Fatalf("policy: %+v", policies) |
| 169 | } |
| 170 | if got := strings.Join(ops.calls, ","); got != "open,reserve-0" { |
| 171 | t.Fatalf("attempt journal calls: %s", got) |
| 172 | } |
| 173 | for _, s := range ops.starts { |
| 174 | if !s.Policy.Probe || s.Admitted.Claim.Op.Kind != kind || s.Admitted.Claim.Op.Phase != hostclaim.PhaseProbing || !reflect.DeepEqual(*s.Policy.Inputs, plan10J2AdapterInputs()) { |
| 175 | t.Fatalf("attempt bound to %+v", s) |
| 176 | } |
| 177 | } |
| 178 | }) |
| 179 | } |
| 180 | } |
| 181 | |
| 182 | // A failed record write, inputs naming no release, or a claim that is not |
| 183 | // this rollback's probe at probing by this box stops the attempt before any |
| 184 | // render or wake. |
| 185 | func TestPlan10J2AdapterStopsBeforeTheWake(t *testing.T) { |
| 186 | t.Parallel() |
| 187 | type tc struct { |
| 188 | claimPhase, exec, srcGen string |
| 189 | in provision.MoveInputs |
| 190 | recordErr error |
| 191 | mutateReq func(*recoveryProbeRequest) |
| 192 | mutateRec func(*provision.Record) |
| 193 | want error |
| 194 | } |
| 195 | recordFailed := errors.New("the record write failed") |
| 196 | for name, c := range map[string]tc{ |
| 197 | "record write fails": {recordErr: recordFailed, want: recordFailed}, |
| 198 | "no release": {in: provision.MoveInputs{OverlayETag: "o1"}, want: errRecoveryInputsNoRelease}, |
| 199 | "claim at claimed": {claimPhase: hostclaim.PhaseClaimed, want: hostbox.ErrChanged}, |
| 200 | "claim at probed": {claimPhase: hostclaim.PhaseProbed, want: hostbox.ErrChanged}, |
| 201 | "other executor": {exec: "box-2", want: hostbox.ErrChanged}, |
| 202 | "other rollback": {mutateReq: func(r *recoveryProbeRequest) { r.RollbackID = "rb_zzzzzzzzzzzzzzzzzzzzzzzzzz" }, want: errRecoveryProbeRequest}, |
| 203 | "record not probing": {mutateRec: func(r *provision.Record) { r.Move.Rollback.SourcePhase = provision.RbSourceProbed }, want: errRecoveryProbeRequest}, |
| 204 | "budget not nine": {mutateReq: func(r *recoveryProbeRequest) { r.Attempts = 3 }, want: errRecoveryProbeRequest}, |
| 205 | "clean as sealed": {mutateReq: func(r *recoveryProbeRequest) { r.Coverage = recoveryCoverageSealed }, want: errRecoveryProbeRequest}, |
| 206 | "clean with a gen": {mutateReq: func(r *recoveryProbeRequest) { r.Generation = plan10J2Rollback }, want: errRecoveryProbeRequest}, |
| 207 | "src_gen differs": {srcGen: "other-gen", want: hostbox.ErrChanged}, |
| 208 | "no record hook": {mutateReq: func(r *recoveryProbeRequest) { r.Record = nil }, want: errRecoveryProbeRequest}, |
| 209 | } { |
| 210 | t.Run(name, func(t *testing.T) { |
| 211 | f := newRecoveryFixture(t) |
| 212 | phase := c.claimPhase |
| 213 | if phase == "" { |
| 214 | phase = hostclaim.PhaseProbing |
| 215 | } |
| 216 | srcGen := c.srcGen |
| 217 | if srcGen == "" { |
| 218 | srcGen = "src-gen" |
| 219 | } |
| 220 | inc := plan10J2AdapterClaim(t, f, phase, srcGen) |
| 221 | if c.exec != "" { |
| 222 | if _, err := hostclaim.Update(t.Context(), f.claims, f.read(), func(cl *hostclaim.Claim) error { cl.Op.Exec = c.exec; return nil }); err != nil { |
| 223 | t.Fatal(err) |
| 224 | } |
| 225 | } |
| 226 | rec := plan10J2AdapterRecord(inc, false) |
| 227 | var events []string |
| 228 | req := plan10J2AdapterRequest(&rec, "src-gen", &events) |
| 229 | if c.recordErr != nil { |
| 230 | req.Record = func(context.Context, provision.MoveInputs) error { |
| 231 | events = append(events, "record") |
| 232 | return c.recordErr |
| 233 | } |
| 234 | } |
| 235 | if c.mutateReq != nil { |
| 236 | c.mutateReq(&req) |
| 237 | } |
| 238 | if c.mutateRec != nil { |
| 239 | c.mutateRec(&rec) |
| 240 | } |
| 241 | in := c.in |
| 242 | if in.Release == "" && in.OverlayETag == "" { |
| 243 | in = plan10J2AdapterInputs() |
| 244 | } |
| 245 | a := recoveryProbeAdapter{Box: "box-1", Claims: true, |
| 246 | Wake: func(context.Context, registry.Route, activator.StartPolicy) error { |
| 247 | events = append(events, "wake") |
| 248 | return nil |
| 249 | }, |
| 250 | Route: func(host string) (*registry.Route, error) { return ®istry.Route{Host: host}, nil }, |
| 251 | Current: func(context.Context, release.Store, provision.Record) (provision.MoveInputs, error) { return in, nil }} |
| 252 | out, err := a.probe(t.Context(), f.claims, rec, req) |
| 253 | if !errors.Is(err, c.want) || out != recoveryProbeWaiting { |
| 254 | t.Fatalf("got %v %v, want %v", out, err, c.want) |
| 255 | } |
| 256 | for _, e := range events { |
| 257 | if e == "wake" { |
| 258 | t.Fatalf("woke: %v", events) |
| 259 | } |
| 260 | } |
| 261 | if c.recordErr == nil && len(events) != 0 { |
| 262 | t.Fatalf("recorded: %v", events) |
| 263 | } |
| 264 | }) |
| 265 | } |
| 266 | } |
| 267 | |
| 268 | // Without the host claim or the activator's recovery probe entry the |
| 269 | // adapter is a typed dependency refusal: nothing is read, recorded or woken. |
| 270 | func TestPlan10J2AdapterRefusesWithoutItsDependencies(t *testing.T) { |
| 271 | route := func(host string) (*registry.Route, error) { return ®istry.Route{Host: host}, nil } |
| 272 | current := func(context.Context, release.Store, provision.Record) (provision.MoveInputs, error) { |
| 273 | return plan10J2AdapterInputs(), nil |
| 274 | } |
| 275 | wake := func(context.Context, registry.Route, activator.StartPolicy) error { return nil } |
| 276 | for name, a := range map[string]recoveryProbeAdapter{ |
| 277 | "runtime unavailable": {Box: "box-1", Claims: true, Wake: wake, Route: route, Current: current, Ready: func() bool { return false }}, |
| 278 | "claim off": {Box: "box-1", Claims: false, Wake: wake, Route: route, Current: current}, |
| 279 | "no wake entry": {Box: "box-1", Claims: true, Route: route, Current: current}, |
| 280 | "no route": {Box: "box-1", Claims: true, Wake: wake, Current: current}, |
| 281 | "no inputs": {Box: "box-1", Claims: true, Wake: wake, Route: route}, |
| 282 | } { |
| 283 | t.Run(name, func(t *testing.T) { |
| 284 | rec := plan10J2AdapterRecord("inc", false) |
| 285 | var events []string |
| 286 | out, err := a.probe(t.Context(), release.NewDirStore(t.TempDir()), rec, plan10J2AdapterRequest(&rec, "src-gen", &events)) |
| 287 | var refusal *ClaimDependencyError |
| 288 | if !errors.As(err, &refusal) || out != recoveryProbeWaiting || len(events) != 0 || rec.Move.Rollback.RecoveryInputs != nil { |
| 289 | t.Fatalf("%v %v %v", out, err, events) |
| 290 | } |
| 291 | }) |
| 292 | } |
| 293 | } |
| 294 | |
| 295 | // The wake's outcome maps onto the recovery's: answered goes to probed, a |
| 296 | // spent nine-attempt budget stops for an operator, a lapsed window or a |
| 297 | // changed claim waits for the next pass, anything else is an error. |
| 298 | func TestPlan10J2AdapterOutcomes(t *testing.T) { |
| 299 | t.Parallel() |
| 300 | other := errors.New("boom") |
| 301 | for name, tc := range map[string]struct { |
| 302 | wakeErr error |
| 303 | out recoveryProbeOutcome |
| 304 | err error |
| 305 | }{ |
| 306 | "answered": {nil, recoveryProbeAnswered, nil}, |
| 307 | "exhausted": {fmt.Errorf("reserve: %w", ErrRecoveryProbeExhausted), recoveryProbeExhausted, nil}, |
| 308 | "window": {moves.ErrAttemptOver, recoveryProbeWaiting, nil}, |
| 309 | "changed": {hostbox.ErrChanged, recoveryProbeWaiting, nil}, |
| 310 | "other": {other, recoveryProbeWaiting, other}, |
| 311 | } { |
| 312 | t.Run(name, func(t *testing.T) { |
| 313 | f := newRecoveryFixture(t) |
| 314 | rec := plan10J2AdapterRecord(plan10J2AdapterClaim(t, f, hostclaim.PhaseProbing, "src-gen"), false) |
| 315 | var events []string |
| 316 | a := recoveryProbeAdapter{Box: "box-1", Claims: true, |
| 317 | Wake: func(context.Context, registry.Route, activator.StartPolicy) error { return tc.wakeErr }, |
| 318 | Route: func(host string) (*registry.Route, error) { return ®istry.Route{Host: host}, nil }, |
| 319 | Current: func(context.Context, release.Store, provision.Record) (provision.MoveInputs, error) { |
| 320 | return plan10J2AdapterInputs(), nil |
| 321 | }} |
| 322 | out, err := a.probe(t.Context(), f.claims, rec, plan10J2AdapterRequest(&rec, "src-gen", &events)) |
| 323 | if out != tc.out || (tc.err == nil) != (err == nil) || (tc.err != nil && !errors.Is(err, tc.err)) { |
| 324 | t.Fatalf("got %v %v", out, err) |
| 325 | } |
| 326 | }) |
| 327 | } |
| 328 | } |
| 329 | |
| 330 | // An unavailable wake entry keeps the recovery at probing without recording |
| 331 | // inputs. The production binding itself is checked below with both flags. |
| 332 | func TestPlan10J2MissingWakeKeepsRecoveryAtProbing(t *testing.T) { |
| 333 | t.Parallel() |
| 334 | cfg := moveCfg{Box: "ie-box", claimRecords: true, Route: func(string) (*registry.Route, error) { return nil, nil }} |
| 335 | w := &movesWorker{mirrors: map[string]lease.MirrorStore{}} |
| 336 | armRecoveryProbe(&cfg, w, nil, nil) |
| 337 | if cfg.recoveryProbe == nil { |
| 338 | t.Fatal("the production recovery probe hook is nil") |
| 339 | } |
| 340 | h := newRecoverHarness(t, false) |
| 341 | h.released(t) |
| 342 | h.sw = &movesWorker{mirrors: map[string]lease.MirrorStore{}} |
| 343 | for i := 0; i < 2; i++ { |
| 344 | c := h.sourceCfg(t) |
| 345 | c.claimRecords = true |
| 346 | armRecoveryProbe(&c, h.sw, nil, nil) |
| 347 | h.sw.cfg = c |
| 348 | if err := movesPass(context.Background(), h.s, h.sw); err != nil { |
| 349 | t.Fatal(err) |
| 350 | } |
| 351 | } |
| 352 | if rb := h.sourceAt(t, provision.RbSourceProbing); rb.RecoveryInputs != nil { |
| 353 | t.Fatalf("recorded without a wake entry: %+v", rb.RecoveryInputs) |
| 354 | } |
| 355 | } |
| 356 | |
| 357 | // With a wake entry the same production adapter drives the real recovery |
| 358 | // pass: each return records the inputs it renders at probing, before the |
| 359 | // wake, and the recovery goes on to opened. |
| 360 | // The production adapter accepts the claim's legacy encoding of a source |
| 361 | // generation (whole-branch fixes, item 4): "" is a source whose sessions |
| 362 | // predate admission records, which recoverRun.probing passes through |
| 363 | // from the claim's journal unchanged and the owners normalise as @root. |
| 364 | // A declared return and a clean return of such a source reach the wake; |
| 365 | // a request naming another generation than the claim's still does not. |
| 366 | func TestPlan10J2AdapterAcceptsALegacySourceGeneration(t *testing.T) { |
| 367 | ctx := context.Background() |
| 368 | for name, declared := range map[string]bool{"a clean return": false, "a declared return": true} { |
| 369 | t.Run(name, func(t *testing.T) { |
| 370 | s := release.NewDirStore(t.TempDir()) |
| 371 | created := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) |
| 372 | inc, err := hostclaim.Incarnation("bab", "app", p1Host, created) |
| 373 | if err != nil { |
| 374 | t.Fatal(err) |
| 375 | } |
| 376 | rec := provision.Record{Host: p1Host, Account: "bab", App: "app", Region: "ie", |
| 377 | Move: &provision.MoveState{ID: p1ID, Incarnation: inc, From: "ie", To: "uk", State: provision.MoveRollingBack, |
| 378 | Rollback: &provision.MoveRollback{ID: "rb_legacy", SourcePhase: provision.RbSourceProbing, TargetPhase: provision.RbTargetReleased}}} |
| 379 | journal := map[string]string{hostclaim.JournalSrcGen: "", hostclaim.JournalSourceStamp: "15", hostclaim.JournalCutSession: "15"} |
| 380 | if declared { |
| 381 | lossKey, err := hostclaim.LossRecordKey("l1") |
| 382 | if err != nil { |
| 383 | t.Fatal(err) |
| 384 | } |
| 385 | rec.Move.Rollback.LossRef = lossKey |
| 386 | journal["loss.l1"] = `{"kind":"move-target-lost","target":"uk-box"}` |
| 387 | } |
| 388 | c := &hostclaim.Claim{V: 2, Host: p1Host, Gen: 9, |
| 389 | Holder: &hostclaim.Holder{Family: "instance", Key: "apps/bab/app/instances/" + p1Host + ".json", Account: "bab", App: "app", Incarnation: inc, CreatedAt: created, Location: "instances/" + p1Host, Stamp: 15, Session: 20, WriterBox: "uk-box", |
| 390 | Admissions: []hostclaim.AdmissionRecord{{Session: 20, Box: "uk-box", Generation: p1ID, Region: "uk", PredecessorRegion: "ie", Mode: "cold", Coverage: "sealed"}}}, |
| 391 | Op: hostclaim.Op{Kind: hostclaim.KindRestoring, ID: p1ID, Phase: hostclaim.PhaseProbing, Exec: "ie-box", Participants: []string{"ie-box", "uk-box"}, |
| 392 | Intent: map[string]string{"to": "uk", "source": "ie-box", "target": "uk-box", "rollback": "rb_legacy"}, Journal: journal}} |
| 393 | if declared { |
| 394 | c.LossRefs, c.Lost = []string{"l1"}, []string{"uk-box"} |
| 395 | } |
| 396 | key, _ := hostclaim.Key(p1Host) |
| 397 | body, _ := json.Marshal(c) |
| 398 | if _, err := s.Put(ctx, key, body, ""); err != nil { |
| 399 | t.Fatal(err) |
| 400 | } |
| 401 | woke := 0 |
| 402 | a := recoveryProbeAdapter{Box: "ie-box", Claims: true, |
| 403 | Route: func(string) (*registry.Route, error) { return ®istry.Route{Host: p1Host, DBPath: "/data/x.db"}, nil }, |
| 404 | Current: func(context.Context, release.Store, provision.Record) (provision.MoveInputs, error) { |
| 405 | return provision.MoveInputs{Release: "v1"}, nil |
| 406 | }, |
| 407 | Wake: func(context.Context, registry.Route, activator.StartPolicy) error { woke++; return nil }} |
| 408 | req := recoveryProbeRequest{RollbackID: "rb_legacy", Clean: true, Declared: declared, Attempts: recoveryProbeAttempts, SourceGeneration: "", |
| 409 | Record: func(context.Context, provision.MoveInputs) error { return nil }} |
| 410 | if !declared { |
| 411 | req.Coverage = recoveryCoverageClean |
| 412 | } |
| 413 | if out, err := a.probe(ctx, s, rec, req); err != nil || out != recoveryProbeAnswered || woke != 1 { |
| 414 | t.Fatalf("a legacy source's return: %v %v woke=%d", out, err, woke) |
| 415 | } |
| 416 | req.SourceGeneration = "other" |
| 417 | if _, err := a.probe(ctx, s, rec, req); !errors.Is(err, hostbox.ErrChanged) || woke != 1 { |
| 418 | t.Fatalf("another generation than the claim's: %v woke=%d", err, woke) |
| 419 | } |
| 420 | }) |
| 421 | } |
| 422 | } |
| 423 | |
| 424 | func TestPlan10J2AdapterDrivesTheRecoveryPass(t *testing.T) { |
| 425 | t.Parallel() |
| 426 | for _, dirty := range []bool{false, true} { |
| 427 | t.Run(fmt.Sprintf("dirty=%v", dirty), func(t *testing.T) { |
| 428 | h := newRecoverHarness(t, dirty) |
| 429 | h.released(t) |
| 430 | h.sw = &movesWorker{mirrors: map[string]lease.MirrorStore{}} |
| 431 | var wakes []activator.StartPolicy |
| 432 | wake := func(ctx context.Context, rt registry.Route, p activator.StartPolicy) error { |
| 433 | rb := h.read(t, p1Host).Move.Rollback |
| 434 | if rb.SourcePhase != provision.RbSourceProbing || rb.RecoveryInputs == nil || p.Inputs == nil || !reflect.DeepEqual(*rb.RecoveryInputs, *p.Inputs) { |
| 435 | t.Errorf("woke before the record: %+v / %+v", rb, p.Inputs) |
| 436 | } |
| 437 | c := h.claim(t) |
| 438 | if rt.Host != p1Host || c.Op.Kind != hostclaim.KindRestoring || c.Op.Phase != hostclaim.PhaseProbing || c.Op.Exec != "ie-box" { |
| 439 | t.Errorf("woke %s with the claim at %+v", rt.Host, c.Op) |
| 440 | } |
| 441 | wakes = append(wakes, p) |
| 442 | return nil |
| 443 | } |
| 444 | for i := 0; i < 3; i++ { |
| 445 | c := h.sourceCfg(t) |
| 446 | c.claimRecords = true |
| 447 | armRecoveryProbe(&c, h.sw, wake, nil) |
| 448 | a := c.recoveryProbe |
| 449 | c.recoveryProbe = func(ctx context.Context, s release.Store, rec provision.Record, req recoveryProbeRequest) (recoveryProbeOutcome, error) { |
| 450 | h.probes = append(h.probes, req) |
| 451 | return a(ctx, s, rec, req) |
| 452 | } |
| 453 | h.sw.cfg = c |
| 454 | h.sw.cfg.LaunchEnv = func(string) (map[string]string, map[string]bool, error) { return map[string]string{"A": "1"}, nil, nil } |
| 455 | h.sw.cfg.InputVersions = func(context.Context, release.Store, provision.Record) (map[string]string, string, error) { |
| 456 | in := h.renderedInputs() |
| 457 | return in.BundleETags, in.OverlayETag, nil |
| 458 | } |
| 459 | if err := movesPass(context.Background(), h.s, h.sw); err != nil { |
| 460 | t.Fatal(err) |
| 461 | } |
| 462 | } |
| 463 | h.opened(t) |
| 464 | if len(wakes) != 1 || !wakes[0].Probe || len(h.probes) != 1 { |
| 465 | t.Fatalf("wakes %+v probes %d", wakes, len(h.probes)) |
| 466 | } |
| 467 | req := h.probes[0] |
| 468 | if req.Clean == dirty || req.Attempts != recoveryProbeAttempts || (dirty && (req.Coverage != recoveryCoverageSealed || req.Generation == "")) || (!dirty && (req.Coverage != recoveryCoverageClean || req.SourceGeneration != h.claim(t).Op.Journal[hostclaim.JournalSrcGen])) { |
| 469 | t.Fatalf("request %+v", req) |
| 470 | } |
| 471 | }) |
| 472 | } |
| 473 | } |
| 474 | |
| 475 | func TestPlan10J2ArmPhase1ConnectsRecoveryOnlyWithClaimFlag(t *testing.T) { |
| 476 | for _, enabled := range []bool{false, true} { |
| 477 | t.Run(fmt.Sprint(enabled), func(t *testing.T) { |
| 478 | cfg := moveCfg{claimRecords: enabled} |
| 479 | w := &movesWorker{mirrors: map[string]lease.MirrorStore{}} |
| 480 | armPhase1(&cfg, w, activator.Config{}, t.TempDir(), nil, &activator.Service{}, nil, hostwrite.Config{Enabled: enabled}, func(string) string { return "" }) |
| 481 | if (cfg.recoveryProbe != nil) != enabled { |
| 482 | t.Fatal("production recovery hook does not follow claim flag") |
| 483 | } |
| 484 | }) |
| 485 | } |
| 486 | } |
| 487 | |
| 488 | func TestPlan10J2RevertRunUsesProductionAdapter(t *testing.T) { |
| 489 | t.Parallel() |
| 490 | h := newRevertHarness(t) |
| 491 | h.fromStopped(t) |
| 492 | h.arm(t) |
| 493 | wakes := 0 |
| 494 | a := recoveryProbeAdapter{Box: "ie-box", Claims: true, |
| 495 | Route: func(string) (*registry.Route, error) { return &h.route, nil }, |
| 496 | Current: func(context.Context, release.Store, provision.Record) (provision.MoveInputs, error) { |
| 497 | return revertRendered(), nil |
| 498 | }, |
| 499 | Wake: func(ctx context.Context, rt registry.Route, p activator.StartPolicy) error { |
| 500 | wakes++ |
| 501 | m := h.move(t) |
| 502 | if m.RecoveryInputs == nil || !reflect.DeepEqual(*m.RecoveryInputs, *p.Inputs) { |
| 503 | t.Fatal("wake before revert inputs") |
| 504 | } |
| 505 | c := h.claim(t) |
| 506 | if c.Op.Kind != hostclaim.KindReverting || c.Op.Phase != hostclaim.PhaseProbing || c.Op.Journal[hostclaim.JournalRevPhase] != hostclaim.PhaseProbing { |
| 507 | t.Fatal("wrong claim") |
| 508 | } |
| 509 | return nil |
| 510 | }} |
| 511 | h.cfg.recoveryProbe = a.probe |
| 512 | h.pass(t) |
| 513 | if wakes != 1 { |
| 514 | t.Fatalf("wakes=%d", wakes) |
| 515 | } |
| 516 | h.reopened(t) |
| 517 | } |
| 518 | |
| 519 | func TestPlan10J2RevertMissingProviderWaitsWithoutInputsOrWake(t *testing.T) { |
| 520 | t.Parallel() |
| 521 | h := newRevertHarness(t) |
| 522 | h.fromStopped(t) |
| 523 | h.arm(t) |
| 524 | calls := 0 |
| 525 | a := recoveryProbeAdapter{Box: "ie-box", Claims: true, Ready: func() bool { return false }, Route: func(string) (*registry.Route, error) { return &h.route, nil }, Current: func(context.Context, release.Store, provision.Record) (provision.MoveInputs, error) { |
| 526 | calls++ |
| 527 | return revertRendered(), nil |
| 528 | }, Wake: func(context.Context, registry.Route, activator.StartPolicy) error { calls++; return nil }} |
| 529 | h.cfg.recoveryProbe = a.probe |
| 530 | h.pass(t) |
| 531 | before := h.move(t).UpdatedAt |
| 532 | h.clock.Advance(16 * time.Minute) |
| 533 | h.pass(t) |
| 534 | m := h.move(t) |
| 535 | if calls != 0 || m.RecoveryInputs != nil || m.State != provision.MoveReverting || !m.UpdatedAt.Equal(before) { |
| 536 | t.Fatalf("unbound provider changed recovery: calls=%d move=%+v", calls, m) |
| 537 | } |
| 538 | } |
| 539 | |
| 540 | func TestPlan10J2NinthFailedStartSticksAndKeepsJournal(t *testing.T) { |
| 541 | t.Parallel() |
| 542 | h := newRevertHarness(t) |
| 543 | h.fromStopped(t) |
| 544 | h.arm(t) |
| 545 | starts, ids := 0, 0 |
| 546 | var last OperationStart |
| 547 | provider := func() *movesProbeAttempts { |
| 548 | return &movesProbeAttempts{Claims: h.s, DataDir: h.cfg.DataDir, |
| 549 | Records: func(ctx context.Context, account, app, host string) (*provision.Record, error) { |
| 550 | return provision.GetRecord(ctx, h.s, account, app, host) |
| 551 | }, |
| 552 | Admitted: func(context.Context, *provision.Record) (int, error) { return 9, nil }, |
| 553 | NewID: func() string { ids++; return fmt.Sprintf("attempt-%d", ids) }, Now: time.Now} |
| 554 | } |
| 555 | a := recoveryProbeAdapter{Box: "ie-box", Claims: true, Ready: func() bool { return provider().RecoveryProbeAttemptsReady() }, Route: func(string) (*registry.Route, error) { return &h.route, nil }, Current: func(context.Context, release.Store, provision.Record) (provision.MoveInputs, error) { |
| 556 | return revertRendered(), nil |
| 557 | }, |
| 558 | Wake: func(ctx context.Context, rt registry.Route, p activator.StartPolicy) error { |
| 559 | o, err := hostclaim.Read(ctx, h.s, rt.Host) |
| 560 | if err != nil { |
| 561 | return err |
| 562 | } |
| 563 | holder := o.Claim.Holder |
| 564 | last = OperationStart{Admitted: o, Policy: p, Owner: hostguard.FenceTarget{Host: rt.Host, Incarnation: holder.Incarnation, Stamp: holder.Stamp, Session: holder.Session, Box: "ie-box"}} |
| 565 | ops := provider() |
| 566 | for i := 0; i < 3; i++ { |
| 567 | if err := ops.Reserve(ctx, last, starts); err != nil { |
| 568 | return err |
| 569 | } |
| 570 | starts++ |
| 571 | if err := ops.Failed(ctx, last, starts-1); err != nil { |
| 572 | return err |
| 573 | } |
| 574 | } |
| 575 | return moves.ErrAttemptOver |
| 576 | }} |
| 577 | h.cfg.recoveryProbe = a.probe |
| 578 | for i := 0; i < 3; i++ { |
| 579 | h.pass(t) |
| 580 | } |
| 581 | m := h.move(t) |
| 582 | if starts != 9 || m.State != provision.MoveStuck || m.StuckReason != provision.StuckRecoveryProbeAttempts { |
| 583 | t.Fatalf("starts=%d move=%+v", starts, m) |
| 584 | } |
| 585 | j, err := moves.ReadJournal(h.cfg.DataDir, p1Host, p1ID) |
| 586 | if err != nil { |
| 587 | t.Fatal(err) |
| 588 | } |
| 589 | if count, err := recoveryAttemptCount(j, recoveryAttemptKey(last, 0)); err != nil || count != 9 { |
| 590 | t.Fatalf("journal count=%d err=%v", count, err) |
| 591 | } |
| 592 | } |
| 593 | |
| 594 | func TestPlan10J2RevertAdapterRejectsChangedBinding(t *testing.T) { |
| 595 | t.Parallel() |
| 596 | for _, tc := range []string{"reopened", "generation", "rev_phase", "kind", "executor"} { |
| 597 | t.Run(tc, func(t *testing.T) { |
| 598 | f := newRecoveryFixture(t) |
| 599 | o, err := hostclaim.Update(t.Context(), f.claims, f.read(), func(c *hostclaim.Claim) error { |
| 600 | c.Op = plan10J2Op(hostclaim.KindReverting, hostclaim.PhaseProbing) |
| 601 | c.Op.Journal = map[string]string{hostclaim.JournalRevPhase: hostclaim.PhaseProbing} |
| 602 | switch tc { |
| 603 | case "rev_phase": |
| 604 | c.Op.Journal[hostclaim.JournalRevPhase] = hostclaim.PhaseProbed |
| 605 | case "kind": |
| 606 | c.Op.Kind = hostclaim.KindRestoring |
| 607 | case "executor": |
| 608 | c.Op.Exec = "box-2" |
| 609 | } |
| 610 | return nil |
| 611 | }) |
| 612 | if err != nil { |
| 613 | t.Fatal(err) |
| 614 | } |
| 615 | rec := provision.Record{Host: recoveryHost, Move: &provision.MoveState{ID: o.Claim.Op.ID, Incarnation: o.Claim.Holder.Incarnation, State: provision.MoveReverting}} |
| 616 | req := recoveryProbeRequest{Clean: true, SourceGeneration: o.Claim.Holder.Generation, Attempts: recoveryProbeAttempts, Record: func(context.Context, provision.MoveInputs) error { t.Fatal("recorded invalid revert"); return nil }} |
| 617 | if tc == "generation" { |
| 618 | req.SourceGeneration = "other" |
| 619 | } |
| 620 | if tc == "reopened" { |
| 621 | now := time.Now() |
| 622 | rec.Move.ReopenedAt = &now |
| 623 | } |
| 624 | a := recoveryProbeAdapter{Claims: true, Box: "box-1", Route: func(string) (*registry.Route, error) { t.Fatal("looked up invalid revert"); return nil, nil }, Current: func(context.Context, release.Store, provision.Record) (provision.MoveInputs, error) { |
| 625 | t.Fatal("selected invalid revert") |
| 626 | return provision.MoveInputs{}, nil |
| 627 | }, Wake: func(context.Context, registry.Route, activator.StartPolicy) error { |
| 628 | t.Fatal("woke invalid revert") |
| 629 | return nil |
| 630 | }} |
| 631 | if _, err := a.probe(t.Context(), f.claims, rec, req); err == nil { |
| 632 | t.Fatal("invalid revert accepted") |
| 633 | } |
| 634 | }) |
| 635 | } |
| 636 | } |
| 637 | |