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 "crypto/sha256"
7 "encoding/hex"
8 "encoding/json"
9 "errors"
10 "fmt"
11 "log"
12 "net/http"
13 "os"
14 "slices"
15 "sort"
16 "strings"
17 "time"
18
19 "github.com/carlosframework/platform/internal/domains"
20 "github.com/carlosframework/platform/internal/hostclaim"
21 "github.com/carlosframework/platform/internal/hostops"
22 "github.com/carlosframework/platform/internal/moves"
23 "github.com/carlosframework/platform/internal/provision"
24 "github.com/carlosframework/platform/internal/release"
25 "github.com/carlosframework/platform/internal/status"
26)
27
28// The offline operator move's doors (docs/superpowers/plans/2026-10-20-
29// offline-operator-move.md, Task 6). One hibernating instance moves between
30// two regions with planned downtime, an operator at each step:
31//
32// POST .../instances/{host}/move/offline/stage {"region":"uk"} pre-copy: -> staging
33// POST .../instances/{host}/move/offline {"region":"uk"} start (staging -> stopping)
34// POST .../instances/{host}/move/offline/seal stopping -> sealing
35// POST .../instances/{host}/move/offline/flip sealed -> landing
36// POST .../instances/{host}/move/offline/open landed -> open
37// POST .../instances/{host}/move/offline/finish open -> finishing -> cleared
38// POST .../instances/{host}/move/offline/abort back to the source
39// GET .../instances/{host}/move/offline the status view
40//
41// Every door is moveOperatorGate (the byte-identical 404 for anyone but an
42// operator's own CLI token), then MV-O20 when this console runs host claims
43// or Phase 1 moves, then its own checks, then exactly one SetRecordMove
44// transition and a serial bump, and answers 202 with the status view.
45//
46// The doors decide from evidence the console reads and never from what the
47// operator says: the record, the edges files, the boxes' status reports and
48// the reports each box writes under control/offline-moves/. The console
49// writes nothing but the record.
50
51// offlineMoveCapability is the edges-file capability a box publishes only
52// when hibernation is configured and host claims, Phase 1 and an armed or
53// enforced guard are all off (internal/host, Task 3).
54const offlineMoveCapability = "offline-move-1"
55
56// offlineStageCapability is the one a box publishes beside it when it can
57// stage (internal/host movesoffline_stage.go): run the pre-copy as the
58// stager and remove it on abort. A box without it treats a staging record
59// as nothing to do, so the stage door refuses a region holding one.
60const offlineStageCapability = "offline-stage-1"
61
62// offlineReportFresh is how recently a box's report must be written for a
63// door to believe it. Boxes rewrite their reports on every moves pass, and a
64// source or sealer box every 15 s or so while its report is one a door reads
65// (internal/host offlineReassertEvery), so a report older than this is from a
66// box that has stopped reporting, and the door says so (MV-O178).
67const offlineReportFresh = 2 * time.Minute
68
69// Every sentence a door speaks has its row in the copy record (MV-O20 to
70// MV-O31, MV-O50, MV-O51) or is an existing approved one, written out whole
71// where it is used so the copy gate reads it.
72
73// offlineName is a region code as the operator reads it: the home's
74// configured name, or the word home when none is configured.
75func (s *Server) offlineName(code string) string {
76 if name := s.homeRegionOut(code); name != "" {
77 return name
78 }
79 return "home"
80}
81
82// offlineDoor is what every door has by the time its own checks begin.
83type offlineDoor struct {
84 acct *Account
85 app string
86 host string
87 rec *provision.Record
88 addr string
89}
90
91// offlineGate is the front half every door shares: the move family's gate
92// and operator check (a 404 for anyone else), then MV-O20.
93func (s *Server) offlineGate(w http.ResponseWriter, r *http.Request) (offlineDoor, bool) {
94 acct, app, host, rec, addr, ok := s.moveOperatorGate(w, r)
95 if !ok {
96 return offlineDoor{}, false
97 }
98 if s.cfg.HostClaim || s.cfg.MovesEnabled {
99 jsonError(w, http.StatusConflict, "offline moves are not available while host claims or Phase 1 moves are on")
100 return offlineDoor{}, false
101 }
102 return offlineDoor{acct: acct, app: app, host: host, rec: rec, addr: addr}, true
103}
104
105// offlineInFlight is the move of a door that acts on one: an offline move
106// the record carries, else MV-O24.
107func (s *Server) offlineInFlight(w http.ResponseWriter, d offlineDoor) (*provision.MoveState, bool) {
108 if d.rec.Move == nil || !d.rec.Move.Offline() {
109 jsonError(w, http.StatusConflict, fmt.Sprintf("%s has no offline move in flight", d.host))
110 return nil, false
111 }
112 return d.rec.Move, true
113}
114
115// offlineStateRefusal is MV-O25.
116func offlineStateRefusal(state, host string) *offlineRefusal {
117 return &offlineRefusal{status: http.StatusConflict, msg: fmt.Sprintf("this move is %s. run carlos move offline status --host %s to see what it waits for", state, host)}
118}
119
120// offlineWaitRefusal is M-C10 with <from> filled in: a source box has not
121// confirmed the stop.
122func (s *Server) offlineWaitRefusal(box, from, host string) *offlineRefusal {
123 return &offlineRefusal{status: http.StatusConflict, msg: fmt.Sprintf("Waiting for %s in %s to confirm it has stopped %s. %s is offline.", box, from, host, host)}
124}
125
126// offlineStaleRefusal is MV-O178: box's report is there but older than
127// offlineReportFresh. That says nothing about the box's process or its stop,
128// only that the door cannot believe the report yet; a box taking part in a
129// move rewrites it every few seconds, so the step is retried, not looked into
130// (harness Run 9, finding 7: flip answered MV-O31 for a stale report, and an
131// operator went looking for a process that was not there).
132func offlineStaleRefusal(box string) *offlineRefusal {
133 return &offlineRefusal{status: http.StatusConflict, msg: fmt.Sprintf("the report from %s is not current. it reports again within a minute. try again then", box)}
134}
135
136// offlineRefusal is what a door refuses with: the status and the sentence.
137type offlineRefusal struct {
138 status int
139 msg string
140}
141
142func writeRefusal(w http.ResponseWriter, ref *offlineRefusal) {
143 jsonError(w, ref.status, ref.msg)
144}
145
146// offlineConflict is the sentence for a transition's refusal. Never the
147// error's own text: phase1Conflict passes only recorded sentences and
148// answers a fixed one for anything else.
149func offlineConflict(err error) string {
150 msg := strings.TrimPrefix(err.Error(), provision.ErrMoveConflict.Error()+": ")
151 switch {
152 // The offline transitions' own recorded sentences (MV-O34 to MV-O43).
153 // Each is a whole sentence or one format filled with a state, a box or a
154 // region the record named, so it may go out as it is.
155 case msg == "this is not an offline move",
156 msg == "the record carries a different offline move",
157 msg == "this is an offline move. use carlos move offline",
158 strings.HasPrefix(msg, "a seal names the box that seals and the boxes that stopped the instance."),
159 strings.HasPrefix(msg, "this offline move is ") && strings.Contains(msg, ". this step follows "),
160 strings.HasPrefix(msg, "this offline move is ") && strings.Contains(msg, " is sealed by "),
161 strings.HasPrefix(msg, "this offline move is ") && strings.HasSuffix(msg, ". it cannot be aborted once it is open"),
162 strings.HasPrefix(msg, "this offline move is ") && strings.HasSuffix(msg, ". it finishes from finishing or reverting"),
163 strings.HasPrefix(msg, "the record's region is ") && strings.HasSuffix(msg, " the instance changed under the move"),
164 strings.HasPrefix(msg, "the instance is in ") && strings.Contains(msg, ", not "),
165 // The move family's own recorded refusals, as phase1Conflict passes
166 // them.
167 msg == "no move in flight",
168 msg == "malformed move start",
169 strings.HasPrefix(msg, "a move to ") && strings.Contains(msg, " is already "),
170 strings.HasPrefix(msg, "the instance is already in "),
171 strings.HasPrefix(msg, "a ") && strings.HasSuffix(msg, " does not move. delete it and create one in the new region"):
172 return msg
173 }
174 return "this move's state does not allow that. read its state and try again"
175}
176
177// ---- evidence ----
178
179// offlineReport is one report read from the bucket, with the bytes its
180// digest is taken over.
181type offlineReport struct {
182 moves.OfflineReport
183 raw []byte
184}
185
186// offlineEvidence is every report of one move, as read, and its seal: the
187// immutable record of what the sealer decided (moves.OfflineSealKey), nil
188// when absent or not a valid seal of this move.
189type offlineEvidence struct {
190 reports []offlineReport
191 seal *offlineReport
192}
193
194func (e offlineEvidence) get(role, box string) (offlineReport, bool) {
195 for _, r := range e.reports {
196 if r.Role == role && r.Box == box {
197 return r, true
198 }
199 }
200 return offlineReport{}, false
201}
202
203func (e offlineEvidence) byRole(role string) []offlineReport {
204 var out []offlineReport
205 for _, r := range e.reports {
206 if r.Role == role {
207 out = append(out, r)
208 }
209 }
210 return out
211}
212
213// readOfflineEvidence lists and reads every report of the move. A report
214// that does not parse, fails moves.ValidateOfflineReport, or does not sit at
215// the key its own fields name is not evidence and is skipped, which can only
216// make a door refuse. A listing or a read that fails is the error.
217func (s *Server) readOfflineEvidence(ctx context.Context, host, id string) (offlineEvidence, error) {
218 prefix := moves.OfflineReportPrefix(host, id)
219 if prefix == "" {
220 return offlineEvidence{}, nil
221 }
222 keys, err := s.cfg.Releases.List(ctx, prefix)
223 if err != nil {
224 return offlineEvidence{}, err
225 }
226 var ev offlineEvidence
227 sealKey := moves.OfflineSealKey(host, id)
228 for _, key := range keys {
229 if key == sealKey {
230 continue // read below, as the seal and not as a report
231 }
232 raw, _, err := s.cfg.Releases.Get(ctx, key)
233 if errors.Is(err, os.ErrNotExist) {
234 continue
235 }
236 if err != nil {
237 return offlineEvidence{}, err
238 }
239 var rep moves.OfflineReport
240 if err := json.Unmarshal(raw, &rep); err != nil || moves.ValidateOfflineReport(rep) != nil ||
241 rep.Host != host || rep.Op != id || moves.OfflineReportKey(rep.Host, rep.Op, rep.Role, rep.Box) != key {
242 log.Printf("console: offline move %s: ignoring %s: not a report of this move", host, key)
243 continue
244 }
245 ev.reports = append(ev.reports, offlineReport{OfflineReport: rep, raw: raw})
246 }
247 sort.Slice(ev.reports, func(i, j int) bool {
248 if ev.reports[i].Role != ev.reports[j].Role {
249 return ev.reports[i].Role < ev.reports[j].Role
250 }
251 return ev.reports[i].Box < ev.reports[j].Box
252 })
253 raw, _, err := s.cfg.Releases.Get(ctx, sealKey)
254 switch {
255 case errors.Is(err, os.ErrNotExist):
256 case err != nil:
257 return offlineEvidence{}, err
258 default:
259 var rep moves.OfflineReport
260 if err := json.Unmarshal(raw, &rep); err != nil || moves.ValidateOfflineSeal(rep, host, id) != nil {
261 log.Printf("console: offline move %s: ignoring %s: not the seal of this move", host, sealKey)
262 } else {
263 ev.seal = &offlineReport{OfflineReport: rep, raw: raw}
264 }
265 }
266 return ev, nil
267}
268
269func offlineFresh(r moves.OfflineReport, now time.Time) bool {
270 age := now.Sub(r.At)
271 return !r.At.IsZero() && age <= offlineReportFresh && age >= -time.Minute
272}
273
274func offlineStopped(r moves.OfflineReport) bool { return r.Held && r.Fenced && r.Quiesced }
275
276// offlineSealItems and offlineLandingItems are the one place the doors ask
277// the manifest which items a report must carry OK: flip needs the sealer's
278// report OK for every item the source side reports (Source "seal"), open the
279// target's for every item the landing owns.
280func offlineSealItems(r moves.OfflineReport) (bool, []string) {
281 return r.SourceItemsOK()
282}
283
284func offlineLandingItems(r moves.OfflineReport) (bool, []string) {
285 return r.ItemsOK(moves.OfflineOwnerLanding)
286}
287
288// offlineEvidenceMatches says whether the record's source.evidence names
289// this report: the report's sha256 as hex, bare or after the report's key
290// and one separator.
291func offlineEvidenceMatches(evidence, key string, raw []byte) bool {
292 sum := sha256.Sum256(raw)
293 h := hex.EncodeToString(sum[:])
294 tail, ok := strings.CutPrefix(evidence, key)
295 if ok && tail != "" && strings.Contains("@: #", tail[:1]) {
296 evidence = tail[1:]
297 }
298 return evidence == h || evidence == "sha256:"+h
299}
300
301// offlineFleet is the edges files and each box's status report, read
302// together. Incomplete evidence is not an answer: ok is false.
303type offlineFleet struct {
304 snap edgesSnapshot
305 metrics map[string]*status.BoxMetrics
306}
307
308func (s *Server) readOfflineFleet(ctx context.Context) (offlineFleet, bool) {
309 snap, err := s.liveEdges(ctx)
310 if err != nil || !snap.EvidenceComplete() {
311 log.Printf("console: offline move: the edges files are not complete evidence (error %v)", err)
312 return offlineFleet{}, false
313 }
314 boxes, complete, err := status.LoadBoxesComplete(ctx, s.cfg.Releases)
315 if err != nil || !complete {
316 log.Printf("console: offline move: the boxes' status reports are not complete evidence (complete %v, error %v)", complete, err)
317 return offlineFleet{}, false
318 }
319 f := offlineFleet{snap: snap, metrics: map[string]*status.BoxMetrics{}}
320 for _, b := range boxes {
321 f.metrics[b.Label] = b.Metrics
322 }
323 sort.Slice(f.snap.Files, func(i, j int) bool { return f.snap.Files[i].Box < f.snap.Files[j].Box })
324 return f, true
325}
326
327// offlineCanHold says whether a box's published state lets it hold an
328// ordinary instance: hibernation not configured off, and the shared pool not
329// served off. Absent is unknown, and unknown could hold.
330func offlineCanHold(f edgesFilePlacementView) bool {
331 return (f.HibernationConfigured == nil || *f.HibernationConfigured) &&
332 (f.ServesSharedPool == nil || *f.ServesSharedPool)
333}
334
335func offlineCapable(f edgesFilePlacementView) bool {
336 return slices.Contains(f.Capabilities, offlineMoveCapability)
337}
338
339func offlineServes(f edgesFilePlacementView, host string) bool {
340 _, ok := f.Hosts[host]
341 return ok
342}
343
344// offlineClaimsOnBox is what a claims-on box has to show the start door
345// (ruling 2026-10-20, point 3). Every field is published state; none is the
346// operator's word. Census and SharedPool are pointers: nil is "the box does
347// not say", and an unsaid fact refuses.
348type offlineClaimsOnBox struct {
349 HostRow bool // its edges file lists the host
350 Census *bool // its report says root's census.json is present
351 SharedPool *bool // its edges file says whether it serves the shared pool
352}
353
354// ok: no row for the host, census.json reported present, and pools that do
355// not match the record's placement (the record is in the shared pool, so a
356// box that serves it matches).
357func (b offlineClaimsOnBox) ok() bool {
358 return !b.HostRow && b.Census != nil && *b.Census && b.SharedPool != nil && !*b.SharedPool
359}
360
361// offlineCensusReported is whether a box's status report says root's
362// census.json is present. No report field carries it today, and the move
363// adds none, so every claims-on box fails this condition until one does.
364func offlineCensusReported(*status.BoxMetrics) *bool { return nil }
365
366// offlineSourceBoxes are the edges files of the record's source placement
367// that could hold the host, in box order.
368func (f offlineFleet) boxesAt(code string) []edgesFilePlacementView {
369 var out []edgesFilePlacementView
370 for _, b := range f.snap.Files {
371 if b.Placement == code && offlineCanHold(b) {
372 out = append(out, b)
373 }
374 }
375 return out
376}
377
378// ---- start ----
379
380// offlineSelect is the start door's box rules, from the fleet read and
381// nothing else: exactly one target box, at least one source box, the
382// capability on each, no claims-on box in either region that could touch the
383// host, an empty target, the deployment's secrets key on the target and a
384// published data bucket there.
385func (s *Server) offlineSelect(ctx context.Context, f offlineFleet, host, from, to string) *offlineRefusal {
386 fromName, toName := s.offlineName(from), s.offlineName(to)
387 src, tgt := f.boxesAt(from), f.boxesAt(to)
388 if len(tgt) != 1 {
389 return &offlineRefusal{status: http.StatusConflict, msg: fmt.Sprintf("%s has %d boxes. an offline move needs exactly one box in the region it moves to", toName, len(tgt))}
390 }
391 if len(src) == 0 {
392 return &offlineRefusal{status: http.StatusBadRequest, msg: "no eligible box in " + fromName + " publishes an edges file"}
393 }
394 for _, b := range append(slices.Clone(src), tgt...) {
395 if !offlineCapable(b) {
396 return &offlineRefusal{status: http.StatusConflict, msg: fmt.Sprintf("%s cannot take part in an offline move. it runs an older release or has host claims on", b.Box)}
397 }
398 }
399 // Ruling 2026-10-20, point 3: a box in either region that runs claims, or
400 // whose report does not say it does not, must publish no row for the
401 // host, report census.json present and serve pools the record's
402 // placement does not match. A box with the capability has vouched for
403 // itself (it publishes it only with claims off) unless its report says
404 // claims are on.
405 for _, b := range f.snap.Files {
406 if b.Placement != from && b.Placement != to {
407 continue
408 }
409 m := f.metrics[b.Box]
410 if (offlineCapable(b) && (m == nil || !m.HostClaimEnabled)) || (m != nil && !m.HostClaimEnabled) {
411 continue
412 }
413 if !(offlineClaimsOnBox{HostRow: offlineServes(b, host), Census: offlineCensusReported(m), SharedPool: b.ServesSharedPool}).ok() {
414 return &offlineRefusal{status: http.StatusConflict, msg: fmt.Sprintf("%s cannot take part in an offline move. it runs an older release or has host claims on", b.Box)}
415 }
416 }
417 t := tgt[0]
418 if offlineServes(t, host) {
419 return &offlineRefusal{status: http.StatusConflict, msg: fmt.Sprintf("%s already serves %s. an offline move needs the region it moves to to be empty of it", t.Box, host)}
420 }
421 if fp := s.expectedSecretsFP(); fp == "" || t.SecretsKeyFP != fp {
422 return &offlineRefusal{status: http.StatusConflict, msg: fmt.Sprintf("%s does not hold this deployment's secrets key", t.Box)}
423 }
424 if t.DataBucket == "" {
425 caps, _, _ := movePlacements(ctx, s.cfg.Releases, s.moveHomeName())
426 return &offlineRefusal{status: http.StatusBadRequest, msg: "region " + toName + " cannot take a move in this deployment. it publishes no staging bucket. Regions that can: " + s.stagingNames(caps)}
427 }
428 return nil
429}
430
431// offlineClaimEvidence refuses a host that has, anywhere, a claim: the
432// record's acquisition, control/hosts/<host>.json, a domains claim carrying
433// one, or the app's admission (MV-O29); and a deployment that has begun the
434// claim backfill (MV-O20). A read that fails is a 502 and never an absence.
435func (s *Server) offlineClaimEvidence(ctx context.Context, d offlineDoor) *offlineRefusal {
436 has := &offlineRefusal{status: http.StatusConflict, msg: fmt.Sprintf("%s has a host claim. offline moves are only for hosts without one", d.host)}
437 unreadable := &offlineRefusal{status: http.StatusBadGateway, msg: "could not read the host claim"}
438 if d.rec.Acquisition != "" {
439 return has
440 }
441 obs, err := hostclaim.Read(ctx, s.cfg.Releases, d.host)
442 if err != nil {
443 log.Printf("console: offline move %s: claim read: %v", d.host, err)
444 return unreadable
445 }
446 if !obs.Absent {
447 return has
448 }
449 if c, err := domains.GetClaim(ctx, s.cfg.Releases, d.host); err == nil && c.Acquisition != "" {
450 return has
451 } else if err != nil && !errors.Is(err, os.ErrNotExist) {
452 log.Printf("console: offline move %s: domains claim read: %v", d.host, err)
453 return unreadable
454 }
455 key, err := hostops.AdmissionKey(d.acct.Sqid, d.app)
456 if err != nil {
457 log.Printf("console: offline move %s: admission key: %v", d.host, err)
458 return unreadable
459 }
460 if _, _, err := s.cfg.Releases.Get(ctx, key); err == nil {
461 return has
462 } else if !errors.Is(err, os.ErrNotExist) {
463 log.Printf("console: offline move %s: admission read: %v", d.host, err)
464 return unreadable
465 }
466 // A plan at done is allowed; one that has begun, or that cannot be
467 // verified, is not.
468 switch err := hostops.CheckCreateFreeze(ctx, s.cfg.Releases, s.cfg.ReleasePub); {
469 case err == nil:
470 case errors.Is(err, hostops.ErrPending), errors.Is(err, hostops.ErrIdentity):
471 return &offlineRefusal{status: http.StatusConflict, msg: "offline moves are not available while host claims or Phase 1 moves are on"}
472 default:
473 log.Printf("console: offline move %s: backfill plan read: %v", d.host, err)
474 return unreadable
475 }
476 return nil
477}
478
479// offlineBeginChecks is what start and stage check before either writes:
480// the instance's class, its claims, the region asked for, the app's
481// regions and the fleet's box rules (offlineSelect). It answers the region
482// code and the fleet it read, or false once it has answered the request.
483// A staged move is checked again in full when it starts: the fleet and the
484// claims may have changed since the stage.
485func (s *Server) offlineBeginChecks(w http.ResponseWriter, r *http.Request, d offlineDoor) (string, offlineFleet, bool) {
486 rec, host := d.rec, d.host
487 if s.refuseSeedMove(w, rec) {
488 return "", offlineFleet{}, false
489 }
490 if rec.Role != "" {
491 jsonError(w, http.StatusBadRequest, "a replica does not move. delete it and create one in the new region")
492 return "", offlineFleet{}, false
493 }
494 if len(rec.Replicas) > 0 {
495 jsonError(w, http.StatusBadRequest, "a home with replicas does not move. remove its replicas first")
496 return "", offlineFleet{}, false
497 }
498 if rec.Steering != "" || rec.Unit != "" {
499 jsonError(w, http.StatusBadRequest, fmt.Sprintf("%s is steered or runs under a unit. offline moves cannot carry either yet", host))
500 return "", offlineFleet{}, false
501 }
502 ctx := r.Context()
503 if ref := s.offlineClaimEvidence(ctx, d); ref != nil {
504 writeRefusal(w, ref)
505 return "", offlineFleet{}, false
506 }
507 var req struct {
508 Region string `json:"region"`
509 }
510 if !readJSON(w, r, &req) {
511 return "", offlineFleet{}, false
512 }
513 region := strings.TrimSpace(req.Region)
514 to := s.homeRegionIn(region)
515 if region == "" || !provision.ValidRegion(to) {
516 jsonError(w, http.StatusBadRequest, "invalid region")
517 return "", offlineFleet{}, false
518 }
519 if to == rec.Region {
520 jsonError(w, http.StatusConflict, "the instance is already in "+region)
521 return "", offlineFleet{}, false
522 }
523 m, err := provision.GetManifest(ctx, s.cfg.Releases, d.acct.Sqid, d.app)
524 if err != nil && !errors.Is(err, os.ErrNotExist) {
525 log.Printf("console: offline move %s: manifest: %v", host, err)
526 jsonError(w, http.StatusServiceUnavailable, "could not read the app's instances manifest. try again")
527 return "", offlineFleet{}, false
528 }
529 if m != nil && !s.moveRegionAllowed(m.Regions, to) {
530 jsonError(w, http.StatusBadRequest, "region "+region+" is not one of this app's regions: "+strings.Join(m.Regions, ", "))
531 return "", offlineFleet{}, false
532 }
533 fleet, ok := s.readOfflineFleet(ctx)
534 if !ok {
535 jsonError(w, http.StatusConflict, "the fleet's edges files could not all be read. try again")
536 return "", offlineFleet{}, false
537 }
538 if to == "" || rec.Region == "" {
539 // A box that sets CARLOS_REGION to the home's name is home to this
540 // console and not to itself, so it cannot take a move into home, and
541 // out of home the placement it publishes is not the record's.
542 for _, f := range fleet.snap.Files {
543 if f.Placement != "" && homePlacementCode(s.moveHomeName(), f.Placement) == "" {
544 jsonError(w, http.StatusConflict, f.Box+" sets CARLOS_REGION, so it cannot take a move into the home region")
545 return "", offlineFleet{}, false
546 }
547 }
548 }
549 if ref := s.offlineSelect(ctx, fleet, host, rec.Region, to); ref != nil {
550 writeRefusal(w, ref)
551 return "", offlineFleet{}, false
552 }
553 return to, fleet, true
554}
555
556// offlineStagedFor is the refusal of a start or a stage that names another
557// region than the one the instance is staged for (MV-O179).
558func (s *Server) offlineStagedFor(host, to string) *offlineRefusal {
559 return &offlineRefusal{status: http.StatusConflict, msg: fmt.Sprintf("%s is staged for %s. abort the move to move it to another region", host, s.offlineName(to))}
560}
561
562// offlineStaged is the record's offline move when it is staging.
563func offlineStaged(rec *provision.Record) (*provision.MoveState, bool) {
564 if mv := rec.Move; mv.Offline() && mv.State == provision.OfflineStaging {
565 return mv, true
566 }
567 return nil, false
568}
569
570// offlineStagerGone is MV-O190: the pinned stager of a staged move no
571// longer publishes an edges file in the source region. Its disappearance
572// is never proof that it stopped copying, so start and seal refuse.
573func (s *Server) offlineStagerGone(box, host, from string) *offlineRefusal {
574 return &offlineRefusal{status: http.StatusConflict, msg: fmt.Sprintf("%s, which staged %s, no longer publishes an edges file in %s. bring it back or abort the move", box, host, from)}
575}
576
577// offlineStageCoords refuses a staged move whose target publishes other
578// staging coordinates than the ones the stage fixed on the record (MV-O192,
579// Astra round 2, finding 2): the staged copy is in the stored bucket, and
580// nothing is ever retargeted. offlineSelect has already required exactly
581// one target box.
582func (s *Server) offlineStageCoords(f offlineFleet, mv *provision.MoveState, host string) *offlineRefusal {
583 if mv.StagedAt == nil {
584 return nil
585 }
586 tgt := f.boxesAt(mv.To)
587 if len(tgt) != 1 || tgt[0].DataBucket != mv.StageBucket || tgt[0].DataBucketRegion != mv.StageRegion {
588 return &offlineRefusal{status: http.StatusConflict, msg: fmt.Sprintf("%s now publishes a different data bucket or region from what was recorded when %s was staged. abort the move and stage it again", s.offlineName(mv.To), host)}
589 }
590 return nil
591}
592
593// offlineStagerPresent refuses a staged move whose pinned stager is not in
594// the source region's fleet now (MV-O190).
595func (s *Server) offlineStagerPresent(f offlineFleet, mv *provision.MoveState, host string) *offlineRefusal {
596 if mv.StagedAt == nil {
597 return nil
598 }
599 for _, b := range f.boxesAt(mv.From) {
600 if b.Box == mv.Stager {
601 return nil
602 }
603 }
604 return s.offlineStagerGone(mv.Stager, host, s.offlineName(mv.From))
605}
606
607func (s *Server) handleAPIInstanceOfflineStart(w http.ResponseWriter, r *http.Request) {
608 d, ok := s.offlineGate(w, r)
609 if !ok {
610 return
611 }
612 to, fleet, ok := s.offlineBeginChecks(w, r, d)
613 if !ok {
614 return
615 }
616 // A staged instance starts the move it is staged for: staging to
617 // stopping, the instance offline from now. The lock is the stage's.
618 if mv, staged := offlineStaged(d.rec); staged {
619 if to != mv.To {
620 writeRefusal(w, s.offlineStagedFor(d.host, mv.To))
621 return
622 }
623 if ref := s.offlineStagerPresent(fleet, mv, d.host); ref != nil {
624 writeRefusal(w, ref)
625 return
626 }
627 if ref := s.offlineStageCoords(fleet, mv, d.host); ref != nil {
628 writeRefusal(w, ref)
629 return
630 }
631 s.offlineAdvance(w, r, d, provision.OfflineStartStaged(mv.ID, d.addr, time.Now()))
632 return
633 }
634 s.offlineBegin(w, r, d, func(id string) func(*provision.Record) error {
635 return provision.OfflineStart(id, d.rec.Region, to, d.addr, time.Now())
636 })
637}
638
639// handleAPIInstanceOfflineStage is the pre-copy door. On a record at rest it
640// creates the move in offline-staging, checked exactly as start is and
641// under the same one-move lock, and the source region's stager runs one
642// round on the serial bump. On a record already staging for that region it
643// moves staged_at forward (OfflineRestage), so the stager runs one more
644// round; each round copies only what is new (the receipts).
645func (s *Server) handleAPIInstanceOfflineStage(w http.ResponseWriter, r *http.Request) {
646 d, ok := s.offlineGate(w, r)
647 if !ok {
648 return
649 }
650 to, fleet, ok := s.offlineBeginChecks(w, r, d)
651 if !ok {
652 return
653 }
654 if mv, staged := offlineStaged(d.rec); staged {
655 if to != mv.To {
656 writeRefusal(w, s.offlineStagedFor(d.host, mv.To))
657 return
658 }
659 if ref := s.offlineStageCoords(fleet, mv, d.host); ref != nil {
660 writeRefusal(w, ref)
661 return
662 }
663 s.offlineAdvance(w, r, d, provision.OfflineRestage(mv.ID, time.Now()))
664 return
665 }
666 for _, b := range append(fleet.boxesAt(d.rec.Region), fleet.boxesAt(to)...) {
667 if !slices.Contains(b.Capabilities, offlineStageCapability) {
668 jsonError(w, http.StatusConflict, fmt.Sprintf("%s cannot stage an offline move. it runs an older release", b.Box))
669 return
670 }
671 }
672 // The stager is pinned here, for the whole move: the one box that ever
673 // writes this move's staging prefix, and the one whose cleanup an abort
674 // waits for (Astra round 1, finding 2). offlineSelect has already
675 // required at least one source box.
676 var names []string
677 for _, b := range fleet.boxesAt(d.rec.Region) {
678 names = append(names, b.Box)
679 }
680 // The staging destination is fixed here too, before any copy (Astra
681 // round 2, finding 2): the one target box's data bucket and region, as
682 // offlineSelect found it. A target that publishes a source box's own
683 // bucket is refused: the activator there would promote the staged copy
684 // over the serving replica on its next wake (MV-O191).
685 tgt := fleet.boxesAt(to)[0]
686 for _, b := range fleet.boxesAt(d.rec.Region) {
687 if b.DataBucket == tgt.DataBucket {
688 jsonError(w, http.StatusConflict, fmt.Sprintf("%s publishes the same data bucket as %s. a staged copy there would replace the serving one", s.offlineName(to), s.offlineName(d.rec.Region)))
689 return
690 }
691 }
692 st := provision.StageTarget{Stager: moves.OfflineStager(names), Bucket: tgt.DataBucket, Region: tgt.DataBucketRegion}
693 s.offlineBegin(w, r, d, func(id string) func(*provision.Record) error {
694 return provision.OfflineStage(id, d.rec.Region, to, d.addr, st, time.Now())
695 })
696}
697
698// offlineBegin mints the move's id and writes the transition begin makes
699// with it (OfflineStart or OfflineStage) under the one-move lock, then
700// bumps the serial and answers 202 with the status view.
701func (s *Server) offlineBegin(w http.ResponseWriter, r *http.Request, d offlineDoor, begin func(id string) func(*provision.Record) error) {
702 ctx := r.Context()
703 host := d.host
704 id, err := moves.NewMoveID(rand.Reader)
705 if err != nil {
706 log.Printf("console: offline move %s: move id: %v", host, err)
707 jsonError(w, http.StatusBadGateway, "could not mint a move id")
708 return
709 }
710 start := begin(id)
711 probe := *d.rec
712 if err := start(&probe); err != nil {
713 jsonError(w, http.StatusConflict, offlineConflict(err))
714 return
715 }
716 // One offline move at a time: the lock is taken before the record is
717 // written, and released if the write fails.
718 if ref := s.offlineTakeLock(ctx, d, id); ref != nil {
719 writeRefusal(w, ref)
720 return
721 }
722 // The write is bounded well under offlineLockGrace, so the lock cannot be
723 // judged stale while it is still in flight.
724 wctx, cancel := context.WithTimeout(ctx, offlineStartWrite)
725 defer cancel()
726 got, err := s.offlineApply(wctx, d, start)
727 if err != nil {
728 // A failed write does not prove nothing was written (a commit whose
729 // reply was lost). The lock is never released here: if the record
730 // carries this move the start succeeded, and if it does not the
731 // lock goes stale and frees itself.
732 rctx, rcancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
733 defer rcancel()
734 if cur, rerr := provision.GetRecord(rctx, s.cfg.Releases, d.acct.Sqid, d.app, host); rerr == nil && cur.Move.Offline() && cur.Move.ID == id {
735 got, err = cur, nil
736 }
737 }
738 if err != nil {
739 s.offlineWriteFailed(w, d, err)
740 return
741 }
742 s.bumpSerial(ctx)
743 writeJSON(w, http.StatusAccepted, s.offlineViewOf(ctx, got))
744}
745
746// offlineStartWrite bounds the start door's record write.
747const offlineStartWrite = 30 * time.Second
748
749// offlineApply is one transition through the record writer.
750func (s *Server) offlineApply(ctx context.Context, d offlineDoor, mut func(*provision.Record) error) (*provision.Record, error) {
751 return provision.SetRecordMove(ctx, s.cfg.Releases, d.acct.Sqid, d.app, d.host, mut)
752}
753
754// offlineWriteFailed answers a transition's failure: a refusal with its
755// recorded sentence, anything else as unwritten.
756func (s *Server) offlineWriteFailed(w http.ResponseWriter, d offlineDoor, err error) {
757 if errors.Is(err, provision.ErrMoveConflict) {
758 jsonError(w, http.StatusConflict, offlineConflict(err))
759 return
760 }
761 log.Printf("console: offline move %s/%s/%s: %v", d.acct.Sqid, d.app, d.host, err)
762 jsonError(w, http.StatusBadGateway, "could not write the move. try again")
763}
764
765// offlineWrite applies one transition and bumps the serial, or answers the
766// refusal itself. When the move it leaves is cleared (finish, or the finish
767// that ends an abort) it releases the lock.
768func (s *Server) offlineWrite(w http.ResponseWriter, r *http.Request, d offlineDoor, mut func(*provision.Record) error) (*provision.Record, bool) {
769 ctx := r.Context()
770 rec, err := s.offlineApply(ctx, d, mut)
771 if err != nil {
772 s.offlineWriteFailed(w, d, err)
773 return nil, false
774 }
775 s.bumpSerial(ctx)
776 if rec.Move == nil && d.rec.Move != nil {
777 s.offlineReleaseLock(ctx, d.host, d.rec.Move.ID)
778 }
779 return rec, true
780}
781
782// offlineAdvance applies one transition and answers 202 with the status
783// view of the record it left.
784func (s *Server) offlineAdvance(w http.ResponseWriter, r *http.Request, d offlineDoor, mut func(*provision.Record) error) {
785 if rec, ok := s.offlineWrite(w, r, d, mut); ok {
786 writeJSON(w, http.StatusAccepted, s.offlineViewOf(r.Context(), rec))
787 }
788}
789
790// offlineLockGrace is how long a lock is believed whatever its record says:
791// the start door takes the lock, then writes the record.
792const offlineLockGrace = 2 * time.Minute
793
794// offlineTakeLock takes control/offline-moves/active.json for this host and
795// move, create-only. A lock already there is live while the record it names
796// still carries that move, or is younger than offlineLockGrace (MV-O30 naming
797// its host); otherwise it is stale (a crashed start, a failed release) and is
798// replaced under its etag. A
799// lock that cannot be read is not guessed at.
800func (s *Server) offlineTakeLock(ctx context.Context, d offlineDoor, id string) *offlineRefusal {
801 unreadable := &offlineRefusal{status: http.StatusBadGateway, msg: "could not read the instance"}
802 body, err := json.Marshal(moves.OfflineActive{Host: d.host, Op: id, Account: d.acct.Sqid, App: d.app, At: time.Now().UTC()})
803 if err != nil {
804 return unreadable
805 }
806 _, err = s.cfg.Releases.Put(ctx, moves.OfflineActiveKey, body, "")
807 if err == nil {
808 return nil
809 }
810 if !errors.Is(err, release.ErrCASConflict) {
811 log.Printf("console: offline move %s: taking the lock: %v", d.host, err)
812 return unreadable
813 }
814 raw, etag, err := s.cfg.Releases.Get(ctx, moves.OfflineActiveKey)
815 var held moves.OfflineActive
816 if err != nil || json.Unmarshal(raw, &held) != nil || held.Host == "" {
817 log.Printf("console: offline move %s: reading the lock: %v", d.host, err)
818 return unreadable
819 }
820 busy := &offlineRefusal{status: http.StatusConflict, msg: fmt.Sprintf("%s is moving offline. move one instance at a time", held.Host)}
821 // A lock is taken before its record is written, so a record without the
822 // move is only a stale lock once the holder has had time to write it.
823 if time.Since(held.At) < offlineLockGrace {
824 return busy
825 }
826 other, err := provision.GetRecord(ctx, s.cfg.Releases, held.Account, held.App, held.Host)
827 switch {
828 case errors.Is(err, os.ErrNotExist):
829 case err != nil:
830 log.Printf("console: offline move %s: reading the lock holder %s: %v", d.host, held.Host, err)
831 return unreadable
832 case other.Move.Offline() && other.Move.ID == held.Op:
833 return busy
834 }
835 if _, err := s.cfg.Releases.Put(ctx, moves.OfflineActiveKey, body, etag); err != nil {
836 if errors.Is(err, release.ErrCASConflict) {
837 return busy
838 }
839 log.Printf("console: offline move %s: replacing a stale lock: %v", d.host, err)
840 return unreadable
841 }
842 return nil
843}
844
845// offlineReleaseLock deletes the lock under the etag it read, when it is
846// this move's. A failure is logged and left: the next start finds the lock
847// stale (its record no longer carries the move) and replaces it.
848func (s *Server) offlineReleaseLock(ctx context.Context, host, id string) {
849 ctx = context.WithoutCancel(ctx)
850 raw, etag, err := s.cfg.Releases.Get(ctx, moves.OfflineActiveKey)
851 if errors.Is(err, os.ErrNotExist) {
852 return
853 }
854 var held moves.OfflineActive
855 if err != nil || json.Unmarshal(raw, &held) != nil {
856 log.Printf("console: offline move %s: reading the lock to release it: %v", host, err)
857 return
858 }
859 if held.Host != host || held.Op != id {
860 return
861 }
862 if err := release.DeleteIfMatch(ctx, s.cfg.Releases, moves.OfflineActiveKey, etag); err != nil {
863 log.Printf("console: offline move %s: releasing the lock: %v", host, err)
864 }
865}
866
867// ---- the step each state waits for ----
868
869// The verbs.
870const (
871 offlineVerbStart = "start"
872 offlineVerbSeal = "seal"
873 offlineVerbFlip = "flip"
874 offlineVerbOpen = "open"
875 offlineVerbFinish = "finish"
876)
877
878// offlineVerbOf is the one door a state is waiting on. sealing and landing
879// wait for a box, not for the operator.
880func offlineVerbOf(state string) string {
881 switch state {
882 case provision.OfflineStaging:
883 return offlineVerbStart
884 case provision.OfflineStopping:
885 return offlineVerbSeal
886 case provision.OfflineSealed:
887 return offlineVerbFlip
888 case provision.OfflineLanded:
889 return offlineVerbOpen
890 case provision.OfflineOpen, provision.OfflineFinishing, provision.OfflineReverting:
891 return offlineVerbFinish
892 }
893 return ""
894}
895
896// offlineStep is a door's checks passed: the transition to write.
897type offlineStep struct {
898 mut func(*provision.Record) error
899}
900
901// offlineOpenMut is the open transition. The one place the door names it.
902func offlineOpenMut(id string, now time.Time) func(*provision.Record) error {
903 return provision.OfflineOpenTransition(id, now)
904}
905
906// offlineStepFor runs the checks of the verb the move's state waits for,
907// from the evidence. A refusal is the sentence the door answers and the
908// status view shows as what the move waits for.
909func (s *Server) offlineStepFor(ctx context.Context, d offlineDoor, mv *provision.MoveState, ev offlineEvidence, now time.Time) (offlineStep, *offlineRefusal) {
910 host, from := d.host, s.offlineName(mv.From)
911 switch mv.State {
912 case provision.OfflineStaging:
913 // What start would check of the fleet now; its request checks
914 // (the class, the claims, the region) are the start door's.
915 fleet, ok := s.readOfflineFleet(ctx)
916 if !ok {
917 return offlineStep{}, &offlineRefusal{status: http.StatusConflict, msg: "the fleet's edges files could not all be read. try again"}
918 }
919 if ref := s.offlineSelect(ctx, fleet, host, mv.From, mv.To); ref != nil {
920 return offlineStep{}, ref
921 }
922 if ref := s.offlineStagerPresent(fleet, mv, host); ref != nil {
923 return offlineStep{}, ref
924 }
925 if ref := s.offlineStageCoords(fleet, mv, host); ref != nil {
926 return offlineStep{}, ref
927 }
928 return offlineStep{mut: provision.OfflineStartStaged(mv.ID, d.addr, now)}, nil
929 case provision.OfflineStopping:
930 fleet, ok := s.readOfflineFleet(ctx)
931 if !ok {
932 return offlineStep{}, &offlineRefusal{status: http.StatusConflict, msg: "the fleet's edges files could not all be read. try again"}
933 }
934 sealer, boxes, ref := s.offlineSealCheck(fleet, ev, mv, host, from, now)
935 if ref != nil {
936 return offlineStep{}, ref
937 }
938 return offlineStep{mut: provision.OfflineSeal(mv.ID, sealer, boxes)}, nil
939 case provision.OfflineSealed:
940 if ref := s.offlineFlipCheck(ev, mv, host, from, now); ref != nil {
941 return offlineStep{}, ref
942 }
943 return offlineStep{mut: provision.OfflineFlip(mv.ID)}, nil
944 case provision.OfflineLanded:
945 if ref := s.offlineOpenCheck(ev, mv, host, from, now); ref != nil {
946 return offlineStep{}, ref
947 }
948 return offlineStep{mut: offlineOpenMut(mv.ID, now)}, nil
949 case provision.OfflineOpen:
950 if ref := offlineFinishCheck(ev, mv, host, moves.OfflinePhaseOpened, "", moves.OfflinePhaseParked, mv.Boxes); ref != nil {
951 return offlineStep{}, ref
952 }
953 return offlineStep{mut: provision.OfflineFinishingTransition(mv.ID)}, nil
954 case provision.OfflineFinishing:
955 if ref := offlineFinishCheck(ev, mv, host, "", moves.OfflinePhaseLifted, moves.OfflinePhaseGone, mv.Boxes); ref != nil {
956 return offlineStep{}, ref
957 }
958 return offlineStep{mut: provision.OfflineFinish(mv.ID, now)}, nil
959 case provision.OfflineReverting:
960 // Every box the seal named, and the sealer. A move aborted before
961 // the seal named neither: the boxes are the source region's as the
962 // edges files publish them now, and there is no sealer (it never
963 // took a tombstone).
964 boxes, sealerPhase := mv.Boxes, moves.OfflinePhaseReverted
965 if mv.Sealer == "" {
966 fleet, ok := s.readOfflineFleet(ctx)
967 if !ok {
968 return offlineStep{}, &offlineRefusal{status: http.StatusConflict, msg: "the fleet's edges files could not all be read. try again"}
969 }
970 boxes, sealerPhase = nil, ""
971 for _, b := range fleet.boxesAt(mv.From) {
972 boxes = append(boxes, b.Box)
973 }
974 }
975 if ref := offlineFinishCheck(ev, mv, host, "", sealerPhase, moves.OfflinePhaseReverted, boxes); ref != nil {
976 return offlineStep{}, ref
977 }
978 // A staged move also waits for its pinned stager to say the staged
979 // copy is gone, whatever its last round reported (Astra round 1,
980 // findings 1 and 2).
981 if mv.StagedAt != nil {
982 if rep, ok := ev.get(moves.OfflineRoleStager, mv.Stager); !ok || rep.Phase != moves.OfflinePhaseReverted {
983 return offlineStep{}, offlineStateRefusal(mv.State, host)
984 }
985 }
986 return offlineStep{mut: provision.OfflineFinish(mv.ID, now)}, nil
987 }
988 return offlineStep{}, offlineStateRefusal(mv.State, host)
989}
990
991// offlineSealCheck: every source box reports this move held, fenced and
992// quiesced, and its edges file no longer lists the host. It names the sealer
993// and the boxes the later doors wait for.
994func (s *Server) offlineSealCheck(f offlineFleet, ev offlineEvidence, mv *provision.MoveState, host, from string, now time.Time) (sealer string, boxes []string, ref *offlineRefusal) {
995 src := f.boxesAt(mv.From)
996 if len(src) == 0 {
997 return "", nil, &offlineRefusal{status: http.StatusBadRequest, msg: "no eligible box in " + from + " publishes an edges file"}
998 }
999 // A staged move's pinned stager is part of the barrier whatever the
1000 // fleet says (Astra round 2, finding 1): it must still be in the
1001 // source region's fleet (so it is one of the boxes below, and of the
1002 // boxes the seal names), and it must have said, after the move left
1003 // staging and under its pass lock, that no stage round is in flight
1004 // and none will start (its stager report, phase stopped, fresh). The
1005 // staging destination must still be the one the stage fixed.
1006 if mv.StagedAt != nil {
1007 if ref := s.offlineStagerPresent(f, mv, host); ref != nil {
1008 return "", nil, ref
1009 }
1010 if ref := s.offlineStageCoords(f, mv, host); ref != nil {
1011 return "", nil, ref
1012 }
1013 ack, ok := ev.get(moves.OfflineRoleStager, mv.Stager)
1014 if ok && ack.Phase == moves.OfflinePhaseStopped && !offlineFresh(ack.OfflineReport, now) {
1015 return "", nil, offlineStaleRefusal(mv.Stager)
1016 }
1017 if !ok || ack.Phase != moves.OfflinePhaseStopped {
1018 return "", nil, s.offlineWaitRefusal(mv.Stager, from, host)
1019 }
1020 }
1021 var data []string
1022 for _, b := range src {
1023 rep, ok := ev.get(moves.OfflineRoleSource, b.Box)
1024 if ok && !offlineFresh(rep.OfflineReport, now) {
1025 return "", nil, offlineStaleRefusal(b.Box)
1026 }
1027 if !ok || !offlineStopped(rep.OfflineReport) || offlineServes(b, host) {
1028 return "", nil, s.offlineWaitRefusal(b.Box, from, host)
1029 }
1030 boxes = append(boxes, b.Box)
1031 if rep.LocalDB || rep.Files || rep.Env {
1032 data = append(data, b.Box)
1033 }
1034 }
1035 for _, b := range boxes {
1036 if rep, _ := ev.get(moves.OfflineRoleSource, b); rep.Sidecar {
1037 return "", nil, &offlineRefusal{status: http.StatusConflict, msg: fmt.Sprintf("%s keeps a sidecar file for %s. instances with sidecars cannot move yet", b, host)}
1038 }
1039 }
1040 if len(data) > 1 {
1041 return "", nil, &offlineRefusal{status: http.StatusConflict, msg: fmt.Sprintf("%s keeps local data on more than one box: %s. remove the stale copy first", host, strings.Join(data, ", "))}
1042 }
1043 sealer = boxes[0]
1044 if len(data) == 1 {
1045 sealer = data[0]
1046 }
1047 return sealer, boxes, nil
1048}
1049
1050// offlineSealHolds says the record's source.evidence names the seal on disk,
1051// byte for byte. The seal is written once, so the hash holds for the whole
1052// move whatever the sealer's living report does each pass.
1053func offlineSealHolds(ev offlineEvidence, mv *provision.MoveState, host string) bool {
1054 return ev.seal != nil && ev.seal.Box == mv.Sealer && mv.Source != nil &&
1055 offlineEvidenceMatches(mv.Source.Evidence, moves.OfflineSealKey(host, mv.ID), ev.seal.raw)
1056}
1057
1058// offlineFlipCheck: the seal is the one the record names and every item the
1059// source side reports is OK in it; every source box has stopped and left no
1060// process or layout journal, on a report this fresh.
1061func (s *Server) offlineFlipCheck(ev offlineEvidence, mv *provision.MoveState, host, from string, now time.Time) *offlineRefusal {
1062 if !offlineSealHolds(ev, mv, host) {
1063 return offlineStateRefusal(mv.State, host)
1064 }
1065 if items, _ := offlineSealItems(ev.seal.OfflineReport); !items {
1066 return offlineStateRefusal(mv.State, host)
1067 }
1068 for _, box := range mv.Boxes {
1069 rep, ok := ev.get(moves.OfflineRoleSource, box)
1070 if !ok {
1071 return s.offlineWaitRefusal(box, from, host)
1072 }
1073 if !offlineFresh(rep.OfflineReport, now) {
1074 return offlineStaleRefusal(box)
1075 }
1076 if !offlineStopped(rep.OfflineReport) {
1077 return s.offlineWaitRefusal(box, from, host)
1078 }
1079 if rep.Procs != 0 || rep.LayoutJournal {
1080 return processRefusal(box, host)
1081 }
1082 }
1083 return nil
1084}
1085
1086func processRefusal(box, host string) *offlineRefusal {
1087 return &offlineRefusal{status: http.StatusConflict, msg: fmt.Sprintf("%s still runs a process for %s. wait for it to stop, then flip", box, host)}
1088}
1089
1090// offlineOpenCheck is the re-check of the stop: the target's landing report
1091// has every landing item OK, the seal is still the one the record names, the
1092// sealer's living report is from this pass and holds the tombstone for this
1093// move, and every source box is still held, fenced and quiesced.
1094func (s *Server) offlineOpenCheck(ev offlineEvidence, mv *provision.MoveState, host, from string, now time.Time) *offlineRefusal {
1095 tgt := ev.byRole(moves.OfflineRoleTarget)
1096 if len(tgt) != 1 {
1097 return offlineStateRefusal(mv.State, host)
1098 }
1099 if items, _ := offlineLandingItems(tgt[0].OfflineReport); !items {
1100 return offlineStateRefusal(mv.State, host)
1101 }
1102 if !offlineSealHolds(ev, mv, host) {
1103 return offlineStateRefusal(mv.State, host)
1104 }
1105 seal, ok := ev.get(moves.OfflineRoleSealer, mv.Sealer)
1106 if ok && !offlineFresh(seal.OfflineReport, now) {
1107 return offlineStaleRefusal(mv.Sealer)
1108 }
1109 if !ok || seal.TombstoneOwner != moves.TombstoneOwner(mv.ID) || !seal.TombstoneUntil.After(now) {
1110 return offlineStateRefusal(mv.State, host)
1111 }
1112 for _, box := range mv.Boxes {
1113 rep, ok := ev.get(moves.OfflineRoleSource, box)
1114 if ok && !offlineFresh(rep.OfflineReport, now) {
1115 return offlineStaleRefusal(box)
1116 }
1117 if !ok || !offlineStopped(rep.OfflineReport) {
1118 return s.offlineWaitRefusal(box, from, host)
1119 }
1120 }
1121 return nil
1122}
1123
1124// offlineFinishCheck: the target's phase (when targetPhase is set), the
1125// sealer's phase (when sealerPhase is set) and every source box's phase,
1126// each as the box's own report says. Otherwise MV-O25. Open to finishing
1127// wants the target opened and every source parked; finishing to cleared the
1128// sealer lifted and every source gone; reverting to cleared the sealer and
1129// every source reverted (the target is not asked: a target box that died
1130// after the flip must not hold the abort).
1131func offlineFinishCheck(ev offlineEvidence, mv *provision.MoveState, host, targetPhase, sealerPhase, sourcePhase string, boxes []string) *offlineRefusal {
1132 refuse := offlineStateRefusal(mv.State, host)
1133 if targetPhase != "" {
1134 tgt := ev.byRole(moves.OfflineRoleTarget)
1135 if len(tgt) != 1 || tgt[0].Phase != targetPhase {
1136 return refuse
1137 }
1138 }
1139 if sealerPhase != "" {
1140 if rep, ok := ev.get(moves.OfflineRoleSealer, mv.Sealer); !ok || rep.Phase != sealerPhase {
1141 return refuse
1142 }
1143 }
1144 if len(boxes) == 0 {
1145 return refuse
1146 }
1147 for _, box := range boxes {
1148 rep, ok := ev.get(moves.OfflineRoleSource, box)
1149 if !ok || rep.Phase != sourcePhase || !offlineRetiredTerminal(rep.Retired) {
1150 return refuse
1151 }
1152 }
1153 return nil
1154}
1155
1156// offlineRetiredTerminal says every path a parked report lists is settled:
1157// parked, absent, or left with the reason it could not be parked (a
1158// root-owned directory, a view mount). A left path is terminal; the move
1159// goes on and the status view shows it. An unknown action, or a left with no
1160// reason, is not a settled path.
1161func offlineRetiredTerminal(retired []moves.RetiredPath) bool {
1162 for _, p := range retired {
1163 switch p.Action {
1164 case moves.RetiredParked, moves.RetiredAbsent:
1165 case moves.RetiredLeft:
1166 if p.Reason == "" {
1167 return false
1168 }
1169 default:
1170 return false
1171 }
1172 if p.Path == "" {
1173 return false
1174 }
1175 }
1176 return true
1177}
1178
1179// offlineVerbDoor is the shape of seal, flip, open and finish: the gate, an
1180// offline move in flight, the state the verb follows, the evidence, the
1181// verb's checks, one transition.
1182func (s *Server) offlineVerbDoor(verb string) http.HandlerFunc {
1183 return func(w http.ResponseWriter, r *http.Request) {
1184 d, ok := s.offlineGate(w, r)
1185 if !ok {
1186 return
1187 }
1188 mv, ok := s.offlineInFlight(w, d)
1189 if !ok {
1190 return
1191 }
1192 if offlineVerbOf(mv.State) != verb {
1193 writeRefusal(w, offlineStateRefusal(mv.State, d.host))
1194 return
1195 }
1196 ctx := r.Context()
1197 ev, err := s.readOfflineEvidence(ctx, d.host, mv.ID)
1198 if err != nil {
1199 log.Printf("console: offline move %s: reports: %v", d.host, err)
1200 jsonError(w, http.StatusBadGateway, "could not read the move's reports. try again")
1201 return
1202 }
1203 step, ref := s.offlineStepFor(ctx, d, mv, ev, time.Now())
1204 if ref != nil {
1205 writeRefusal(w, ref)
1206 return
1207 }
1208 s.offlineAdvance(w, r, d, step.mut)
1209 }
1210}
1211
1212func (s *Server) handleAPIInstanceOfflineAbort(w http.ResponseWriter, r *http.Request) {
1213 d, ok := s.offlineGate(w, r)
1214 if !ok {
1215 return
1216 }
1217 mv, ok := s.offlineInFlight(w, d)
1218 if !ok {
1219 return
1220 }
1221 switch mv.State {
1222 case provision.OfflineOpen, provision.OfflineFinishing:
1223 jsonError(w, http.StatusConflict, fmt.Sprintf("%s is already served from %s. move it back with a new move", d.host, s.offlineName(mv.To)))
1224 return
1225 case provision.OfflineReverting:
1226 // Already reverting: nothing to write.
1227 writeJSON(w, http.StatusAccepted, s.offlineViewOf(r.Context(), d.rec))
1228 return
1229 }
1230 s.offlineAdvance(w, r, d, provision.OfflineAbort(mv.ID))
1231}
1232
1233// ---- status ----
1234
1235// offlineReportView is a report as the status view carries it: every field
1236// the box wrote, and whether it is current enough for a door to believe.
1237type offlineReportView struct {
1238 moves.OfflineReport
1239 Fresh bool `json:"fresh"`
1240}
1241
1242// offlineLeftView is a path a source box could not park and left in place,
1243// with the reason it gave (the parked report's inventory).
1244type offlineLeftView struct {
1245 Box string `json:"box"`
1246 Path string `json:"path"`
1247 Reason string `json:"reason"`
1248}
1249
1250// offlineItemView is one row of the manifest with the latest check of it.
1251// Status is ok, waiting, failed or unchecked (no report decides the item).
1252type offlineItemView struct {
1253 ID string `json:"id"`
1254 Item string `json:"item"`
1255 Disposition string `json:"disposition"`
1256 Owner string `json:"owner"`
1257 Source string `json:"source,omitempty"`
1258 Status string `json:"status"`
1259 Detail string `json:"detail,omitempty"`
1260}
1261
1262// offlineView is the status answer, and the 202 every mutating door gives.
1263// State is "none" when no offline move is in flight (Last then carries the
1264// outcome of the one that ended). Next is the verb that moves the move on
1265// right now; empty while a box has work to do. Waiting is the sentence that
1266// verb would be refused with. CanAbort says abort is legal. Failed says the
1267// sealer or the target reported it could not do its step: abort is the way on.
1268type offlineView struct {
1269 Host string `json:"host"`
1270 State string `json:"state"`
1271 ID string `json:"id,omitempty"`
1272 From string `json:"from,omitempty"`
1273 To string `json:"to,omitempty"`
1274 Sealer string `json:"sealer,omitempty"`
1275 Stager string `json:"stager,omitempty"`
1276 Boxes []string `json:"boxes,omitempty"`
1277 RequestedBy string `json:"requested_by,omitempty"`
1278 RequestedAt int64 `json:"requested_at,omitempty"`
1279 UpdatedAt int64 `json:"updated_at,omitempty"`
1280 OpenedAt int64 `json:"opened_at,omitempty"`
1281 Next string `json:"next,omitempty"`
1282 Waiting string `json:"waiting,omitempty"`
1283 CanAbort bool `json:"can_abort,omitempty"`
1284 // NeverOffline: the move was staged and never started, so its
1285 // instance has kept serving throughout (an abort from staging).
1286 NeverOffline bool `json:"never_offline,omitempty"`
1287 // StagedAt is the record's staged_at in Unix nanoseconds: the latest
1288 // stage request. Stage.Request equals it once the stager has answered
1289 // that request.
1290 StagedAt int64 `json:"staged_at,omitempty"`
1291 Failed bool `json:"failed,omitempty"`
1292 Left []offlineLeftView `json:"left,omitempty"`
1293 Stage *offlineStageView `json:"stage,omitempty"`
1294 Reports []offlineReportView `json:"reports,omitempty"`
1295 Items []offlineItemView `json:"items,omitempty"`
1296 Last *provision.MoveOutcome `json:"last,omitempty"`
1297}
1298
1299// offlineStageView is the pre-copy's progress, from the latest stager
1300// report: when its last round ended, the replica as that round listed it,
1301// how much of it the target's staging prefix holds, and what the round
1302// copied. Phase failed carries the box's own diagnostic in Error. Stager
1303// reports are rewritten each moves pass, not every few seconds, so their
1304// age is shown and never judged.
1305type offlineStageView struct {
1306 Box string `json:"box"`
1307 Phase string `json:"phase"`
1308 At int64 `json:"at"`
1309 ReplicaObjects int `json:"replica_objects"`
1310 ReplicaBytes int64 `json:"replica_bytes"`
1311 StagedObjects int `json:"staged_objects"`
1312 StagedBytes int64 `json:"staged_bytes"`
1313 RoundObjects int `json:"round_objects"`
1314 RoundBytes int64 `json:"round_bytes"`
1315 Error string `json:"error,omitempty"`
1316 // Request is the staged_at (Unix nanoseconds) the round answered.
1317 Request int64 `json:"request,omitempty"`
1318}
1319
1320// offlineStageOf is the stage view of the newest stager report, nil when no
1321// box has staged this move.
1322func offlineStageOf(ev offlineEvidence) *offlineStageView {
1323 var latest *offlineReport
1324 for _, r := range ev.byRole(moves.OfflineRoleStager) {
1325 if latest == nil || r.At.After(latest.At) {
1326 latest = &r
1327 }
1328 }
1329 if latest == nil {
1330 return nil
1331 }
1332 r := latest.OfflineReport
1333 v := &offlineStageView{Box: r.Box, Phase: r.Phase, At: r.At.Unix(),
1334 ReplicaObjects: r.ReplicaObjects, ReplicaBytes: r.ReplicaBytes, StagedObjects: r.StagedObjects, StagedBytes: r.StagedBytes,
1335 RoundObjects: r.RoundObjects, RoundBytes: r.RoundBytes, Error: r.StageError}
1336 if !r.StageRequest.IsZero() {
1337 v.Request = r.StageRequest.UnixNano()
1338 }
1339 return v
1340}
1341
1342// offlineViewOf builds the status view of a record. Evidence it cannot read
1343// leaves the reports out and says so in Waiting; it never fails the answer.
1344func (s *Server) offlineViewOf(ctx context.Context, rec *provision.Record) offlineView {
1345 v := offlineView{Host: rec.Host, State: "none", Last: rec.LastMove}
1346 mv := rec.Move
1347 if mv == nil || !mv.Offline() {
1348 return v
1349 }
1350 now := time.Now()
1351 v.State, v.ID = mv.State, mv.ID
1352 v.From, v.To = s.offlineName(mv.From), s.offlineName(mv.To)
1353 v.Sealer, v.Boxes, v.Stager = mv.Sealer, mv.Boxes, mv.Stager
1354 v.NeverOffline = !mv.StartedOffline()
1355 if mv.StagedAt != nil {
1356 v.StagedAt = mv.StagedAt.UnixNano()
1357 }
1358 v.RequestedBy, v.RequestedAt, v.UpdatedAt = mv.RequestedBy, mv.RequestedAt.Unix(), mv.UpdatedAt.Unix()
1359 if mv.OpenedAt != nil {
1360 v.OpenedAt = mv.OpenedAt.Unix()
1361 }
1362 switch mv.State {
1363 case provision.OfflineStaging, provision.OfflineStopping, provision.OfflineSealing, provision.OfflineSealed, provision.OfflineLanding, provision.OfflineLanded:
1364 v.CanAbort = true
1365 }
1366 ev, err := s.readOfflineEvidence(ctx, rec.Host, mv.ID)
1367 if err != nil {
1368 log.Printf("console: offline move %s: reading the reports: %v", rec.Host, err)
1369 v.Waiting = "could not read the move's reports. try again"
1370 return v
1371 }
1372 for _, r := range ev.reports {
1373 v.Reports = append(v.Reports, offlineReportView{OfflineReport: r.OfflineReport, Fresh: offlineFresh(r.OfflineReport, now)})
1374 }
1375 v.Items = offlineItems(ev, mv)
1376 v.Stage = offlineStageOf(ev)
1377 for _, r := range ev.byRole(moves.OfflineRoleSource) {
1378 for _, p := range r.Retired {
1379 if p.Action == moves.RetiredLeft {
1380 v.Left = append(v.Left, offlineLeftView{Box: r.Box, Path: p.Path, Reason: p.Reason})
1381 }
1382 }
1383 }
1384 if verb := offlineVerbOf(mv.State); verb != "" {
1385 d := offlineDoor{host: rec.Host}
1386 if _, ref := s.offlineStepFor(ctx, d, mv, ev, now); ref != nil {
1387 v.Waiting = ref.msg
1388 } else {
1389 v.Next = verb
1390 }
1391 }
1392 // A box that says it failed (the sealer, or the target that cannot
1393 // land) means abort is the way on. The CLI says it with MV-O6.
1394 if v.CanAbort {
1395 for _, r := range ev.reports {
1396 if r.Phase == moves.OfflinePhaseFailed && (r.Role == moves.OfflineRoleSealer || r.Role == moves.OfflineRoleTarget) {
1397 v.Failed = true
1398 }
1399 }
1400 }
1401 return v
1402}
1403
1404// offlineItems is the manifest with each item's latest check, from the
1405// report of the step that owns it: the sealer's for a seal item, the
1406// target's landing report for a landing item. A check present and not OK is
1407// failed when the report that holds it says failed, else waiting (the box
1408// retries it). An item no report decides is unchecked, and a door-owned one
1409// was decided at start.
1410func offlineItems(ev offlineEvidence, mv *provision.MoveState) []offlineItemView {
1411 owners := map[string][]offlineReport{moves.OfflineOwnerLanding: ev.byRole(moves.OfflineRoleTarget)}
1412 if r, ok := ev.get(moves.OfflineRoleSealer, mv.Sealer); ok {
1413 owners[moves.OfflineOwnerSeal] = []offlineReport{r}
1414 }
1415 if ev.seal != nil {
1416 // The seal is what the sealer decided; the living report is the
1417 // latest word on the same items, and is asked first.
1418 owners[moves.OfflineOwnerSeal] = append(owners[moves.OfflineOwnerSeal], *ev.seal)
1419 }
1420 out := make([]offlineItemView, 0, len(moves.OfflineManifest))
1421 for _, it := range moves.OfflineManifest {
1422 row := offlineItemView{ID: it.ID, Item: it.Item, Disposition: it.Disposition, Owner: it.Owner, Source: it.Source, Status: "waiting"}
1423 switch it.Owner {
1424 case moves.OfflineOwnerDoor:
1425 row.Status = "ok"
1426 case moves.OfflineOwnerNone:
1427 row.Status = "unchecked"
1428 default:
1429 for _, r := range owners[it.Owner] {
1430 c, found := r.Items[it.ID]
1431 if !found {
1432 continue
1433 }
1434 row.Detail = c.Reason
1435 switch {
1436 case c.OK:
1437 row.Status = "ok"
1438 case r.Phase == moves.OfflinePhaseFailed:
1439 row.Status = "failed"
1440 }
1441 break
1442 }
1443 }
1444 out = append(out, row)
1445 }
1446 return out
1447}
1448
1449func (s *Server) handleAPIInstanceOfflineStatus(w http.ResponseWriter, r *http.Request) {
1450 d, ok := s.offlineGate(w, r)
1451 if !ok {
1452 return
1453 }
1454 writeJSON(w, http.StatusOK, s.offlineViewOf(r.Context(), d.rec))
1455}
1456