moves log: a stuck target release names the target (final review M1)
TransitionOf gave role=target only for a stuck rollback whose target was returning. The target's release can also stop for an operator, at releasing with StuckReleaseTargetFiles, and its immutable stuck entry then named the source. It now names the target. The regression covers the release, the return and a source stuck.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 files changed,
+41
−3
internal/moves/transition.go+5 −3internal/moves/transition_test.go+36 −0
diff --git a/internal/moves/transition.go b/internal/moves/transition.go| index a71fc31fb..548201780 100644 |
| --- a/internal/moves/transition.go |
| +++ b/internal/moves/transition.go |
| @@ -147,10 +147,12 @@ func TransitionOf(before, after provision.Record) (Transition, bool) { |
| return Transition{}, false |
| } |
| e.Event, e.Identity = "stuck", "stuck-"+strconv.FormatUint(am.RecoveryRetryEpoch, 10) |
| - // A rollback's target stops for an operator only at its return |
| - // (returning); every other stuck is the source's. |
| + // A rollback's target stops for an operator at its return |
| + // (returning) and at its release (releasing, whose files cannot be |
| + // archived: final review M1); every other stuck is the source's. |
| e.Fields["role"] = "source" |
| - if am.Rollback != nil && am.Rollback.TargetPhase == provision.RbTargetReturning { |
| + if rb := am.Rollback; rb != nil && (rb.TargetPhase == provision.RbTargetReturning || |
| + rb.TargetPhase == provision.RbTargetReleasing && am.StuckReason == provision.StuckReleaseTargetFiles) { |
| e.Fields["role"] = "target" |
| } |
| if am.StuckReason != "" { |
diff --git a/internal/moves/transition_test.go b/internal/moves/transition_test.go| new file mode 100644 |
| index 000000000..64265e7e4 |
| --- /dev/null |
| +++ b/internal/moves/transition_test.go |
| @@ -0,0 +1,36 @@ |
| +package moves |
| + |
| +import ( |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/provision" |
| +) |
| + |
| +// Final review M1: a stuck entry names the participant that stopped. The |
| +// target stops for an operator at its return (returning) and at its release |
| +// (releasing, StuckReleaseTargetFiles); every other stuck is the source's. |
| +func TestStuckNamesTheParticipantThatStopped(t *testing.T) { |
| + rec := func(state, target, source, reason string, dirty bool) provision.Record { |
| + return provision.Record{Host: "a.example.com", Move: &provision.MoveState{ID: "mv_abcdefghijklmnopqrstuvwxyz", Incarnation: "i", From: "ie", To: "uk", |
| + State: state, StuckReason: reason, |
| + Rollback: &provision.MoveRollback{ID: "rb_abcdefghijklmnopqrstuvwxyz", TargetPhase: target, SourcePhase: source, Dirty: dirty}}} |
| + } |
| + for _, c := range []struct { |
| + name string |
| + target, source string |
| + reason string |
| + dirty bool |
| + want string |
| + }{ |
| + {"the target's release", provision.RbTargetReleasing, provision.RbSourceWaiting, provision.StuckReleaseTargetFiles, false, "target"}, |
| + {"the target's return", provision.RbTargetReturning, provision.RbSourceWaiting, provision.StuckReturnTargetFiles, true, "target"}, |
| + {"the source's verification", provision.RbTargetCleaned, provision.RbSourceVerified, provision.StuckBaselineMismatch, false, "source"}, |
| + } { |
| + before := rec(provision.MoveRollingBack, c.target, c.source, "", c.dirty) |
| + after := rec(provision.MoveStuck, c.target, c.source, c.reason, c.dirty) |
| + e, ok := TransitionOf(before, after) |
| + if !ok || e.Event != "stuck" || e.Fields["role"] != c.want || e.Fields["reason"] != c.reason { |
| + t.Fatalf("%s: %+v %v, want stuck with role %s", c.name, e, ok, c.want) |
| + } |
| + } |
| +} |