carlos / platform Public

console: the self-serve door bodies and a mode-independent view

The request door: the gate; admission (the switch); the judged record
(404 absent or stale, 502 corrupt or unreadable); recordEligibility, an
open run, a move in flight, an open row; the body (start_at,
display_name); both halves of the start validation with the stage rules
and the participants' identities; one source box; the source's facts
(OverCap before Known; Known false or Truncated cannot be measured; facts
of another lifetime or not current are a box that has not reported); the
lane; a legacy record migrated bound to the observation (admitted on) and
checked again; AdmitSelfServeMove in requested, with the estimate and a
24 h confirmation window; the serial bump; 202 with the view. The record
is the job's to write.

Cut: the named row, MarkSelfServeCut (the handoff); a cut that matched
nothing re-reads: cut requested or under way 202, a committed expiry
expired, a cancellation cancelled, else not ready. Cancel:
RequestSelfServeAbort, whose predicate now also requires
cut_requested_at = 0; a miss re-reads: requested 202, a cut confirmed,
else no move in flight.

The status door joins the rows whenever the console is claims-off with
Phase 1 off (selfServeViewOf's five rules; no row is today's view byte for
byte); GET ?estimate=1 answers the prepare's checks through the lane and
the estimate, writing nothing. The estimate's terms are Task 9's shape
with the defaults only. The gate test's owner subtest now expects the
bodies' answers.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev aa7b9c81e1951ba81c8571a81336aae7289e1df5 parent 5ff7757
7 files changed, +804 −38
  • internal/console/apiinstancemove.go +20 −1
  • internal/console/selfserve_door.go +544 −0
  • internal/console/selfserve_estimate.go +82 −0
  • internal/console/selfserve_gate.go +2 −25
  • internal/console/selfserve_gate_test.go +20 −6
  • internal/console/selfserve_store.go +8 −6
  • internal/console/selfserve_view.go +128 −0
