hostclaim: Plan 10 fix round 1 (revert branch in the recovery adapter, durable nine-start budget, quiet unwired passes, release outcome by kind, census counts, sweep keeps non-moves candidates, loss store bucket and progression checks, console policy precondition test, real loss fixtures, moves copy census rows)
Answers Opus P2-1/P2-2 and P3-1..6 and Astra's P3 on 9a32deaf. The recovery attempt count lives in the box-local moves journal keyed by move, rollback and claim incarnation; an unbound provider waits without recording inputs or waking. HC5-N2 and HC5-N3 are pending copy rows, so moves.DefaultEnabled stays false until Paul approves them.
Co-Authored-By: Codex <noreply@openai.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
25 files changed,
+986
−84
approved_moves_copy_test.go+7 −1internal/activator/claimwake.go+8 −7internal/activator/recoveryprobe.go+1 −1internal/activator/recoveryprobe_test.go+10 −1internal/console/claim_authority_census_test.go+1 −0internal/console/claim_move_recovery_wiring_test.go+160 −0internal/console/testdata/claim-authority-sites.txt+10 −0internal/host/claimoperations.go+11 −0internal/host/claimwakeassembly.go+1 −1internal/host/movesprobeattempts.go+101 −11internal/host/movesprobeattempts_test.go+133 −0internal/host/movesrecoveryprobe.go+17 −3internal/host/movesrevert.go+9 −0internal/host/plan10_j2_adapter_test.go+179 −7internal/hostclaim/lossrecord.go+10 −1internal/hostops/move_loss_grant.go+25 −5internal/hostops/move_loss_scope_fix_test.go+181 −0internal/hostops/move_loss_test.go+0 −11internal/hostops/move_recovery_grant.go+8 −11internal/hostops/move_recovery_grant_test.go+39 −1internal/hostops/recovery.go+3 −0internal/park/move_sweep_test.go+46 −1internal/park/sweep.go+19 −17internal/provision/plan9_b8_census_test.go+4 −4testdata/claim-moves-copy.md+3 −1
CI passed — run details
diff --git a/approved_moves_copy_test.go b/approved_moves_copy_test.go| index ccd011d44..ae295e66b 100644 |
| --- a/approved_moves_copy_test.go |
| +++ b/approved_moves_copy_test.go |
| @@ -39,7 +39,7 @@ const movesCopyRecordPath = "docs/superpowers/plans/2026-10-06-moves-phase1-copy |
| // "Gate and copy" task appends its ids here as it appends its rows to the |
| // record. The record and this literal must agree both ways. |
| var movesCopyIDs = []string{ |
| - "HC5-N1", |
| + "HC5-N1", "HC5-N2", "HC5-N3", |
| "M-C1", "M-C2", "M-C3", "M-C4", "M-C5", "M-C6", "M-C7", "M-C8", "M-C9", |
| "M-C10", "M-C11", "M-C12", "M-C13", "M-C14", "M-C15", "M-C16", "M-C17", |
| "M-C18", "M-C19", "M-C20", "M-C21", "M-C22", "MV-N1", "MV-N2", "MV-N3", |
| @@ -543,6 +543,12 @@ func TestMovesTextAppliedPlaceholders(t *testing.T) { |
| // copy" task appends the files it touched; TestMovesScanFilesCoverTheDiff |
| // fails when the list falls behind. |
| var movesScanFiles = []string{ |
| + "internal/hostops/move_recovery_grant.go", |
| + "internal/hostops/move_loss_owner.go", |
| + "internal/host/movesrecoveryprobe.go", |
| + "internal/host/claimproduction.go", |
| + "internal/host/claimowners.go", |
| + "internal/host/claimmoveaccess.go", |
| "cmd/console/main.go", |
| "internal/activator/backend_exec.go", |
| "internal/activator/claimwake.go", |
diff --git a/internal/activator/claimwake.go b/internal/activator/claimwake.go| index bc5abd0bd..68534ff90 100644 |
| --- a/internal/activator/claimwake.go |
| +++ b/internal/activator/claimwake.go |
| @@ -66,13 +66,14 @@ type startPolicy = StartPolicy |
| // Admit reserves the first serving attempt and binds its resulting cold session. |
| // Prepared probes instead consume the moves owner's single-use admission. |
| type ClaimRuntime struct { |
| - FailedAdmission func(context.Context, hostclaim.Observed, error) error |
| - Box string |
| - Read func(context.Context, string) (hostclaim.Observed, error) |
| - StopForeign func(context.Context, string) error |
| - Prerequisites func(context.Context, registry.Route, hostclaim.Observed, startPolicy) error |
| - Admit func(context.Context, registry.Route, hostclaim.Observed, startPolicy) (hostclaim.Observed, error) |
| - Prepare func(context.Context, registry.Route, hostclaim.Observed, startPolicy) (ClaimStart, error) |
| + RecoveryAttemptsReady func() bool |
| + FailedAdmission func(context.Context, hostclaim.Observed, error) error |
| + Box string |
| + Read func(context.Context, string) (hostclaim.Observed, error) |
| + StopForeign func(context.Context, string) error |
| + Prerequisites func(context.Context, registry.Route, hostclaim.Observed, startPolicy) error |
| + Admit func(context.Context, registry.Route, hostclaim.Observed, startPolicy) (hostclaim.Observed, error) |
| + Prepare func(context.Context, registry.Route, hostclaim.Observed, startPolicy) (ClaimStart, error) |
| // Pins reports that Prepare honours StartPolicy.Pin. A hold swap is |
| // refused on a claims-on box whose runtime does not, before anything |
| // stops. |
diff --git a/internal/activator/recoveryprobe.go b/internal/activator/recoveryprobe.go| index 0df1801e1..3783923bf 100644 |
| --- a/internal/activator/recoveryprobe.go |
| +++ b/internal/activator/recoveryprobe.go |
| @@ -105,7 +105,7 @@ func (a *Service) RecoveryProbeReady() bool { |
| return false |
| } |
| r := a.cfg.Claims |
| - return r != nil && r.Box != "" && r.Read != nil && r.StopForeign != nil && r.Prerequisites != nil && r.Admit != nil && r.Prepare != nil && r.FailedAdmission != nil |
| + return r != nil && r.RecoveryAttemptsReady != nil && r.RecoveryAttemptsReady() && r.Box != "" && r.Read != nil && r.StopForeign != nil && r.Prerequisites != nil && r.Admit != nil && r.Prepare != nil && r.FailedAdmission != nil |
| } |
| // recoveryProbeLimit bounds one probe call, as wake bounds a wake. |
diff --git a/internal/activator/recoveryprobe_test.go b/internal/activator/recoveryprobe_test.go| index 5610a5b26..db34ba5c6 100644 |
| --- a/internal/activator/recoveryprobe_test.go |
| +++ b/internal/activator/recoveryprobe_test.go |
| @@ -88,7 +88,8 @@ func probeService(t *testing.T, obs hostclaim.Observed) (*Service, *ClaimRuntime |
| work, _ := (hostwrite.Config{}).Select(t.Context(), probeHost, "spawn") |
| reads := 0 |
| r := &ClaimRuntime{ |
| - Box: "box", |
| + RecoveryAttemptsReady: func() bool { return true }, |
| + Box: "box", |
| // The wake's first read sees obs; every later one (startAdmitted's |
| // fresh read) sees the admission. |
| Read: func(_ context.Context, host string) (hostclaim.Observed, error) { |
| @@ -829,3 +830,11 @@ func TestRecoveryHoldSurvivesAdoptionAtProbed(t *testing.T) { |
| } |
| } |
| } |
| + |
| +func TestRecoveryProbeUnboundAttemptsAreNotReady(t *testing.T) { |
| + a, r, _ := probeService(t, probeObserved(probeClaim(hostclaim.KindRestoring, hostclaim.PhaseProbing, "box"))) |
| + r.RecoveryAttemptsReady = nil |
| + if a.RecoveryProbeReady() { |
| + t.Fatal("recovery probe ready without recovery attempts provider") |
| + } |
| +} |
diff --git a/internal/console/claim_authority_census_test.go b/internal/console/claim_authority_census_test.go| index 725ce6f57..1a702ccfc 100644 |
| --- a/internal/console/claim_authority_census_test.go |
| +++ b/internal/console/claim_authority_census_test.go |
| @@ -90,6 +90,7 @@ func TestClaimAuthoritySourceCensus(t *testing.T) { |
| slices.Sort(want) |
| got := claimAuthoritySites(t) |
| if !slices.Equal(got, want) { |
| + t.Errorf("authority census differs: got %d calls, want %d", len(got), len(want)) |
| for _, s := range got { |
| if !slices.Contains(want, s) { |
| t.Errorf("unreviewed authority use: %s", s) |
diff --git a/internal/console/claim_move_recovery_wiring_test.go b/internal/console/claim_move_recovery_wiring_test.go| new file mode 100644 |
| index 000000000..8e75a20a3 |
| --- /dev/null |
| +++ b/internal/console/claim_move_recovery_wiring_test.go |
| @@ -0,0 +1,160 @@ |
| +package console |
| + |
| +import ( |
| + "go/ast" |
| + "go/parser" |
| + "go/token" |
| + "io/fs" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +func moveRecoveryPolicyBound(f *ast.File) bool { |
| + bound := false |
| + named := func(e ast.Expr) bool { |
| + switch e := e.(type) { |
| + case *ast.Ident: |
| + return e.Name == "ClaimMoveRecovery" |
| + case *ast.SelectorExpr: |
| + return e.Sel.Name == "ClaimMoveRecovery" |
| + } |
| + return false |
| + } |
| + nonnil := func(e ast.Expr) bool { id, ok := e.(*ast.Ident); return !ok || id.Name != "nil" } |
| + ast.Inspect(f, func(n ast.Node) bool { |
| + switch n := n.(type) { |
| + case *ast.KeyValueExpr: |
| + if named(n.Key) && nonnil(n.Value) { |
| + bound = true |
| + } |
| + case *ast.AssignStmt: |
| + for i, lhs := range n.Lhs { |
| + if named(lhs) && (i >= len(n.Rhs) || nonnil(n.Rhs[i])) { |
| + bound = true |
| + } |
| + } |
| + } |
| + return true |
| + }) |
| + return bound |
| +} |
| + |
| +func releaseBeforeClose(f *ast.File, fn, release, close string) bool { |
| + for _, d := range f.Decls { |
| + f, ok := d.(*ast.FuncDecl) |
| + if !ok || f.Name.Name != fn { |
| + continue |
| + } |
| + var released, closed token.Pos |
| + ast.Inspect(f.Body, func(n ast.Node) bool { |
| + call, ok := n.(*ast.CallExpr) |
| + if !ok { |
| + return true |
| + } |
| + sel, ok := call.Fun.(*ast.SelectorExpr) |
| + if !ok { |
| + return true |
| + } |
| + if sel.Sel.Name == release && released == 0 { |
| + released = call.Pos() |
| + } |
| + if sel.Sel.Name == close && closed == 0 { |
| + closed = call.Pos() |
| + } |
| + return true |
| + }) |
| + return released != 0 && (closed == 0 || released < closed) |
| + } |
| + return false |
| +} |
| + |
| +func TestClaimMoveRecoveryWiringWaitsForRecordFirstOwners(t *testing.T) { |
| + root := "../.." |
| + var owners []string |
| + for _, owner := range []struct{ file, fn, release, close string }{ |
| + {"movesparks.go", "closeMove", "ReleaseMove", "MoveClose"}, |
| + {"movesrecover.go", "finalize", "ReleaseMove", "MoveRolledBackClose"}, |
| + {"movesrevert.go", "opened", "ReleaseMove", "close"}, |
| + {"movesabort.go", "abortSource", "releaseAbort", "MoveGuardedAbortComplete"}, |
| + } { |
| + path := filepath.Join(root, "internal/host", owner.file) |
| + f, err := parser.ParseFile(token.NewFileSet(), path, nil, 0) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if releaseBeforeClose(f, owner.fn, owner.release, owner.close) { |
| + owners = append(owners, owner.file+":"+owner.fn) |
| + } |
| + } |
| + if len(owners) == 0 { |
| + return |
| + } |
| + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { |
| + if err != nil { |
| + return err |
| + } |
| + if d.IsDir() { |
| + if path != root && (strings.HasPrefix(d.Name(), ".") || d.Name() == "testdata" || d.Name() == "vendor") { |
| + return filepath.SkipDir |
| + } |
| + return nil |
| + } |
| + if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { |
| + return nil |
| + } |
| + f, err := parser.ParseFile(token.NewFileSet(), path, nil, 0) |
| + if err != nil { |
| + return err |
| + } |
| + if moveRecoveryPolicyBound(f) { |
| + t.Errorf("%s wires ClaimMoveRecovery while owners release first: %v", path, owners) |
| + } |
| + return nil |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func TestClaimMoveRecoveryWiringGuardDetectsBindings(t *testing.T) { |
| + for _, src := range []string{ |
| + `package p; func f(){ c.ClaimMoveRecovery = policy }`, |
| + `package p; var c = Config{ClaimMoveRecovery: policy}`, |
| + } { |
| + f, err := parser.ParseFile(token.NewFileSet(), "fixture.go", src, 0) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !moveRecoveryPolicyBound(f) { |
| + t.Fatal("binding escaped census") |
| + } |
| + } |
| + for _, src := range []string{ |
| + `package p; func f(){ c.ClaimMoveRecovery = nil }`, |
| + `package p; var c = Config{ClaimMoveRecovery: nil}`, |
| + } { |
| + f, err := parser.ParseFile(token.NewFileSet(), "fixture.go", src, 0) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if moveRecoveryPolicyBound(f) { |
| + t.Fatal("nil policy counted as binding") |
| + } |
| + } |
| + for _, tc := range []struct { |
| + src string |
| + want bool |
| + }{ |
| + {`package p; func owner(){hostclaim.ReleaseMove();provision.MoveClose()}`, true}, |
| + {`package p; func owner(){provision.MoveClose();hostclaim.ReleaseMove()}`, false}, |
| + } { |
| + f, err := parser.ParseFile(token.NewFileSet(), "fixture.go", tc.src, 0) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := releaseBeforeClose(f, "owner", "ReleaseMove", "MoveClose"); got != tc.want { |
| + t.Fatalf("release ordering=%v", got) |
| + } |
| + } |
| +} |
diff --git a/internal/console/testdata/claim-authority-sites.txt b/internal/console/testdata/claim-authority-sites.txt| index 916e5cc68..96aaeeb23 100644 |
| --- a/internal/console/testdata/claim-authority-sites.txt |
| +++ b/internal/console/testdata/claim-authority-sites.txt |
| @@ -21,10 +21,16 @@ |
| ../hostops/mutation_boundary.go|NewMutationBoundary|NewObservedMutationBoundary(enabled, raw, check, nil) |
| ../hostops/production_held_start.go|startAppLifecycle|BeginPurge(id, by, a.Now(), regions, boxes) |
| ../hostops/purge_control.go|DrainPurgeRegistrations|w.SavePurgeJournal(ctx, j, tag) |
| +../hostops/purge_control.go|DrainPurgeRegistrations|w.SavePurgeJournal(ctx, j, tag) |
| +../hostops/purge_control.go|DrainPurgeRegistrations|w.SavePurgeJournal(ctx, j, tag) |
| ../hostops/purge_fences.go|CompletePurgeFences|c.Control.RemovePurgeJournal(ctx, account, app, id) |
| ../hostops/purge_fences.go|CompletePurgeFences|c.Control.SavePurgeJournal(ctx, fresh, tag) |
| +../hostops/purge_fences.go|CompletePurgeFences|c.Control.SavePurgeJournal(ctx, fresh, tag) |
| ../hostops/purge_hosts.go|CensusPurge|c.Control.SavePurgeJournal(ctx, j, tag) |
| ../hostops/purge_hosts.go|ClaimPurgeHosts|c.Control.SavePurgeJournal(ctx, fresh, tag) |
| +../hostops/purge_hosts.go|ClaimPurgeHosts|c.Control.SavePurgeJournal(ctx, fresh, tag) |
| +../hostops/purge_hosts.go|RetirePurgeHosts|c.Control.SavePurgeJournal(ctx, fresh, tag) |
| +../hostops/purge_hosts.go|RetirePurgeHosts|c.Control.SavePurgeJournal(ctx, fresh, tag) |
| ../hostops/purge_hosts.go|RetirePurgeHosts|c.Control.SavePurgeJournal(ctx, fresh, tag) |
| ../hostops/purge_journal.go|DeletePurgeSweep|w.SavePurgeJournal(ctx, fresh, tag) |
| ../hostops/purge_journal.go|PreparePurgeSweep|w.SavePurgeJournal(ctx, j, tag) |
| @@ -66,8 +72,12 @@ claim_purge.go|Resume|s.store.WithAppMutation(ctx, []sqlGrant{permitGrant(ctx, s |
| claim_purge.go|Resume|s.store.WithAppMutation(ctx, []sqlGrant{permitGrant(ctx, s.cfg.Releases, account, scope)}, func(st *Store) error { return st.BindClaimAppOperation(acct.ID, app, appID, id, revision) }) |
| claim_purge.go|Resume|w.BeginPurge(ctx, account, app, appID, id, p.Owner) |
| claim_purge.go|Resume|w.RemovePurgeJournal(ctx, account, app, id) |
| +claim_purge.go|Resume|w.RemovePurgeJournal(ctx, account, app, id) |
| +claim_purge.go|Resume|w.SavePurgeJournal(ctx, fresh, tag) |
| claim_purge.go|Resume|w.SavePurgeJournal(ctx, fresh, tag) |
| claim_purge.go|Resume|w.SavePurgeJournal(ctx, j, tag) |
| +claim_purge.go|Resume|w.SavePurgeJournal(ctx, j, tag) |
| +claim_purge.go|Resume|w.SavePurgeJournal(ctx, j, tag) |
| claim_purge.go|captureDomains|w.SavePurgeJournal(ctx, j, tag) |
| claim_purge.go|teardown|w.SavePurgeJournal(ctx, j, tag) |
| claim_relay.go|relayAppMutation|release.WithAppMutation(ctx, s.cfg.Releases, acct, app, action, fn) |
diff --git a/internal/host/claimoperations.go b/internal/host/claimoperations.go| index 3b31b9d96..de67e118a 100644 |
| --- a/internal/host/claimoperations.go |
| +++ b/internal/host/claimoperations.go |
| @@ -193,3 +193,14 @@ func claimSpawnAction(c *hostclaim.Claim) string { |
| } |
| return "spawn" |
| } |
| + |
| +func (w *ClaimWakeAssembly) recoveryAttemptsReady() bool { |
| + for _, kind := range []string{hostclaim.KindRestoring, hostclaim.KindReverting} { |
| + ops, ready := w.operationOwner(hostclaim.Observed{Claim: &hostclaim.Claim{Op: hostclaim.Op{Kind: kind}}}) |
| + provider, ok := ops.(interface{ RecoveryProbeAttemptsReady() bool }) |
| + if !ok || !provider.RecoveryProbeAttemptsReady() || ready == nil { |
| + return false |
| + } |
| + } |
| + return true |
| +} |
diff --git a/internal/host/claimwakeassembly.go b/internal/host/claimwakeassembly.go| index 5b468ab17..6c64291ce 100644 |
| --- a/internal/host/claimwakeassembly.go |
| +++ b/internal/host/claimwakeassembly.go |
| @@ -201,7 +201,7 @@ func (w *ClaimWakeAssembly) Build(ctx context.Context, source hostbox.WorkSource |
| read := func(ctx context.Context, h string) (hostclaim.Observed, error) { |
| return hostclaim.Read(ctx, writer.Claims, h) |
| } |
| - runtime := &activator.ClaimRuntime{Box: writer.Box, Read: read} |
| + runtime := &activator.ClaimRuntime{Box: writer.Box, Read: read, RecoveryAttemptsReady: w.recoveryAttemptsReady} |
| runtime.StopForeign = func(ctx context.Context, h string) error { |
| o, err := read(ctx, h) |
| if err != nil { |
diff --git a/internal/host/movesprobeattempts.go b/internal/host/movesprobeattempts.go| index 29239567b..98dc5ae7f 100644 |
| --- a/internal/host/movesprobeattempts.go |
| +++ b/internal/host/movesprobeattempts.go |
| @@ -19,7 +19,7 @@ import ( |
| ) |
| // movesProbeAttempts is the moves OperationAttempts implementation for a |
| -// landing probe (moves Phase 1 Plan 3 Task 5b; claim Plan 9 Group D, D2; |
| +// landing or recovery probe (moves Phase 1 Plan 3 Task 5b; claim Plan 9 Group D, D2; |
| // rulings H2, H10). The claim binds its methods into the admitted probe's |
| // activator.ClaimStart; this type journals, counts and settles the probe's |
| // starts in the box-local op journal (moves.Journal, <DataDir>/.moves/<host>/ |
| @@ -76,6 +76,7 @@ type movesProbeAttempts struct { |
| // session is bound to. |
| type boundAttempt struct { |
| k int |
| + key string |
| attempt hostclaim.WakeRetryAttempt |
| } |
| @@ -104,14 +105,16 @@ func (p *movesProbeAttempts) valid() bool { |
| return p != nil && p.Claims != nil && p.Records != nil && p.DataDir != "" && p.Admitted != nil && p.NewID != nil && p.Now != nil |
| } |
| -// bound is every method's check: it returns the record's attempt index k for |
| -// the admitted probe s, or 0 for a committed wake (Plan 3 Task 7), or a |
| -// refusal. |
| +// bound returns the landing record attempt, zero for a committed wake, |
| +// or -1 for a recovery probe. Every case validates the admitted writer. |
| func (p *movesProbeAttempts) bound(ctx context.Context, s OperationStart) (int, error) { |
| if !p.valid() { |
| return 0, hostwrite.ErrUnavailable |
| } |
| a := s.Admitted |
| + if a.Claim != nil && claimRecoveryKind(a.Claim.Op.Kind) { |
| + return -1, p.boundRecovery(ctx, s) |
| + } |
| if a.Claim == nil || a.Claim.Holder == nil || a.Claim.Op.Kind != hostclaim.KindLanding { |
| return 0, hostbox.ErrUnsupported |
| } |
| @@ -178,8 +181,12 @@ func (p *movesProbeAttempts) boundCommitted(ctx context.Context, s OperationStar |
| } |
| // startKey names a start's journal step: the record attempt k for a probe, |
| -// the admitted session for a committed wake (k 0). |
| +// the admitted session for a committed wake (k 0), or the recovery and |
| +// admitted session for a recovery probe (k -1). |
| func startKey(s OperationStart, k int) string { |
| + if k < 0 { |
| + return recoveryAttemptKey(s) + "-" + strconv.FormatUint(s.Owner.Session, 10) |
| + } |
| if k == 0 { |
| return "committed-" + strconv.FormatUint(s.Owner.Session, 10) |
| } |
| @@ -198,8 +205,8 @@ func probeKey(s OperationStart) string { |
| // Absent, it is written into j when write is set (the caller writes j) and |
| // refused otherwise. |
| func bindSession(j *moves.Journal, s OperationStart, k int, write bool) error { |
| - if k == 0 { |
| - return nil // a committed wake has no record attempt |
| + if k <= 0 { |
| + return nil // committed wakes and recoveries have no landing record attempt |
| } |
| step := probeStepSession + strconv.FormatUint(s.Owner.Session, 10) |
| raw, ok := j.Steps[step] |
| @@ -236,7 +243,7 @@ func (p *movesProbeAttempts) OpenProbe(ctx context.Context, s OperationStart, pr |
| if err := bindSession(j, s, k, true); err != nil { |
| return time.Time{}, err |
| } |
| - step := probeStepOpened + strconv.Itoa(k) |
| + step := probeStepOpened + startKey(s, k) |
| if raw, ok := j.Steps[step]; ok { |
| if err := j.Write(p.DataDir); err != nil { |
| return time.Time{}, err |
| @@ -335,6 +342,16 @@ func (p *movesProbeAttempts) Reserve(ctx context.Context, s OperationStart, n in |
| } |
| } |
| if st.Attempt.ID == "" { |
| + if k < 0 { |
| + count, err := recoveryAttemptCount(j, s) |
| + if err != nil { |
| + return err |
| + } |
| + if count >= recoveryProbeAttempts { |
| + return ErrRecoveryProbeExhausted |
| + } |
| + j.Mark(recoveryAttemptKey(s), strconv.Itoa(count+1)) |
| + } |
| o := s.Owner |
| st.Attempt = hostclaim.WakeRetryAttempt{ID: p.NewID(), Box: o.Box, Enrollment: o.Enrollment, BootID: o.BootID, ControllerID: o.ControllerID, Session: o.Session, InvocationID: p.NewID(), LaunchInputs: digest} |
| if st.Attempt.ID == "" || st.Attempt.InvocationID == "" || st.Attempt.ID == st.Attempt.InvocationID { |
| @@ -347,7 +364,7 @@ func (p *movesProbeAttempts) Reserve(ctx context.Context, s OperationStart, n in |
| if p.current == nil { |
| p.current = map[string]boundAttempt{} |
| } |
| - p.current[probeKey(s)] = boundAttempt{k: k, attempt: st.Attempt} |
| + p.current[probeKey(s)] = boundAttempt{k: k, key: startKey(s, k), attempt: st.Attempt} |
| return nil |
| } |
| @@ -368,7 +385,7 @@ func (p *movesProbeAttempts) CurrentAttempt(s OperationStart) hostclaim.WakeRetr |
| } |
| ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) |
| defer cancel() |
| - if k, err := p.bound(ctx, s); err != nil || k != cur.k { |
| + if k, err := p.bound(ctx, s); err != nil || k != cur.k || startKey(s, k) != cur.key { |
| return hostclaim.WakeRetryAttempt{} |
| } |
| return cur.attempt |
| @@ -404,7 +421,19 @@ func (p *movesProbeAttempts) settle(ctx context.Context, s OperationStart, n int |
| return hostbox.ErrChanged |
| } |
| st.State = state |
| - return writeStart(p.DataDir, j, startKey(s, k), st) |
| + if err := writeStart(p.DataDir, j, startKey(s, k), st); err != nil { |
| + return err |
| + } |
| + if k < 0 && state == probeFailed { |
| + count, err := recoveryAttemptCount(j, s) |
| + if err != nil { |
| + return err |
| + } |
| + if count >= recoveryProbeAttempts { |
| + return ErrRecoveryProbeExhausted |
| + } |
| + } |
| + return nil |
| } |
| // Healthy accepts start n after readiness, replication and publication: |
| @@ -468,3 +497,64 @@ func (p *movesProbeAttempts) Healthy(ctx context.Context, s OperationStart, n in |
| func probeAlive(cmd *exec.Cmd) bool { |
| return cmd != nil && cmd.Process != nil && cmd.Process.Signal(syscall.Signal(0)) == nil |
| } |
| + |
| +// RecoveryProbeAttemptsReady is checked before the adapter records inputs. |
| +func (p *movesProbeAttempts) RecoveryProbeAttemptsReady() bool { return p.valid() } |
| + |
| +func (p *movesProbeAttempts) boundRecovery(ctx context.Context, s OperationStart) error { |
| + a := s.Admitted |
| + if a.Claim.Holder == nil || !s.Policy.Probe { |
| + return hostbox.ErrChanged |
| + } |
| + h := a.Claim.Holder |
| + if s.Owner.Host != a.Host || s.Owner.Incarnation != h.Incarnation || s.Owner.Stamp != h.Stamp || s.Owner.Session != h.Session || s.Owner.Box != a.Claim.Op.Exec { |
| + return hostbox.ErrChanged |
| + } |
| + fresh, err := hostclaim.Read(ctx, p.Claims, a.Host) |
| + if err != nil { |
| + return err |
| + } |
| + if !claimSameWriter(a, fresh, hostclaim.SessionContinues) || fresh.Claim.Op.Phase != hostclaim.PhaseProbing || fresh.Claim.Op.Exec != s.Owner.Box { |
| + return hostbox.ErrChanged |
| + } |
| + rec, err := p.Records(ctx, h.Account, h.App, a.Host) |
| + if err != nil { |
| + return err |
| + } |
| + if rec == nil || rec.Host != a.Host || rec.Move == nil || rec.Move.ID != a.Claim.Op.ID || rec.Move.Incarnation != h.Incarnation { |
| + return hostbox.ErrChanged |
| + } |
| + m := rec.Move |
| + if a.Claim.Op.Kind == hostclaim.KindReverting { |
| + if m.State != provision.MoveReverting || m.ReopenedAt != nil || fresh.Claim.Op.Journal[hostclaim.JournalRevPhase] != hostclaim.PhaseProbing { |
| + return hostbox.ErrChanged |
| + } |
| + } else if m.State != provision.MoveRollingBack || m.Rollback == nil || m.Rollback.ID == "" || m.Rollback.ID != a.Claim.Op.Intent["rollback"] || m.Rollback.ID != fresh.Claim.Op.Intent["rollback"] || m.Rollback.SourcePhase != provision.RbSourceProbing { |
| + return hostbox.ErrChanged |
| + } |
| + return nil |
| +} |
| + |
| +// The move has its own journal directory. JSON preserves the rollback and |
| +// incarnation boundaries; sessions share this counter across controller restarts. |
| +func recoveryAttemptKey(s OperationStart) string { |
| + c := s.Admitted.Claim |
| + rollback := c.Op.ID |
| + if c.Op.Kind == hostclaim.KindRestoring { |
| + rollback = c.Op.Intent["rollback"] |
| + } |
| + raw, _ := json.Marshal([]string{c.Op.ID, rollback, c.Holder.Incarnation}) |
| + return "recovery_attempts_" + string(raw) |
| +} |
| + |
| +func recoveryAttemptCount(j *moves.Journal, s OperationStart) (int, error) { |
| + raw, ok := j.Steps[recoveryAttemptKey(s)] |
| + if !ok { |
| + return 0, nil |
| + } |
| + n, err := strconv.Atoi(raw) |
| + if err != nil || n < 0 || n > recoveryProbeAttempts { |
| + return 0, hostbox.ErrChanged |
| + } |
| + return n, nil |
| +} |
diff --git a/internal/host/movesprobeattempts_test.go b/internal/host/movesprobeattempts_test.go| index 885421b8f..179cefc81 100644 |
| --- a/internal/host/movesprobeattempts_test.go |
| +++ b/internal/host/movesprobeattempts_test.go |
| @@ -16,6 +16,7 @@ import ( |
| "github.com/carlosframework/platform/internal/hostwrite" |
| "github.com/carlosframework/platform/internal/moves" |
| "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| @@ -372,3 +373,135 @@ func TestMovesProbeAttemptsCommittedWake(t *testing.T) { |
| t.Fatal("a committed wake's start over a landing record") |
| } |
| } |
| + |
| +func recoveryPAFixture(t *testing.T, kind string) *paFixture { |
| + t.Helper() |
| + f := newPAFixture(t) |
| + c := f.read().Claim |
| + c.Op.Kind, c.Op.Phase = kind, hostclaim.PhaseProbing |
| + c.Op.Exec, c.Holder.WriterBox = "ie-box", "ie-box" |
| + c.Op.Journal = map[string]string{hostclaim.JournalRevPhase: hostclaim.PhaseProbing} |
| + f.rec.Move.State = provision.MoveReverting |
| + if kind == hostclaim.KindRestoring { |
| + c.Op.Intent["rollback"] = plan10J2Rollback |
| + f.rec.Move.State = provision.MoveRollingBack |
| + f.rec.Move.Rollback = &provision.MoveRollback{ID: plan10J2Rollback, SourcePhase: provision.RbSourceProbing} |
| + } |
| + f.putClaim(c) |
| + f.start.Admitted = f.read() |
| + f.start.Owner.Box = "ie-box" |
| + f.start.Owner.Phase = hostclaim.PhaseProbing |
| + return f |
| +} |
| + |
| +func TestRecoveryProbeAttemptsDurableBudget(t *testing.T) { |
| + for _, kind := range []string{hostclaim.KindRestoring, hostclaim.KindReverting} { |
| + t.Run(kind, func(t *testing.T) { |
| + f := recoveryPAFixture(t, kind) |
| + for n := 0; n < 9; n++ { |
| + // Three starts per wake, then a new admitted session and provider. |
| + local := n % 3 |
| + if local == 0 && n > 0 { |
| + c := f.read().Claim |
| + c.Gen++ |
| + c.Holder.Session = c.Gen |
| + f.putClaim(c) |
| + f.start.Admitted = f.read() |
| + f.start.Owner.Session = c.Gen |
| + f.ops = f.attempts() |
| + } |
| + if _, err := f.ops.OpenProbe(t.Context(), f.start, time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := f.ops.Reserve(t.Context(), f.start, local); err != nil { |
| + t.Fatalf("start %d: %v", n+1, err) |
| + } |
| + if f.ops.CurrentAttempt(f.start).ID == "" { |
| + t.Fatal("no launch reservation") |
| + } |
| + if local == 0 { |
| + if err := f.ops.Reserve(t.Context(), f.start, 0); err != nil { |
| + t.Fatal("idempotent reservation", err) |
| + } |
| + } |
| + err := f.ops.Failed(t.Context(), f.start, local) |
| + if n == 8 { |
| + if !errors.Is(err, ErrRecoveryProbeExhausted) { |
| + t.Fatalf("ninth failure: %v", err) |
| + } |
| + } else if err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + f.ops = f.attempts() |
| + if err := f.ops.Reserve(t.Context(), f.start, 3); !errors.Is(err, ErrRecoveryProbeExhausted) { |
| + t.Fatalf("restart lost count: %v", err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestRecoveryProbeAttemptsReadinessAndBinding(t *testing.T) { |
| + f := recoveryPAFixture(t, hostclaim.KindRestoring) |
| + w := &ClaimWakeAssembly{Operations: &plan9D2Attempts{}, ProbeReady: func(context.Context, *exec.Cmd, registry.Route, ClaimLaunchInputs, string) error { return nil }} |
| + if w.recoveryAttemptsReady() { |
| + t.Fatal("landing-only provider admitted recovery") |
| + } |
| + w.Operations = f.ops |
| + if !w.recoveryAttemptsReady() { |
| + t.Fatal("recovery provider not ready") |
| + } |
| + w.Operations = claimOperationRouter{hostclaim.KindRestoring: {Attempts: f.ops, ProbeReady: w.ProbeReady}} |
| + if w.recoveryAttemptsReady() { |
| + t.Fatal("missing revert provider admitted") |
| + } |
| + if err := f.ops.Reserve(t.Context(), f.start, 0); err != nil { |
| + t.Fatal(err) |
| + } |
| + oldKey := recoveryAttemptKey(f.start) |
| + c := f.read().Claim |
| + c.Op.Intent["rollback"] = "rb_zzzzzzzzzzzzzzzzzzzzzzzzzz" |
| + f.putClaim(c) |
| + f.start.Admitted = f.read() |
| + f.rec.Move.Rollback.ID = c.Op.Intent["rollback"] |
| + if recoveryAttemptKey(f.start) == oldKey { |
| + t.Fatal("rollback not bound") |
| + } |
| + if got := f.ops.CurrentAttempt(f.start); got.ID != "" { |
| + t.Fatal("reservation escaped its rollback") |
| + } |
| + if err := f.ops.Reserve(t.Context(), f.start, 0); err != nil { |
| + t.Fatal(err) |
| + } |
| + j, err := moves.ReadJournal(f.dataDir, paHost, paOp) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if j.Steps[oldKey] != "1" || j.Steps[recoveryAttemptKey(f.start)] != "1" { |
| + t.Fatal("rollback budgets combined") |
| + } |
| + for _, mutate := range []func(*OperationStart){ |
| + func(s *OperationStart) { s.Admitted.Claim.Holder.Incarnation = "other" }, |
| + func(s *OperationStart) { s.Admitted.Claim.Op.ID = "other" }, |
| + } { |
| + copied := f.start |
| + raw, _ := json.Marshal(f.start.Admitted.Claim) |
| + var claim hostclaim.Claim |
| + _ = json.Unmarshal(raw, &claim) |
| + copied.Admitted.Claim = &claim |
| + mutate(&copied) |
| + if recoveryAttemptKey(copied) == recoveryAttemptKey(f.start) { |
| + t.Fatal("budget key omitted identity") |
| + } |
| + } |
| +} |
| + |
| +func TestRecoveryProbeAttemptsRefuseChangedRollback(t *testing.T) { |
| + f := recoveryPAFixture(t, hostclaim.KindRestoring) |
| + c := f.read().Claim |
| + c.Op.Intent["rollback"] = "rb_zzzzzzzzzzzzzzzzzzzzzzzzzz" |
| + f.putClaim(c) |
| + if err := f.ops.Reserve(t.Context(), f.start, 0); !errors.Is(err, hostbox.ErrChanged) { |
| + t.Fatalf("changed rollback admitted: %v", err) |
| + } |
| +} |
diff --git a/internal/host/movesrecoveryprobe.go b/internal/host/movesrecoveryprobe.go| index 296c5c84f..13dccacb7 100644 |
| --- a/internal/host/movesrecoveryprobe.go |
| +++ b/internal/host/movesrecoveryprobe.go |
| @@ -167,6 +167,12 @@ func (a recoveryProbeAdapter) probe(ctx context.Context, s release.Store, rec pr |
| // budget and the coverage and generation of its return. |
| func recoveryProbeMatches(rec provision.Record, req recoveryProbeRequest) error { |
| m := rec.Move |
| + if m != nil && m.ID != "" && m.State == provision.MoveReverting { |
| + if m.ReopenedAt != nil || req.RollbackID != "" || req.Coverage != "" || req.Generation != "" || !req.Clean || req.Attempts != recoveryProbeAttempts || req.Record == nil { |
| + return errRecoveryProbeRequest |
| + } |
| + return nil |
| + } |
| if m == nil || m.ID == "" || m.State != provision.MoveRollingBack || m.Rollback == nil || m.Rollback.ID != req.RollbackID || m.Rollback.SourcePhase != provision.RbSourceProbing { |
| return fmt.Errorf("%w: not rollback %s at probing", errRecoveryProbeRequest, req.RollbackID) |
| } |
| @@ -188,9 +194,17 @@ func recoveryProbeMatches(rec provision.Record, req recoveryProbeRequest) error |
| // incarnation, naming the request's src_gen. Anything else waits. |
| func (a recoveryProbeAdapter) admits(obs hostclaim.Observed, rec provision.Record, req recoveryProbeRequest) error { |
| c := obs.Claim |
| - if obs.Absent || c == nil || c.Holder == nil || c.Op.Kind != hostclaim.KindRestoring || c.Op.ID != rec.Move.ID || c.Op.Intent["rollback"] != req.RollbackID || |
| - c.Op.Phase != hostclaim.PhaseProbing || c.Op.Exec != a.Box || c.Holder.Incarnation != rec.Move.Incarnation { |
| - return fmt.Errorf("%w: the claim is not rollback %s's probe on %s", hostbox.ErrChanged, req.RollbackID, a.Box) |
| + if obs.Absent || c == nil || c.Holder == nil || c.Op.ID != rec.Move.ID || c.Op.Phase != hostclaim.PhaseProbing || c.Op.Exec != a.Box || c.Holder.Incarnation != rec.Move.Incarnation { |
| + return hostbox.ErrChanged |
| + } |
| + if rec.Move.State == provision.MoveReverting { |
| + if c.Op.Kind != hostclaim.KindReverting || c.Op.Journal[hostclaim.JournalRevPhase] != hostclaim.PhaseProbing || c.Holder.Generation != req.SourceGeneration { |
| + return hostbox.ErrChanged |
| + } |
| + return nil |
| + } |
| + if c.Op.Kind != hostclaim.KindRestoring || c.Op.Intent["rollback"] != req.RollbackID { |
| + return hostbox.ErrChanged |
| } |
| if req.SourceGeneration != "" && c.Op.Journal[hostclaim.JournalSrcGen] != req.SourceGeneration { |
| return fmt.Errorf("%w: the claim's src_gen is not the request's", hostbox.ErrChanged) |
diff --git a/internal/host/movesrevert.go b/internal/host/movesrevert.go| index a7894f5ab..406b0294e 100644 |
| --- a/internal/host/movesrevert.go |
| +++ b/internal/host/movesrevert.go |
| @@ -572,6 +572,10 @@ func (r *revertRun) probing(ctx context.Context, obs hostclaim.Observed) (bool, |
| return err |
| }} |
| out, err := cfg.recoveryProbe(ctx, r.s, r.rec, req) |
| + var missing *ClaimDependencyError |
| + if errors.As(err, &missing) { |
| + return false, nil |
| + } |
| if err != nil { |
| log.Printf("moves: %s: the revert's probe of %s: %v", host, id, err) |
| out = recoveryProbeWaiting |
| @@ -612,6 +616,11 @@ func (r *revertRun) probing(ctx context.Context, obs hostclaim.Observed) (bool, |
| if err != nil { |
| return false, err |
| } |
| + // The probe wrote its attempt count into the same journal. |
| + j, err = moves.ReadJournal(cfg.DataDir, host, id) |
| + if err != nil { |
| + return false, err |
| + } |
| j.Mark(rvStepProbeExhausted, string(body)) |
| if err := j.Write(cfg.DataDir); err != nil { |
| return false, err |
diff --git a/internal/host/plan10_j2_adapter_test.go b/internal/host/plan10_j2_adapter_test.go| index 1350c3864..159aac957 100644 |
| --- a/internal/host/plan10_j2_adapter_test.go |
| +++ b/internal/host/plan10_j2_adapter_test.go |
| @@ -13,6 +13,7 @@ import ( |
| "github.com/carlosframework/platform/internal/activator" |
| "github.com/carlosframework/platform/internal/hostbox" |
| "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostguard" |
| "github.com/carlosframework/platform/internal/hostwrite" |
| "github.com/carlosframework/platform/internal/lease" |
| "github.com/carlosframework/platform/internal/moves" |
| @@ -72,8 +73,9 @@ func plan10J2AdapterClaim(t *testing.T, f *recoveryFixture, phase, srcGen string |
| // probe readiness to the admitted session. The held render (Materialize) |
| // follows the record. |
| func TestPlan10J2AdapterRecordsInputsBeforeTheGroupDProbe(t *testing.T) { |
| - for _, dirty := range []bool{false, true} { |
| - t.Run(fmt.Sprintf("dirty=%v", dirty), func(t *testing.T) { |
| + for _, tc := range []struct{ dirty, revert bool }{{}, {dirty: true}, {revert: true}} { |
| + dirty := tc.dirty |
| + t.Run(fmt.Sprintf("dirty=%v/revert=%v", dirty, tc.revert), func(t *testing.T) { |
| f := newRecoveryFixture(t) |
| inc := plan10J2AdapterClaim(t, f, hostclaim.PhaseProbing, "src-gen") |
| c := f.controller("boot-1", "controller-1", 4101, "start-1") |
| @@ -82,9 +84,33 @@ func TestPlan10J2AdapterRecordsInputsBeforeTheGroupDProbe(t *testing.T) { |
| c.wake.ProbeReady = func(context.Context, *exec.Cmd, registry.Route, ClaimLaunchInputs, string) error { return nil } |
| rec := plan10J2AdapterRecord(inc, dirty) |
| var events []string |
| + req := plan10J2AdapterRequest(&rec, "src-gen", &events) |
| + kind := hostclaim.KindRestoring |
| + if tc.revert { |
| + kind = hostclaim.KindReverting |
| + o, err := hostclaim.Update(t.Context(), f.claims, f.read(), func(c *hostclaim.Claim) error { |
| + c.Op = plan10J2Op(hostclaim.KindReverting, hostclaim.PhaseProbing) |
| + c.Op.Journal = map[string]string{hostclaim.JournalRevPhase: hostclaim.PhaseProbing} |
| + return nil |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + rec.Move.State, rec.Move.Rollback = provision.MoveReverting, nil |
| + req = recoveryProbeRequest{Clean: true, SourceGeneration: o.Claim.Holder.Generation, Attempts: recoveryProbeAttempts, Record: func(_ context.Context, in provision.MoveInputs) error { |
| + events = append(events, "record") |
| + return provision.MoveRecordRevertInputs(provision.MoveGuard{ID: rec.Move.ID, Incarnation: inc}, in)(&rec) |
| + }} |
| + } |
| + recorded := func() *provision.MoveInputs { |
| + if tc.revert { |
| + return rec.Move.RecoveryInputs |
| + } |
| + return rec.Move.Rollback.RecoveryInputs |
| + } |
| f.materialize = func(context.Context, hostclaim.Observed) error { |
| events = append(events, "render") |
| - if rec.Move.Rollback.RecoveryInputs == nil { |
| + if recorded() == nil { |
| t.Error("rendered before the inputs were recorded") |
| } |
| return nil |
| @@ -93,8 +119,8 @@ func TestPlan10J2AdapterRecordsInputsBeforeTheGroupDProbe(t *testing.T) { |
| wake := func(ctx context.Context, rt registry.Route, p activator.StartPolicy) error { |
| events = append(events, "wake") |
| policies = append(policies, p) |
| - if rec.Move.Rollback.RecoveryInputs == nil || p.Inputs == nil || !reflect.DeepEqual(*p.Inputs, *rec.Move.Rollback.RecoveryInputs) { |
| - t.Errorf("the wake does not carry the recorded inputs: %+v / %+v", p.Inputs, rec.Move.Rollback.RecoveryInputs) |
| + if recorded() == nil || p.Inputs == nil || !reflect.DeepEqual(*p.Inputs, *recorded()) { |
| + t.Errorf("the wake does not carry the recorded inputs: %+v / %+v", p.Inputs, recorded()) |
| } |
| if err := c.wake.admitsOperation(f.read(), p, "box-1"); err != nil { |
| return err |
| @@ -130,7 +156,7 @@ func TestPlan10J2AdapterRecordsInputsBeforeTheGroupDProbe(t *testing.T) { |
| Current: func(context.Context, release.Store, provision.Record) (provision.MoveInputs, error) { |
| return plan10J2AdapterInputs(), nil |
| }} |
| - out, err := a.probe(t.Context(), f.claims, rec, plan10J2AdapterRequest(&rec, "src-gen", &events)) |
| + out, err := a.probe(t.Context(), f.claims, rec, req) |
| if err != nil || out != recoveryProbeAnswered { |
| t.Fatalf("probe: %v %v", out, err) |
| } |
| @@ -144,7 +170,7 @@ func TestPlan10J2AdapterRecordsInputsBeforeTheGroupDProbe(t *testing.T) { |
| t.Fatalf("attempt journal calls: %s", got) |
| } |
| for _, s := range ops.starts { |
| - if !s.Policy.Probe || s.Admitted.Claim.Op.Kind != hostclaim.KindRestoring || s.Admitted.Claim.Op.Phase != hostclaim.PhaseProbing || !reflect.DeepEqual(*s.Policy.Inputs, plan10J2AdapterInputs()) { |
| + if !s.Policy.Probe || s.Admitted.Claim.Op.Kind != kind || s.Admitted.Claim.Op.Phase != hostclaim.PhaseProbing || !reflect.DeepEqual(*s.Policy.Inputs, plan10J2AdapterInputs()) { |
| t.Fatalf("attempt bound to %+v", s) |
| } |
| } |
| @@ -389,3 +415,149 @@ func TestPlan10J2ArmPhase1ConnectsRecoveryOnlyWithClaimFlag(t *testing.T) { |
| }) |
| } |
| } |
| + |
| +func TestPlan10J2RevertRunUsesProductionAdapter(t *testing.T) { |
| + h := newRevertHarness(t) |
| + h.fromStopped(t) |
| + h.arm(t) |
| + wakes := 0 |
| + a := recoveryProbeAdapter{Box: "ie-box", Claims: true, |
| + Route: func(string) (*registry.Route, error) { return &h.route, nil }, |
| + Current: func(context.Context, release.Store, provision.Record) (provision.MoveInputs, error) { |
| + return revertRendered(), nil |
| + }, |
| + Wake: func(ctx context.Context, rt registry.Route, p activator.StartPolicy) error { |
| + wakes++ |
| + m := h.move(t) |
| + if m.RecoveryInputs == nil || !reflect.DeepEqual(*m.RecoveryInputs, *p.Inputs) { |
| + t.Fatal("wake before revert inputs") |
| + } |
| + c := h.claim(t) |
| + if c.Op.Kind != hostclaim.KindReverting || c.Op.Phase != hostclaim.PhaseProbing || c.Op.Journal[hostclaim.JournalRevPhase] != hostclaim.PhaseProbing { |
| + t.Fatal("wrong claim") |
| + } |
| + return nil |
| + }} |
| + h.cfg.recoveryProbe = a.probe |
| + h.pass(t) |
| + if wakes != 1 { |
| + t.Fatalf("wakes=%d", wakes) |
| + } |
| + h.reopened(t) |
| +} |
| + |
| +func TestPlan10J2RevertMissingProviderWaitsWithoutInputsOrWake(t *testing.T) { |
| + h := newRevertHarness(t) |
| + h.fromStopped(t) |
| + h.arm(t) |
| + calls := 0 |
| + 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) { |
| + calls++ |
| + return revertRendered(), nil |
| + }, Wake: func(context.Context, registry.Route, activator.StartPolicy) error { calls++; return nil }} |
| + h.cfg.recoveryProbe = a.probe |
| + h.pass(t) |
| + before := h.move(t).UpdatedAt |
| + h.clock.Advance(16 * time.Minute) |
| + h.pass(t) |
| + m := h.move(t) |
| + if calls != 0 || m.RecoveryInputs != nil || m.State != provision.MoveReverting || !m.UpdatedAt.Equal(before) { |
| + t.Fatalf("unbound provider changed recovery: calls=%d move=%+v", calls, m) |
| + } |
| +} |
| + |
| +func TestPlan10J2NinthFailedStartSticksAndKeepsJournal(t *testing.T) { |
| + h := newRevertHarness(t) |
| + h.fromStopped(t) |
| + h.arm(t) |
| + starts, ids := 0, 0 |
| + var last OperationStart |
| + provider := func() *movesProbeAttempts { |
| + return &movesProbeAttempts{Claims: h.s, DataDir: h.cfg.DataDir, |
| + Records: func(ctx context.Context, account, app, host string) (*provision.Record, error) { |
| + return provision.GetRecord(ctx, h.s, account, app, host) |
| + }, |
| + Admitted: func(context.Context, *provision.Record) (int, error) { return 9, nil }, |
| + NewID: func() string { ids++; return fmt.Sprintf("attempt-%d", ids) }, Now: time.Now} |
| + } |
| + 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) { |
| + return revertRendered(), nil |
| + }, |
| + Wake: func(ctx context.Context, rt registry.Route, p activator.StartPolicy) error { |
| + o, err := hostclaim.Read(ctx, h.s, rt.Host) |
| + if err != nil { |
| + return err |
| + } |
| + holder := o.Claim.Holder |
| + last = OperationStart{Admitted: o, Policy: p, Owner: hostguard.FenceTarget{Host: rt.Host, Incarnation: holder.Incarnation, Stamp: holder.Stamp, Session: holder.Session, Box: "ie-box"}} |
| + ops := provider() |
| + for i := 0; i < 3; i++ { |
| + if err := ops.Reserve(ctx, last, starts); err != nil { |
| + return err |
| + } |
| + starts++ |
| + if err := ops.Failed(ctx, last, starts-1); err != nil { |
| + return err |
| + } |
| + } |
| + return moves.ErrAttemptOver |
| + }} |
| + h.cfg.recoveryProbe = a.probe |
| + for i := 0; i < 3; i++ { |
| + h.pass(t) |
| + } |
| + m := h.move(t) |
| + if starts != 9 || m.State != provision.MoveStuck || m.StuckReason != provision.StuckRecoveryProbeAttempts { |
| + t.Fatalf("starts=%d move=%+v", starts, m) |
| + } |
| + j, err := moves.ReadJournal(h.cfg.DataDir, p1Host, p1ID) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if count, err := recoveryAttemptCount(j, last); err != nil || count != 9 { |
| + t.Fatalf("journal count=%d err=%v", count, err) |
| + } |
| +} |
| + |
| +func TestPlan10J2RevertAdapterRejectsChangedBinding(t *testing.T) { |
| + for _, tc := range []string{"reopened", "generation", "rev_phase", "kind", "executor"} { |
| + t.Run(tc, func(t *testing.T) { |
| + f := newRecoveryFixture(t) |
| + o, err := hostclaim.Update(t.Context(), f.claims, f.read(), func(c *hostclaim.Claim) error { |
| + c.Op = plan10J2Op(hostclaim.KindReverting, hostclaim.PhaseProbing) |
| + c.Op.Journal = map[string]string{hostclaim.JournalRevPhase: hostclaim.PhaseProbing} |
| + switch tc { |
| + case "rev_phase": |
| + c.Op.Journal[hostclaim.JournalRevPhase] = hostclaim.PhaseProbed |
| + case "kind": |
| + c.Op.Kind = hostclaim.KindRestoring |
| + case "executor": |
| + c.Op.Exec = "box-2" |
| + } |
| + return nil |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + rec := provision.Record{Host: recoveryHost, Move: &provision.MoveState{ID: o.Claim.Op.ID, Incarnation: o.Claim.Holder.Incarnation, State: provision.MoveReverting}} |
| + 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 }} |
| + if tc == "generation" { |
| + req.SourceGeneration = "other" |
| + } |
| + if tc == "reopened" { |
| + now := time.Now() |
| + rec.Move.ReopenedAt = &now |
| + } |
| + 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) { |
| + t.Fatal("selected invalid revert") |
| + return provision.MoveInputs{}, nil |
| + }, Wake: func(context.Context, registry.Route, activator.StartPolicy) error { |
| + t.Fatal("woke invalid revert") |
| + return nil |
| + }} |
| + if _, err := a.probe(t.Context(), f.claims, rec, req); err == nil { |
| + t.Fatal("invalid revert accepted") |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/hostclaim/lossrecord.go b/internal/hostclaim/lossrecord.go| index fce0867be..3e496aa6b 100644 |
| --- a/internal/hostclaim/lossrecord.go |
| +++ b/internal/hostclaim/lossrecord.go |
| @@ -338,7 +338,7 @@ func CreateLossRecord(ctx context.Context, s release.Store, sg release.Signer, r |
| // proving that the accepted evidence and consequences prepared earlier are |
| // byte-for-byte unchanged. |
| func ReplaceLossRecord(ctx context.Context, s release.Store, sg release.Signer, before ObservedLossRecord, next LossRecord) (ObservedLossRecord, error) { |
| - if before.ETag == "" || before.Record.Validate() != nil || next.Validate() != nil || !immutableLossFieldsEqual(before.Record, next) || !validLossProgression(before.Record, next) { |
| + if err := ValidateLossRecordReplacement(before, next); err != nil { |
| return ObservedLossRecord{}, ErrLossRecord |
| } |
| key, _ := LossRecordKey(next.ID) |
| @@ -384,3 +384,12 @@ func validLossProgression(before, next LossRecord) bool { |
| // IsDestroyedLocalOnly reports the complete attested consequence shape, not |
| // merely its intent marker. Owners and durable records use the same predicate. |
| func IsDestroyedLocalOnly(c LossConsequences) bool { return destroyedLocalOnly(c) } |
| + |
| +// ValidateLossRecordReplacement also guards scoped transports that receive |
| +// signed bytes directly, without passing through ReplaceLossRecord. |
| +func ValidateLossRecordReplacement(before ObservedLossRecord, next LossRecord) error { |
| + if before.ETag == "" || before.Record.Validate() != nil || next.Validate() != nil || !immutableLossFieldsEqual(before.Record, next) || !validLossProgression(before.Record, next) { |
| + return ErrLossRecord |
| + } |
| + return nil |
| +} |
diff --git a/internal/hostops/move_loss_grant.go b/internal/hostops/move_loss_grant.go| index 03abad6ed..4b7eaee01 100644 |
| --- a/internal/hostops/move_loss_grant.go |
| +++ b/internal/hostops/move_loss_grant.go |
| @@ -5,6 +5,7 @@ import ( |
| "context" |
| "crypto/ed25519" |
| "encoding/json" |
| + "errors" |
| "reflect" |
| "slices" |
| "time" |
| @@ -36,7 +37,7 @@ import ( |
| // lossBinding is one declaration's scope. |
| type lossBinding struct { |
| - lossID, host, opID, kind, box, claimETag, incarnation, account, app string |
| + lossID, host, opID, kind, box, claimETag, incarnation, account, app, bucket string |
| } |
| // moveLossService is the service a typed method runs, with the moves owner |
| @@ -133,7 +134,7 @@ func (w *ControlWriter) DeclareMoveLoss(ctx context.Context, signer release.Sign |
| // The operation has moved on: only an accepted declaration replays. |
| return hostclaim.Observed{}, hostresolve.ErrResolve |
| } |
| - b := lossBinding{lossID: c.ID, host: host, opID: c.Op.ID, kind: c.Op.Kind, box: c.Box, claimETag: c.ClaimETag, incarnation: c.HolderIncarnation, account: account, app: app} |
| + b := lossBinding{bucket: bucket, lossID: c.ID, host: host, opID: c.Op.ID, kind: c.Op.Kind, box: c.Box, claimETag: c.ClaimETag, incarnation: c.HolderIncarnation, account: account, app: app} |
| scoped := &lossScopedStore{raw: w.raw, pub: w.pub, now: w.now, b: b} |
| if _, err := moveLossService(scoped, w.pub, signer, bucket, w.now).Declare(ctx, actor, host, in); err != nil { |
| return hostclaim.Observed{}, err |
| @@ -191,7 +192,7 @@ func (s *lossScopedStore) Put(ctx context.Context, key string, body []byte, etag |
| case key == "control/host-loss-acceptances/"+s.b.lossID+".json": |
| err = s.acceptance(ctx, body, etag) |
| case key == lossKey: |
| - err = s.lossRecord(body) |
| + err = s.lossRecord(ctx, body, etag) |
| case key == hostactors.Key: |
| err = s.quarantine(ctx, body, etag) |
| case key == claimKey: |
| @@ -247,18 +248,37 @@ func (s *lossScopedStore) acceptance(ctx context.Context, body []byte, etag stri |
| RosterETag string `json:"roster_etag"` |
| Loss hostclaim.LossRecord `json:"loss"` |
| } |
| - if release.VerifyRootEnvelope(s.pub, body, &a) != nil || a.V != 1 || a.RosterETag == "" || a.Loss.State != "prepared" || !s.bound(a.Loss) { |
| + if release.VerifyRootEnvelope(s.pub, body, &a) != nil || a.V != 1 || s.b.bucket == "" || a.Bucket != s.b.bucket || a.RosterETag == "" || a.Loss.State != "prepared" || !s.bound(a.Loss) { |
| return errMoveLossScope |
| } |
| _, err := s.registered(ctx) |
| return err |
| } |
| -func (s *lossScopedStore) lossRecord(body []byte) error { |
| +func (s *lossScopedStore) lossRecord(ctx context.Context, body []byte, etag string) error { |
| var r hostclaim.LossRecord |
| if release.VerifyRootEnvelope(s.pub, body, &r) != nil || !s.bound(r) { |
| return errMoveLossScope |
| } |
| + if _, err := s.registered(ctx); err != nil { |
| + return err |
| + } |
| + before, err := hostclaim.ReadLossRecord(ctx, s.raw, s.pub, s.b.lossID) |
| + if errors.Is(err, hostclaim.ErrLossRecordAbsent) { |
| + if etag != "" || r.State != "prepared" { |
| + return errMoveLossScope |
| + } |
| + return nil |
| + } |
| + if err != nil { |
| + return err |
| + } |
| + if before.ETag != etag { |
| + return release.ErrCASConflict |
| + } |
| + if hostclaim.ValidateLossRecordReplacement(before, r) != nil { |
| + return errMoveLossScope |
| + } |
| return nil |
| } |
diff --git a/internal/hostops/move_loss_scope_fix_test.go b/internal/hostops/move_loss_scope_fix_test.go| new file mode 100644 |
| index 000000000..66922f47f |
| --- /dev/null |
| +++ b/internal/hostops/move_loss_scope_fix_test.go |
| @@ -0,0 +1,181 @@ |
| +package hostops |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +type lossScopeCapture struct { |
| + release.Store |
| + w *lossWorld |
| + state, key, id string |
| + body []byte |
| + etag string |
| + ctx context.Context |
| +} |
| + |
| +func (s *lossScopeCapture) Put(ctx context.Context, key string, body []byte, etag string) (string, error) { |
| + if key == s.key { |
| + loss, err := hostclaim.ReadLossRecord(ctx, s.Store, s.w.pub, s.id) |
| + if err == nil && loss.Record.State == s.state { |
| + s.ctx = ctx |
| + s.body = append([]byte(nil), body...) |
| + s.etag = etag |
| + return "", errors.New("captured permitted write") |
| + } |
| + } |
| + return s.Store.Put(ctx, key, body, etag) |
| +} |
| + |
| +func scopedLossFixture(t *testing.T, state, kind string) (*lossWorld, *lossScopedStore, *lossScopeCapture) { |
| + t.Helper() |
| + w := newLossWorld(t, "landing", nil) |
| + sub := w.submission(w.inspect(t, "uk-box")) |
| + o := w.claim(t) |
| + key := w.key |
| + if kind == "claim" { |
| + key = w.claimK |
| + } |
| + capture := &lossScopeCapture{Store: w.raw, w: w, state: state, key: key, id: sub.Confirmation} |
| + cw, err := NewControlWriter(capture, w.pub, func() time.Time { return w.clock }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + _, err = cw.DeclareMoveLoss(t.Context(), w.signer, lossBucket, "operator", "bab", "app", lossHost, "uk-box", sub) |
| + if err == nil || capture.body == nil { |
| + t.Fatalf("write not captured: %v", err) |
| + } |
| + loss, err := hostclaim.ReadLossRecord(t.Context(), w.raw, w.pub, sub.Confirmation) |
| + if err != nil || loss.Record.State != state { |
| + t.Fatalf("signed declaration: %+v %v", loss, err) |
| + } |
| + s := &lossScopedStore{raw: w.raw, pub: w.pub, now: func() time.Time { return w.clock }, b: lossBinding{bucket: lossBucket, lossID: sub.Confirmation, host: lossHost, opID: lossOp, kind: hostclaim.KindLanding, box: "uk-box", claimETag: o.ETag, incarnation: w.inc, account: "bab", app: "app"}} |
| + return w, s, capture |
| +} |
| + |
| +func TestJ4ScopedWritesComparePreparedAndCommittedTransitions(t *testing.T) { |
| + for _, state := range []string{"prepared", "committed"} { |
| + for _, kind := range []string{"claim", "record"} { |
| + t.Run(state+"/"+kind, func(t *testing.T) { |
| + _, s, capture := scopedLossFixture(t, state, kind) |
| + if _, err := s.Put(capture.ctx, capture.key, capture.body, capture.etag); err != nil { |
| + t.Fatalf("permitted write refused: %v", err) |
| + } |
| + for _, field := range []string{"identity", "transition", "sequence"} { |
| + t.Run(field, func(t *testing.T) { |
| + w, s, capture := scopedLossFixture(t, state, kind) |
| + var altered map[string]any |
| + dec := json.NewDecoder(strings.NewReader(string(capture.body))) |
| + dec.UseNumber() |
| + if err := dec.Decode(&altered); err != nil { |
| + t.Fatal(err) |
| + } |
| + if kind == "claim" { |
| + switch field { |
| + case "identity": |
| + altered["holder"].(map[string]any)["account"] = "other" |
| + case "transition": |
| + altered["op"].(map[string]any)["exec"] = "foreign" |
| + case "sequence": |
| + altered["gen"] = 99999 |
| + } |
| + } else { |
| + switch field { |
| + case "identity": |
| + altered["account"] = "other" |
| + case "transition": |
| + altered["move"].(map[string]any)["reason"] = "other" |
| + case "sequence": |
| + altered["record_seq"] = 99999 |
| + } |
| + } |
| + body, err := json.Marshal(altered) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + before := lossSnapshot(t, w) |
| + if _, err = s.Put(capture.ctx, capture.key, body, capture.etag); !errors.Is(err, errMoveLossScope) { |
| + t.Fatalf("expected scope comparison refusal, got %v", err) |
| + } |
| + if lossSnapshot(t, w) != before { |
| + t.Fatal("altered write landed") |
| + } |
| + if _, err = s.Put(capture.ctx, capture.key, capture.body, capture.etag); err != nil { |
| + t.Fatalf("equivalent valid write refused after negative: %v", err) |
| + } |
| + }) |
| + } |
| + }) |
| + } |
| + } |
| +} |
| + |
| +func TestJ4ScopedLossRecordRejectsRegressionAndEvidenceChanges(t *testing.T) { |
| + for _, change := range []string{"regress", "evidence", "registration"} { |
| + t.Run(change, func(t *testing.T) { |
| + w, s, capture := scopedLossFixture(t, "committed", "claim") |
| + loss, err := hostclaim.ReadLossRecord(t.Context(), w.raw, w.pub, capture.id) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + next := loss.Record |
| + switch change { |
| + case "regress": |
| + next.State = "prepared" |
| + next.ResultClaimGen = 0 |
| + case "evidence": |
| + next.Actor = "another-operator" |
| + case "registration": |
| + // Removing the app admission ends the scoped transport's authority. |
| + key, _ := AdmissionKey("bab", "app") |
| + if err := w.raw.Delete(t.Context(), key); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + body, err := release.SignRootEnvelope(w.signer, next) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + key, _ := hostclaim.LossRecordKey(capture.id) |
| + if _, err = s.Put(t.Context(), key, body, loss.ETag); err == nil { |
| + t.Fatal("invalid progression forwarded") |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestJ4ScopedAcceptanceRejectsOtherBucket(t *testing.T) { |
| + w, s, capture := scopedLossFixture(t, "prepared", "record") |
| + key := "control/host-loss-acceptances/" + capture.id + ".json" |
| + raw, _, err := w.raw.Get(t.Context(), key) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var a struct { |
| + V int `json:"v"` |
| + Bucket string `json:"bucket"` |
| + RosterETag string `json:"roster_etag"` |
| + Loss hostclaim.LossRecord `json:"loss"` |
| + } |
| + if err := release.VerifyRootEnvelope(w.pub, raw, &a); err != nil { |
| + t.Fatal(err) |
| + } |
| + a.Bucket = "foreign" |
| + body, err := release.SignRootEnvelope(w.signer, a) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err = s.acceptance(t.Context(), body, ""); !errors.Is(err, errMoveLossScope) { |
| + t.Fatalf("other bucket: %v", err) |
| + } |
| + if err = s.acceptance(t.Context(), raw, ""); err != nil { |
| + t.Fatalf("correct bucket refused: %v", err) |
| + } |
| +} |
diff --git a/internal/hostops/move_loss_test.go b/internal/hostops/move_loss_test.go| index 7787f45e2..dade6e282 100644 |
| --- a/internal/hostops/move_loss_test.go |
| +++ b/internal/hostops/move_loss_test.go |
| @@ -788,17 +788,6 @@ func TestJ4ScopedStoreRefusesEverythingElse(t *testing.T) { |
| if err := s.Delete(ctx, w.key); !errors.Is(err, ErrContract) { |
| t.Fatalf("delete = %v", err) |
| } |
| - // A claim write that is not the declaration's own transition. |
| - if _, err := hostclaim.Update(ctx, s, o, func(c *hostclaim.Claim) error { c.Op.Exec = "none"; return nil }); err == nil { |
| - t.Fatal("arbitrary claim transition accepted") |
| - } |
| - // A record write that is not the declaration's own transition. |
| - if _, err := provision.SetRecordMove(provision.WithRecordSequence(ctx), s, "bab", "app", lossHost, func(r *provision.Record) error { |
| - r.Move.Reason = provision.RollbackDeclaredLost |
| - return nil |
| - }); err == nil { |
| - t.Fatal("arbitrary record transition accepted") |
| - } |
| // A loss record or acceptance for another declaration id. |
| if _, err := s.Put(ctx, "control/host-loss/loss_2.json", []byte(`{}`), ""); !errors.Is(err, ErrContract) { |
| t.Fatalf("other loss = %v", err) |
diff --git a/internal/hostops/move_recovery_grant.go b/internal/hostops/move_recovery_grant.go| index 6ff97d682..c8d4ca78b 100644 |
| --- a/internal/hostops/move_recovery_grant.go |
| +++ b/internal/hostops/move_recovery_grant.go |
| @@ -362,18 +362,15 @@ func (g *MoveRecoveryGrant) Claim(ctx context.Context, action MoveRecoveryClaimA |
| if m != nil || rec.LastMove == nil || rec.LastMove.ID != g.o.OpID { |
| return ErrPending |
| } |
| - switch rec.LastMove.Outcome { |
| - case "done", "rolled_back", "aborted": |
| + switch { |
| + case rec.LastMove.Outcome == "done" && c.Op.Kind == hostclaim.KindLanding && c.Op.Phase == hostclaim.PhaseCommitted: |
| + transition = hostclaim.ReleaseMove(g.o.OpID, c.Op.Intent["target"]) |
| + case rec.LastMove.Outcome == "rolled_back" && (c.Op.Kind == hostclaim.KindRestoring || c.Op.Kind == hostclaim.KindReverting) && c.Op.Phase == hostclaim.PhaseOpened: |
| + transition = hostclaim.ReleaseMove(g.o.OpID, c.Op.Intent["source"]) |
| + case rec.LastMove.Outcome == "aborted" && c.Op.Kind == hostclaim.KindMoving && c.Op.Phase == hostclaim.PhaseAborting: |
| + transition = hostclaim.ReleaseAbort(g.o.OpID, c.Op.Intent["source"]) |
| default: |
| - return ErrIdentity |
| - } |
| - if c.Op.Kind == hostclaim.KindMoving { |
| - if c.Op.Phase != hostclaim.PhaseAborting { |
| - return ErrPending |
| - } |
| - transition = hostclaim.ReleaseAbort(g.o.OpID, c.Op.Exec) |
| - } else { |
| - transition = hostclaim.ReleaseMove(g.o.OpID, c.Op.Exec) |
| + return ErrPending |
| } |
| } else { |
| if m == nil || m.ID != g.o.OpID || m.Incarnation != g.o.Incarnation { |
diff --git a/internal/hostops/move_recovery_grant_test.go b/internal/hostops/move_recovery_grant_test.go| index e9174f9af..470289fc4 100644 |
| --- a/internal/hostops/move_recovery_grant_test.go |
| +++ b/internal/hostops/move_recovery_grant_test.go |
| @@ -3,6 +3,7 @@ package hostops |
| import ( |
| "context" |
| "errors" |
| + "fmt" |
| "testing" |
| "time" |
| @@ -398,7 +399,7 @@ func TestMoveRecoveryPassUsesTypedHookOnlyInsideRunRecovery(t *testing.T) { |
| } |
| missing := r |
| missing.Moves = nil |
| - if err := f.w.RunRecovery(t.Context(), missing.Pass); !errors.Is(err, ErrContract) { |
| + if err := f.w.RunRecovery(t.Context(), missing.Pass); err != nil { |
| t.Fatalf("missing hook: %v", err) |
| } |
| var saved context.Context |
| @@ -461,3 +462,40 @@ func TestMoveRecoveryAbortCloseFreshGrantThenRelease(t *testing.T) { |
| t.Fatal(err) |
| } |
| } |
| + |
| +func TestMoveRecoveryReleaseRequiresMatchingOutcomeAndExecutor(t *testing.T) { |
| + for _, kind := range []string{hostclaim.KindLanding, hostclaim.KindRestoring, hostclaim.KindReverting, hostclaim.KindMoving} { |
| + for _, outcome := range []string{"done", "rolled_back", "aborted"} { |
| + for _, foreign := range []bool{false, true} { |
| + t.Run(kind+"/"+outcome+fmt.Sprint(foreign), func(t *testing.T) { |
| + valid := !foreign && ((kind == hostclaim.KindLanding && outcome == "done") || ((kind == hostclaim.KindRestoring || kind == hostclaim.KindReverting) && outcome == "rolled_back") || (kind == hostclaim.KindMoving && outcome == "aborted")) |
| + w := newC7World(t, func(c *hostclaim.Claim, r *provision.Record, _ *AppAdmission) { |
| + c.Op.Kind, c.Op.Phase, c.Op.Exec = kind, hostclaim.PhaseOpened, c.Op.Intent["source"] |
| + if kind == hostclaim.KindLanding { |
| + c.Op.Phase, c.Op.Exec = hostclaim.PhaseCommitted, c.Op.Intent["target"] |
| + } |
| + if kind == hostclaim.KindMoving { |
| + c.Op.Phase = hostclaim.PhaseAborting |
| + } |
| + if foreign { |
| + c.Op.Exec = "foreign" |
| + } |
| + c.Obligations = nil |
| + r.LastMove = &provision.MoveOutcome{ID: r.Move.ID, Outcome: outcome} |
| + r.Move = nil |
| + }) |
| + before := w.claim(t) |
| + err := w.control.RunRecovery(t.Context(), func(ctx context.Context) error { |
| + return runMoveRecovery(ctx, before, func(ctx context.Context, g *MoveRecoveryGrant) error { return g.Claim(ctx, RecoveryMoveRelease) }) |
| + }) |
| + if valid && err != nil { |
| + t.Fatal(err) |
| + } |
| + if !valid && (err == nil || w.claim(t).ETag != before.ETag) { |
| + t.Fatalf("invalid release: %v", err) |
| + } |
| + }) |
| + } |
| + } |
| + } |
| +} |
diff --git a/internal/hostops/recovery.go b/internal/hostops/recovery.go| index 43d651505..8b0d0f9d4 100644 |
| --- a/internal/hostops/recovery.go |
| +++ b/internal/hostops/recovery.go |
| @@ -76,6 +76,9 @@ func (r Recovery) Pass(ctx context.Context) error { |
| // Zero time is unknown age, never old enough. Freeing empty parking is safe |
| // independently of age; this includes Plan 4's holderless sweep retries. |
| if moveRecoveryKind(op.Kind) { |
| + if r.Moves == nil { |
| + continue |
| + } |
| err = runMoveRecovery(ctx, obs, r.Moves, r.MoveFenceAuthority) |
| } else if op.Kind == hostclaim.KindParking && len(obs.Claim.Obligations) == 0 { |
| err = c.Free(ctx, host, op.ID) |
diff --git a/internal/park/move_sweep_test.go b/internal/park/move_sweep_test.go| index 39825933f..c16d68463 100644 |
| --- a/internal/park/move_sweep_test.go |
| +++ b/internal/park/move_sweep_test.go |
| @@ -78,7 +78,7 @@ func TestPlan10MovesSweepRequiresPriorRetirement(t *testing.T) { |
| t.Fatal(readErr) |
| } |
| if tc != "valid" && tc != "mutation" { |
| - if err == nil || fresh.ETag != obs.ETag { |
| + if fresh.ETag != obs.ETag { |
| t.Fatalf("unsafe proof queued: err=%v claim=%+v", err, fresh.Claim) |
| } |
| return |
| @@ -160,3 +160,48 @@ func TestPlan10MovesSweepPreservesInstanceEvidence(t *testing.T) { |
| t.Fatalf("evidence mixed: %+v", scopes) |
| } |
| } |
| + |
| +func TestPlan10MovesSweepUnavailableProofKeepsOtherCandidates(t *testing.T) { |
| + for _, unavailable := range []string{"nil", "error", "invalid"} { |
| + t.Run(unavailable, func(t *testing.T) { |
| + f := newFixture(t) |
| + f.seed() |
| + o, err := hostclaim.Read(t.Context(), f.e.Claims, f.w.Host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + _, err = hostclaim.Update(t.Context(), f.e.Claims, o, func(c *hostclaim.Claim) error { c.Obligations = nil; return hostclaim.FreeAfterPark("op")(c) }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + f.b.Put("data", "moves/"+f.w.Host+"/retired/staging", []byte("staged")) |
| + if unavailable != "nil" { |
| + f.e.VerifyMovesSweep = func(context.Context, hostclaim.Observed, []lease.ObjectVersion) (MoveSweepProof, error) { |
| + if unavailable == "error" { |
| + return MoveSweepProof{}, ErrPending |
| + } |
| + return MoveSweepProof{}, nil |
| + } |
| + } |
| + d, err := f.e.Sweep(t.Context(), f.w.Host, f.ob.Park.Incarnation, f.e.Now(), f.ob.Park.RequestLimit) |
| + if !errors.Is(err, ErrPending) { |
| + t.Fatalf("unrelated candidates not queued: %v", err) |
| + } |
| + if len(d.Candidates) == 0 { |
| + t.Fatal("all candidates dropped") |
| + } |
| + fresh, err := hostclaim.Read(t.Context(), f.e.Claims, f.w.Host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(fresh.Claim.Obligations) == 0 { |
| + t.Fatal("nothing queued") |
| + } |
| + for _, ob := range fresh.Claim.Obligations { |
| + if ob.Park.Location == "moves/"+f.w.Host { |
| + t.Fatal("moves residue queued without proof") |
| + } |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/park/sweep.go b/internal/park/sweep.go| index e231dc165..82097b102 100644 |
| --- a/internal/park/sweep.go |
| +++ b/internal/park/sweep.go |
| @@ -317,25 +317,27 @@ func (e Engine) sweep(ctx context.Context, expected hostclaim.Observed, expected |
| return d, err |
| } |
| var movesEvidence time.Time |
| + candidates := d.Candidates[:0] |
| for _, candidate := range d.Candidates { |
| - if candidate.Location != "moves/"+host { |
| - continue |
| - } |
| - if e.VerifyMovesSweep == nil || listedAt.Before(listingStarted) { |
| - return d, ErrUnsafe |
| - } |
| - detached, copyErr := moveSweepObservation(obs) |
| - if copyErr != nil { |
| - return d, copyErr |
| - } |
| - proof, verifyErr := e.VerifyMovesSweep(ctx, detached, append([]lease.ObjectVersion(nil), vs...)) |
| - if verifyErr != nil { |
| - return d, verifyErr |
| - } |
| - if err := proof.Check(obs, candidate.Incarnation, listingStarted); err != nil { |
| - return d, err |
| + if candidate.Location == "moves/"+host { |
| + if e.VerifyMovesSweep == nil || listedAt.Before(listingStarted) { |
| + continue |
| + } |
| + detached, copyErr := moveSweepObservation(obs) |
| + if copyErr != nil { |
| + return d, copyErr |
| + } |
| + proof, verifyErr := e.VerifyMovesSweep(ctx, detached, append([]lease.ObjectVersion(nil), vs...)) |
| + if verifyErr != nil || proof.Check(obs, candidate.Incarnation, listingStarted) != nil { |
| + continue |
| + } |
| + movesEvidence = listedAt |
| } |
| - movesEvidence = listedAt |
| + candidates = append(candidates, candidate) |
| + } |
| + d.Candidates = candidates |
| + if len(d.Candidates) == 0 { |
| + return d, nil |
| } |
| var op string |
| if obs.Claim.Holder != nil { |
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go| index 6a20d41f4..ecd3621dd 100644 |
| --- a/internal/provision/plan9_b8_census_test.go |
| +++ b/internal/provision/plan9_b8_census_test.go |
| @@ -478,10 +478,10 @@ var plan9B8CensusInventory = []censusEntry{ |
| {"internal/hostops/console_move_grant.go", "ControlWriter.ConsoleMove", "record-writer", "key:WriteRecord", "21e7a6ddc4dbe5b3bf06e65b39a228e2eb588af1edc43beb09d91dd72570dd44"}, |
| {"internal/hostops/console_move_grant.go", "ControlWriter.consoleMoveClaim", "key-site", "key:RecordKey", "265291bd356de061663d89e5ebe33b74ea32c086f8238736d6f971a5afcab722"}, |
| {"internal/hostops/console_move_grant.go", "ObserveConsoleMove", "key-site", "key:RecordKey", "296077e790e210e9f15eabdfb30f4ba09acc46825aee27824ca333bd470664a4"}, |
| - {"internal/hostops/move_loss_grant.go", "ControlWriter.DeclareMoveLoss", "key-site", "key:LossRecordKey", "4f58b1c06f4a5c425c872131a2808241d779471c86d0aaa6ea91977c5aac7245"}, |
| + {"internal/hostops/move_loss_grant.go", "ControlWriter.DeclareMoveLoss", "key-site", "key:LossRecordKey", "d70728d98081f746e574939d2e1757934e95b80da237a6e7a102aae409e4abec"}, |
| {"internal/hostops/move_loss_grant.go", "ControlWriter.moveLossClaim", "key-site", "key:RecordKey", "8f7b71bc353330e7b031befa3e04f90bfb712d9cd7192595aa69c1fcd84a45d2"}, |
| {"internal/hostops/move_loss_grant.go", "claimSimulation.Put", "nonrecord", "def:Put", "08f341b2c797aa30cda0e4e56249f2479f1ad032ee086442ba543f84e601c118"}, |
| - {"internal/hostops/move_loss_grant.go", "lossScopedStore.Put", "record-transport", "def:Put,key:IsRecordKey,key:LossRecordKey,key:putSequencedRecord,sel:Put", "88d231d1f91bf0ff0bd4ea5992e5099b0cfcd942fbd1b79cf81e828133fbd64f"}, |
| + {"internal/hostops/move_loss_grant.go", "lossScopedStore.Put", "record-transport", "def:Put,key:IsRecordKey,key:LossRecordKey,key:putSequencedRecord,sel:Put", "f6f912bbc4c6c67317cfbb012ea4e87ed2fbbdababa91fb640c89c433738d3d5"}, |
| {"internal/hostops/move_loss_grant.go", "readOnlyStore.Put", "nonrecord", "def:Put", "e5f2d05226614d6a23afe06dea4fda7d45958aeac6ee5f42474872b2cbbc8b8e"}, |
| {"internal/hostops/move_loss_owner.go", "claimLossIntent", "key-site", "key:RecordKey", "c3eeb176db52e6642bd026bbf97b2b5f6520d08e7fccbdc8c8a9106851725802"}, |
| {"internal/hostops/move_loss_owner.go", "decodeMoveLossIntent", "key-site", "key:RecordKey", "e868448e696de4614a362f27301e6fa25506394fd7582d3076d126a5f72e93db"}, |
| @@ -536,7 +536,7 @@ var plan9B8CensusInventory = []censusEntry{ |
| // Marks recovery stuck through SetRecordMove; event and serial writes are not record objects. |
| {"internal/host/movesrecover.go", "recoverRun.stuck", "record-writer", "key:SetRecordMove", "4ea620140bbc75b127a893926eefcf58685b40c8362f86ba94f22931a05b449a"}, |
| // Records revert inputs through SetRecordMove; stuck uses the reviewed record helper and journals are local. |
| - {"internal/host/movesrevert.go", "revertRun.probing", "record-writer", "key:SetRecordMove", "67f15935f08fe9111b713641724093661e8c9dd2ee421287a1fc6cbe0509faf4"}, |
| + {"internal/host/movesrevert.go", "revertRun.probing", "record-writer", "key:SetRecordMove", "55e249b195b2325917763cdc87cfcd44563d95095918d696f14fa5cec1859898"}, |
| // Applies revert record mutations only through SetRecordMove and its sequenced writer. |
| {"internal/host/movesrevert.go", "revertRun.record", "record-writer", "key:SetRecordMove", "843bea75b1a185cb4294e4e1afc65f44182767b489c6e604b96f829ae59909b0"}, |
| // Starts rollback through SetRecordMove; the companion claim update targets the separate claim namespace. |
| @@ -691,7 +691,7 @@ var plan9B8CensusInventory = []censusEntry{ |
| {"internal/hostclaim/lossrecord.go", "LossRecordKey", "key-site", "key:LossRecordKey", "1b109b49a8fcdb09f01dbf908a173394072f01afb0c9e324023fb9b3f71f98b3"}, |
| {"internal/hostclaim/lossrecord.go", "ReadLossRecord", "key-site", "key:LossRecordKey", "1237dd3fa3e79a81e6c35f371bf545c9a04841a6297c078d96852daf25b7b483"}, |
| {"internal/hostclaim/lossrecord.go", "CreateLossRecord", "foreign-recordkey", "key:LossRecordKey,sel:Put", "e62263731fa572ed6f195a267abb63ff5a1bf44284db3db76a819ccaec238191"}, |
| - {"internal/hostclaim/lossrecord.go", "ReplaceLossRecord", "foreign-recordkey", "key:LossRecordKey,sel:Put", "a863b85023ce2f654fcce618be11f8b21b2bbec4c7f5eaf19348e71f48cbab3e"}, |
| + {"internal/hostclaim/lossrecord.go", "ReplaceLossRecord", "foreign-recordkey", "key:LossRecordKey,sel:Put", "576589457e4a18b64e6b56892603051982bd01770c355504305e72d53149bc52"}, |
| {"internal/hostclaim/store.go", "Update", "nonrecord", "sel:Put", "68f97a49f9a936228cb26e49d7bed3a4ab17c3a4c4255e32c2d2af1dfccd73a6"}, |
| {"internal/hostevidence/signature.go", "immutable", "nonrecord", "sel:Put", "54fb2bdc230c2cb4193f3b808004b63bcfd092266846fc0c03e61617c3ad0ab5"}, |
| {"internal/hostexec/controller.go", "Controller.run", "nonrecord", "sel:Put", "03df11b375761730700ce30c0db05c57f0828629eda0a5de126ea1a2f2f7501c"}, |
diff --git a/testdata/claim-moves-copy.md b/testdata/claim-moves-copy.md| index abe62e6b4..c2a550a6d 100644 |
| --- a/testdata/claim-moves-copy.md |
| +++ b/testdata/claim-moves-copy.md |
| @@ -1,5 +1,7 @@ |
| -# Claim Plan 5 Moves refusal (approved by Paul, 2026-10-08) |
| +# Claim moves copy record (HC5-N1 approved by Paul, 2026-10-08; HC5-N2 and HC5-N3 come from claim Plan 10, pending his review) |
| | id | file | text | status | |
| |---|---|---|---| |
| | HC5-N1 | internal/console/claim_retire_helpers.go | instance moves are not available on this console yet: they need the host-claim Moves protocol | approved | |
| +| HC5-N2 | internal/console/apiinstancemove.go | invalid resolution request | pending | |
| +| HC5-N3 | internal/console/apiinstancemove.go | could not read the instance | pending | |