carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package console
2
3import (
4 "context"
5 "crypto/rand"
6 "database/sql"
7 "encoding/json"
8 "errors"
9 "fmt"
10 "github.com/carlosframework/platform/internal/park"
11 "log"
12 "net/http"
13 "os"
14 "slices"
15 "strings"
16 "time"
17
18 "github.com/carlosframework/platform/internal/activator"
19 "github.com/carlosframework/platform/internal/appconfig"
20 "github.com/carlosframework/platform/internal/hostactors"
21 "github.com/carlosframework/platform/internal/hostclaim"
22 "github.com/carlosframework/platform/internal/hostfence"
23 "github.com/carlosframework/platform/internal/hostops"
24 "github.com/carlosframework/platform/internal/hostresolve"
25 "github.com/carlosframework/platform/internal/moves"
26 "github.com/carlosframework/platform/internal/objstore"
27 "github.com/carlosframework/platform/internal/provision"
28 "github.com/carlosframework/platform/internal/registry"
29 "github.com/carlosframework/platform/internal/release"
30 "github.com/carlosframework/platform/internal/svcrole"
31)
32
33// The residence-move doors (issue #331, instance-move spec): the console
34// writes REQUESTS onto the record and bumps the serial; everything that
35// touches data happens on the source box's move worker. Three verbs:
36//
37// POST .../instances/{host}/move {"region":"uk"} → preparing
38// POST .../instances/{host}/move/cut → cutting
39// DELETE .../instances/{host}/move → aborted
40//
41// State is read through the ordinary instance GET — the record carries
42// `move` verbatim while one is in flight.
43
44// moveGate is the shared front half: the instance family's gate, the
45// byte-identical 404 for a host that names no record, and the class rail —
46// only an exec-backed instance route's state is the platform's to move
47// (spec §Out of scope), and fleet placements keep their deliberate
48// state-does-not-follow semantics.
49func (s *Server) moveGate(w http.ResponseWriter, r *http.Request) (acct *Account, app, host string, rec *provision.Record, ok bool) {
50 c := mainMoveCopy()
51 if s.cfg.MovesEnabled {
52 c = phase1MoveCopy()
53 }
54 _, acct, app, gok := s.instanceGateSaying(w, r, "instance", c.gate)
55 if !gok {
56 return nil, "", "", nil, false
57 }
58 host = r.PathValue("host")
59 if !provision.ValidHostShape(host) {
60 http.NotFound(w, r)
61 return nil, "", "", nil, false
62 }
63 ctx := r.Context()
64 rec, err := provision.GetRecord(ctx, s.cfg.Releases, acct.Sqid, app, host)
65 if errors.Is(err, os.ErrNotExist) {
66 http.NotFound(w, r)
67 return nil, "", "", nil, false
68 }
69 if err != nil {
70 log.Printf("console: api instance move %s/%s/%s: %v", acct.Sqid, app, host, err)
71 jsonError(w, http.StatusBadGateway, "could not read the instance")
72 return nil, "", "", nil, false
73 }
74 if k := rec.Kind; k != "" && k != "instance" {
75 jsonError(w, http.StatusBadRequest, c.kind(k))
76 return nil, "", "", nil, false
77 }
78 if rec.Addr != "" || len(rec.Addrs) > 0 {
79 jsonError(w, http.StatusBadRequest, c.addr)
80 return nil, "", "", nil, false
81 }
82 if _, fleet := registry.FleetPlacement(rec.Region); fleet {
83 jsonError(w, http.StatusBadRequest, c.fleet)
84 return nil, "", "", nil, false
85 }
86 if rec.Pool != "" {
87 jsonError(w, http.StatusBadRequest, c.pool)
88 return nil, "", "", nil, false
89 }
90 if !rec.HibernateOrDefault() {
91 jsonError(w, http.StatusBadRequest, c.hibernate)
92 return nil, "", "", nil, false
93 }
94 return acct, app, host, rec, true
95}
96
97// moveCopy is what moveGate says: its class rails and the shared gates'
98// sentences. With the switch off the move doors speak main's bytes
99// (mainMoveCopy()); with it on, the Phase 1 sentences (phase1MoveCopy(), copy
100// record MV-N47 to MV-N54), which no other door ever speaks.
101type moveCopy struct {
102 gate gateCopy
103 kind func(kind string) string
104 addr, fleet, pool, hibernate string
105}
106
107// mainMoveCopy returns main's sentences, byte for byte (the dashes are escaped
108// only in the source).
109func mainMoveCopy() moveCopy {
110 return moveCopy{
111 gate: mainGateCopy(),
112 kind: func(k string) string { return "only exec-backed instances move \u2014 this route is kind " + k },
113 addr: "an addr-backed route's process is not platform-managed \u2014 nothing to move",
114 fleet: "fleet-placed instances keep state-does-not-follow semantics \u2014 move is for platform regions",
115 pool: "pooled instances do not move in v1 \u2014 their data lives on the pool's own box",
116 hibernate: "a non-hibernating instance runs outside the activator \u2014 nothing manages its data, nothing can move it",
117 }
118}
119
120func phase1MoveCopy() moveCopy {
121 return moveCopy{
122 gate: gateCopy{
123 ambiguous: func(seg, alts string) string {
124 return fmt.Sprintf("account %q is ambiguous. pass --account-id with one of: %s", seg, alts)
125 },
126 // moveGate asks the family's gate for the noun "instance" and no
127 // other, so the recorded sentence (MV-N52) is written whole.
128 tooMany: func(string) string { return "too many instance requests. wait a minute and try again" },
129 noBucket: "this console has no deployment bucket, so it cannot answer this",
130 },
131 kind: func(k string) string { return "only exec-backed instances move. this route is kind " + k },
132 addr: "an addr-backed route's process is not platform-managed. there is nothing to move",
133 fleet: "fleet-placed instances keep state-does-not-follow semantics. moves are for platform regions",
134 pool: "pooled instances do not move yet. their data lives on the pool's own box",
135 hibernate: "a non-hibernating instance runs outside the activator. nothing manages its data, so nothing can move it",
136 }
137}
138
139// moveScopedHolder is MV-N167's sentence, the one the cut's admission
140// records (provision.RefusedReplicaLayout); the door speaks it for a holder
141// it will not move (ruling T5-lease). A literal, because the copy gate
142// reads this file's sentences; TestMoveDoorRefusesAScopedHolder pins it
143// equal to the recorded constant.
144const moveScopedHolder = "the instance keeps its data under a per-incarnation path, which moves cannot cut yet"
145
146// moveView is the flat answer every move door speaks — titogo's consumer
147// contract (teamtito/titogo#1848, region-doors design): state, target, one
148// freshness stamp. "none" is a real state, not an error: a host with no
149// move in flight answers 200 {"state":"none"} so a poller needs no 404
150// branch. Target carries the same NAME the caller used — the home code is
151// un-normalized on the way out (see homeRegionOut).
152//
153// With CARLOS_MOVES_PHASE1 on (Plan 6 Task 2, preflight L2 and L4) the view
154// also carries the in-flight move's id, the outcome the last close left on
155// the record (D11's last), and the Phase 1 marker, so a CLI can bind its
156// wait to the move it requested and can tell a Phase 1 console from a
157// legacy one before it sends anything. None of the three reveals anything
158// the state did not, so every caller of the flat view sees them, the
159// Instance-role token included (M18). With the switch off they are absent
160// and the bytes are main's.
161type moveView struct {
162 State string `json:"state"`
163 Target string `json:"target,omitempty"`
164 UpdatedAt int64 `json:"updated_at,omitempty"`
165 ID string `json:"id,omitempty"`
166 Last *provision.MoveOutcome `json:"last,omitempty"`
167 Protocol string `json:"protocol,omitempty"`
168 // Phase is the step within the state (movePhase), so a poller reads
169 // detail=1 only when the state or the phase changes (preflight L19).
170 Phase string `json:"phase,omitempty"`
171}
172
173// movePhase is a move's phase as the flat view shows it: the cut's phase
174// while cutting, the landing's while landing, and the rollback's target and
175// source phases while rolling back. Other states have none.
176func movePhase(m *provision.MoveState) string {
177 switch m.State {
178 case provision.MoveCutting:
179 return m.CutPhase
180 case provision.MoveLanding:
181 return m.LandingPhase
182 case provision.MoveRollingBack:
183 if m.Rollback != nil {
184 return m.Rollback.TargetPhase + "/" + m.Rollback.SourcePhase
185 }
186 }
187 return ""
188}
189
190// moveProtocolPhase1 is the Phase 1 marker every move answer carries with
191// the switch on (preflight L4). The CLI refuses to send a mutation to a
192// console whose answer lacks it.
193const moveProtocolPhase1 = "phase1"
194
195func (s *Server) moveViewOf(rec *provision.Record) moveView {
196 v := moveView{State: "none"}
197 if rec != nil && rec.Move != nil {
198 v = moveView{
199 State: rec.Move.State,
200 Target: s.homeRegionOut(rec.Move.To),
201 UpdatedAt: rec.Move.UpdatedAt.Unix(),
202 }
203 }
204 if s.cfg.MovesEnabled {
205 v.Protocol = moveProtocolPhase1
206 if rec != nil {
207 if rec.Move != nil {
208 v.ID, v.Phase = rec.Move.ID, movePhase(rec.Move)
209 }
210 v.Last = rec.LastMove
211 }
212 }
213 return v
214}
215
216// armedCodes renders the armed half of an armedPlacements answer as the
217// sorted comma list every refusal names — one spelling for the create door
218// and the move door alike.
219func armedCodes(set map[string]bool) string {
220 codes := make([]string, 0, len(set))
221 for c, armed := range set {
222 if armed {
223 codes = append(codes, c)
224 }
225 }
226 slices.Sort(codes)
227 return strings.Join(codes, ", ")
228}
229
230// stagingCodes is armedCodes for the move door's stricter bar: the regions
231// that publish a staging bucket, which is the set a move can actually name.
232// A separate renderer rather than a flag on armedCodes, because a refusal
233// that listed "armed" regions a move still could not use would send the
234// caller round the same loop again.
235func stagingCodes(caps map[string]placementCapability) string {
236 codes := make([]string, 0, len(caps))
237 for c, cap := range caps {
238 if cap.Reachable && cap.Staging {
239 codes = append(codes, c)
240 }
241 }
242 slices.Sort(codes)
243 if len(codes) == 0 {
244 return "(none)"
245 }
246 return strings.Join(codes, ", ")
247}
248
249// stagingNames is stagingCodes as the Phase 1 door says it (MV-N20): each
250// code under the name a caller passes (homeRegionOut), so movePlacements'
251// folded home is listed by its name. An unnamed home has no name a caller
252// could pass and is left out, and a home that is both folded and published
253// under its name is listed once.
254func (s *Server) stagingNames(caps map[string]placementCapability) string {
255 names := make([]string, 0, len(caps))
256 for c, cap := range caps {
257 if name := s.homeRegionOut(c); name != "" && cap.Reachable && cap.Staging {
258 names = append(names, name)
259 }
260 }
261 slices.Sort(names)
262 names = slices.Compact(names)
263 if len(names) == 0 {
264 return "(none)"
265 }
266 return strings.Join(names, ", ")
267}
268
269// moveHomeName is the home region's configured name as the move door uses
270// it: the claim's home (dbClaimHomeRegion) on a host-claim console,
271// CONSOLE_HOME_REGION otherwise. It is the name homeRegionIn and
272// homeRegionOut translate with (they spell the rule out inline, which keeps
273// the copy gate's derivation of their sentences shallow), and box selection
274// reads the home with it, so the door and the selection agree when the
275// console's home is the claim-mode fallback.
276func (s *Server) moveHomeName() string {
277 if s.cfg.HostClaim {
278 return s.cfg.dbClaimHomeRegion()
279 }
280 return s.cfg.HomeRegion
281}
282
283// moveRegionAllowed is the manifest's allow-list for a move target. A
284// named target must be listed as it is. The home target ("") is allowed
285// when some entry names the home, read through homeRegionIn as the door
286// reads the caller's region. An empty list allows any target.
287func (s *Server) moveRegionAllowed(allowed []string, to string) bool {
288 if len(allowed) == 0 {
289 return true
290 }
291 if to != "" {
292 return slices.Contains(allowed, to)
293 }
294 for _, r := range allowed {
295 if s.homeRegionIn(r) == "" {
296 return true
297 }
298 }
299 return false
300}
301
302// homeRegionIn/Out translate between the home region's NAME (Config's
303// CONSOLE_HOME_REGION, what pickers and callers speak — issue #346) and the
304// record's "" (the unconstrained value the fleet's reconcile has always
305// scoped by). The record NEVER stores the code; the API never shows "".
306func (s *Server) homeRegionIn(region string) string {
307 home := s.cfg.HomeRegion
308 if s.cfg.HostClaim {
309 home = s.cfg.dbClaimHomeRegion()
310 }
311 return homePlacementCode(home, region)
312}
313
314// homePlacementCode is homeRegionIn's rule for a given home name: the
315// home's name is the record's "", any other region is itself, and with no
316// home named nothing is the home. claimMoveRosterPlaces reads roster
317// regions through it too, so the door and the API translate home one way.
318func homePlacementCode(home, region string) string {
319 return park.HomePlacementCode(home, region)
320}
321
322func (s *Server) homeRegionOut(region string) string {
323 if region == "" {
324 if s.cfg.HostClaim {
325 return s.cfg.dbClaimHomeRegion()
326 }
327 return s.cfg.HomeRegion
328 }
329 return region
330}
331
332// moveTransition applies one guarded transition and maps its outcomes:
333// ErrMoveConflict → 409 with the transition's own sentence, success → the
334// flat move view + a serial bump so the source box notices in seconds.
335func (s *Server) moveTransition(w http.ResponseWriter, r *http.Request, acct *Account, app, host string, mut func(*provision.Record) error, okStatus int) {
336 s.moveTransitionSaying(w, r, acct, app, host, mut, okStatus, error.Error, "could not write the move \u2014 try again") // main's bytes
337}
338
339// moveTransitionP1 is moveTransition for the Phase 1 door: a refusal is its
340// recorded sentence (phase1Conflict), never err.Error(), and the failure
341// sentence has no em dash. The legacy door keeps main's bytes.
342func (s *Server) moveTransitionP1(w http.ResponseWriter, r *http.Request, acct *Account, app, host string, mut func(*provision.Record) error, okStatus int) {
343 s.moveTransitionSaying(w, r, acct, app, host, mut, okStatus, phase1Conflict, "could not write the move. try again")
344}
345
346func (s *Server) moveTransitionSaying(w http.ResponseWriter, r *http.Request, acct *Account, app, host string, mut func(*provision.Record) error, okStatus int, conflict func(error) string, failed string) {
347 ctx := r.Context()
348 // Host-claim consoles: request, cut and abort all rewrite the record's
349 // move journal, which only the (unbuilt) Moves protocol may do under
350 // its own operation. Refused before any write.
351 if s.cfg.HostClaim {
352 jsonError(w, http.StatusConflict, claimMoveUnsupportedMsg)
353 return
354 }
355 rec, err := provision.SetRecordMove(ctx, s.cfg.Releases, acct.Sqid, app, host, mut)
356 if errors.Is(err, provision.ErrMoveConflict) {
357 jsonError(w, http.StatusConflict, conflict(err))
358 return
359 }
360 if err != nil {
361 log.Printf("console: api instance move %s/%s/%s: %v", acct.Sqid, app, host, err)
362 jsonError(w, http.StatusBadGateway, failed)
363 return
364 }
365 s.bumpSerial(ctx)
366 writeJSON(w, okStatus, s.moveViewOf(rec))
367}
368
369// handleAPIInstanceMoveStatus answers the poll: the flat view, "none" when
370// quiet — the tenant's freeze loop lives on this door.
371//
372// With the switch on (Plan 6 Task 2), an operator's GET with detail=1 gets
373// the detail answer (moveDetail); anyone else's gets the flat view, with no
374// error (M18). A committed landing whose claim has been converted to a
375// relocation (Paul's J3) answers state relocating in both, so the flat view
376// reads the claim while the record says committed, and only then. If that
377// read fails the flat view says what the record says; the poll goes on.
378func (s *Server) handleAPIInstanceMoveStatus(w http.ResponseWriter, r *http.Request) {
379 acct, app, host, rec, ok := s.moveGate(w, r)
380 if !ok {
381 return
382 }
383 view := s.moveViewOf(rec)
384 if !s.cfg.MovesEnabled {
385 writeJSON(w, http.StatusOK, view)
386 return
387 }
388 if r.URL.Query().Get("detail") == "1" {
389 if _, isOp := s.moveOperator(r); isOp {
390 s.writeMoveDetail(w, r, acct, app, host, rec, view)
391 return
392 }
393 }
394 if rec.Move != nil && rec.Move.State == provision.MoveCommitted {
395 obs, err := hostclaim.Read(r.Context(), s.cfg.Releases, host)
396 if err != nil {
397 log.Printf("console: api instance move %s/%s/%s: status: claim read: %v", acct.Sqid, app, host, err)
398 } else if moveConvertedAfterCommit(rec, obs) {
399 view.State = provision.MoveRelocating
400 }
401 }
402 writeJSON(w, http.StatusOK, view)
403}
404
405func (s *Server) legacyHandleAPIInstanceMoveRequest(w http.ResponseWriter, r *http.Request) {
406 acct, app, host, rec, ok := s.moveGate(w, r)
407 if !ok {
408 return
409 }
410 if s.refuseSeedMove(w, rec) {
411 return
412 }
413 // A replica is refused at the request only (G14): moveGate also fronts
414 // the status and abort doors, and a replica that got a move before this
415 // rail existed must still be able to read and abort it.
416 if rec.Role != "" {
417 jsonError(w, http.StatusBadRequest, "a replica does not move. delete it and create one in the new region")
418 return
419 }
420 var req struct {
421 Region string `json:"region"`
422 }
423 if !readJSON(w, r, &req) {
424 return
425 }
426 region := strings.TrimSpace(req.Region)
427 to := s.homeRegionIn(region)
428 if region == "" || !provision.ValidRegion(to) {
429 jsonError(w, http.StatusBadRequest, "invalid region")
430 return
431 }
432 // A move to the HOME region ("" after normalization) has no armed-edges
433 // row to validate against and no staging lane wired for the home's
434 // bucket yet — refused plainly rather than wedging in preparing.
435 if to == "" {
436 jsonError(w, http.StatusBadRequest, "moves to the home region are not supported yet — the home publishes no staging lane")
437 return
438 }
439 // The armed gate is MANDATORY for moves — unlike create there is no
440 // dormant path, because a move needs a live target box to restore on;
441 // a deployment with no armed placements cannot be a move target at all.
442 //
443 // And the bar here is STAGING, not merely hosting: the requirement is
444 // unconditional and is the target's published data-bucket coordinates,
445 // because that is precisely what the worker on the other side demands.
446 // internal/host's moveTargets skips any target whose edges file carries
447 // no data_bucket, and runMoveRound then answers "target region %s
448 // publishes no data bucket yet — waiting" on every pass, forever. Before
449 // this the door accepted such a target on reachability alone and the
450 // move wedged in preparing with no way out but a manual cancel — the
451 // door and the worker disagreeing about the same fact, which is the
452 // failure this whole family of gates exists to prevent. Unknown is not
453 // an acceptable answer for a move the way it is for a create: a create
454 // into an unconfigured box strands one instance, while a move stakes a
455 // LIVE instance's residence on a lane that does not exist.
456 caps, published := placements(r.Context(), s.cfg.Releases)
457 if !published {
458 jsonError(w, http.StatusBadRequest, "this deployment publishes no armed regions — nothing to move to")
459 return
460 }
461 if !caps[to].Reachable || !caps[to].Staging {
462 jsonError(w, http.StatusBadRequest, "region "+to+" cannot take a move in this deployment — it publishes no staging bucket. Regions that can: "+stagingCodes(caps))
463 return
464 }
465 s.moveTransition(w, r, acct, app, host, provision.MoveRequest(to, time.Now()), http.StatusAccepted)
466}
467
468func (s *Server) legacyHandleAPIInstanceMoveCut(w http.ResponseWriter, r *http.Request) {
469 acct, app, host, _, ok := s.moveGate(w, r)
470 if !ok {
471 return
472 }
473 s.moveTransition(w, r, acct, app, host, provision.MoveMarkCutting(), http.StatusAccepted)
474}
475
476// legacyHandleAPIInstanceMoveAbort: abort is two-phase (review round 1):
477// this door marks MoveAborting and the
478// SOURCE box deletes the staged copy before clearing the field — a bare
479// clear would leave staging behind to win a later landing on the target
480// (staging-wins). 202, like its siblings: the caller polls the record.
481func (s *Server) legacyHandleAPIInstanceMoveAbort(w http.ResponseWriter, r *http.Request) {
482 acct, app, host, _, ok := s.moveGate(w, r)
483 if !ok {
484 return
485 }
486 s.moveTransition(w, r, acct, app, host, provision.MoveAbort(), http.StatusAccepted)
487}
488
489// ---- Phase 1 (CARLOS_MOVES_PHASE1, index D13) ----
490//
491// With the switch off each handler below hands the request to its legacy
492// twin above, unchanged, and the resolve door answers the family's 404:
493// the console behaves exactly as before Phase 1. With it on, every
494// mutation is operator-only, the request door CASes the host claim from
495// serving to moving before it journals the move (§7.1, K2), and cut and
496// abort carry the journal's id and incarnation (MoveGuard).
497
498// moveOperatorGate is moveGate for the mutations (§12): the caller must be
499// an operator presenting their own CLI token, else the family's
500// byte-identical 404. A member's token and the Instance-role service token
501// keep the status GET only (M18), so the member-minted instance token can
502// no longer move anything (codex 11). The routes keep
503// svcrole.Allow(svcrole.Instance) so a service token meets this 404 rather
504// than the role gate's 403.
505func (s *Server) moveOperatorGate(w http.ResponseWriter, r *http.Request) (acct *Account, app, host string, rec *provision.Record, addr string, ok bool) {
506 acct, app, host, rec, gok := s.moveGate(w, r)
507 if !gok {
508 return nil, "", "", nil, "", false
509 }
510 addr, ok = s.moveOperator(r)
511 if !ok {
512 http.NotFound(w, r)
513 return nil, "", "", nil, "", false
514 }
515 return acct, app, host, rec, addr, true
516}
517
518// moveOperator reports whether the request presents an operator's own CLI
519// token, and whose. A service token, a member's token and an error reading
520// the operator flag are all "no" (the error is logged).
521func (s *Server) moveOperator(r *http.Request) (addr string, ok bool) {
522 addr, ok = s.store.CLITokenAddress(bearer(r), time.Now())
523 if !ok {
524 return "", false
525 }
526 isOp, err := s.store.IsOperator(addr)
527 if err != nil {
528 log.Printf("console: api instance move: is-operator %s: %v", addr, err)
529 }
530 if err != nil || !isOp {
531 return "", false
532 }
533 return addr, true
534}
535
536// moveGuardOf is the guard a door derives from the record it read. A record
537// with no move yields the zero guard, which every guarded transition
538// refuses with its own 409 ("no journaled move in flight", MV-N12).
539func moveGuardOf(rec *provision.Record) provision.MoveGuard {
540 if rec.Move == nil {
541 return provision.MoveGuard{}
542 }
543 return provision.MoveGuard{ID: rec.Move.ID, Incarnation: rec.Move.Incarnation}
544}
545
546// handleAPIInstanceMoveRequest is the request door (§7.1). Its order: the
547// class rails and the operator (moveOperatorGate), the record's own rails,
548// the body, the region, the app's regions, the instance's storage binding,
549// the armed placements, the fleet (selectMoveBoxes: one eligible box on
550// each side, the floor), then the claim (present, this incarnation,
551// serving, executed by the source's eligible box). Every refusal comes
552// before the claim CAS, so a refused request writes nothing.
553//
554// If the record write fails after the claim CAS, the door answers 502 and
555// logs: the claim is then moving with no journaled move, and the console
556// pass of Plan 5 Task 7 releases it.
557func (s *Server) handleAPIInstanceMoveRequest(w http.ResponseWriter, r *http.Request) {
558 if !s.cfg.MovesEnabled {
559 s.legacyHandleAPIInstanceMoveRequest(w, r)
560 return
561 }
562 acct, app, host, rec, addr, ok := s.moveOperatorGate(w, r)
563 if !ok {
564 return
565 }
566 if s.refuseSeedMove(w, rec) {
567 return
568 }
569 // Host-claim consoles start the move under the app's admission: the
570 // claim runtime's authority registers it (RegisterMove), and the start
571 // is hostops.BeginClaimMove then hostops.StartClaimMoveRecord, never a
572 // plain BeginMove (whose claim C7 cannot convert). A console with the
573 // flag on but no runtime cannot admit anything, so it refuses before any
574 // write, like cut and abort (moveTransitionSaying).
575 if s.cfg.HostClaim && s.cfg.ClaimAuthority == nil {
576 jsonError(w, http.StatusConflict, claimMoveUnsupportedMsg)
577 return
578 }
579 ctx := r.Context()
580 if rec.Role != "" {
581 jsonError(w, http.StatusBadRequest, "a replica does not move. delete it and create one in the new region")
582 return
583 }
584 if len(rec.Replicas) > 0 {
585 jsonError(w, http.StatusBadRequest, "a home with replicas does not move. remove its replicas first")
586 return
587 }
588 var req struct {
589 Region string `json:"region"`
590 Cut string `json:"cut"`
591 }
592 if !readJSON(w, r, &req) {
593 return
594 }
595 cut := req.Cut
596 if cut == "" {
597 cut = "auto"
598 }
599 if cut != "auto" && cut != "manual" {
600 jsonError(w, http.StatusBadRequest, `cut must be "auto" or "manual"`)
601 return
602 }
603 region := strings.TrimSpace(req.Region)
604 to := s.homeRegionIn(region)
605 if region == "" || !provision.ValidRegion(to) {
606 jsonError(w, http.StatusBadRequest, "invalid region")
607 return
608 }
609 // Ruling R1 (home part 2): a move into the home region is admitted only
610 // on a host-claim console, whose claim pins the home's engine region
611 // (@home) under the roster's designation. The plain path refuses it
612 // before any read or write.
613 if to == "" && !s.cfg.HostClaim {
614 jsonError(w, http.StatusBadRequest, "moves into the home region need host claims. this console runs without them")
615 return
616 }
617 // toName is the target as the caller named it, which is
618 // homeRegionOut(to): homeRegionIn maps only the home's own name to "",
619 // so the round trip gives region back (Ruling R3: the caller names the
620 // home by its configured name). Every sentence below names the home so,
621 // never by the record's "".
622 toName := region
623 if to == rec.Region {
624 jsonError(w, http.StatusConflict, "the instance is already in "+toName)
625 return
626 }
627 // Both policy reads fail closed (round-4 finding 13): an unreadable
628 // manifest or storage binding is a 503, never a pass.
629 m, err := provision.GetManifest(ctx, s.cfg.Releases, acct.Sqid, app)
630 if err != nil && !errors.Is(err, os.ErrNotExist) {
631 log.Printf("console: api instance move %s/%s/%s: manifest: %v", acct.Sqid, app, host, err)
632 jsonError(w, http.StatusServiceUnavailable, "could not read the app's instances manifest. try again")
633 return
634 }
635 if m != nil && !s.moveRegionAllowed(m.Regions, to) {
636 jsonError(w, http.StatusBadRequest, "region "+toName+" is not one of this app's regions: "+strings.Join(m.Regions, ", "))
637 return
638 }
639 // Private storage is the instance's BINDING, not the app's create-time
640 // policy (which is mutable and says nothing about an instance that
641 // already exists): the create door stamps a store per instance under
642 // its environment, or its slug when it has none (InstanceStoreEnv), and
643 // turning the policy off later removes no store. A name that cannot be
644 // an environment can hold no store, so it is not read.
645 if env := provision.InstanceStoreEnv(rec); env == "" || appconfig.ValidEnvironmentName(env) {
646 store, _, err := objstore.GetIn(ctx, s.cfg.Releases, acct.Sqid, app, env)
647 if err != nil {
648 log.Printf("console: api instance move %s/%s/%s: storage binding: %v", acct.Sqid, app, host, err)
649 jsonError(w, http.StatusServiceUnavailable, "could not read this instance's storage binding. try again")
650 return
651 }
652 if store != nil {
653 jsonError(w, http.StatusBadRequest, host+" uses private storage. Instances with private storage cannot move yet.")
654 return
655 }
656 }
657 // Region HA (spec 2 §7, H27): a destination with no box that has room
658 // is the no-room refusal, before the placement checks below answer it
659 // as a 400. It only refuses: nothing is chosen. An incomplete read is
660 // the temporarily unavailable 503, never no room.
661 if err := s.roomIn(ctx, to, ""); err != nil {
662 writeClaimResponseError(w, err)
663 return
664 }
665 caps, homeNamed, published := movePlacements(ctx, s.cfg.Releases, s.moveHomeName())
666 // Review Focus 5: a box that sets CARLOS_REGION to the home's name is
667 // home to the console but not to itself, so it cannot take a move into
668 // home. It is refused here, before the staging check: the home's fold
669 // takes placementless files only, so that check would answer "publishes
670 // no staging bucket", which hides what the operator has to fix. The
671 // files are not liveness-joined, so a stale file of a box that has gone
672 // refuses too (fail closed): the sentence names the file's box.
673 if to == "" && len(homeNamed) > 0 {
674 jsonError(w, http.StatusConflict, homeNamed[0]+" sets CARLOS_REGION, so it cannot take a move into the home region")
675 return
676 }
677 if !published {
678 jsonError(w, http.StatusBadRequest, "this deployment publishes no armed regions. nothing to move to")
679 return
680 }
681 if !caps[to].Reachable || !caps[to].Staging {
682 jsonError(w, http.StatusBadRequest, "region "+toName+" cannot take a move in this deployment. it publishes no staging bucket. Regions that can: "+s.stagingNames(caps))
683 return
684 }
685 from := s.homeRegionOut(rec.Region)
686 sel, err := s.selectMoveBoxes(ctx, host, to, rec.Region, acct.Sqid+"/"+app)
687 // No box in the destination has room (region HA spec 2 §7, H27), or the
688 // read that would tell could not be completed: the claim doors' answers,
689 // not the protocol floor's below.
690 var (
691 full *noCapacityError
692 check *roomCheckError
693 )
694 if errors.As(err, &full) || errors.As(err, &check) {
695 writeClaimResponseError(w, err)
696 return
697 }
698 if errors.Is(err, errEdgesIncomplete) {
699 jsonError(w, http.StatusConflict, "the fleet's edges files could not all be read. try again")
700 return
701 }
702 if err != nil {
703 log.Printf("console: api instance move %s/%s/%s: protocol floor: %v", acct.Sqid, app, host, err)
704 jsonError(w, http.StatusServiceUnavailable, "could not read the moves protocol floor. try again")
705 return
706 }
707 // Phase 1 is for one-box regions (spec rev 7.20 §7.1, index D15): a
708 // region with more than one eligible box, on either side, is refused.
709 if sel.Many {
710 jsonError(w, http.StatusBadRequest, sel.ManyRegion+" has more than one box. Moves between regions with several boxes are not supported yet.")
711 return
712 }
713 if sel.Target == "" {
714 jsonError(w, http.StatusBadRequest, "no box in "+toName+" publishes an edges file yet")
715 return
716 }
717 if sel.Source == "" {
718 jsonError(w, http.StatusBadRequest, "no eligible box in "+from+" publishes an edges file")
719 return
720 }
721 if sel.ConsoleBelowFloor {
722 jsonError(w, http.StatusConflict, "this console is below the moves protocol floor. roll the console first")
723 return
724 }
725 if !sel.FloorOK {
726 jsonError(w, http.StatusConflict, "a box in "+from+" or "+toName+" is below the moves protocol floor. roll the platform first")
727 return
728 }
729 inc, err := hostclaim.Incarnation(acct.Sqid, app, host, rec.CreatedAt)
730 if err != nil {
731 jsonError(w, http.StatusBadRequest, "the record's identity is malformed")
732 return
733 }
734 obs, err := hostclaim.Read(ctx, s.cfg.Releases, host)
735 if err != nil {
736 log.Printf("console: api instance move %s/%s/%s: claim read: %v", acct.Sqid, app, host, err)
737 jsonError(w, http.StatusBadGateway, "could not read the host claim")
738 return
739 }
740 if obs.Absent || obs.Claim.Holder == nil {
741 jsonError(w, http.StatusConflict, "the instance has no host claim yet. it is adopted on its next reconcile pass")
742 return
743 }
744 if obs.Claim.Holder.Incarnation != inc {
745 jsonError(w, http.StatusConflict, "the host claim names another incarnation of this instance")
746 return
747 }
748 if obs.Claim.Op.Kind != hostclaim.KindServing {
749 jsonError(w, http.StatusConflict, "the instance is under a "+obs.Claim.Op.Kind+" operation. wait for it to finish")
750 return
751 }
752 // Ruling T5-lease (Task 5 fix round 2): Phase 1 cuts only a holder
753 // whose location is the activator's own lease prefix. A scoped holder
754 // (instances/<host>/<incarnation>) is refused here, before any claim
755 // write, with the sentence the cut's admission would record (MV-N167);
756 // when this console knows the activator's prefix, so is any other
757 // location than the activator's. Moving such a host waits for a later
758 // plan.
759 if loc := strings.TrimSuffix(obs.Claim.Holder.Location, "/"); loc == "instances/"+host+"/"+inc ||
760 (s.cfg.HibernatePrefix != "" && loc != activator.HibernatePrefix(s.cfg.HibernatePrefix, host)) {
761 jsonError(w, http.StatusConflict, moveScopedHolder)
762 return
763 }
764 // §7.1 binds the source to the claim's executor (round-5 finding 7).
765 if obs.Claim.Op.Exec != sel.Source {
766 jsonError(w, http.StatusConflict, "the source box "+obs.Claim.Op.Exec+" is not the eligible box of "+from)
767 return
768 }
769 // Ruling E2, kept as the early refusal: from ONE roster observation,
770 // the source must be an active roster entry with an enrollment and must
771 // carry no termination fence. Anything else is not a box to move from
772 // (fail closed). A roster or fence that cannot be read or trusted is a
773 // 503, with the error logged and never sent. On a host-claim console
774 // BeginClaimMove checks both boxes again from the roster and records
775 // both enrollments on the op, which is what the claim's conversion (C7)
776 // is pinned to.
777 dir := hostactors.Directory{Store: s.cfg.Releases, PublicKey: s.cfg.ReleasePub, Bucket: s.cfg.Bucket}
778 roster, err := dir.Read(ctx)
779 if err != nil {
780 log.Printf("console: api instance move %s/%s/%s: host roster: %v", acct.Sqid, app, host, err)
781 jsonError(w, http.StatusServiceUnavailable, "could not read the host roster. try again")
782 return
783 }
784 if _, eligible := sourceEnrollment(roster.Roster, obs.Claim.Op.Exec); !eligible {
785 jsonError(w, http.StatusConflict, "the source box is not eligible to move from")
786 return
787 }
788 fenced, err := dir.TerminationFence(ctx, roster, obs.Claim.Op.Exec, time.Now())
789 if err != nil {
790 log.Printf("console: api instance move %s/%s/%s: termination fence of %s: %v", acct.Sqid, app, host, obs.Claim.Op.Exec, err)
791 jsonError(w, http.StatusServiceUnavailable, "could not read the host roster. try again")
792 return
793 }
794 if fenced {
795 jsonError(w, http.StatusConflict, "the source box is not eligible to move from")
796 return
797 }
798 id, err := moves.NewMoveID(rand.Reader)
799 if err != nil {
800 log.Printf("console: api instance move %s/%s/%s: move id: %v", acct.Sqid, app, host, err)
801 jsonError(w, http.StatusBadGateway, "could not mint a move id")
802 return
803 }
804 now := time.Now()
805 ms := provision.MoveStart{ID: id, Incarnation: inc, From: rec.Region, To: to, Cut: cut, By: addr, Now: now}
806 start := provision.MoveStartTransition(ms)
807 // The record's own refusals (a move already journaled, a legacy move
808 // in flight) are tried on a copy first, so they too come before the
809 // claim CAS. The transition only replaces Move, so a shallow copy
810 // leaves rec as read.
811 probe := *rec
812 if err := start(&probe); err != nil {
813 jsonError(w, http.StatusConflict, phase1Conflict(err))
814 return
815 }
816 exec := obs.Claim.Op.Exec
817 if s.cfg.HostClaim {
818 s.startClaimModeMove(w, r, acct, app, host, obs, ms, sel.Target, from, rec.Region, dir, roster)
819 return
820 }
821 moving, err := hostclaim.Update(ctx, s.cfg.Releases, obs, hostclaim.BeginMove(id, addr, exec, sel.Target, to, now))
822 if err != nil {
823 moveClaimCASFailed(w, acct, app, host, toName, err)
824 return
825 }
826 got, err := provision.SetRecordMove(ctx, s.cfg.Releases, acct.Sqid, app, host, start)
827 if err != nil {
828 log.Printf("console: api instance move %s/%s/%s: the claim is moving under %s but the record write failed: %v", acct.Sqid, app, host, id, err)
829 got = s.settleFailedMoveWrite(w, r, ctx, acct, app, host, id, exec, moving, false)
830 if got == nil {
831 return
832 }
833 }
834 s.bumpSerial(ctx)
835 writeJSON(w, http.StatusAccepted, s.moveViewOf(got))
836}
837
838// moveClaimCASFailed answers a refused or failed start CAS, plain or
839// admitted, with its recorded sentence. The error itself is only logged.
840// toName is the target as the caller named it (homeRegionOut), never the
841// record's "" for the home.
842func moveClaimCASFailed(w http.ResponseWriter, acct *Account, app, host, toName string, err error) {
843 switch {
844 case errors.Is(err, hostclaim.ErrRelocationFinishing):
845 jsonError(w, http.StatusConflict, "the instance's last relocation is still finishing. try again in a few minutes")
846 case errors.Is(err, hostclaim.ErrPendingChore):
847 jsonError(w, http.StatusConflict, "the instance has unfinished cleanup in "+toName+". wait for it to finish")
848 case errors.Is(err, hostclaim.ErrConflict), errors.Is(err, hostclaim.ErrWrongState):
849 jsonError(w, http.StatusConflict, "the host claim changed under this request. try again")
850 default:
851 log.Printf("console: api instance move %s/%s/%s: claim CAS: %v", acct.Sqid, app, host, err)
852 jsonError(w, http.StatusBadGateway, "could not write the host claim")
853 }
854}
855
856// startClaimModeMove is the request door's start on a host-claim console
857// (claim Plan 5 Task 7): the claim runtime's authority registers the move
858// under the app's admission (the registration id is the move id), then
859// BeginClaimMove CASes the same claim observation from serving to moving
860// with the app id, the registration and both roster enrollments on the op,
861// and StartClaimMoveRecord journals the move on the record as one effect
862// of that registration. The door never builds an authority of its own.
863//
864// Before anything is registered, the door's own roster observation (the
865// one E2 read) must place the move as BeginClaimMove will require: both
866// boxes active and enrolled, the source in the record's region, the target
867// in to, and no termination fence on the target. A roster that does not is
868// MV-N177. BeginClaimMove rereads the roster and the record; if it still
869// refuses (ErrIdentity, ErrRoster), something changed under the request
870// and the door does not guess what (MV-N178).
871//
872// Every refusal after RegisterMove and before the claim CAS writes nothing
873// but the registration, which the admission sweep settles once its TTL
874// passes. A failed record start after the CAS is settled as on the plain
875// path, with the release written under the registration's lifecycle grant.
876func (s *Server) startClaimModeMove(w http.ResponseWriter, r *http.Request, acct *Account, app, host string, obs hostclaim.Observed, ms provision.MoveStart, target, from, recRegion string, dir hostactors.Directory, roster hostactors.Observed) {
877 ctx := r.Context()
878 a := s.cfg.ClaimAuthority
879 // to is the record's code ("" for the home), which the claim calls
880 // receive; toName is what the sentences say (home part 2).
881 to := ms.To
882 toName := s.homeRegionOut(to)
883 if !roster.Roster.MatchesHome(s.cfg.dbClaimHomeRegion()) || !claimMoveRosterPlaces(roster.Roster, obs.Claim.Op.Exec, target, recRegion, to, s.cfg.dbClaimHomeRegion()) {
884 jsonError(w, http.StatusConflict, "the host roster does not allow this move. the source and target must be active boxes in "+from+" and "+toName)
885 return
886 }
887 fenced, err := dir.TerminationFence(ctx, roster, target, time.Now())
888 if err != nil {
889 log.Printf("console: api instance move %s/%s/%s: termination fence of %s: %v", acct.Sqid, app, host, target, err)
890 jsonError(w, http.StatusServiceUnavailable, "could not read the host roster. try again")
891 return
892 }
893 if fenced {
894 jsonError(w, http.StatusConflict, "the host roster does not allow this move. the source and target must be active boxes in "+from+" and "+toName)
895 return
896 }
897 appID, err := s.store.ClaimAppID(acct.ID, app)
898 if errors.Is(err, sql.ErrNoRows) || errors.Is(err, hostops.ErrIdentity) {
899 jsonError(w, http.StatusConflict, "this app has no host-claim admission yet. try again shortly")
900 return
901 }
902 if err != nil {
903 log.Printf("console: api instance move %s/%s/%s: app id: %v", acct.Sqid, app, host, err)
904 jsonError(w, http.StatusServiceUnavailable, "could not read this app's admission. try again")
905 return
906 }
907 h, err := a.RegisterMove(ctx, obs, appID, ms.ID)
908 if err != nil {
909 switch {
910 case errors.Is(err, os.ErrNotExist):
911 jsonError(w, http.StatusConflict, "this app has no host-claim admission yet. try again shortly")
912 case errors.Is(err, hostops.ErrPending):
913 jsonError(w, http.StatusConflict, "another operation holds this app. try again when it finishes")
914 default:
915 log.Printf("console: api instance move %s/%s/%s: register move %s: %v", acct.Sqid, app, host, ms.ID, err)
916 jsonError(w, http.StatusBadGateway, "could not register the move. try again")
917 }
918 return
919 }
920 moving, err := hostops.BeginClaimMove(ctx, s.cfg.Releases, a.Directory, h, obs, ms.ID, ms.By, target, to, ms.Now, s.cfg.dbClaimHomeRegion())
921 if err != nil {
922 switch {
923 case errors.Is(err, hostops.ErrIdentity), errors.Is(err, hostactors.ErrRoster):
924 log.Printf("console: api instance move %s/%s/%s: begin move %s: %v", acct.Sqid, app, host, ms.ID, err)
925 jsonError(w, http.StatusConflict, "the instance or its boxes changed under this request. read its state and try again")
926 case errors.Is(err, hostops.ErrPending):
927 jsonError(w, http.StatusConflict, "another operation holds this app. try again when it finishes")
928 default:
929 moveClaimCASFailed(w, acct, app, host, toName, err)
930 }
931 return
932 }
933 got, err := hostops.StartClaimMoveRecord(ctx, s.cfg.Releases, h, moving, ms)
934 if err != nil {
935 log.Printf("console: api instance move %s/%s/%s: the claim is moving under %s but the record start failed: %v", acct.Sqid, app, host, ms.ID, err)
936 // No retry is promised (MV-N179): the record-start effect is left
937 // uncertain and refuses the next start on this record until the
938 // claim code resolves it (task E-switch fix round 2).
939 got = s.settleFailedMoveWrite(w, r, h.Lifecycle(ctx), acct, app, host, ms.ID, obs.Claim.Op.Exec, moving, true)
940 if got == nil {
941 return
942 }
943 }
944 s.bumpSerial(ctx)
945 writeJSON(w, http.StatusAccepted, s.moveViewOf(got))
946}
947
948// settleFailedMoveWrite decides a record write that failed after the claim
949// CAS succeeded. A failed write may still have landed, so the record is
950// read again first:
951//
952// - it carries this move's id: the write landed and only its answer was
953// lost. The move is returned and the door answers success;
954// - it definitely does not: the claim goes back to serving under exec
955// (ReleaseMove, guarded by RequireMoveOp so it can only undo this
956// move, before its seal) and the caller is told to try again;
957// - the read fails, or the release does: the outcome is unknown, so the
958// claim is left as it is and the answer promises nothing. The host and
959// move id are logged. Plan 5's recovery pass, which must land before
960// the switch is turned on anywhere, finds a moving claim with no
961// journaled move and releases it.
962//
963// claimCtx carries the authority the release's claim writes run under: the
964// request's own context on the plain path, the move registration's
965// lifecycle grant on a host-claim console (whose data boundary refuses an
966// unbound claim CAS). held picks the 502's sentence once the claim is
967// released: the plain path's invites a retry; the host-claim path's
968// (MV-N179, held) does not, because its record-start effect stays uncertain
969// and blocks the next one until the claim code can resolve it.
970//
971// It answers w itself and returns nil in every case but the first.
972func (s *Server) settleFailedMoveWrite(w http.ResponseWriter, r *http.Request, claimCtx context.Context, acct *Account, app, host, id, exec string, moving hostclaim.Observed, held bool) *provision.Record {
973 ctx := r.Context()
974 unknown := func(why string, err error) {
975 log.Printf("console: api instance move %s/%s/%s: move %s: %s: %v; the claim is left moving", acct.Sqid, app, host, id, why, err)
976 jsonError(w, http.StatusBadGateway, "could not confirm whether the move was recorded. read its state before trying again")
977 }
978 rec, err := provision.GetRecord(ctx, s.cfg.Releases, acct.Sqid, app, host)
979 if err != nil {
980 unknown("record reread", err)
981 return nil
982 }
983 if rec.Move != nil && rec.Move.ID == id {
984 return rec
985 }
986 release := func(c *hostclaim.Claim) error {
987 if err := hostclaim.RequireMoveOp(id, hostclaim.KindMoving, hostclaim.PhaseMoving)(c); err != nil {
988 return err
989 }
990 return hostclaim.ReleaseMove(id, exec)(c)
991 }
992 _, err = hostclaim.Update(claimCtx, s.cfg.Releases, moving, release)
993 if errors.Is(err, hostclaim.ErrConflict) {
994 // Something else wrote the claim since the CAS: read it and try
995 // once more. The guard refuses unless it is still this move.
996 var cur hostclaim.Observed
997 if cur, err = hostclaim.Read(ctx, s.cfg.Releases, host); err == nil {
998 _, err = hostclaim.Update(claimCtx, s.cfg.Releases, cur, release)
999 }
1000 }
1001 if err != nil {
1002 unknown("claim release", err)
1003 return nil
1004 }
1005 if held {
1006 jsonError(w, http.StatusBadGateway, "the move could not be recorded and is held until an operator checks the record")
1007 return nil
1008 }
1009 jsonError(w, http.StatusBadGateway, "could not record the move. try again")
1010 return nil
1011}
1012
1013// handleAPIInstanceMoveCut is ready → cutting/requested under the guard. A
1014// move journaled before ids existed (Legacy) has no guard to carry; it can
1015// only be aborted (MV-N7 says so).
1016func (s *Server) handleAPIInstanceMoveCut(w http.ResponseWriter, r *http.Request) {
1017 if !s.cfg.MovesEnabled {
1018 s.legacyHandleAPIInstanceMoveCut(w, r)
1019 return
1020 }
1021 acct, app, host, rec, _, ok := s.moveOperatorGate(w, r)
1022 if !ok {
1023 return
1024 }
1025 if rec.Move.Legacy() {
1026 jsonError(w, http.StatusConflict, "this move was started by an older console and has no id. abort it and request it again")
1027 return
1028 }
1029 if s.cfg.HostClaim {
1030 s.claimConsoleMove(w, r, acct, app, host, rec, hostops.ConsoleMoveCut)
1031 return
1032 }
1033 s.moveTransitionP1(w, r, acct, app, host, provision.MoveGuardedCut(moveGuardOf(rec)), http.StatusAccepted)
1034}
1035
1036// handleAPIInstanceMoveAbort marks the move aborting under the guard
1037// (legal in preparing, ready and cutting/requested, §7.11). It has no
1038// claim-side effect: the claim stays moving until the source's interim
1039// abort deletes staging and releases it (hostclaim.ReleaseMove, Task 14).
1040// A Legacy() record takes the legacy MoveAbort (F7(a)), the one transition
1041// that accepts it. Once the cut has started the answer is M-C13 (preflight
1042// L5) on either console: without the host claim abortBeforeTheCut decides
1043// it under the record's CAS; a host-claim console decides it from the record
1044// the operator gate read, before claim Plan 10's typed grant
1045// (claimConsoleMove), which after the seal would refuse with "the move
1046// changed" (final review I5). A stuck move keeps the grant's own answer.
1047func (s *Server) handleAPIInstanceMoveAbort(w http.ResponseWriter, r *http.Request) {
1048 if !s.cfg.MovesEnabled {
1049 s.legacyHandleAPIInstanceMoveAbort(w, r)
1050 return
1051 }
1052 acct, app, host, rec, _, ok := s.moveOperatorGate(w, r)
1053 if !ok {
1054 return
1055 }
1056 if rec.Move.Legacy() {
1057 s.moveTransitionP1(w, r, acct, app, host, legacyAbortOf(*rec.Move), http.StatusAccepted)
1058 return
1059 }
1060 if s.cfg.HostClaim {
1061 // A console without its claim runtime keeps HC5-N1 for every move
1062 // (claimConsoleMove), and a record with no move keeps its refusal.
1063 if a := s.cfg.ClaimAuthority; a != nil && a.Control != nil && rec.Move != nil && moveCutStarted(rec.Move) {
1064 jsonError(w, http.StatusConflict, moveCutStartedSentence)
1065 return
1066 }
1067 s.claimConsoleMove(w, r, acct, app, host, rec, hostops.ConsoleMoveAbort)
1068 return
1069 }
1070 s.moveTransitionP1(w, r, acct, app, host, abortBeforeTheCut(moveGuardOf(rec)), http.StatusAccepted)
1071}
1072
1073// moveCutStartedSentence is M-C13, the abort door's answer once the cut has
1074// started (preflight L5): the move then finishes forward or moves back by
1075// itself, and an abort could only confuse the operator about which.
1076const moveCutStartedSentence = "This move has started its cut. It will finish or move back on its own."
1077
1078// moveCutStarted is "past cutting/requested and not stuck": a cut past its
1079// request (MoveGuardedAbort's own bar), a landing, a committed move, a
1080// rollback or a revert. A stuck move keeps provision's refusal, and
1081// relocating and aborting keep theirs.
1082func moveCutStarted(m *provision.MoveState) bool {
1083 switch m.State {
1084 case provision.MoveCutting:
1085 return m.CutPhase != provision.CutRequested
1086 case provision.MoveLanding, provision.MoveCommitted, provision.MoveRollingBack, provision.MoveReverting:
1087 return true
1088 }
1089 return false
1090}
1091
1092// abortBeforeTheCut is MoveGuardedAbort that refuses with M-C13 when the
1093// journal it is about to abort, this guard's move, has started its cut. It
1094// decides on the record SetRecordMove reads under its CAS, not on the
1095// door's earlier read, so a cut that advances in between is still M-C13.
1096// Anything else is MoveGuardedAbort's own answer.
1097func abortBeforeTheCut(g provision.MoveGuard) func(*provision.Record) error {
1098 abort := provision.MoveGuardedAbort(g)
1099 return func(rec *provision.Record) error {
1100 if m := rec.Move; m != nil && m.ID == g.ID && m.Incarnation == g.Incarnation && moveCutStarted(m) {
1101 return fmt.Errorf("%w: %s", provision.ErrMoveConflict, moveCutStartedSentence)
1102 }
1103 return abort(rec)
1104 }
1105}
1106
1107// handleAPIInstanceMoveResolve is the operator's door out of a stuck move
1108// (M-C11). Its body is {"retry": true} (Plan 4 Task 7, §7.8, §7.10): the
1109// record write is provision.MoveRetry under the guard from the record, which
1110// answers 409 unless the move is stuck. The retry clears the stuck reason
1111// only; the box then resumes at the phase the stuck rollback recorded
1112// (a verification runs again and is never bypassed, a probe gets its nine
1113// attempts again, a revert resumes its rev_phase). The answer is the flat
1114// view: the stuck reason is the CLI's to say (M-C11), read from the record.
1115// Any other body is "nothing to resolve yet" until Plan 4 Task 10 and Plan 6
1116// Task 2 give the door the rest. With the switch off it is the 404 the
1117// route answered before it existed.
1118func (s *Server) handleAPIInstanceMoveResolve(w http.ResponseWriter, r *http.Request) {
1119 if !s.cfg.MovesEnabled {
1120 http.NotFound(w, r)
1121 return
1122 }
1123 acct, app, host, rec, addr, ok := s.moveOperatorGate(w, r)
1124 if !ok {
1125 return
1126 }
1127 if s.cfg.HostClaim {
1128 s.claimMoveResolve(w, r, acct, app, host, rec, addr)
1129 return
1130 }
1131 var req struct {
1132 Retry bool `json:"retry"`
1133 DeclareLost string `json:"declare_lost"`
1134 }
1135 if !readJSON(w, r, &req) {
1136 return
1137 }
1138 // A console without the host claim cannot run the claim's loss protocol
1139 // (claim Plan 10's inspect and declare): MV-N11, nothing written
1140 // (preflight L7).
1141 if req.DeclareLost != "" {
1142 jsonError(w, http.StatusConflict, "declare-lost is not enabled in this deployment yet")
1143 return
1144 }
1145 if !req.Retry {
1146 jsonError(w, http.StatusConflict, "nothing to resolve yet")
1147 return
1148 }
1149 s.moveTransitionP1(w, r, acct, app, host, provision.MoveRetry(moveGuardOf(rec)), http.StatusAccepted)
1150}
1151
1152// claimMoveResolve is the resolve door on a host-claim console (claim Plan
1153// 10, rulings J17 and J4). Its body is exactly one of
1154//
1155// {"retry": true} the typed retry (claimConsoleMove)
1156// {"declare_lost": "<box>"} the signed challenge for the target's loss
1157// {"declare_lost": "<box>", "submission": {…}} the declaration of that challenge
1158//
1159// decoded strictly: an unknown field, or retry beside a declaration, is
1160// refused before anything is read. An empty body is still "nothing to
1161// resolve yet". Without its claim runtime the console keeps HC5-N1.
1162func (s *Server) claimMoveResolve(w http.ResponseWriter, r *http.Request, acct *Account, app, host string, rec *provision.Record, actor string) {
1163 var req struct {
1164 Retry bool `json:"retry"`
1165 DeclareLost string `json:"declare_lost"`
1166 Submission *hostfence.Submission `json:"submission"`
1167 }
1168 if !readJSONStrictLimit(w, r, &req, 1<<20) {
1169 return
1170 }
1171 switch {
1172 case req.Retry && (req.DeclareLost != "" || req.Submission != nil), req.DeclareLost == "" && req.Submission != nil:
1173 jsonError(w, http.StatusBadRequest, "invalid resolution request")
1174 case req.Retry:
1175 s.claimConsoleMove(w, r, acct, app, host, rec, hostops.ConsoleMoveRetry)
1176 case req.DeclareLost != "":
1177 s.claimMoveLoss(w, r, acct, app, host, actor, req.DeclareLost, req.Submission)
1178 default:
1179 jsonError(w, http.StatusConflict, "nothing to resolve yet")
1180 }
1181}
1182
1183// claimMoveLoss inspects (no submission) or declares (a submission) the
1184// loss of a move's target through the configured ClaimAuthority, never an
1185// authority, writer or store of its own. A declaration answers the move's
1186// flat view, like retry.
1187func (s *Server) claimMoveLoss(w http.ResponseWriter, r *http.Request, acct *Account, app, host, actor, box string, sub *hostfence.Submission) {
1188 a := s.cfg.ClaimAuthority
1189 if a == nil || a.Control == nil {
1190 jsonError(w, http.StatusConflict, claimMoveUnsupportedMsg)
1191 return
1192 }
1193 ctx := r.Context()
1194 if sub == nil {
1195 in, err := a.InspectMoveLoss(ctx, actor, acct.Sqid, app, host, box)
1196 if err != nil {
1197 claimMoveLossFailed(w, acct, app, host, err)
1198 return
1199 }
1200 writeJSON(w, http.StatusOK, in)
1201 return
1202 }
1203 if err := s.declareMoveLossLogged(ctx, acct.Sqid, app, host, *sub, func() error {
1204 return a.DeclareMoveLoss(ctx, actor, acct.Sqid, app, host, box, *sub)
1205 }); err != nil {
1206 claimMoveLossFailed(w, acct, app, host, err)
1207 return
1208 }
1209 s.bumpSerial(ctx)
1210 got, err := provision.GetRecord(ctx, s.cfg.Releases, acct.Sqid, app, host)
1211 if err != nil {
1212 log.Printf("console: api instance move %s/%s/%s: declared loss: record reread: %v", acct.Sqid, app, host, err)
1213 jsonError(w, http.StatusBadGateway, "could not read the instance")
1214 return
1215 }
1216 writeJSON(w, http.StatusAccepted, s.moveViewOf(got))
1217}
1218
1219// claimMoveLossFailed answers a refused inspection or declaration: a
1220// recorded refusal is its sentence, a stale or refused binding is
1221// moveChangedSentence, anything else the fixed failure. Errors are logged.
1222func claimMoveLossFailed(w http.ResponseWriter, acct *Account, app, host string, err error) {
1223 switch {
1224 case errors.Is(err, provision.ErrMoveConflict):
1225 jsonError(w, http.StatusConflict, phase1Conflict(err))
1226 case errors.Is(err, hostresolve.ErrResolve), errors.Is(err, hostfence.ErrChallenge), errors.Is(err, hostfence.ErrObservation), errors.Is(err, hostactors.ErrRoster),
1227 errors.Is(err, hostclaim.ErrLossRecord), errors.Is(err, hostclaim.ErrStaleOp), errors.Is(err, hostclaim.ErrLossOwnerRequired):
1228 log.Printf("console: api instance move %s/%s/%s: declared loss refused: %v", acct.Sqid, app, host, err)
1229 jsonError(w, http.StatusConflict, moveChangedSentence)
1230 default:
1231 claimConsoleMoveFailed(w, acct, app, host, err)
1232 }
1233}
1234
1235// claimConsoleMove is the cut, abort and retry doors on a host-claim console
1236// (claim Plan 10, ruling J17): the configured ControlWriter's typed
1237// transition, bound to the claim and record observed here. It never builds
1238// an authority, a writer or a store. A console without its claim runtime,
1239// and a move journal with no id, keep HC5-N1. Every refusal is a recorded
1240// sentence; anything else is logged and answers the fixed failure.
1241func (s *Server) claimConsoleMove(w http.ResponseWriter, r *http.Request, acct *Account, app, host string, rec *provision.Record, action hostops.ConsoleMoveAction) {
1242 a := s.cfg.ClaimAuthority
1243 if a == nil || a.Control == nil || rec.Move.Legacy() {
1244 jsonError(w, http.StatusConflict, claimMoveUnsupportedMsg)
1245 return
1246 }
1247 if rec.Move == nil {
1248 jsonError(w, http.StatusConflict, "no journaled move in flight")
1249 return
1250 }
1251 ctx := r.Context()
1252 o, err := hostops.ObserveConsoleMove(ctx, s.cfg.Releases, acct.Sqid, app, host)
1253 var got *provision.Record
1254 if err == nil && (o.OpID != rec.Move.ID || o.Incarnation != rec.Move.Incarnation || o.RecordSeq != rec.RecordSeq) {
1255 // The gate read a different move, incarnation or record than the
1256 // claim and record observed now: something replaced it in between.
1257 err = hostops.ErrIdentity
1258 }
1259 if err == nil {
1260 got, err = a.Control.ConsoleMove(ctx, o, action)
1261 }
1262 if err != nil {
1263 claimConsoleMoveFailed(w, acct, app, host, err)
1264 return
1265 }
1266 s.bumpSerial(ctx)
1267 writeJSON(w, http.StatusAccepted, s.moveViewOf(got))
1268}
1269
1270// claimConsoleMoveFailed answers a refused typed move write. A provision
1271// refusal is its recorded sentence; a changed claim, record or registration
1272// is moveChangedSentence; the error itself is only logged.
1273func claimConsoleMoveFailed(w http.ResponseWriter, acct *Account, app, host string, err error) {
1274 switch {
1275 case errors.Is(err, provision.ErrMoveConflict):
1276 jsonError(w, http.StatusConflict, phase1Conflict(err))
1277 case errors.Is(err, hostops.ErrIdentity), errors.Is(err, hostops.ErrPending), errors.Is(err, hostclaim.ErrConflict),
1278 errors.Is(err, hostclaim.ErrWrongState), errors.Is(err, release.ErrCASConflict), errors.Is(err, os.ErrNotExist):
1279 log.Printf("console: api instance move %s/%s/%s: typed move write refused: %v", acct.Sqid, app, host, err)
1280 jsonError(w, http.StatusConflict, moveChangedSentence)
1281 default:
1282 log.Printf("console: api instance move %s/%s/%s: typed move write: %v", acct.Sqid, app, host, err)
1283 jsonError(w, http.StatusBadGateway, "could not write the move. try again")
1284 }
1285}
1286
1287// registerMovesPhase1Routes adds the routes that exist only with the switch
1288// on. The Instance role stays on resolve only so a service token meets the
1289// family's 404 rather than the role gate's 403.
1290func (s *Server) registerMovesPhase1Routes() {
1291 s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/move/resolve", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceMoveResolve)
1292}
1293
1294// errMoveChanged refuses a write whose move is no longer the one the door
1295// read. It wraps ErrMoveConflict so moveTransition answers 409.
1296var errMoveChanged = fmt.Errorf("%w: %s", provision.ErrMoveConflict, moveChangedSentence)
1297
1298const moveChangedSentence = "the move changed since this request read it. read its state and try again"
1299
1300// legacyAbortOf is the legacy MoveAbort bound to the legacy move the door
1301// read (F7(a)). SetRecordMove reads the record again before it writes, and
1302// MoveAbort alone would abort whatever preparing or ready move it found
1303// there, so a request delayed past that move's end could abort its
1304// replacement. A legacy journal has no id; its target and request time
1305// identify it.
1306func legacyAbortOf(seen provision.MoveState) func(*provision.Record) error {
1307 return func(rec *provision.Record) error {
1308 m := rec.Move
1309 if m == nil || !m.Legacy() || m.To != seen.To || !m.RequestedAt.Equal(seen.RequestedAt) {
1310 return errMoveChanged
1311 }
1312 return provision.MoveAbort()(rec)
1313 }
1314}
1315
1316// phase1Conflict maps a provision transition refusal to its recorded
1317// sentence (copy record MV-N12 to MV-N16, MV-N38 to MV-N46, and Plan 4's
1318// MV-N300 to MV-N309: the rollback, retry and declare-lost refusals and the
1319// rollback and stuck reasons): never the
1320// "provision: move state conflict:" prefix, never an em dash. A refusal
1321// this table does not know answers a fixed sentence, so a new provision
1322// sentence cannot reach an operator unreviewed.
1323func phase1Conflict(err error) string {
1324 msg := strings.TrimPrefix(err.Error(), provision.ErrMoveConflict.Error()+": ")
1325 switch {
1326 case msg == "past the point of no return \u2014 the cut completes forward":
1327 // The legacy MoveAbort's sentence, reworded.
1328 return "past the point of no return. the cut completes forward"
1329 case strings.Contains(msg, "\u2014"):
1330 case msg == "no journaled move in flight",
1331 msg == "malformed move start",
1332 msg == "past the point of no return. the cut completes forward or moves back on its own",
1333 msg == "cut is only legal from ready",
1334 msg == "already aborting",
1335 msg == "no move in flight",
1336 msg == moveChangedSentence,
1337 msg == moveCutStartedSentence,
1338 // provision.RefusedCommitted, RefusedNotStuck and
1339 // RefusedNothingToDeclare (TestPhase1ConflictPassesPlan4Refusals
1340 // holds the literals to the constants).
1341 msg == "the move has committed. it cannot roll back",
1342 msg == "the move is not stuck. there is nothing to retry",
1343 msg == "the target holds nothing the move still needs. there is nothing to declare lost",
1344 // The reasons Plan 4 records (ruling J9): provision.RollbackDeclaredLost,
1345 // StuckBaselineMismatch, StuckRecoveryProbeAttempts, StuckRevertProbeBudget,
1346 // and the shared installer's StuckReturnFilesOccupied,
1347 // StuckReturnInstallMismatch and StuckReturnUnknownStamp (Task 3), and
1348 // the target's StuckReturnTargetDatabase, StuckReturnTargetChanged
1349 // and StuckReturnTargetFiles (Task 4).
1350 msg == "the target box was declared lost",
1351 msg == "the source no longer matches what it recorded when it stopped",
1352 msg == "the recovery probe ran out of attempts",
1353 msg == "the source did not answer within 15 minutes of moving back",
1354 msg == "the files directory holds files the return did not write",
1355 msg == "the returned database does not match the build",
1356 msg == "the source holds files of an installation the return cannot account for",
1357 msg == "the target's stopped database is missing",
1358 msg == "the target changed after it stopped",
1359 msg == "the target's files cannot be archived",
1360 strings.HasPrefix(msg, "the record's move is ") && strings.Contains(msg, " for incarnation "),
1361 strings.HasPrefix(msg, "a move to ") && strings.Contains(msg, " is already "),
1362 strings.HasPrefix(msg, "the instance is already in "),
1363 strings.HasPrefix(msg, "a move in state ") && strings.HasSuffix(msg, " cannot be aborted"),
1364 strings.HasPrefix(msg, "a ") && strings.HasSuffix(msg, " does not move. delete it and create one in the new region"):
1365 return msg
1366 }
1367 return "this move's state does not allow that. read its state and try again"
1368}
1369
1370// claimMoveRosterPlaces is BeginClaimMove's roster precondition, read from
1371// the door's own observation: both boxes have active, enrolled entries
1372// under their box references, they differ, the source is in the record's
1373// region and the target in to, which is not the source's region.
1374//
1375// The roster and the record name regions differently for one region, the
1376// home: the roster refuses an empty region, so a home box is enrolled under
1377// the home's name (CONSOLE_HOME_REGION, as boxchoice reads it), while the
1378// record keeps "". Both boxes' roster regions are read as record codes
1379// through homePlacementCode with home, the deployment's home name (the rule
1380// homeRegionIn applies), so a move out of home is placed and a named region
1381// is compared as it is. With no home named nothing translates, and a home
1382// source is refused as before (home-region parks, part 1).
1383func claimMoveRosterPlaces(r hostactors.Roster, source, target, recRegion, to string, home ...string) bool {
1384 return hostops.ClaimMoveRosterPlaces(r, source, target, recRegion, to, home...)
1385}
1386
1387// sourceEnrollment is the enrollment of the box that executes the claim, and
1388// whether the roster lets a move start from it: an entry under that box
1389// reference (never a label), in state "active", with a non-empty
1390// enrollment. The termination fence is a separate check on the same
1391// observation.
1392func sourceEnrollment(r hostactors.Roster, box string) (string, bool) {
1393 b, ok := r.Boxes[box]
1394 if !ok || b.State != "active" || b.Enrollment == "" {
1395 return "", false
1396 }
1397 return b.Enrollment, true
1398}
1399
1400// ---- GET …/move?detail=1 (Plan 6 Task 2, spec §12) ----
1401
1402// moveDetail is the operator's detail answer: the flat view, unchanged, and
1403// beside it the detail object. Everything in it is a record field, a claim
1404// field, a report the boxes published for the CLI, or an object key: never
1405// an evidence or log body, and never a launch environment value. The
1406// console cannot tell an HMAC from a raw value of the same shape in a
1407// malformed report, so neither report's env section passes any value: the
1408// source's environment is names and kinds (moveSourceReport), and the
1409// target's env input is blanked.
1410type moveDetail struct {
1411 moveView
1412 Detail moveDetailBody `json:"detail"`
1413}
1414
1415type moveDetailBody struct {
1416 // Move is the record's journal as read; nil when no move is in flight.
1417 Move *provision.MoveState `json:"move,omitempty"`
1418 // Claim is the host claim's operation; nil when the host has no claim.
1419 Claim *moveClaimView `json:"claim,omitempty"`
1420 // TargetReport and SourceReport are the reports under the move's report
1421 // prefix, nil when absent or unreadable as a report.
1422 TargetReport *moves.TargetReport `json:"target_report,omitempty"`
1423 SourceReport *moveSourceReport `json:"source_report,omitempty"`
1424 // Evidence names the stop evidence the record points at, by key.
1425 Evidence moveEvidenceKeys `json:"evidence"`
1426 // Log is every key under the move's log/ prefix, sorted (preflight L26:
1427 // a move writes a few dozen; the CLI reads bodies only on demand).
1428 Log []string `json:"log"`
1429 // Timings are a closed move's timings against their budgets (Plan 6
1430 // Task 4, moves.Timings), in seconds: the timing fields of its done or
1431 // rolled_back log entry and nothing else of it, as the entry's writer
1432 // worked them out from the record's journal. Absent until that entry
1433 // is logged.
1434 Timings map[string]float64 `json:"timings,omitempty"`
1435 // WaitingForBox is the region a relocating move waits on for a live
1436 // enrolled box (M-C22), empty otherwise and when it cannot be told.
1437 WaitingForBox string `json:"waiting_for_box,omitempty"`
1438 // From is the move's origin and Region the record's region now, each
1439 // named as the caller names it (homeRegionOut), so the CLI's sentences
1440 // (M-C1, M-C7, M-C9) never print the record's "" for the home.
1441 From string `json:"from,omitempty"`
1442 Region string `json:"region"`
1443}
1444
1445// moveSourceReport is the source report with its environment replaced by
1446// one entry per variable: the name and its T3 kind (moves.Class). The
1447// report's Env (per-name HMACs and their digest) is shadowed by this Env
1448// and never encoded.
1449type moveSourceReport struct {
1450 moves.SourceReport
1451 Env []moveEnvEntry `json:"env"`
1452}
1453
1454// moveEnvEntry is a launch environment variable as the detail shows it. The
1455// reports compare values only by HMAC and record no per-name result, so
1456// the kind is all there is to say beside the name.
1457type moveEnvEntry struct {
1458 Name string `json:"name"`
1459 Kind string `json:"kind"`
1460}
1461
1462// sourceReportWithoutValues is the source report as the detail shows it.
1463func sourceReportWithoutValues(r moves.SourceReport) *moveSourceReport {
1464 names := make([]string, 0, len(r.Env.Vars))
1465 for name := range r.Env.Vars {
1466 names = append(names, name)
1467 }
1468 slices.Sort(names)
1469 out := &moveSourceReport{Env: make([]moveEnvEntry, 0, len(names))}
1470 for _, name := range names {
1471 out.Env = append(out.Env, moveEnvEntry{Name: name, Kind: moves.Class(name)})
1472 }
1473 r.Env = moves.EnvReport{}
1474 out.SourceReport = r
1475 return out
1476}
1477
1478// moveClaimView is the claim operation's shape and nothing else (no journal,
1479// no holder). Target is the move's target box, Intent["target"], which M-C10
1480// names (preflight L28).
1481type moveClaimView struct {
1482 Kind string `json:"kind"`
1483 Phase string `json:"phase,omitempty"`
1484 Exec string `json:"exec,omitempty"`
1485 Participants []string `json:"participants,omitempty"`
1486 Target string `json:"target,omitempty"`
1487}
1488
1489// moveEvidenceKeys are the stop evidence keys the record carries: the
1490// source's (Move.Source.Evidence, written at stopped) and the target's
1491// (Move.Rollback.Evidence, written when a rollback released it).
1492type moveEvidenceKeys struct {
1493 Source string `json:"source,omitempty"`
1494 Target string `json:"target,omitempty"`
1495}
1496
1497// moveRelocationRegion is the region of a relocating claim operation that
1498// belongs to this record's move (the same holder incarnation), and whether
1499// there is one.
1500func moveRelocationRegion(rec *provision.Record, obs hostclaim.Observed) (string, bool) {
1501 if rec == nil || rec.Move == nil || obs.Absent || obs.Claim == nil || obs.Claim.Holder == nil {
1502 return "", false
1503 }
1504 c := obs.Claim
1505 if c.Op.Kind != hostclaim.KindRelocating || c.Holder.Incarnation != rec.Move.Incarnation || c.Op.Intent["region"] == "" {
1506 return "", false
1507 }
1508 return c.Op.Intent["region"], true
1509}
1510
1511// moveConvertedAfterCommit is Paul's J3 as the status door sees it: the
1512// record says committed and the claim is relocating this incarnation in the
1513// move's target region, because the target was terminated after the commit.
1514// The door answers relocating for it.
1515func moveConvertedAfterCommit(rec *provision.Record, obs hostclaim.Observed) bool {
1516 region, ok := moveRelocationRegion(rec, obs)
1517 return ok && rec.Move.State == provision.MoveCommitted && park.PlacementMatches(obs.Claim.Op.Intent["home_region"], rec.Move.To, region)
1518}
1519
1520// writeMoveDetail answers an operator's detail=1. The claim read and the
1521// report and log reads fail closed with a fixed sentence; a report that does
1522// not parse is left out, as the boxes treat it (no report). The region
1523// check behind waiting_for_box never fails the answer: when it cannot tell,
1524// the field is left out and the state still says relocating.
1525func (s *Server) writeMoveDetail(w http.ResponseWriter, r *http.Request, acct *Account, app, host string, rec *provision.Record, view moveView) {
1526 ctx := r.Context()
1527 obs, err := hostclaim.Read(ctx, s.cfg.Releases, host)
1528 if err != nil {
1529 log.Printf("console: api instance move %s/%s/%s: detail: claim read: %v", acct.Sqid, app, host, err)
1530 jsonError(w, http.StatusBadGateway, "could not read the host claim")
1531 return
1532 }
1533 out := moveDetail{moveView: view, Detail: moveDetailBody{Move: rec.Move, Log: []string{}, Region: s.homeRegionOut(rec.Region)}}
1534 if rec.Move != nil {
1535 out.Detail.From = s.homeRegionOut(rec.Move.From)
1536 }
1537 if !obs.Absent && obs.Claim != nil {
1538 op := obs.Claim.Op
1539 out.Detail.Claim = &moveClaimView{Kind: op.Kind, Phase: op.Phase, Exec: op.Exec, Participants: op.Participants, Target: op.Intent["target"]}
1540 }
1541 if m := rec.Move; m != nil {
1542 if m.Source != nil {
1543 out.Detail.Evidence.Source = m.Source.Evidence
1544 }
1545 if m.Rollback != nil {
1546 out.Detail.Evidence.Target = m.Rollback.Evidence
1547 }
1548 }
1549 // The reports and the log of the move in flight, or of the last one to
1550 // close. A legacy journal has no id and so no report prefix.
1551 opID := ""
1552 if rec.Move != nil {
1553 opID = rec.Move.ID
1554 } else if rec.LastMove != nil {
1555 opID = rec.LastMove.ID
1556 }
1557 if provision.ValidMoveID(opID) {
1558 prefix := moves.ReportPrefix(rec.Account, rec.App, rec.Host, opID)
1559 var target moves.TargetReport
1560 var source moves.SourceReport
1561 tok, terr := s.readMoveReport(ctx, moves.TargetReportKey(prefix), &target)
1562 sok, serr := s.readMoveReport(ctx, moves.SourceReportKey(prefix), &source)
1563 keys, lerr := s.cfg.Releases.List(ctx, prefix+"log/")
1564 if err := errors.Join(terr, serr, lerr); err != nil {
1565 log.Printf("console: api instance move %s/%s/%s: detail: reports of %s: %v", acct.Sqid, app, host, opID, err)
1566 jsonError(w, http.StatusBadGateway, "could not read the move's reports. try again")
1567 return
1568 }
1569 if tok {
1570 target.Inputs.EnvReport = ""
1571 out.Detail.TargetReport = &target
1572 }
1573 if sok {
1574 out.Detail.SourceReport = sourceReportWithoutValues(source)
1575 }
1576 slices.Sort(keys)
1577 out.Detail.Log = append(out.Detail.Log, keys...)
1578 if rec.Move == nil && rec.LastMove != nil {
1579 timings, err := s.readMoveTimings(ctx, prefix, rec.LastMove.Outcome)
1580 if err != nil {
1581 log.Printf("console: api instance move %s/%s/%s: detail: timings of %s: %v", acct.Sqid, app, host, opID, err)
1582 jsonError(w, http.StatusBadGateway, "could not read the move's reports. try again")
1583 return
1584 }
1585 out.Detail.Timings = timings
1586 }
1587 }
1588 if (rec.Move != nil && rec.Move.State == provision.MoveRelocating) || moveConvertedAfterCommit(rec, obs) {
1589 out.State = provision.MoveRelocating
1590 // Any phase that runs on no box (starting, releasing and pinning
1591 // today, hostclaim's validateRelocating) is waiting for one.
1592 if region, ok := moveRelocationRegion(rec, obs); ok && obs.Claim.Op.Exec == "none" {
1593 code, valid := park.EnginePlacement(obs.Claim.Op.Intent["home_region"], region)
1594 live, known := false, false
1595 if valid {
1596 live, known = s.regionHasLiveBox(ctx, code)
1597 }
1598 if known && !live {
1599 out.Detail.WaitingForBox = s.homeRegionOut(code)
1600 }
1601 }
1602 }
1603 writeJSON(w, http.StatusOK, out)
1604}
1605
1606// readMoveTimings is the timings a closed move's terminal log entry carries
1607// (moves.TimingsOf over its Fields): nil for an outcome that carries none
1608// (aborted), for an entry not logged yet, and for one that does not parse
1609// (logged); an error when the read failed.
1610func (s *Server) readMoveTimings(ctx context.Context, prefix, outcome string) (map[string]float64, error) {
1611 if outcome != "done" && outcome != "rolled_back" {
1612 return nil, nil
1613 }
1614 var ev moves.LogEvent
1615 ok, err := s.readMoveReport(ctx, moves.LogKeyFor(prefix, outcome), &ev)
1616 if !ok || err != nil {
1617 return nil, err
1618 }
1619 if t := moves.TimingsOf(ev.Fields); len(t) > 0 {
1620 return t, nil
1621 }
1622 return nil, nil
1623}
1624
1625// readMoveReport reads one report: (false, nil) when it is absent or does
1626// not parse (logged), an error when the read failed.
1627func (s *Server) readMoveReport(ctx context.Context, key string, into any) (bool, error) {
1628 body, _, err := s.cfg.Releases.Get(ctx, key)
1629 if errors.Is(err, os.ErrNotExist) {
1630 return false, nil
1631 }
1632 if err != nil {
1633 return false, fmt.Errorf("read %s: %w", key, err)
1634 }
1635 if err := json.Unmarshal(body, into); err != nil {
1636 log.Printf("console: move report %s does not parse: %v", key, err)
1637 return false, nil
1638 }
1639 return true, nil
1640}
1641
1642// regionHasLiveBox reports whether the region (record vocabulary) has a live
1643// enrolled box, through the region HA contracts (Task 2 rulings, L18):
1644// boxchoice.Eligible (eligibleBoxes) over the live edges snapshot, with the
1645// roster read through hostactors.Directory, never directly. A roster entry
1646// counts when it is active, has an enrollment, is in the region, and has no
1647// termination fence; its placement and cloud are the roster's. The AWS
1648// region is the one the code's eligible boxes publish (regionCands), as the
1649// move door selects. known is false when the snapshot is incomplete or the
1650// roster or a fence cannot be read or trusted: then nothing is concluded.
1651func (s *Server) regionHasLiveBox(ctx context.Context, region string) (live, known bool) {
1652 snap, err := s.liveEdges(ctx)
1653 if err != nil || !snap.EvidenceComplete() {
1654 log.Printf("console: move detail: the edges snapshot of %s is incomplete (%v)", region, err)
1655 return false, false
1656 }
1657 dir := hostactors.Directory{Store: s.cfg.Releases, PublicKey: s.cfg.ReleasePub, Bucket: s.cfg.Bucket}
1658 o, err := dir.Read(ctx)
1659 if err != nil {
1660 log.Printf("console: move detail: host roster: %v", err)
1661 return false, false
1662 }
1663 now := time.Now()
1664 roster := map[string]rosterBox{}
1665 for ref, b := range o.Roster.Boxes {
1666 if b.State != "active" || b.Enrollment == "" || s.homeRegionIn(b.Region) != region {
1667 continue
1668 }
1669 fenced, err := dir.TerminationFence(ctx, o, ref, now)
1670 if err != nil {
1671 log.Printf("console: move detail: termination fence of %s: %v", ref, err)
1672 return false, false
1673 }
1674 if !fenced {
1675 roster[ref] = rosterBox{Placement: b.Region, Cloud: b.Cloud}
1676 }
1677 }
1678 aws := ""
1679 for _, c := range s.regionCands(snap) {
1680 if c.Code == region {
1681 aws = c.AWSRegion
1682 }
1683 }
1684 return len(eligibleBoxes(snap, roster, region, aws, s.cfg.dbClaimHomeRegion(), s.expectedSecretsFP(), nil)) > 0, true
1685}
1686
1687// ---- the delete door during a move (Plan 6 Task 2b, preflight L6) ----
1688
1689// moveDeleteRefusal is M-C19, what the delete door says for a host that is
1690// being moved.
1691func moveDeleteRefusal(host string) string {
1692 return host + " is being moved. Delete it after the move finishes, or abort the move first."
1693}
1694
1695// moveClaimKinds are the claim operations a move runs under. A relocating
1696// operation is a move's only when the record still journals the move (a
1697// region HA relocation of its own is not one).
1698var moveClaimKinds = map[string]bool{hostclaim.KindMoving: true, hostclaim.KindLanding: true, hostclaim.KindReverting: true, hostclaim.KindRestoring: true}
1699
1700// refuseDeleteDuringMove is the delete door's move check, with the switch
1701// on: before any retire effect, a host that is being moved answers 409
1702// M-C19 and nothing is written. On a console without the host claim that is
1703// a record carrying a move (any state, legacy included); on a host-claim
1704// console it is a claim whose operation is a move's. The claim would refuse
1705// the retire anyway; this check only says why, before the retire registers
1706// anything. A record that is not there is main's idempotent delete. A read
1707// that fails is a 502 (HC5-N3), never a pass. It reports whether it
1708// answered.
1709func (s *Server) refuseDeleteDuringMove(ctx context.Context, w http.ResponseWriter, acct, app, host string) bool {
1710 if !s.cfg.MovesEnabled {
1711 return false
1712 }
1713 rec, err := provision.GetRecord(ctx, s.cfg.Releases, acct, app, host)
1714 if errors.Is(err, os.ErrNotExist) {
1715 return false
1716 }
1717 if err != nil {
1718 log.Printf("console: api instances delete %s/%s/%s: move check: record: %v", acct, app, host, err)
1719 jsonError(w, http.StatusBadGateway, "could not read the instance")
1720 return true
1721 }
1722 moving := rec.Move != nil
1723 if s.cfg.HostClaim {
1724 obs, err := hostclaim.Read(ctx, s.cfg.Releases, host)
1725 if err != nil {
1726 log.Printf("console: api instances delete %s/%s/%s: move check: claim: %v", acct, app, host, err)
1727 jsonError(w, http.StatusBadGateway, "could not read the instance")
1728 return true
1729 }
1730 kind := ""
1731 if !obs.Absent && obs.Claim != nil {
1732 kind = obs.Claim.Op.Kind
1733 }
1734 moving = moveClaimKinds[kind] || (kind == hostclaim.KindRelocating && rec.Move != nil)
1735 }
1736 if !moving {
1737 return false
1738 }
1739 jsonError(w, http.StatusConflict, moveDeleteRefusal(host))
1740 return true
1741}
1742