| 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®ion=<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}) |
| +} |