| index 2616cdfda..a20198f96 100644 |
| --- a/internal/provision/provision.go |
| +++ b/internal/provision/provision.go |
| @@ -57,6 +57,14 @@ type Record struct { |
| // unchanged. Written ONLY through SetRecordMove's guarded transitions. |
| Move *MoveState `json:"move,omitempty"` |
| |
| + // LastMove is the terminal outcome the close CAS leaves as it clears |
| + // Move (index D11), so a CLI that missed the intermediate states still |
| + // reads how the move it requested ended. Written by the guarded close |
| + // transitions only (MoveGuardedAbortComplete, and the done and |
| + // rolled_back closes the later plans add). Additive and omitempty: |
| + // absent on every record at rest on main. |
| + LastMove *MoveOutcome `json:"last_move,omitempty"` |
| + |
| // --- shape fields (2026-08-10 route-records design §3) -------------- |
| // Every one is omitempty with a zero value that reproduces the mint |
| // reconcile hardcoded before they existed: a record written by an older |
| @@ -251,6 +259,22 @@ type Manifest struct { |
| // every manifest in a production bucket already has. There is nothing |
| // for absence to mean that false does not already mean. |
| PrivateStorage bool `json:"private_storage,omitempty"` |
| + |
| + // Moves is the app's move declaration (move-any-app design §7.3 S1): |
| + // the readiness path a probe requests. Absent means "/". |
| + Moves *MovesManifest `json:"moves,omitempty"` |
| +} |
| + |
| +type MovesManifest struct { |
| + ReadyPath string `json:"ready_path,omitempty"` |
| +} |
| + |
| +// ReadyPathOrDefault is the probe's path (§7.7). |
| +func (m Manifest) ReadyPathOrDefault() string { |
| + if m.Moves != nil && m.Moves.ReadyPath != "" { |
| + return m.Moves.ReadyPath |
| + } |
| + return "/" |
| } |
| |
| // ManifestKey, RecordsPrefix and RecordKey are the bucket key conventions |
| @@ -901,13 +925,22 @@ func updateRecord(ctx context.Context, s release.Store, acct, app, host string, |
| return rec, err |
| } |
| |
| -// MoveState is the record's in-flight move (issue #331). State is one of |
| -// the Move* constants; To is the target placement code. ReadyAt is stamped |
| -// by the source box's preparing→ready promotion, so the tenant can see how |
| -// stale its freeze-window budget is. |
| +// MoveState is the record's move journal (move-any-app design §7.2). The |
| +// claim carries the cross-operation serialization; this field carries one |
| +// move's own phases, because only a move reads them. Every field after |
| +// UpdatedAt is additive and omitempty: a v0.22 console reads the ones it |
| +// knows and refuses to abort any state it does not (G16). |
| type MoveState struct { |
| - To string `json:"to"` |
| + // ID is the move id (mv_…), Incarnation the holder incarnation the |
| + // claim named when the move began. Both guard every box CAS (§7.2). |
| + ID string `json:"id,omitempty"` |
| + Incarnation string `json:"incarnation,omitempty"` |
| + From string `json:"from,omitempty"` |
| + To string `json:"to"` |
| + // Cut is "auto" or "manual" (§7.1). |
| + Cut string `json:"cut,omitempty"` |
| State string `json:"state"` |
| + RequestedBy string `json:"requested_by,omitempty"` |
| RequestedAt time.Time `json:"requested_at"` |
| ReadyAt *time.Time `json:"ready_at,omitempty"` |
| // UpdatedAt is when the state last changed — stamped by SetRecordMove |
| @@ -915,11 +948,98 @@ type MoveState struct { |
| // console's flat move view can carry one freshness stamp without each |
| // transition re-implementing it. |
| UpdatedAt time.Time `json:"updated_at"` |
| + |
| + // Inputs are the versions pinned at readiness and re-checked at |
| + // admission (§7.3 T3, §7.4 admitted, INV-6). |
| + Inputs *MoveInputs `json:"inputs,omitempty"` |
| + |
| + CutPhase string `json:"cut_phase,omitempty"` |
| + CutAt *time.Time `json:"cut_at,omitempty"` |
| + CutBudgetS int `json:"cut_budget_s,omitempty"` |
| + CutDeadline *time.Time `json:"cut_deadline,omitempty"` |
| + Source *MoveSource `json:"source,omitempty"` |
| + |
| + FlippedAt *time.Time `json:"flipped_at,omitempty"` |
| + LandingDeadline *time.Time `json:"landing_deadline,omitempty"` |
| + LandingPhase string `json:"landing_phase,omitempty"` |
| + Landing *MoveLandingJournal `json:"landing,omitempty"` |
| + |
| + CommittedAt *time.Time `json:"committed_at,omitempty"` |
| + OpenedAt *time.Time `json:"opened_at,omitempty"` |
| + SourceParkedAt *time.Time `json:"source_parked_at,omitempty"` |
| + |
| + Rollback *MoveRollback `json:"rollback,omitempty"` |
| + Reason string `json:"reason,omitempty"` |
| } |
| |
| -// The move states, in order. There is no "done" state: the terminal |
| -// transition flips Region and CLEARS Move in one write — a record at rest |
| -// never says anything about moves. |
| +// MoveInputs (what the target was checked against, §7.3 T3) is NOT |
| +// defined here: it is main's internal/provision/moveinputs_contract.go |
| +// (claim Plan 6, d649d8d4), the shared contract this journal references. |
| + |
| +// MoveSource is what the source recorded at `stopped` (§7.4): the |
| +// baselines every recovery compares against. |
| +type MoveSource struct { |
| + Replication string `json:"replication,omitempty"` |
| + NextDue int64 `json:"next_due,omitempty"` |
| + FinalTXID uint64 `json:"final_txid,omitempty"` |
| + DBSHA256 string `json:"db_sha256,omitempty"` |
| + Files *MoveFilesDigest `json:"files,omitempty"` |
| + TimersSHA256 string `json:"timers_sha256,omitempty"` |
| + Evidence string `json:"evidence,omitempty"` |
| +} |
| + |
| +type MoveFilesDigest struct { |
| + Plain string `json:"plain,omitempty"` |
| + Sealed string `json:"sealed,omitempty"` |
| + Bytes int64 `json:"bytes,omitempty"` |
| + Entries int `json:"entries,omitempty"` |
| +} |
| + |
| +// MoveLandingJournal is the landing's own record (§7.2 "landing"). Named |
| +// so because MoveLanding is the state constant below. |
| +type MoveLandingJournal struct { |
| + InstalledDBSHA256 string `json:"installed_db_sha256,omitempty"` |
| + Attempts int `json:"attempts,omitempty"` |
| +} |
| + |
| +// MoveOutcome is the terminal result the close CAS leaves on the record |
| +// as it clears Move (index D11), so a CLI that missed the intermediate |
| +// states still reads the outcome of the move it requested. |
| +type MoveOutcome struct { |
| + ID string `json:"id"` |
| + Outcome string `json:"outcome"` // done | rolled_back | aborted |
| + At time.Time `json:"at"` |
| + OfflineS float64 `json:"offline_s,omitempty"` |
| +} |
| + |
| +// MoveRollback is §7.8's journal. |
| +type MoveRollback struct { |
| + ID string `json:"id,omitempty"` |
| + RequestedAt *time.Time `json:"requested_at,omitempty"` |
| + Reason string `json:"reason,omitempty"` |
| + TargetPhase string `json:"target_phase,omitempty"` |
| + Dirty bool `json:"dirty,omitempty"` |
| + Evidence string `json:"evidence,omitempty"` |
| + SourcePhase string `json:"source_phase,omitempty"` |
| + RecoveredAt *time.Time `json:"recovered_at,omitempty"` |
| + OpenedAt *time.Time `json:"opened_at,omitempty"` |
| + TargetParkedAt *time.Time `json:"target_parked_at,omitempty"` |
| + ReleaseDeadline *time.Time `json:"release_deadline,omitempty"` |
| + // RecoveryInputs are the inputs a recovery install rendered itself |
| + // (§7.7, J2-R2-5); never compared with Inputs. |
| + RecoveryInputs *MoveInputs `json:"recovery_inputs,omitempty"` |
| +} |
| + |
| +// Legacy reports a move journal written before ids existed (a v0.22 |
| +// console). The guarded transitions below refuse it: a legacy record is |
| +// driven only by the legacy transitions (MovePromoteReady, MoveAbort, |
| +// MoveAbortComplete and the rest). The new door routes a Legacy() record |
| +// to the legacy MoveAbort, and the new worker completes a legacy aborting |
| +// record through the legacy code path only. |
| +func (m *MoveState) Legacy() bool { return m != nil && m.ID == "" } |
| + |
| +// The move states. There is no "done" state: the close CAS clears Move, so |
| +// a record at rest never says anything about moves. |
| const ( |
| MovePreparing = "preparing" |
| MoveReady = "ready" |
| @@ -930,7 +1050,62 @@ const ( |
| // a bare clear would win a LATER landing on that box (staging-wins, |
| // backend_exec.go's promoteStagedReplica) with data from an abandoned |
| // move. |
| - MoveAborting = "aborting" |
| + MoveAborting = "aborting" |
| + MoveReverting = "reverting" |
| + MoveLanding = "landing" |
| + MoveCommitted = "committed" |
| + MoveRollingBack = "rolling_back" |
| + MoveStuck = "stuck" |
| +) |
| + |
| +// Cut phases (§7.4), landing phases (§7.6), reverting phases (§7.4) and |
| +// rollback phases (§7.8). Each is written before its side effects. |
| +const ( |
| + CutRequested = "requested" |
| + CutAdmitted = "admitted" |
| + CutStopping = "stopping" |
| + CutStopped = "stopped" |
| + CutStaged = "staged" |
| + CutArchived = "archived" |
| + CutFlipped = "flipped" |
| + |
| + LandChecking = "checking" |
| + LandParking = "parking" |
| + LandBuilding = "building" |
| + LandPublished = "published" |
| + LandLocal = "local" |
| + LandConfigured = "configured" |
| + LandRestored = "restored" |
| + LandProbing = "probing" |
| + LandProbed = "probed" |
| + |
| + RevEvidence = "evidence" |
| + RevVerify = "verify" |
| + RevProbing = "probing" |
| + RevProbed = "probed" |
| + RevOpened = "opened" |
| + |
| + RbTargetReleasing = "releasing" |
| + RbTargetReleased = "released" |
| + RbTargetReturning = "returning" |
| + RbTargetReturned = "returned" |
| + RbTargetCleaning = "cleaning" |
| + RbTargetCleaned = "cleaned" |
| + |
| + RbSourceWaiting = "waiting" |
| + RbSourceClaimed = "claimed" |
| + RbSourceRestoring = "restoring" |
| + RbSourceVerified = "verified" |
| + RbSourceParking = "parking" |
| + RbSourceBuilding = "building" |
| + RbSourcePublished = "published" |
| + RbSourceLocal = "local" |
| + RbSourceConfigured = "configured" |
| + RbSourceRestored = "restored" |
| + RbSourceProbing = "probing" |
| + RbSourceProbed = "probed" |
| + RbSourceRecovered = "recovered" |
| + RbSourceOpened = "opened" |
| ) |
| |
| // ErrMoveConflict is a transition refused because the record's current move |
| @@ -984,6 +1159,149 @@ func MoveRequest(to string, now time.Time) func(*Record) error { |
| } |
| } |
| |
| +// MoveStart is what the request door decided (§7.1, §7.2): the id it |
| +// minted, the incarnation the claim CAS named, the cut mode and who asked. |
| +type MoveStart struct { |
| + ID, Incarnation, From, To, Cut, By string |
| + Now time.Time |
| +} |
| + |
| +// MoveStartTransition starts a move. Refused when one is in flight, when |
| +// the target is the current region, for a replica (a replica never gets a |
| +// route on a platform box, G14), and for any malformed start: a move |
| +// without an id has no journal to run. |
| +func MoveStartTransition(p MoveStart) func(*Record) error { |
| + return func(rec *Record) error { |
| + if rec.Move != nil { |
| + return fmt.Errorf("%w: a move to %s is already %s", ErrMoveConflict, rec.Move.To, rec.Move.State) |
| + } |
| + if !moveIDShape.MatchString(p.ID) || len(p.Incarnation) != 64 || (p.Cut != "auto" && p.Cut != "manual") || p.Now.IsZero() { |
| + return fmt.Errorf("%w: malformed move start", ErrMoveConflict) |
| + } |
| + if rec.Region == p.To || p.From != rec.Region { |
| + return fmt.Errorf("%w: the instance is already in %s", ErrMoveConflict, rec.Region) |
| + } |
| + if rec.Role != "" { |
| + return fmt.Errorf("%w: a %s does not move. delete it and create one in the new region", ErrMoveConflict, rec.Role) |
| + } |
| + rec.Move = &MoveState{ID: p.ID, Incarnation: p.Incarnation, From: p.From, To: p.To, Cut: p.Cut, State: MovePreparing, RequestedBy: p.By, RequestedAt: p.Now.UTC()} |
| + return nil |
| + } |
| +} |
| + |
| +// moveIDShape is a move id: "mv_" and 26 lowercase base32 characters. |
| +// |
| +// Twin of moves.opIDShape (internal/moves/ids.go), which also accepts the |
| +// rollback prefix: the copy is forced, because moves imports provision, so |
| +// provision cannot import moves. Keep the two in step. |
| +var moveIDShape = regexp.MustCompile(`^mv_[a-z2-7]{26}$`) |
| + |
| +// MoveGuard is what every Phase 1 transition carries (§7.2): the move id |
| +// and the holder incarnation. A write whose guard does not match the |
| +// record's move is a late write for another move or another incarnation |
| +// and is refused, so a delayed round for move A can never advance a |
| +// replacement move B that happens to be in the same state. |
| +type MoveGuard struct{ ID, Incarnation string } |
| + |
| +func (g MoveGuard) check(rec *Record) error { |
| + if rec.Move == nil || rec.Move.Legacy() { |
| + return fmt.Errorf("%w: no journaled move in flight", ErrMoveConflict) |
| + } |
| + if rec.Move.ID != g.ID || rec.Move.Incarnation != g.Incarnation { |
| + return fmt.Errorf("%w: the record's move is %s for incarnation %s", ErrMoveConflict, rec.Move.ID, rec.Move.Incarnation) |
| + } |
| + return nil |
| +} |
| + |
| +// MoveGuardedPromoteReady is the new worker's preparing → ready. It pins |
| +// the inputs the target was checked against onto the journal at readiness |
| +// (§7.2, §7.4), so the admission re-check has something to compare with. |
| +func MoveGuardedPromoteReady(g MoveGuard, now time.Time, in MoveInputs) func(*Record) error { |
| + return func(rec *Record) error { |
| + if err := g.check(rec); err != nil { |
| + return err |
| + } |
| + if err := MovePromoteReady(now)(rec); err != nil { |
| + return err |
| + } |
| + pinned := in |
| + if in.BundleETags != nil { |
| + pinned.BundleETags = make(map[string]string, len(in.BundleETags)) |
| + for k, v := range in.BundleETags { |
| + pinned.BundleETags[k] = v |
| + } |
| + } |
| + rec.Move.Inputs = &pinned |
| + return nil |
| + } |
| +} |
| + |
| +// MoveGuardedCut is the cut's entry (the console's cut door, or the source |
| +// for cut == "auto"): ready → cutting/requested. |
| +func MoveGuardedCut(g MoveGuard) func(*Record) error { |
| + return func(rec *Record) error { |
| + if err := g.check(rec); err != nil { |
| + return err |
| + } |
| + if rec.Move.State != MoveReady { |
| + return fmt.Errorf("%w: cut is only legal from ready", ErrMoveConflict) |
| + } |
| + rec.Move.State, rec.Move.CutPhase = MoveCutting, CutRequested |
| + return nil |
| + } |
| +} |
| + |
| +// MoveGuardedAbort: legal in preparing, ready and cutting/requested (§7.11). |
| +func MoveGuardedAbort(g MoveGuard) func(*Record) error { |
| + return func(rec *Record) error { |
| + if err := g.check(rec); err != nil { |
| + return err |
| + } |
| + switch rec.Move.State { |
| + case MovePreparing, MoveReady: |
| + case MoveCutting: |
| + if rec.Move.CutPhase != CutRequested { |
| + return fmt.Errorf("%w: past the point of no return. the cut completes forward or moves back on its own", ErrMoveConflict) |
| + } |
| + case MoveAborting: |
| + return fmt.Errorf("%w: already aborting", ErrMoveConflict) |
| + default: |
| + return fmt.Errorf("%w: a move in state %q cannot be aborted", ErrMoveConflict, rec.Move.State) |
| + } |
| + rec.Move.State = MoveAborting |
| + return nil |
| + } |
| +} |
| + |
| +// MoveGuardedAbortComplete clears an aborting move and records the outcome. |
| +func MoveGuardedAbortComplete(g MoveGuard, now time.Time) func(*Record) error { |
| + return func(rec *Record) error { |
| + if err := g.check(rec); err != nil { |
| + return err |
| + } |
| + if rec.Move.State != MoveAborting { |
| + return fmt.Errorf("%w: abort-complete is only legal from aborting", ErrMoveConflict) |
| + } |
| + rec.LastMove = &MoveOutcome{ID: g.ID, Outcome: "aborted", At: now.UTC()} |
| + rec.Move = nil |
| + return nil |
| + } |
| +} |
| + |
| +// InstanceStoreEnv is the environment name an instance's private object |
| +// store hangs off: the record's own Environment, or its Slug when it has |
| +// none. Twin of the create door's inline rule (internal/console/ |
| +// apiinstances.go, "env, derived := rec.Environment, false"); the door is |
| +// not refactored to call this, so keep the two in step. Shared by the |
| +// console's move door and the host round, which cannot see a console-local |
| +// helper. |
| +func InstanceStoreEnv(rec *Record) string { |
| + if rec.Environment != "" { |
| + return rec.Environment |
| + } |
| + return rec.Slug |
| +} |
| + |
| // MovePromoteReady is the source box's preparing→ready promotion. |
| func MovePromoteReady(now time.Time) func(*Record) error { |
| return func(rec *Record) error { |