diff --git a/internal/console/apiinstancemove.go b/internal/console/apiinstancemove.go
index 4a0c8374a..c2e546482 100644
--- a/internal/console/apiinstancemove.go
+++ b/internal/console/apiinstancemove.go
@@ -393,7 +393,26 @@ func (s *Server) handleAPIInstanceMoveStatus(w http.ResponseWriter, r *http.Requ
}
view := s.moveViewOf(rec)
if !s.cfg.MovesEnabled {
- writeJSON(w, http.StatusOK, view)
+ if s.cfg.HostClaim {
+ writeJSON(w, http.StatusOK, view)
+ return
+ }
+ // Claims off with Phase 1 off, whatever the self-serve mode: the
+ // view joins the instance's self-serve rows (selfServeViewOf), and
+ // with no row it is the flat view above, byte for byte. The
+ // estimate of a move not yet requested answers only while the
+ // switch admits one.
+ if r.URL.Query().Get("estimate") == "1" && s.selfServeAdmits() {
+ s.selfServeEstimate(w, r, acct, app, host, rec)
+ return
+ }
+ open, terminal, err := s.store.SelfServeMoveForHost(acct.Sqid, app, host)
+ if err != nil {
+ log.Printf("console: api instance move %s/%s/%s: status: the self-serve rows: %v", acct.Sqid, app, host, err)
+ jsonError(w, http.StatusBadGateway, msgSelfServeReadMove)
+ return
+ }
+ writeJSON(w, http.StatusOK, s.selfServeViewOf(rec, open, terminal, time.Now()))
return
}
if r.URL.Query().Get("detail") == "1" {
diff --git a/internal/console/selfserve_door.go b/internal/console/selfserve_door.go
new file mode 100644
index 000000000..636c3f7dc
--- /dev/null
+++ b/internal/console/selfserve_door.go
@@ -0,0 +1,544 @@
+package console
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "log"
+ "net/http"
+ "strings"
+ "sync/atomic"
+ "time"
+ "unicode"
+ "unicode/utf8"
+
+ "github.com/carlosframework/platform/internal/moves"
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/status"
+)
+
+// The self-serve move doors' bodies (self-serve moves plan 2 Task 5; spec
+// §5, §6). selfServeGate has proven the caller (a move-capable instance
+// token of exactly this account and app) and applied the rails; each body
+// then reads its evidence, refuses before it writes, and writes one thing:
+//
+// POST .../move {"region", "display_name"?, "requested_by"?, "start_at"?} the row, in requested
+// POST .../move/cut {"id"} cut_requested_at (the handoff)
+// DELETE .../move {"id"} abort_requested_at
+//
+// The record is the job's to write (Task 7): the request door admits the
+// row and the job queues the move from it. Every answer is the view
+// (selfServeViewOf).
+
+// The doors' sentences (copy record, self-serve moves plan 2 Task 5).
+const (
+ msgSelfServeNotEnabled = "self-serve moves are not enabled on this console"
+ msgSelfServeReadInstance = "could not read the instance"
+ msgSelfServeReadMove = "could not read the move"
+ msgSelfServeScheduled = "scheduled starts are not available yet"
+ msgSelfServeDisplayName = "display_name must be at most 80 characters of plain text"
+ msgSelfServeSourceStale = "the source box has not reported recently. try again in a minute"
+ msgSelfServeTooLarge = "this account's files are too large to move this way"
+ msgSelfServeUnmeasured = "could not measure the account's files. try again in a minute"
+ msgSelfServeNotConfirmed = "this is not the move you confirmed"
+ msgSelfServeCancelled = "the move was cancelled"
+ msgSelfServeExpired = "the move expired. prepare again"
+ msgSelfServeConfirmed = "the move was confirmed and cannot be cancelled. it completes or returns on its own"
+ msgSelfServeNoMove = "no move is in flight"
+ msgSelfServeWriteFailed = "could not write the move. try again"
+ selfServeDisplayNameMax = 80
+ selfServeConfirmWindow = 24 * time.Hour
+ selfServeFactsFresh = 10 * time.Minute
+ selfServeFactsSkew = time.Minute
+ selfServeCutCopyFloor = 5 * time.Minute
+ selfServeTerminalLastShown = 7 * 24 * time.Hour
+)
+
+// selfServeOneBox is the refusal of a region with other than one box.
+func (s *Server) selfServeOneBox(code string, n int) *offlineRefusal {
+ return &offlineRefusal{status: http.StatusConflict, msg: fmt.Sprintf("%s has %d boxes. a self-serve move needs exactly one box in each region", s.offlineName(code), n)}
+}
+
+// selfServeNoLane is the refusal of a source box with no lane to the
+// target's bucket.
+func (s *Server) selfServeNoLane(from, to string) *offlineRefusal {
+ return &offlineRefusal{status: http.StatusConflict, msg: fmt.Sprintf("no staging lane is configured from %s to %s", s.offlineName(from), s.offlineName(to))}
+}
+
+// selfServeNotReady is the cut door's refusal of a move before ready.
+func selfServeNotReady(phase string) *offlineRefusal {
+ return &offlineRefusal{status: http.StatusConflict, msg: fmt.Sprintf("the move is not ready to cut yet. it is %s", phase)}
+}
+
+func conflict(msg string) *offlineRefusal {
+ return &offlineRefusal{status: http.StatusConflict, msg: msg}
+}
+
+// selfServeDoorHook, when set, is called by the cut and cancel doors
+// between reading the row and their write ("cut", "abort"), and by the
+// request door before it migrates a legacy record ("migrate"). A TEST SEAM: the race tests
+// park a door there while the other door, or the job, moves the row.
+var selfServeDoorHook atomic.Pointer[func(door string)]
+
+func selfServeDoorPause(door string) {
+ if h := selfServeDoorHook.Load(); h != nil {
+ (*h)(door)
+ }
+}
+
+// selfServeInFlight is the 409 for a move already in flight: the record's
+// own sentence for its move ("a move to <to> is already <state>", as every
+// start transition words it), and for an open row the record does not
+// carry yet the same sentence in the view's word for the row.
+func selfServeInFlight(rec *provision.Record, open *SelfServeMove) *offlineRefusal {
+ if rec != nil && rec.Move != nil {
+ return conflict(offlineConflict(fmt.Errorf("%w: a move to %s is already %s", provision.ErrMoveConflict, rec.Move.To, rec.Move.State)))
+ }
+ return conflict(offlineConflict(fmt.Errorf("%w: a move to %s is already %s", provision.ErrMoveConflict, open.To, selfServeRowState(open))))
+}
+
+// selfServeBusy is the prepare's checks of the record and the journal, in
+// order: the record's class (recordEligibility, main's sentences), an open
+// run, a move the record carries, an open row.
+func selfServeBusy(rec *provision.Record, open *SelfServeMove) *offlineRefusal {
+ if ref := recordEligibility(rec, mainMoveCopy()); ref != nil {
+ return ref
+ }
+ if rec.ActiveRun.Open() {
+ return conflict((&provision.MoveRunOpenError{Run: *rec.ActiveRun}).Sentence())
+ }
+ if rec.Move != nil || open != nil {
+ return selfServeInFlight(rec, open)
+ }
+ return nil
+}
+
+// selfServePrepared is what the prepare's checks found: the participants
+// and the stage target, and the source's facts for the host.
+type selfServePrepared struct {
+ sel offlineSelection
+ facts status.HostFacts
+}
+
+// selfServePrepareChecks is the prepare's checks from the start validation
+// to the lane, shared by the request door and the estimate: both halves of
+// the start validation with the stage rules and the participants'
+// identities, exactly one source box, the source's facts for the host,
+// and a lane from the source box to the target's bucket. It reads and
+// never writes.
+func (s *Server) selfServePrepareChecks(ctx context.Context, acct, app, host string, rec *provision.Record, region string) (selfServePrepared, *offlineRefusal) {
+ if ref := s.offlineRecordChecks(ctx, acct, app, host, rec); ref != nil {
+ return selfServePrepared{}, ref
+ }
+ sel, ref := s.offlineRegionChecks(ctx, acct, app, host, rec, region, true, true, "")
+ if ref != nil {
+ return selfServePrepared{}, ref
+ }
+ if len(sel.Source) != 1 {
+ return selfServePrepared{}, s.selfServeOneBox(sel.From, len(sel.Source))
+ }
+ m := sel.Fleet.metrics[sel.Source[0].Label]
+ facts, ref := selfServeSourceFacts(m, host, rec.Incarnation, time.Now())
+ if ref != nil {
+ return selfServePrepared{}, ref
+ }
+ if m == nil || !containsString(m.Lanes, sel.Stage.Bucket) {
+ return selfServePrepared{}, s.selfServeNoLane(sel.From, sel.To)
+ }
+ return selfServePrepared{sel: sel, facts: facts}, nil
+}
+
+func containsString(list []string, v string) bool {
+ for _, x := range list {
+ if x == v {
+ return true
+ }
+ }
+ return false
+}
+
+// selfServeSourceFacts judges the source box's facts for host from its
+// status report, which pinning has already found current. In order: no
+// facts for the host (the walk did not run) cannot be measured; over the
+// cap is final, even on a walk that was cut; a walk that did not finish
+// cannot be measured; facts of another lifetime, or collected more than ten
+// minutes ago or more than a minute ahead, are a box that has not reported
+// for this host recently; a walk cut short cannot be measured.
+func selfServeSourceFacts(m *status.BoxMetrics, host string, inc uint64, now time.Time) (status.HostFacts, *offlineRefusal) {
+ var f status.HostFacts
+ ok := false
+ if m != nil {
+ f, ok = m.Hosts[host]
+ }
+ switch {
+ case !ok:
+ return f, conflict(msgSelfServeUnmeasured)
+ case f.OverCap:
+ return f, conflict(msgSelfServeTooLarge)
+ case !f.Known:
+ return f, conflict(msgSelfServeUnmeasured)
+ }
+ fresh := func(at int64) bool {
+ age := now.Sub(time.Unix(at, 0))
+ return at > 0 && age <= selfServeFactsFresh && age >= -selfServeFactsSkew
+ }
+ if f.Incarnation != inc || !fresh(f.CollectedAt) || !fresh(f.ReplicaCollectedAt) {
+ return f, conflict(msgSelfServeSourceStale)
+ }
+ if f.Truncated {
+ return f, conflict(msgSelfServeUnmeasured)
+ }
+ return f, nil
+}
+
+// validDisplayName is the display name rule: at most 80 runes of valid
+// UTF-8 with no control character (no newline, no tab).
+func validDisplayName(name string) bool {
+ if !utf8.ValidString(name) || utf8.RuneCountInString(name) > selfServeDisplayNameMax {
+ return false
+ }
+ for _, r := range name {
+ if unicode.IsControl(r) {
+ return false
+ }
+ }
+ return true
+}
+
+// selfServeObserve is the request door's read of the record: absent or
+// stale is the family's 404, corrupt or unreadable the 502.
+func (s *Server) selfServeObserve(w http.ResponseWriter, r *http.Request, acct, app, host string) (provision.Observation, bool) {
+ obs := s.observeRecord(r.Context(), acct, app, host)
+ switch {
+ case obs.Verdict == provision.LifetimeUnreadable || obs.Verdict == provision.LifetimeCorrupt:
+ log.Printf("console: self-serve move %s/%s/%s: the record: %v", acct, app, host, obs.Err)
+ jsonError(w, http.StatusBadGateway, msgSelfServeReadInstance)
+ return obs, false
+ case !obs.Present || obs.Verdict == provision.LifetimeStale || obs.Rec == nil || obs.Rec.Retired:
+ http.NotFound(w, r)
+ return obs, false
+ }
+ return obs, true
+}
+
+// selfServeMoveRequest is the prepare. Its order (every refusal before the
+// one write): the gate; admission (the switch); the record, judged; its
+// class, an open run, a move in flight, an open row; the body (start_at,
+// display_name); the start validation's two halves with the stage rules
+// and the participants' identities; one source box; the source's facts;
+// the lane; the lifetime (a legacy record is migrated, bound to the
+// observation, and its record checked again); the row, admitted in
+// requested; the serial bump; 202 with the view.
+func (s *Server) selfServeMoveRequest(w http.ResponseWriter, r *http.Request) {
+ p, acct, app, host, ok := s.selfServeGate(w, r)
+ if !ok {
+ return
+ }
+ if !s.selfServeAdmits() {
+ jsonError(w, http.StatusConflict, msgSelfServeNotEnabled)
+ return
+ }
+ ctx := r.Context()
+ obs, ok := s.selfServeObserve(w, r, acct.Sqid, app, host)
+ if !ok {
+ return
+ }
+ rec := obs.Rec
+ if ref := recordEligibility(rec, mainMoveCopy()); ref != nil {
+ writeRefusal(w, ref)
+ return
+ }
+ open, _, err := s.store.SelfServeMoveForHost(acct.Sqid, app, host)
+ if err != nil {
+ log.Printf("console: self-serve move %s/%s/%s: the rows: %v", acct.Sqid, app, host, err)
+ jsonError(w, http.StatusBadGateway, msgSelfServeReadMove)
+ return
+ }
+ if ref := selfServeBusy(rec, open); ref != nil {
+ writeRefusal(w, ref)
+ return
+ }
+ var req struct {
+ Region string `json:"region"`
+ DisplayName string `json:"display_name"`
+ RequestedBy string `json:"requested_by"`
+ StartAt json.RawMessage `json:"start_at"`
+ }
+ if !readJSON(w, r, &req) {
+ return
+ }
+ if len(req.StartAt) > 0 && string(req.StartAt) != "null" {
+ jsonError(w, http.StatusBadRequest, msgSelfServeScheduled)
+ return
+ }
+ if !validDisplayName(req.DisplayName) {
+ jsonError(w, http.StatusBadRequest, msgSelfServeDisplayName)
+ return
+ }
+ prep, ref := s.selfServePrepareChecks(ctx, acct.Sqid, app, host, rec, req.Region)
+ if ref != nil {
+ writeRefusal(w, ref)
+ return
+ }
+ // The lifetime: the row is bound to the record's incarnation, so a
+ // legacy record is migrated first, under the app and hostname locks and
+ // bound to the observation the checks ran on. A record admitted before
+ // the cutover has no lifetime to migrate into: it is settling until the
+ // console turns lifetimes on.
+ if rec.Incarnation == 0 {
+ if !lifetimesUnit(ctx) {
+ jsonError(w, http.StatusConflict, msgLifetimeSettling)
+ return
+ }
+ selfServeDoorPause("migrate")
+ var (
+ mrec *provision.Record
+ state string
+ merr error
+ )
+ _ = s.withAppHost(acct.Sqid, app, host, func() error {
+ mrec, _, _, state, merr = s.migrateHostLifetimeLocked(ctx, acct.Sqid, app, host, obs.ETag)
+ return nil
+ })
+ if state != lifetimeCommitted || mrec == nil {
+ if merr != nil && !errors.Is(merr, errLifetimeSettling) && !errors.Is(merr, errLifetimePending) {
+ log.Printf("console: self-serve move %s/%s/%s: the lifetime migration: %s %v", acct.Sqid, app, host, state, merr)
+ }
+ jsonError(w, http.StatusConflict, msgLifetimeSettling)
+ return
+ }
+ rec = mrec
+ if ref := selfServeBusy(rec, nil); ref != nil {
+ writeRefusal(w, ref)
+ return
+ }
+ }
+ id, err := moves.NewMoveID(rand.Reader)
+ if err != nil {
+ log.Printf("console: self-serve move %s/%s/%s: move id: %v", acct.Sqid, app, host, err)
+ jsonError(w, http.StatusBadGateway, "could not mint a move id")
+ return
+ }
+ now := time.Now().UTC()
+ terms, seconds := s.estimate(prep.sel.From, prep.sel.To, prep.facts.ReplicaBytes, prep.facts.FilesBytes, prep.facts.ReplicaBytes)
+ rawTerms, err := json.Marshal(terms)
+ if err != nil {
+ jsonError(w, http.StatusBadGateway, msgSelfServeWriteFailed)
+ return
+ }
+ row := SelfServeMove{
+ ID: id, Account: acct.Sqid, App: app, Host: host, Incarnation: rec.Incarnation,
+ From: prep.sel.From, To: prep.sel.To,
+ Participants: provision.Participants{Source: prep.sel.Source, Target: prep.sel.Target},
+ DisplayName: req.DisplayName,
+ RequestedBy: strings.TrimSpace("token:" + p.TokenID + " " + req.RequestedBy),
+ Phase: ssRequested, EstimateSeconds: int64(seconds), EstimateTerms: string(rawTerms),
+ WindowDeadline: now.Add(selfServeConfirmWindow), CreatedAt: now, UpdatedAt: now, PhaseStartedAt: now,
+ }
+ admitted, other, err := s.store.AdmitSelfServeMove(row)
+ if err != nil {
+ log.Printf("console: self-serve move %s/%s/%s: admit %s: %v", acct.Sqid, app, host, id, err)
+ jsonError(w, http.StatusBadGateway, msgSelfServeWriteFailed)
+ return
+ }
+ if !admitted {
+ writeRefusal(w, selfServeInFlight(nil, other))
+ return
+ }
+ s.bumpSerial(ctx)
+ writeJSON(w, http.StatusAccepted, s.selfServeViewOf(rec, &row, nil, now))
+}
+
+// selfServeNamedRow is the cut and cancel doors' first half: the body's id
+// and the instance's open row, which must be the move the caller names.
+func (s *Server) selfServeNamedRow(w http.ResponseWriter, r *http.Request, acct *Account, app, host string) (*SelfServeMove, bool) {
+ var req struct {
+ ID string `json:"id"`
+ }
+ if !readJSON(w, r, &req) {
+ return nil, false
+ }
+ open, _, err := s.store.SelfServeMoveForHost(acct.Sqid, app, host)
+ if err != nil {
+ log.Printf("console: self-serve move %s/%s/%s: the rows: %v", acct.Sqid, app, host, err)
+ jsonError(w, http.StatusBadGateway, msgSelfServeReadMove)
+ return nil, false
+ }
+ if open == nil || req.ID == "" || open.ID != req.ID {
+ jsonError(w, http.StatusConflict, msgSelfServeNotConfirmed)
+ return nil, false
+ }
+ return open, true
+}
+
+// selfServeCutPhase reports whether a row's phase is past the cut's
+// handoff: restage through finishing.
+func selfServeCutPhase(phase string) bool {
+ switch phase {
+ case ssRestage, ssBarrier, ssStopping, ssSealing, ssLanding, ssLanded, ssOpenUncertain, ssOpened, ssHealth, ssFinishing:
+ return true
+ }
+ return false
+}
+
+// selfServeMoveCut is the cut: the handoff to the job (MarkSelfServeCut,
+// the door's only write), whose ready phase performs the transition. A cut
+// that matched nothing is decided from the row read again: a cut already
+// requested or under way is 202 (a repeated cut), a committed expiry is
+// expired, a cancellation is cancelled, and anything else is not ready.
+func (s *Server) selfServeMoveCut(w http.ResponseWriter, r *http.Request) {
+ _, acct, app, host, ok := s.selfServeGate(w, r)
+ if !ok {
+ return
+ }
+ open, ok := s.selfServeNamedRow(w, r, acct, app, host)
+ if !ok {
+ return
+ }
+ selfServeDoorPause("cut")
+ now := time.Now().UTC()
+ wait := 2 * estimateTermsOf(open.EstimateTerms).Copy
+ if wait < selfServeCutCopyFloor {
+ wait = selfServeCutCopyFloor
+ }
+ won, err := s.store.MarkSelfServeCut(open.ID, now, now.Add(wait))
+ if err != nil {
+ log.Printf("console: self-serve move %s: cut: %v", open.ID, err)
+ jsonError(w, http.StatusBadGateway, msgSelfServeWriteFailed)
+ return
+ }
+ if !won {
+ row, ok := s.selfServeReread(w, open.ID)
+ if !ok {
+ return
+ }
+ switch {
+ case !row.CutRequestedAt.IsZero() || selfServeCutPhase(row.Phase):
+ case row.Phase == ssReady && (row.ExpiryPending || row.Request.Expired):
+ jsonError(w, http.StatusConflict, msgSelfServeExpired)
+ return
+ case !row.AbortRequestedAt.IsZero() || row.Outcome == "aborted" || row.Phase == ssReverting:
+ jsonError(w, http.StatusConflict, msgSelfServeCancelled)
+ return
+ default:
+ writeRefusal(w, selfServeNotReady(row.Phase))
+ return
+ }
+ }
+ s.bumpSerial(r.Context())
+ s.selfServeAnswer(w, r, acct, app, host, open.ID, http.StatusAccepted)
+}
+
+// selfServeCancellable are the phases a cancel may still mark: everything
+// before the cut.
+var selfServeCancellable = []string{ssRequested, ssReserving, ssQueued, ssReports, ssStaging, ssPrechecks, ssReady}
+
+// selfServeMoveAbort is the cancel: abort_requested_at (the door's only
+// write), refused once the cut is requested. A cancel that matched nothing
+// is decided from the row read again: a cancel already requested is 202, a
+// confirmed cut is final, and a finished move is no move.
+func (s *Server) selfServeMoveAbort(w http.ResponseWriter, r *http.Request) {
+ _, acct, app, host, ok := s.selfServeGate(w, r)
+ if !ok {
+ return
+ }
+ open, ok := s.selfServeNamedRow(w, r, acct, app, host)
+ if !ok {
+ return
+ }
+ selfServeDoorPause("abort")
+ won, err := s.store.RequestSelfServeAbort(open.ID, selfServeCancellable, time.Now().UTC())
+ if err != nil {
+ log.Printf("console: self-serve move %s: cancel: %v", open.ID, err)
+ jsonError(w, http.StatusBadGateway, msgSelfServeWriteFailed)
+ return
+ }
+ if !won {
+ row, ok := s.selfServeReread(w, open.ID)
+ if !ok {
+ return
+ }
+ switch {
+ case !row.AbortRequestedAt.IsZero():
+ case !row.CutRequestedAt.IsZero() || selfServeCutPhase(row.Phase):
+ jsonError(w, http.StatusConflict, msgSelfServeConfirmed)
+ return
+ default:
+ jsonError(w, http.StatusConflict, msgSelfServeNoMove)
+ return
+ }
+ }
+ s.bumpSerial(r.Context())
+ s.selfServeAnswer(w, r, acct, app, host, open.ID, http.StatusAccepted)
+}
+
+// selfServeReread is the row read again after a write that matched
+// nothing. A row that is gone is no move.
+func (s *Server) selfServeReread(w http.ResponseWriter, id string) (*SelfServeMove, bool) {
+ row, err := s.store.SelfServeMove(id)
+ if err != nil {
+ log.Printf("console: self-serve move %s: reread: %v", id, err)
+ jsonError(w, http.StatusBadGateway, msgSelfServeReadMove)
+ return nil, false
+ }
+ if row == nil {
+ jsonError(w, http.StatusConflict, msgSelfServeNoMove)
+ return nil, false
+ }
+ return row, true
+}
+
+// selfServeAnswer answers code with the view of the instance after a
+// door's write: the record and the row read again. A record that cannot be
+// read then still answers, with the row's own view: the write is done.
+func (s *Server) selfServeAnswer(w http.ResponseWriter, r *http.Request, acct *Account, app, host, id string, code int) {
+ now := time.Now().UTC()
+ row, err := s.store.SelfServeMove(id)
+ if err != nil || row == nil {
+ log.Printf("console: self-serve move %s: the row after the write: %v", id, err)
+ writeJSON(w, code, selfServeMoveView{State: "preparing", Kind: provision.MoveKindOffline, ID: id})
+ return
+ }
+ rec, _, err := s.readRecord(r.Context(), acct.Sqid, app, host)
+ if err != nil {
+ log.Printf("console: self-serve move %s: the record after the write: %v", id, err)
+ writeJSON(w, code, s.selfServeRowView(row))
+ return
+ }
+ var open, terminal *SelfServeMove
+ if row.Outcome == "" {
+ open = row
+ } else {
+ terminal = row
+ }
+ writeJSON(w, code, s.selfServeViewOf(rec, open, terminal, now))
+}
+
+// selfServeEstimate is GET .../move?estimate=1&region=<region>: the
+// prepare's checks for a move not yet requested, through the lane, then the
+// estimate. It writes and migrates nothing.
+func (s *Server) selfServeEstimate(w http.ResponseWriter, r *http.Request, acct *Account, app, host string, rec *provision.Record) {
+ open, _, err := s.store.SelfServeMoveForHost(acct.Sqid, app, host)
+ if err != nil {
+ log.Printf("console: self-serve move %s/%s/%s: the rows: %v", acct.Sqid, app, host, err)
+ jsonError(w, http.StatusBadGateway, msgSelfServeReadMove)
+ return
+ }
+ if ref := selfServeBusy(rec, open); ref != nil {
+ writeRefusal(w, ref)
+ return
+ }
+ prep, ref := s.selfServePrepareChecks(r.Context(), acct.Sqid, app, host, rec, r.URL.Query().Get("region"))
+ if ref != nil {
+ writeRefusal(w, ref)
+ return
+ }
+ _, seconds := s.estimate(prep.sel.From, prep.sel.To, prep.facts.ReplicaBytes, prep.facts.FilesBytes, prep.facts.ReplicaBytes)
+ writeJSON(w, http.StatusOK, struct {
+ EstimateSeconds int `json:"estimate_seconds"`
+ FilesBytes int64 `json:"files_bytes"`
+ ReplicaBytes int64 `json:"replica_bytes"`
+ }{seconds, prep.facts.FilesBytes, prep.facts.ReplicaBytes})
+}
diff --git a/internal/console/selfserve_estimate.go b/internal/console/selfserve_estimate.go
new file mode 100644
index 000000000..af9739b97
--- /dev/null
+++ b/internal/console/selfserve_estimate.go
@@ -0,0 +1,82 @@
+package console
+
+import (
+ "encoding/json"
+ "time"
+)
+
+// The self-serve move's estimate (self-serve moves plan 2; spec §6.2). The
+// door shows it before a move is requested and stores it on the row, and
+// the job's deadlines are multiples of its terms (the cut's copy wait is
+// max(5 min, 2 × Copy)).
+//
+// Task 5 states the terms with their defaults only. Task 9 adds the
+// deployment's timings (the medians of move_timings) under the same
+// signature; until then every move is estimated from the defaults.
+
+// estimateTerms is one estimate, term by term: how long the stop, the
+// final copy, the landing, the open and the promotion should take.
+type estimateTerms struct {
+ Stop, Copy, Land, Open, Promote time.Duration
+}
+
+// term is one phase's rule: a fixed part and, for a phase that moves bytes,
+// a rate.
+type term struct {
+ Fixed time.Duration
+ BytesPerSecond float64
+}
+
+const mib = 1 << 20
+
+// estimateDefaults are the terms before the deployment has timings.
+var estimateDefaults = struct{ Stop, Copy, Land, Open, Promote term }{
+ Stop: term{Fixed: 20 * time.Second},
+ Copy: term{Fixed: 10 * time.Second, BytesPerSecond: 25 * mib},
+ Land: term{Fixed: 30 * time.Second, BytesPerSecond: 40 * mib},
+ Open: term{Fixed: 30 * time.Second},
+ Promote: term{Fixed: 10 * time.Second, BytesPerSecond: 70 * mib},
+}
+
+// of is the term's duration for n bytes.
+func (t term) of(n int64) time.Duration {
+ d := t.Fixed
+ if t.BytesPerSecond > 0 && n > 0 {
+ d += time.Duration(float64(n) / t.BytesPerSecond * float64(time.Second))
+ }
+ return d
+}
+
+// estimate is the move's terms and its whole length in seconds, in whole
+// minutes and at least two: remaining is what the final copy still has to
+// carry (the whole replica before any staging), files the instance's files
+// and replica its replica. from and to name the regions, for Task 9's
+// per-lane timings.
+func (s *Server) estimate(from, to string, remaining, files, replica int64) (estimateTerms, int) {
+ _, _ = from, to
+ d := estimateDefaults
+ t := estimateTerms{
+ Stop: d.Stop.of(0),
+ Copy: d.Copy.of(remaining),
+ Land: d.Land.of(files + replica),
+ Open: d.Open.of(0),
+ Promote: d.Promote.of(replica),
+ }
+ total := t.Stop + t.Copy + t.Land + t.Open + t.Promote
+ minutes := int((total + time.Minute - 1) / time.Minute)
+ if minutes < 2 {
+ minutes = 2
+ }
+ return t, minutes * 60
+}
+
+// estimateTermsOf reads a row's stored terms back; a row without them (or
+// with terms this build cannot read) is the zero terms, so every deadline
+// derived from them is its floor.
+func estimateTermsOf(raw string) estimateTerms {
+ var t estimateTerms
+ if raw != "" {
+ _ = json.Unmarshal([]byte(raw), &t)
+ }
+ return t
+}
diff --git a/internal/console/selfserve_gate.go b/internal/console/selfserve_gate.go
index 4f0208dc7..ea714b159 100644
--- a/internal/console/selfserve_gate.go
+++ b/internal/console/selfserve_gate.go
@@ -62,7 +62,8 @@ func moveCapable(p Principal, acct *Account, app string) bool {
// limiter or with no bucket, while an authorised caller keeps the rails.
// The declared exception: an authorised token with a malformed hostname
// gets the 404 before the rails, where moveGate would answer 429 or 503
-// first. Nothing here reads the store or any move evidence.
+// first. Nothing here reads the store or any move evidence. The bodies are
+// in selfserve_door.go.
func (s *Server) selfServeGate(w http.ResponseWriter, r *http.Request) (p Principal, acct *Account, app, host string, ok bool) {
c := mainMoveCopy().gate
_, acct, app, ok = s.apiAccountAppSaying(w, r, s.store.HasAppIn, c)
@@ -106,27 +107,3 @@ func (s *Server) instanceRails(w http.ResponseWriter, r *http.Request, acct *Acc
}
return true
}
-
-// The three self-serve door bodies. Task 5 writes them; until then each
-// answers the family's 404 once the gate has passed.
-
-func (s *Server) selfServeMoveRequest(w http.ResponseWriter, r *http.Request) {
- if _, _, _, _, ok := s.selfServeGate(w, r); !ok {
- return
- }
- http.NotFound(w, r)
-}
-
-func (s *Server) selfServeMoveCut(w http.ResponseWriter, r *http.Request) {
- if _, _, _, _, ok := s.selfServeGate(w, r); !ok {
- return
- }
- http.NotFound(w, r)
-}
-
-func (s *Server) selfServeMoveAbort(w http.ResponseWriter, r *http.Request) {
- if _, _, _, _, ok := s.selfServeGate(w, r); !ok {
- return
- }
- http.NotFound(w, r)
-}
diff --git a/internal/console/selfserve_gate_test.go b/internal/console/selfserve_gate_test.go
index 65098c846..901e4bf1f 100644
--- a/internal/console/selfserve_gate_test.go
+++ b/internal/console/selfserve_gate_test.go
@@ -173,8 +173,8 @@ func (c *countingSQL) PrepareContext(ctx context.Context, q string) (*sql.Stmt,
// selfServeDoors are the three mutation doors the self-serve gate fronts.
var selfServeDoors = []struct{ name, method, suffix, body string }{
{"request", http.MethodPost, "/move", `{"region":"uk"}`},
- {"cut", http.MethodPost, "/move/cut", ""},
- {"abort", http.MethodDelete, "/move", ""},
+ {"cut", http.MethodPost, "/move/cut", `{"id":"mv_aaaaaaaaaaaaaaaaaaaaaaaaaa"}`},
+ {"abort", http.MethodDelete, "/move", `{"id":"mv_aaaaaaaaaaaaaaaaaaaaaaaaaa"}`},
}
// moveRecordStates writes ltOne's record in each state the gate must never
@@ -507,16 +507,30 @@ func TestMoveCapableGateAtTheRoutes(t *testing.T) {
// instance gate's rails exactly as the instance gate gives them.
t.Run("the owner's token meets the rails", func(t *testing.T) {
setMoveRecord(t, fx.rel, "eligible")
- // Past the rails it reaches the self-serve body, which until Task 5
- // answers the family's 404: the budget shows each request got there.
+ // Past the rails it reaches the self-serve body, which answers for
+ // itself: the request reads the record and the fleet (no box in
+ // uk), and cut and cancel find no move of that id. The budget shows
+ // each request passed the limiter.
fx.srv.cfg.Releases = counted
passed, err := fx.st.NewAppToken(OpsAccountID, ltApp, "paul@keymail.dev", "router", true, time.Now())
if err != nil {
t.Fatal(err)
}
+ bodies := map[string]struct {
+ code int
+ msg string
+ reads bool
+ }{
+ "request": {http.StatusConflict, "uk has 0 boxes. an offline move needs exactly one box in the region it moves to", true},
+ "cut": {http.StatusConflict, "this is not the move you confirmed", false},
+ "abort": {http.StatusConflict, "this is not the move you confirmed", false},
+ }
for _, d := range selfServeDoors {
- code, h, body, _, _, _ := call(d.method, sc.url(ltOne, d.suffix), passed, d.body)
- assertNotFound(t, "the owner's "+d.name+" past the rails", notFound, code, h, body)
+ code, _, body, reads, _, _ := call(d.method, sc.url(ltOne, d.suffix), passed, d.body)
+ want := bodies[d.name]
+ if code != want.code || cutoverErrorOf(body) != want.msg || (reads > 0) != want.reads {
+ t.Errorf("the owner's %s past the rails = %d %s (%d store calls), want %d %q", d.name, code, body, reads, want.code, want.msg)
+ }
}
if left := sc.remaining(passed); left != cliInstanceBurst-len(selfServeDoors) {
t.Errorf("the owner's requests left %d of the budget, want %d: each one passed the limiter",
diff --git a/internal/console/selfserve_store.go b/internal/console/selfserve_store.go
index 856828622..7f2b71f8c 100644
--- a/internal/console/selfserve_store.go
+++ b/internal/console/selfserve_store.go
@@ -461,12 +461,13 @@ func (s *Store) AdmitSelfServeMove(m SelfServeMove) (admitted bool, open *SelfSe
defer tx.Rollback()
res, err := tx.Exec(`INSERT INTO self_serve_moves
(id, account, app, host, incarnation, from_region, to_region, participants, display_name, requested_by,
- phase, phase_started_at, step_deadline, window_deadline, phase_step, reason, created_at, updated_at)
-VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ phase, phase_started_at, step_deadline, window_deadline, phase_step, reason, estimate_seconds, estimate_terms,
+ created_at, updated_at)
+VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT DO NOTHING`,
m.ID, m.Account, m.App, m.Host, inc, m.From, m.To, string(participants), m.DisplayName, m.RequestedBy,
m.Phase, ssNanos(m.PhaseStartedAt), ssNanos(m.StepDeadline), ssNanos(m.WindowDeadline), m.PhaseStep, m.Reason,
- ssNanos(m.CreatedAt), ssNanos(m.UpdatedAt))
+ m.EstimateSeconds, m.EstimateTerms, ssNanos(m.CreatedAt), ssNanos(m.UpdatedAt))
won, err := ssWon(res, err)
if err != nil {
return false, nil, err
@@ -811,9 +812,10 @@ WHERE id = ? AND phase = 'ready' AND abort_requested_at = 0 AND cut_requested_at
}
// RequestSelfServeAbort marks the abort requested while the row is in one
-// of phases and no abort is requested yet:
+// of phases, no abort is requested yet and no cut is (a cut that won is
+// final; the cancel door re-reads and says so):
//
-// WHERE phase IN (...) AND abort_requested_at = 0
+// WHERE phase IN (...) AND abort_requested_at = 0 AND cut_requested_at = 0
func (s *Store) RequestSelfServeAbort(id string, phases []string, now time.Time) (bool, error) {
if len(phases) == 0 {
return false, nil
@@ -826,7 +828,7 @@ func (s *Store) RequestSelfServeAbort(id string, phases []string, now time.Time)
args = append(args, p)
}
return ssWon(s.db.Exec(`UPDATE self_serve_moves SET abort_requested_at = ?, updated_at = ?
-WHERE id = ? AND phase IN (?`+strings.Repeat(", ?", len(phases)-1)+`) AND abort_requested_at = 0`, args...))
+WHERE id = ? AND phase IN (?`+strings.Repeat(", ?", len(phases)-1)+`) AND abort_requested_at = 0 AND cut_requested_at = 0`, args...))
}
// ---- the row's other columns ---------------------------------------------------
diff --git a/internal/console/selfserve_view.go b/internal/console/selfserve_view.go
new file mode 100644
index 000000000..8ac782982
--- /dev/null
+++ b/internal/console/selfserve_view.go
@@ -0,0 +1,128 @@
+package console
+
+import (
+ "time"
+
+ "github.com/carlosframework/platform/internal/provision"
+)
+
+// The self-serve move's view (self-serve moves plan 2 Task 5; spec §6.1):
+// the flat answer of GET .../move, joined from the record and the
+// instance's self-serve rows. Rendering never depends on the mode: the
+// status door joins the rows whenever the console is claims-off with Phase 1
+// off, so a move the console admitted keeps its view after the switch goes
+// off, after a restart and on a later poll. Where no rule below applies the
+// answer is today's view (moveViewOf), byte for byte. Phase 1's moveView is
+// untouched.
+
+// selfServeMoveView is the view of a self-serve move: the legacy words
+// (state, target, updated_at) and the move's own fields. Times are Unix
+// seconds; regions are named as the caller names them (homeRegionOut).
+type selfServeMoveView struct {
+ State string `json:"state"`
+ Target string `json:"target,omitempty"`
+ UpdatedAt int64 `json:"updated_at,omitempty"`
+ Kind string `json:"kind"`
+ ID string `json:"id,omitempty"`
+ EstimateSeconds int `json:"estimate_seconds,omitempty"`
+ OfflineAt int64 `json:"offline_at,omitempty"`
+ Reason string `json:"reason,omitempty"`
+ Abort *selfServeAbortView `json:"abort,omitempty"`
+ Last *selfServeLast `json:"last,omitempty"`
+}
+
+// selfServeAbortView is a cancellation's progress: when it was asked for,
+// when the source served again, when the abort finished.
+type selfServeAbortView struct {
+ RequestedAt int64 `json:"requested_at"`
+ SourceReopenedAt int64 `json:"source_reopened_at,omitempty"`
+ FinishedAt int64 `json:"finished_at,omitempty"`
+}
+
+// selfServeLast is the outcome of the instance's last self-serve move.
+type selfServeLast struct {
+ ID string `json:"id"`
+ Outcome string `json:"outcome"`
+ Reason string `json:"reason,omitempty"`
+ Warning string `json:"warning,omitempty"`
+ From string `json:"from"`
+ To string `json:"to"`
+ At int64 `json:"at"`
+}
+
+func unixOrZero(t time.Time) int64 {
+ if t.IsZero() {
+ return 0
+ }
+ return t.Unix()
+}
+
+// selfServeRowState is a row's view word: preparing through prechecks,
+// ready (cutting from cut_requested_at on), cutting from restage on.
+func selfServeRowState(row *SelfServeMove) string {
+ switch row.Phase {
+ case ssRequested, ssReserving, ssQueued, ssReports, ssStaging, ssPrechecks:
+ return "preparing"
+ case ssReady:
+ if !row.CutRequestedAt.IsZero() {
+ return "cutting"
+ }
+ return "ready"
+ }
+ return "cutting"
+}
+
+// selfServeRowView is the view of an open row on its own.
+func (s *Server) selfServeRowView(row *SelfServeMove) selfServeMoveView {
+ v := selfServeMoveView{
+ State: selfServeRowState(row), Target: s.homeRegionOut(row.To), UpdatedAt: unixOrZero(row.UpdatedAt),
+ Kind: provision.MoveKindOffline, ID: row.ID, EstimateSeconds: int(row.EstimateSeconds), Reason: row.Reason,
+ }
+ if !row.AbortRequestedAt.IsZero() {
+ v.Abort = &selfServeAbortView{RequestedAt: row.AbortRequestedAt.Unix(),
+ SourceReopenedAt: unixOrZero(row.SourceReopenedAt), FinishedAt: unixOrZero(row.AbortFinishedAt)}
+ }
+ return v
+}
+
+// selfServeViewOf is the view of rec joined with the instance's open row
+// and newest terminal row (either may be nil), by these rules, first match:
+//
+// 1. the record carries an offline move with SelfServe, and the open row is
+// that move (its id is the move's and the self-serve id) of this
+// lifetime: the row's view, preparing through ready, cutting from
+// restage, with the record's offline_at;
+// 2. the record carries any other move: today's view, no last;
+// 3. no move on the record and an open row of this lifetime (the row
+// before its queue): preparing;
+// 4. no move on the record and a terminal row of this lifetime updated
+// less than seven days ago: none, with last;
+// 5. otherwise today's view.
+//
+// So a row of another lifetime (a recreated host) never shows.
+func (s *Server) selfServeViewOf(rec *provision.Record, open, terminal *SelfServeMove, now time.Time) any {
+ if rec == nil {
+ return s.moveViewOf(rec)
+ }
+ m := rec.Move
+ switch {
+ case m.Offline() && m.SelfServe != nil && open != nil && open.ID == m.ID && open.ID == m.SelfServe.ID && open.Incarnation == rec.Incarnation:
+ v := s.selfServeRowView(open)
+ if m.SelfServe.OfflineAt != nil {
+ v.OfflineAt = m.SelfServe.OfflineAt.Unix()
+ }
+ return v
+ case m != nil:
+ return s.moveViewOf(rec)
+ case open != nil && open.Incarnation == rec.Incarnation:
+ v := s.selfServeRowView(open)
+ v.State = "preparing"
+ return v
+ case terminal != nil && terminal.Incarnation == rec.Incarnation && now.Sub(terminal.UpdatedAt) < selfServeTerminalLastShown:
+ return selfServeMoveView{State: "none", Kind: provision.MoveKindOffline, Last: &selfServeLast{
+ ID: terminal.ID, Outcome: terminal.Outcome, Reason: terminal.OutcomeReason, Warning: terminal.Warning,
+ From: s.homeRegionOut(terminal.From), To: s.homeRegionOut(terminal.To), At: unixOrZero(terminal.UpdatedAt),
+ }}
+ }
+ return s.moveViewOf(rec)
+}