package provision import ( "encoding/json" "fmt" "time" ) // MoveRelocating is the record state of a move whose source was terminated // before the flip (claim Plan 5, C7). Every forward and abort transition // lists its legal states, so none of them accepts it: the decision is // terminal for the move. const MoveRelocating = "relocating" // ValidMoveID reports a Phase 1 move id (moveIDShape). A claim-mode move's // admission registration is minted under this id. func ValidMoveID(id string) bool { return moveIDShape.MatchString(id) } // MoveRelocationDecision is a durable record decision, not an installation. // Every other MoveState field is preserved so a crash can reconstruct the // move. type MoveRelocationDecision struct { MovingIdentity string `json:"moving_identity"` HolderIdentity string `json:"holder_identity"` ClaimGen uint64 `json:"claim_gen"` ClaimETag string `json:"claim_etag"` SourceBox string `json:"source_box"` Enrollment string `json:"enrollment"` Region string `json:"region"` Fence string `json:"fence"` PreviousState string `json:"previous_state"` PreviousCutPhase string `json:"previous_cut_phase,omitempty"` DecidedAt time.Time `json:"decided_at"` } // MoveRelocatingDecision competes with the flip under the same record CAS: // only one of them can win. region must be the record's own region and the // move's origin; the caller has also bound it to the fence's roster region. // A replay with the same identities is a no-op that keeps the original // decision time. func MoveRelocatingDecision(g MoveGuard, box, enrollment, region, fence string, claimGen uint64, claimETag, movingIdentity, holderIdentity string, now time.Time) func(*Record) error { return func(rec *Record) error { if err := g.check(rec); err != nil { return err } var ref struct{ Enrollment, Key, SHA256 string } if len(movingIdentity) != 64 || len(holderIdentity) != 64 || claimGen == 0 || claimETag == "" || box == "" || enrollment == "" || !ValidRegion(region) || now.IsZero() || json.Unmarshal([]byte(fence), &ref) != nil || ref.Enrollment != enrollment || ref.Key != "control/box-fences/"+enrollment+".json" || len(ref.SHA256) != 64 || rec.Region != region || rec.Move.From != region { return fmt.Errorf("%w: the relocation decision does not match this move", ErrMoveConflict) } if rec.Move.State == MoveRelocating { d := rec.Move.Relocation if d == nil || d.SourceBox != box || d.Enrollment != enrollment || d.Region != region || d.Fence != fence || d.MovingIdentity != movingIdentity || d.HolderIdentity != holderIdentity { return fmt.Errorf("%w: another relocation decision is recorded", ErrMoveConflict) } return nil } switch rec.Move.State { case MovePreparing, MoveReady, MoveCutting: default: return fmt.Errorf("%w: a move in state %q cannot relocate", ErrMoveConflict, rec.Move.State) } if rec.Move.CutPhase == CutFlipped || rec.Move.FlippedAt != nil || rec.Move.CommittedAt != nil || rec.Move.Rollback != nil || rec.Move.Relocation != nil { return fmt.Errorf("%w: the move is past its flip", ErrMoveConflict) } rec.Move.Relocation = &MoveRelocationDecision{MovingIdentity: movingIdentity, HolderIdentity: holderIdentity, ClaimGen: claimGen, ClaimETag: claimETag, SourceBox: box, Enrollment: enrollment, Region: region, Fence: fence, PreviousState: rec.Move.State, PreviousCutPhase: rec.Move.CutPhase, DecidedAt: now.UTC()} rec.Move.State = MoveRelocating return nil } } // MoveLostTargetParked is separate from the ordinary forward phase table: // a permanently lost target cannot run returning/cleaning itself. The caller // must freshly prove the claim's lost binding and drained target obligations. func MoveLostTargetParked(g MoveGuard, rollbackID string, now time.Time) func(*Record) error { return func(rec *Record) error { if err := g.check(rec); err != nil { return err } rb := rec.Move.Rollback if now.IsZero() || rec.Move.State != MoveRollingBack || rb == nil || rollbackID == "" || rb.ID != rollbackID { return fmt.Errorf("%w: no matching rollback", ErrMoveConflict) } if rb.TargetPhase == RbTargetCleaned && rb.TargetParkedAt != nil { return nil } if rb.TargetPhase != RbTargetReleased || rb.Dirty || rb.TargetParkedAt != nil { return fmt.Errorf("%w: the lost target is not cleanly released", ErrMoveConflict) } at := now.UTC() rb.TargetPhase, rb.TargetParkedAt = RbTargetCleaned, &at return nil } }