console: record eligibility and the split start validation; participants pinned by instance
recordEligibility is moveGate's class rails over a record (kind, addr, fleet placement, pool, hibernation; 400s in moveGate's order), so the self-serve door and the job refuse one record one way. offlineBeginChecks is now offlineRecordChecks (seed, replica, replicas, steering or unit, claim evidence) -> the body -> offlineRegionChecks (the region, the manifest, the fleet, the home rule, offlineSelect, the stage rules, participant pinning), byte-identical: the golden replay TestOperatorDoorAnswersAreUnchanged was written from the base tree. Participants are pinned by BoxID and Label from the edges file. Operator doors (requireIDs false) take InstanceID from a current status report (within 10 minutes, naming the box's id) and read no identity record; the self-serve door and the job require the report and verify the identity record under boxid.Key(BoxID). An id-less participant makes the selection a sentinel one. offlineStepFor's staging branch runs both halves for a self-serve move; an operator move keeps today's checks so its view stays main's.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
6 files changed,
+790
−151
internal/console/apiinstancemove.go+26 −15internal/console/apiinstancemove_offline.go+305 −113internal/console/apiinstancemove_offline_snapshot_test.go+235 −0internal/console/apiinstancemove_offline_test.go+6 −1internal/console/offlinereserve.go+4 −22internal/console/testdata/operator-door-answers.golden+214 −0
diff --git a/internal/console/apiinstancemove.go b/internal/console/apiinstancemove.go| index 16290e8bd..4a0c8374a 100644 |
| --- a/internal/console/apiinstancemove.go |
| +++ b/internal/console/apiinstancemove.go |
| @@ -71,27 +71,38 @@ func (s *Server) moveGate(w http.ResponseWriter, r *http.Request) (acct *Account |
| jsonError(w, http.StatusBadGateway, "could not read the instance") |
| return nil, "", "", nil, false |
| } |
| - if k := rec.Kind; k != "" && k != "instance" { |
| - jsonError(w, http.StatusBadRequest, c.kind(k)) |
| + if ref := recordEligibility(rec, c); ref != nil { |
| + writeRefusal(w, ref) |
| return nil, "", "", nil, false |
| } |
| - if rec.Addr != "" || len(rec.Addrs) > 0 { |
| - jsonError(w, http.StatusBadRequest, c.addr) |
| - return nil, "", "", nil, false |
| + return acct, app, host, rec, true |
| +} |
| + |
| +// recordEligibility is moveGate's class rails over a record, in moveGate's |
| +// order: the kind, an addr-backed route, a fleet placement, a pool, a |
| +// non-hibernating instance. Each is a 400 in c's sentences. The self-serve |
| +// door and the job (self-serve moves plan 2 Task 5) run the same checks on |
| +// the record they act on, so every caller refuses one record one way. |
| +func recordEligibility(rec *provision.Record, c moveCopy) *offlineRefusal { |
| + refuse := func(msg string) *offlineRefusal { |
| + return &offlineRefusal{status: http.StatusBadRequest, msg: msg} |
| } |
| - if _, fleet := registry.FleetPlacement(rec.Region); fleet { |
| - jsonError(w, http.StatusBadRequest, c.fleet) |
| - return nil, "", "", nil, false |
| + switch { |
| + case rec.Kind != "" && rec.Kind != "instance": |
| + return refuse(c.kind(rec.Kind)) |
| + case rec.Addr != "" || len(rec.Addrs) > 0: |
| + return refuse(c.addr) |
| } |
| - if rec.Pool != "" { |
| - jsonError(w, http.StatusBadRequest, c.pool) |
| - return nil, "", "", nil, false |
| + if _, fleet := registry.FleetPlacement(rec.Region); fleet { |
| + return refuse(c.fleet) |
| } |
| - if !rec.HibernateOrDefault() { |
| - jsonError(w, http.StatusBadRequest, c.hibernate) |
| - return nil, "", "", nil, false |
| + switch { |
| + case rec.Pool != "": |
| + return refuse(c.pool) |
| + case !rec.HibernateOrDefault(): |
| + return refuse(c.hibernate) |
| } |
| - return acct, app, host, rec, true |
| + return nil |
| } |
| // moveCopy is what moveGate says: its class rails and the shared gates' |
diff --git a/internal/console/apiinstancemove_offline.go b/internal/console/apiinstancemove_offline.go| index 8d7b3a50f..075654cda 100644 |
| --- a/internal/console/apiinstancemove_offline.go |
| +++ b/internal/console/apiinstancemove_offline.go |
| @@ -16,7 +16,9 @@ import ( |
| "strings" |
| "time" |
| + "github.com/carlosframework/platform/internal/boxid" |
| "github.com/carlosframework/platform/internal/domains" |
| + "github.com/carlosframework/platform/internal/hostactors" |
| "github.com/carlosframework/platform/internal/hostclaim" |
| "github.com/carlosframework/platform/internal/hostops" |
| "github.com/carlosframework/platform/internal/moves" |
| @@ -312,6 +314,9 @@ func offlineEvidenceMatches(evidence, key string, raw []byte) bool { |
| type offlineFleet struct { |
| snap edgesSnapshot |
| metrics map[string]*status.BoxMetrics |
| + // generated is each label's report time (status.Box.GeneratedAt), which |
| + // participant pinning judges (offlineReportCurrent). |
| + generated map[string]time.Time |
| } |
| func (s *Server) readOfflineFleet(ctx context.Context) (offlineFleet, bool) { |
| @@ -325,9 +330,10 @@ func (s *Server) readOfflineFleet(ctx context.Context) (offlineFleet, bool) { |
| log.Printf("console: offline move: the boxes' status reports are not complete evidence (complete %v, error %v)", complete, err) |
| return offlineFleet{}, false |
| } |
| - f := offlineFleet{snap: snap, metrics: map[string]*status.BoxMetrics{}} |
| + f := offlineFleet{snap: snap, metrics: map[string]*status.BoxMetrics{}, generated: map[string]time.Time{}} |
| for _, b := range boxes { |
| f.metrics[b.Label] = b.Metrics |
| + f.generated[b.Label] = b.GeneratedAt |
| } |
| sort.Slice(f.snap.Files, func(i, j int) bool { return f.snap.Files[i].Box < f.snap.Files[j].Box }) |
| return f, true |
| @@ -441,35 +447,35 @@ func (s *Server) offlineSelect(ctx context.Context, f offlineFleet, host, from, |
| // record's acquisition, control/hosts/<host>.json, a domains claim carrying |
| // one, or the app's admission (MV-O29); and a deployment that has begun the |
| // claim backfill (MV-O20). A read that fails is a 502 and never an absence. |
| -func (s *Server) offlineClaimEvidence(ctx context.Context, d offlineDoor) *offlineRefusal { |
| - has := &offlineRefusal{status: http.StatusConflict, msg: fmt.Sprintf("%s has a host claim. offline moves are only for hosts without one", d.host)} |
| +func (s *Server) offlineClaimEvidence(ctx context.Context, acct, app, host string, rec *provision.Record) *offlineRefusal { |
| + has := &offlineRefusal{status: http.StatusConflict, msg: fmt.Sprintf("%s has a host claim. offline moves are only for hosts without one", host)} |
| unreadable := &offlineRefusal{status: http.StatusBadGateway, msg: "could not read the host claim"} |
| - if d.rec.Acquisition != "" { |
| + if rec.Acquisition != "" { |
| return has |
| } |
| - obs, err := hostclaim.Read(ctx, s.cfg.Releases, d.host) |
| + obs, err := hostclaim.Read(ctx, s.cfg.Releases, host) |
| if err != nil { |
| - log.Printf("console: offline move %s: claim read: %v", d.host, err) |
| + log.Printf("console: offline move %s: claim read: %v", host, err) |
| return unreadable |
| } |
| if !obs.Absent { |
| return has |
| } |
| - if c, err := domains.GetClaim(ctx, s.cfg.Releases, d.host); err == nil && c.Acquisition != "" { |
| + if c, err := domains.GetClaim(ctx, s.cfg.Releases, host); err == nil && c.Acquisition != "" { |
| return has |
| } else if err != nil && !errors.Is(err, os.ErrNotExist) { |
| - log.Printf("console: offline move %s: domains claim read: %v", d.host, err) |
| + log.Printf("console: offline move %s: domains claim read: %v", host, err) |
| return unreadable |
| } |
| - key, err := hostops.AdmissionKey(d.acct.Sqid, d.app) |
| + key, err := hostops.AdmissionKey(acct, app) |
| if err != nil { |
| - log.Printf("console: offline move %s: admission key: %v", d.host, err) |
| + log.Printf("console: offline move %s: admission key: %v", host, err) |
| return unreadable |
| } |
| if _, _, err := s.cfg.Releases.Get(ctx, key); err == nil { |
| return has |
| } else if !errors.Is(err, os.ErrNotExist) { |
| - log.Printf("console: offline move %s: admission read: %v", d.host, err) |
| + log.Printf("console: offline move %s: admission read: %v", host, err) |
| return unreadable |
| } |
| // A plan at done is allowed; one that has begun, or that cannot be |
| @@ -479,70 +485,67 @@ func (s *Server) offlineClaimEvidence(ctx context.Context, d offlineDoor) *offli |
| case errors.Is(err, hostops.ErrPending), errors.Is(err, hostops.ErrIdentity): |
| return &offlineRefusal{status: http.StatusConflict, msg: "offline moves are not available while host claims or Phase 1 moves are on"} |
| default: |
| - log.Printf("console: offline move %s: backfill plan read: %v", d.host, err) |
| + log.Printf("console: offline move %s: backfill plan read: %v", host, err) |
| return unreadable |
| } |
| return nil |
| } |
| -// offlineBeginChecks is what start and stage check before either writes: |
| -// the instance's class, its claims, the region asked for, the app's |
| -// regions and the fleet's box rules (offlineSelect). It answers the region |
| -// code and the fleet it read, or false once it has answered the request. |
| -// A staged move is checked again in full when it starts: the fleet and the |
| -// claims may have changed since the stage. |
| -func (s *Server) offlineBeginChecks(w http.ResponseWriter, r *http.Request, d offlineDoor) (string, offlineFleet, bool) { |
| - rec, host := d.rec, d.host |
| - if s.refuseSeedMove(w, rec) { |
| - return "", offlineFleet{}, false |
| +// offlineRecordChecks is the half of the start validation that reads the |
| +// record and the host's claims, before any body is read (self-serve moves |
| +// plan 2 Task 5): a seeded record, a replica, a home with replicas, a |
| +// steered or unit-run instance, and a host with a claim anywhere |
| +// (offlineClaimEvidence). The operator start and stage doors, the self-serve |
| +// door and the job run it on the record they act on. |
| +func (s *Server) offlineRecordChecks(ctx context.Context, acct, app, host string, rec *provision.Record) *offlineRefusal { |
| + if b := rec.Seed.MoveBlock(); b != "" { |
| + return &offlineRefusal{status: http.StatusConflict, msg: provision.SeedMoveSentence(b)} |
| } |
| if rec.Role != "" { |
| - jsonError(w, http.StatusBadRequest, "a replica does not move. delete it and create one in the new region") |
| - return "", offlineFleet{}, false |
| + return &offlineRefusal{status: http.StatusBadRequest, msg: "a replica does not move. delete it and create one in the new region"} |
| } |
| if len(rec.Replicas) > 0 { |
| - jsonError(w, http.StatusBadRequest, "a home with replicas does not move. remove its replicas first") |
| - return "", offlineFleet{}, false |
| + return &offlineRefusal{status: http.StatusBadRequest, msg: "a home with replicas does not move. remove its replicas first"} |
| } |
| if rec.Steering != "" || rec.Unit != "" { |
| - jsonError(w, http.StatusBadRequest, fmt.Sprintf("%s is steered or runs under a unit. offline moves cannot carry either yet", host)) |
| - return "", offlineFleet{}, false |
| + return &offlineRefusal{status: http.StatusBadRequest, msg: fmt.Sprintf("%s is steered or runs under a unit. offline moves cannot carry either yet", host)} |
| } |
| - ctx := r.Context() |
| - if ref := s.offlineClaimEvidence(ctx, d); ref != nil { |
| - writeRefusal(w, ref) |
| - return "", offlineFleet{}, false |
| - } |
| - var req struct { |
| - Region string `json:"region"` |
| - } |
| - if !readJSON(w, r, &req) { |
| - return "", offlineFleet{}, false |
| - } |
| - region := strings.TrimSpace(req.Region) |
| + return s.offlineClaimEvidence(ctx, acct, app, host, rec) |
| +} |
| + |
| +// offlineRegionChecks is the half of the start validation that follows the |
| +// body: the region asked for, the app's regions, the fleet, the home rule, |
| +// the fleet's box rules (offlineSelect), the stage rules and the |
| +// participants. stage is the stage door's (and the self-serve door's) set of |
| +// stage rules, the start door's otherwise; for a record already staging, the |
| +// staged move's own rules. requireIDs pins every participant by a fresh |
| +// status report and checks its identity record (pinParticipant); without it |
| +// a participant is pinned by its report when the report allows and id-less |
| +// otherwise, and nothing more is read. returnOf is the move a return |
| +// reverses ("" for any other); its evidence is Task 8's. |
| +// |
| +// It answers the selection, or the refusal the door speaks. Run after |
| +// offlineRecordChecks and the body, it is offlineBeginChecks' order exactly. |
| +func (s *Server) offlineRegionChecks(ctx context.Context, acct, app, host string, rec *provision.Record, region string, stage, requireIDs bool, returnOf string) (offlineSelection, *offlineRefusal) { |
| + region = strings.TrimSpace(region) |
| to := s.homeRegionIn(region) |
| if region == "" || !provision.ValidRegion(to) { |
| - jsonError(w, http.StatusBadRequest, "invalid region") |
| - return "", offlineFleet{}, false |
| + return offlineSelection{}, &offlineRefusal{status: http.StatusBadRequest, msg: "invalid region"} |
| } |
| if to == rec.Region { |
| - jsonError(w, http.StatusConflict, "the instance is already in "+region) |
| - return "", offlineFleet{}, false |
| + return offlineSelection{}, &offlineRefusal{status: http.StatusConflict, msg: "the instance is already in " + region} |
| } |
| - m, err := provision.GetManifest(ctx, s.cfg.Releases, d.acct.Sqid, d.app) |
| + m, err := provision.GetManifest(ctx, s.cfg.Releases, acct, app) |
| if err != nil && !errors.Is(err, os.ErrNotExist) { |
| log.Printf("console: offline move %s: manifest: %v", host, err) |
| - jsonError(w, http.StatusServiceUnavailable, "could not read the app's instances manifest. try again") |
| - return "", offlineFleet{}, false |
| + return offlineSelection{}, &offlineRefusal{status: http.StatusServiceUnavailable, msg: "could not read the app's instances manifest. try again"} |
| } |
| if m != nil && !s.moveRegionAllowed(m.Regions, to) { |
| - jsonError(w, http.StatusBadRequest, "region "+region+" is not one of this app's regions: "+strings.Join(m.Regions, ", ")) |
| - return "", offlineFleet{}, false |
| + return offlineSelection{}, &offlineRefusal{status: http.StatusBadRequest, msg: "region " + region + " is not one of this app's regions: " + strings.Join(m.Regions, ", ")} |
| } |
| fleet, ok := s.readOfflineFleet(ctx) |
| if !ok { |
| - jsonError(w, http.StatusConflict, "the fleet's edges files could not all be read. try again") |
| - return "", offlineFleet{}, false |
| + return offlineSelection{}, &offlineRefusal{status: http.StatusConflict, msg: "the fleet's edges files could not all be read. try again"} |
| } |
| if to == "" || rec.Region == "" { |
| // A box that sets CARLOS_REGION to the home's name is home to this |
| @@ -550,16 +553,237 @@ func (s *Server) offlineBeginChecks(w http.ResponseWriter, r *http.Request, d of |
| // out of home the placement it publishes is not the record's. |
| for _, f := range fleet.snap.Files { |
| if f.Placement != "" && homePlacementCode(s.moveHomeName(), f.Placement) == "" { |
| - jsonError(w, http.StatusConflict, f.Box+" sets CARLOS_REGION, so it cannot take a move into the home region") |
| - return "", offlineFleet{}, false |
| + return offlineSelection{}, &offlineRefusal{status: http.StatusConflict, msg: f.Box + " sets CARLOS_REGION, so it cannot take a move into the home region"} |
| } |
| } |
| } |
| if ref := s.offlineSelect(ctx, fleet, host, rec.Region, to); ref != nil { |
| + return offlineSelection{}, ref |
| + } |
| + sel := offlineSelection{From: rec.Region, To: to, Fleet: fleet, ReturnOf: returnOf} |
| + if ref := s.offlineStageRules(fleet, rec, host, to, stage, &sel); ref != nil { |
| + return offlineSelection{}, ref |
| + } |
| + if ref := s.pinParticipants(ctx, &sel, requireIDs); ref != nil { |
| + return offlineSelection{}, ref |
| + } |
| + return sel, nil |
| +} |
| + |
| +// offlineStageRules are the stage rules of offlineRegionChecks. A record |
| +// already staging: the region it is staged for, the pinned stager still in |
| +// the source region (start only: a restage of a gone stager is the stager's |
| +// own business), and the staging coordinates the stage fixed; a restage |
| +// keeps the move's stage target, a staged start names none. A record at |
| +// rest with stage set: every box of both regions can stage, the target does |
| +// not publish a source box's bucket, and the stage target is fixed here |
| +// (the source region's stager, the target's data bucket and region). |
| +func (s *Server) offlineStageRules(fleet offlineFleet, rec *provision.Record, host, to string, stage bool, sel *offlineSelection) *offlineRefusal { |
| + if mv, staged := offlineStaged(rec); staged { |
| + if to != mv.To { |
| + return s.offlineStagedFor(host, mv.To) |
| + } |
| + if !stage { |
| + if ref := s.offlineStagerPresent(fleet, mv, host); ref != nil { |
| + return ref |
| + } |
| + } |
| + if ref := s.offlineStageCoords(fleet, mv, host); ref != nil { |
| + return ref |
| + } |
| + if stage { |
| + sel.Stage = provision.StageTarget{Stager: mv.Stager, Bucket: mv.StageBucket, Region: mv.StageRegion} |
| + } |
| + return nil |
| + } |
| + if !stage { |
| + return nil |
| + } |
| + for _, b := range append(fleet.boxesAt(rec.Region), fleet.boxesAt(to)...) { |
| + if !slices.Contains(b.Capabilities, offlineStageCapability) { |
| + return &offlineRefusal{status: http.StatusConflict, msg: fmt.Sprintf("%s cannot stage an offline move. it runs an older release", b.Box)} |
| + } |
| + } |
| + // The stager is pinned here, for the whole move: the one box that ever |
| + // writes this move's staging prefix, and the one whose cleanup an abort |
| + // waits for (Astra round 1, finding 2). offlineSelect has already |
| + // required at least one source box. |
| + var names []string |
| + for _, b := range fleet.boxesAt(rec.Region) { |
| + names = append(names, b.Box) |
| + } |
| + // The staging destination is fixed here too, before any copy (Astra |
| + // round 2, finding 2): the one target box's data bucket and region, as |
| + // offlineSelect found it. A target that publishes a source box's own |
| + // bucket is refused: the activator there would promote the staged copy |
| + // over the serving replica on its next wake (MV-O191). |
| + tgt := fleet.boxesAt(to)[0] |
| + for _, b := range fleet.boxesAt(rec.Region) { |
| + if b.DataBucket == tgt.DataBucket { |
| + return &offlineRefusal{status: http.StatusConflict, msg: fmt.Sprintf("%s publishes the same data bucket as %s. a staged copy there would replace the serving one", s.offlineName(to), s.offlineName(rec.Region))} |
| + } |
| + } |
| + sel.Stage = provision.StageTarget{Stager: moves.OfflineStager(names), Bucket: tgt.DataBucket, Region: tgt.DataBucketRegion} |
| + return nil |
| +} |
| + |
| +// pinParticipants fills sel's participants from the fleet offlineSelect |
| +// accepted: every box of the source region, then the one target box, each |
| +// by the box id and label its edges file publishes and the instance its |
| +// status report names (pinParticipant). Sentinel is set when any |
| +// participant has no instance id: such a selection reserves every box. |
| +func (s *Server) pinParticipants(ctx context.Context, sel *offlineSelection, requireIDs bool) *offlineRefusal { |
| + var id participantIdentity |
| + if requireIDs { |
| + var ref *offlineRefusal |
| + if id, ref = s.participantIdentityFor(ctx, sel.Fleet); ref != nil { |
| + return ref |
| + } |
| + } |
| + now := time.Now() |
| + sel.Source = nil |
| + for _, b := range sel.Fleet.boxesAt(sel.From) { |
| + p, ref := s.pinParticipant(ctx, sel.Fleet, b, s.homeRegionIn(b.Placement), requireIDs, id, now) |
| + if ref != nil { |
| + return ref |
| + } |
| + sel.Source = append(sel.Source, p) |
| + } |
| + if tgt := sel.Fleet.boxesAt(sel.To); len(tgt) > 0 { |
| + p, ref := s.pinParticipant(ctx, sel.Fleet, tgt[0], s.homeRegionIn(tgt[0].Placement), requireIDs, id, now) |
| + if ref != nil { |
| + return ref |
| + } |
| + sel.Target = p |
| + } |
| + sel.Sentinel = false |
| + for _, p := range append(append([]provision.Participant(nil), sel.Source...), sel.Target) { |
| + if p.InstanceID == "" { |
| + sel.Sentinel = true |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +// participantIdentity is what checking an identity record needs: the |
| +// verifier, the AWS region each region code's boxes run in, and the roster |
| +// (empty on a deployment that has none, as a claims-off one may). |
| +type participantIdentity struct { |
| + verifier boxid.Verifier |
| + aws map[string]string |
| + roster []boxid.Enrolled |
| +} |
| + |
| +// participantIdentityFor reads what pinParticipant checks identity records |
| +// against: the verifier the move door builds (verifiedRoster), the AWS |
| +// region of each code as regionCandidates reads it from the edges files, |
| +// and the host roster. A roster that is absent is no entries; one that |
| +// cannot be read or trusted refuses, since a box could be enrolled as |
| +// another instance in it. |
| +func (s *Server) participantIdentityFor(ctx context.Context, f offlineFleet) (participantIdentity, *offlineRefusal) { |
| + id := participantIdentity{verifier: boxid.Verifier{Account: s.cfg.AWSAccount, Certs: s.boxCerts}, aws: map[string]string{}} |
| + for _, c := range regionCandidates(f.snap, s.moveHomeName(), s.expectedSecretsFP()) { |
| + id.aws[c.Code] = c.AWSRegion |
| + } |
| + dir := hostactors.Directory{Store: s.cfg.Releases, PublicKey: s.cfg.ReleasePub, Bucket: s.cfg.Bucket} |
| + o, err := dir.Read(ctx) |
| + switch { |
| + case errors.Is(err, os.ErrNotExist): |
| + case err != nil: |
| + log.Printf("console: self-serve move: the host roster: %v", err) |
| + return participantIdentity{}, &offlineRefusal{status: http.StatusServiceUnavailable, msg: "could not read the host roster. try again"} |
| + default: |
| + refs := make([]string, 0, len(o.Roster.Boxes)) |
| + for ref := range o.Roster.Boxes { |
| + refs = append(refs, ref) |
| + } |
| + sort.Strings(refs) |
| + for _, ref := range refs { |
| + id.roster = append(id.roster, boxid.Enrolled{Box: ref, Cloud: o.Roster.Boxes[ref].Cloud}) |
| + } |
| + } |
| + return id, nil |
| +} |
| + |
| +// offlineReportCurrent says whether the box's status report is one a move |
| +// may pin a participant by: written within moves.OfflineIdentityFresh, not |
| +// more than moves.OfflineRequestSkew in the future, by the box whose id the |
| +// edges file publishes, and naming its instance. |
| +func offlineReportCurrent(f offlineFleet, b edgesFilePlacementView, now time.Time) (*status.BoxMetrics, bool) { |
| + m := f.metrics[b.Box] |
| + at, ok := f.generated[b.Box] |
| + if m == nil || !ok || b.BoxID == "" || m.BoxID != b.BoxID || m.InstanceID == "" { |
| + return m, false |
| + } |
| + age := now.Sub(at) |
| + return m, age <= moves.OfflineIdentityFresh && age >= -moves.OfflineRequestSkew |
| +} |
| + |
| +// pinParticipant is one participant: BoxID and Label from the edges file. |
| +// Without requireIDs (every operator door) its InstanceID comes from the |
| +// box's status report when that report is current (offlineReportCurrent), |
| +// and is empty otherwise; Verified is false and no identity record is read. |
| +// With requireIDs (the self-serve door and the job) the report must be |
| +// current, and the box's identity record, read by its box id |
| +// (boxid.Key(BoxID), never the label), decides Verified: absent is |
| +// unverified (the report's InstanceID is the identity), present must Enroll |
| +// to evidence naming that instance (a signed document of this deployment, |
| +// consistent with the roster), and could not be read refuses. |
| +func (s *Server) pinParticipant(ctx context.Context, f offlineFleet, b edgesFilePlacementView, code string, requireIDs bool, id participantIdentity, now time.Time) (provision.Participant, *offlineRefusal) { |
| + p := provision.Participant{BoxID: b.BoxID, Label: b.Box} |
| + m, current := offlineReportCurrent(f, b, now) |
| + if current { |
| + p.InstanceID = m.InstanceID |
| + } |
| + if !requireIDs { |
| + return p, nil |
| + } |
| + if !current { |
| + return provision.Participant{}, &offlineRefusal{status: http.StatusConflict, msg: fmt.Sprintf("the box %s has not reported recently", b.Box)} |
| + } |
| + raw, err := boxid.Load(ctx, s.cfg.Releases, p.BoxID) |
| + switch { |
| + case errors.Is(err, os.ErrNotExist): |
| + return p, nil |
| + case errors.Is(err, boxid.ErrUnverified): |
| + return provision.Participant{}, &offlineRefusal{status: http.StatusConflict, msg: fmt.Sprintf("box %s has an identity record that does not verify", b.Box)} |
| + case err != nil: |
| + log.Printf("console: self-serve move: the identity record of %s (%s): %v", b.Box, p.BoxID, err) |
| + return provision.Participant{}, &offlineRefusal{status: http.StatusBadGateway, msg: fmt.Sprintf("could not read the identity record for box %s", b.Box)} |
| + } |
| + ev, err := id.verifier.Enroll(raw, boxid.Expect{Box: p.BoxID, AWSRegion: id.aws[code], Roster: id.roster}) |
| + if err != nil || ev.Cloud.InstanceID != p.InstanceID { |
| + log.Printf("console: self-serve move: the identity record of %s (%s) does not verify for instance %s: %v", b.Box, p.BoxID, p.InstanceID, err) |
| + return provision.Participant{}, &offlineRefusal{status: http.StatusConflict, msg: fmt.Sprintf("box %s has an identity record that does not verify", b.Box)} |
| + } |
| + p.Verified = true |
| + return p, nil |
| +} |
| + |
| +// offlineBeginChecks is what start and stage check before either writes, |
| +// in this order: offlineRecordChecks, the body, offlineRegionChecks with the |
| +// door's stage rules (an operator door pins no identity). It answers the |
| +// selection, or false once it has answered the request. A staged move is |
| +// checked again in full when it starts: the fleet and the claims may have |
| +// changed since the stage. |
| +func (s *Server) offlineBeginChecks(w http.ResponseWriter, r *http.Request, d offlineDoor, stage bool) (offlineSelection, bool) { |
| + ctx := r.Context() |
| + if ref := s.offlineRecordChecks(ctx, d.acct.Sqid, d.app, d.host, d.rec); ref != nil { |
| writeRefusal(w, ref) |
| - return "", offlineFleet{}, false |
| + return offlineSelection{}, false |
| } |
| - return to, fleet, true |
| + var req struct { |
| + Region string `json:"region"` |
| + } |
| + if !readJSON(w, r, &req) { |
| + return offlineSelection{}, false |
| + } |
| + sel, ref := s.offlineRegionChecks(ctx, d.acct.Sqid, d.app, d.host, d.rec, req.Region, stage, false, "") |
| + if ref != nil { |
| + writeRefusal(w, ref) |
| + return offlineSelection{}, false |
| + } |
| + return sel, true |
| } |
| // offlineStagedFor is the refusal of a start or a stage that names another |
| @@ -618,34 +842,22 @@ func (s *Server) handleAPIInstanceOfflineStart(w http.ResponseWriter, r *http.Re |
| if !ok { |
| return |
| } |
| - to, fleet, ok := s.offlineBeginChecks(w, r, d) |
| + sel, ok := s.offlineBeginChecks(w, r, d, false) |
| if !ok { |
| return |
| } |
| - sel := offlineOperatorSelection(fleet, d.rec.Region, to, provision.StageTarget{}) |
| // A staged instance starts the move it is staged for: staging to |
| // stopping, the instance offline from now. The reservation is the |
| - // stage's, retried as its own. |
| + // stage's, retried as its own. offlineRegionChecks has checked the |
| + // staged move's region, stager and coordinates. |
| if mv, staged := offlineStaged(d.rec); staged { |
| - if to != mv.To { |
| - writeRefusal(w, s.offlineStagedFor(d.host, mv.To)) |
| - return |
| - } |
| - if ref := s.offlineStagerPresent(fleet, mv, d.host); ref != nil { |
| - writeRefusal(w, ref) |
| - return |
| - } |
| - if ref := s.offlineStageCoords(fleet, mv, d.host); ref != nil { |
| - writeRefusal(w, ref) |
| - return |
| - } |
| s.offlineAdvanceBound(w, r, d, sel, mv.ID, func(at time.Time) func(*provision.Record) error { |
| return provision.OfflineStartStaged(mv.ID, d.addr, at, provision.Receipt{}) |
| }) |
| return |
| } |
| s.offlineBegin(w, r, d, sel, func(id string, at time.Time) func(*provision.Record) error { |
| - return provision.OfflineStart(id, d.rec.Region, to, d.addr, at, "") |
| + return provision.OfflineStart(id, d.rec.Region, sel.To, d.addr, at, "") |
| }) |
| } |
| @@ -660,54 +872,18 @@ func (s *Server) handleAPIInstanceOfflineStage(w http.ResponseWriter, r *http.Re |
| if !ok { |
| return |
| } |
| - to, fleet, ok := s.offlineBeginChecks(w, r, d) |
| + sel, ok := s.offlineBeginChecks(w, r, d, true) |
| if !ok { |
| return |
| } |
| if mv, staged := offlineStaged(d.rec); staged { |
| - if to != mv.To { |
| - writeRefusal(w, s.offlineStagedFor(d.host, mv.To)) |
| - return |
| - } |
| - if ref := s.offlineStageCoords(fleet, mv, d.host); ref != nil { |
| - writeRefusal(w, ref) |
| - return |
| - } |
| - sel := offlineOperatorSelection(fleet, d.rec.Region, to, provision.StageTarget{Stager: mv.Stager, Bucket: mv.StageBucket, Region: mv.StageRegion}) |
| s.offlineAdvanceBound(w, r, d, sel, mv.ID, func(at time.Time) func(*provision.Record) error { |
| return provision.OfflineRestage(mv.ID, at, provision.Receipt{}) |
| }) |
| return |
| } |
| - for _, b := range append(fleet.boxesAt(d.rec.Region), fleet.boxesAt(to)...) { |
| - if !slices.Contains(b.Capabilities, offlineStageCapability) { |
| - jsonError(w, http.StatusConflict, fmt.Sprintf("%s cannot stage an offline move. it runs an older release", b.Box)) |
| - return |
| - } |
| - } |
| - // The stager is pinned here, for the whole move: the one box that ever |
| - // writes this move's staging prefix, and the one whose cleanup an abort |
| - // waits for (Astra round 1, finding 2). offlineSelect has already |
| - // required at least one source box. |
| - var names []string |
| - for _, b := range fleet.boxesAt(d.rec.Region) { |
| - names = append(names, b.Box) |
| - } |
| - // The staging destination is fixed here too, before any copy (Astra |
| - // round 2, finding 2): the one target box's data bucket and region, as |
| - // offlineSelect found it. A target that publishes a source box's own |
| - // bucket is refused: the activator there would promote the staged copy |
| - // over the serving replica on its next wake (MV-O191). |
| - tgt := fleet.boxesAt(to)[0] |
| - for _, b := range fleet.boxesAt(d.rec.Region) { |
| - if b.DataBucket == tgt.DataBucket { |
| - 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))) |
| - return |
| - } |
| - } |
| - st := provision.StageTarget{Stager: moves.OfflineStager(names), Bucket: tgt.DataBucket, Region: tgt.DataBucketRegion} |
| - s.offlineBegin(w, r, d, offlineOperatorSelection(fleet, d.rec.Region, to, st), func(id string, at time.Time) func(*provision.Record) error { |
| - return provision.OfflineStage(id, d.rec.Region, to, d.addr, st, at, "") |
| + s.offlineBegin(w, r, d, sel, func(id string, at time.Time) func(*provision.Record) error { |
| + return provision.OfflineStage(id, d.rec.Region, sel.To, d.addr, sel.Stage, at, "") |
| }) |
| } |
| @@ -969,8 +1145,24 @@ func (s *Server) offlineStepFor(ctx context.Context, d offlineDoor, mv *provisio |
| host, from := d.host, s.offlineName(mv.From) |
| switch mv.State { |
| case provision.OfflineStaging: |
| - // What start would check of the fleet now; its request checks |
| - // (the class, the claims, the region) are the start door's. |
| + // A self-serve move's start is the job's: it runs the start |
| + // validation in full on the record, both halves, with the |
| + // participants' identities (self-serve moves plan 2 Task 5). |
| + if mv.SelfServe != nil && d.rec != nil { |
| + rec := d.rec |
| + if ref := s.offlineRecordChecks(ctx, rec.Account, rec.App, host, rec); ref != nil { |
| + return offlineStep{}, ref |
| + } |
| + if _, ref := s.offlineRegionChecks(ctx, rec.Account, rec.App, host, rec, s.homeRegionOut(mv.To), false, true, mv.ReturnOf); ref != nil { |
| + return offlineStep{}, ref |
| + } |
| + return offlineStep{mut: offlineStamped(now, func(at time.Time) func(*provision.Record) error { |
| + return provision.OfflineStartStaged(mv.ID, d.addr, at, provision.Receipt{}) |
| + })}, nil |
| + } |
| + // An operator's: what start would check of the fleet now; its |
| + // request checks (the class, the claims, the region) are the start |
| + // door's, so the view's answer stays main's. |
| fleet, ok := s.readOfflineFleet(ctx) |
| if !ok { |
| return offlineStep{}, &offlineRefusal{status: http.StatusConflict, msg: "the fleet's edges files could not all be read. try again"} |
| @@ -1454,7 +1646,7 @@ func (s *Server) offlineViewOf(ctx context.Context, rec *provision.Record) offli |
| } |
| } |
| if verb := offlineVerbOf(mv.State); verb != "" { |
| - d := offlineDoor{host: rec.Host} |
| + d := offlineDoor{host: rec.Host, rec: rec} |
| if _, ref := s.offlineStepFor(ctx, d, mv, ev, now); ref != nil { |
| v.Waiting = ref.msg |
| } else { |
diff --git a/internal/console/apiinstancemove_offline_snapshot_test.go b/internal/console/apiinstancemove_offline_snapshot_test.go| new file mode 100644 |
| index 000000000..09204bc08 |
| --- /dev/null |
| +++ b/internal/console/apiinstancemove_offline_snapshot_test.go |
| @@ -0,0 +1,235 @@ |
| +package console |
| + |
| +import ( |
| + "encoding/json" |
| + "fmt" |
| + "net/http" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/hostops" |
| + "github.com/carlosframework/platform/internal/provision" |
| +) |
| + |
| +// The operator start and stage doors' answers, replayed from main (self-serve |
| +// moves plan 2 Task 5). The golden file was written by this test run against |
| +// the tree before Task 5 split the doors' checks (base cf3dd10f), with |
| +// CARLOS_OPERATOR_SNAPSHOT_WRITE=1; it is never rewritten after the split. |
| +// Every refusal of both doors is here, each with the good body and with a |
| +// malformed one, so a class refusal combined with a malformed body keeps its |
| +// order; so are the successes on boxes whose status reports carry no metrics |
| +// and no identity record, and the staged move's own refusals and view. |
| + |
| +const operatorSnapshotGolden = "testdata/operator-door-answers.golden" |
| + |
| +type operatorSnapshotCase struct { |
| + name string |
| + boxes []offlineBox |
| + setup func(t *testing.T, w *offlineWorld) |
| +} |
| + |
| +func operatorSnapshotCases() []operatorSnapshotCase { |
| + edit := func(mut func(*provision.Record)) func(t *testing.T, w *offlineWorld) { |
| + return func(t *testing.T, w *offlineWorld) { |
| + w.edit(func(r *provision.Record) error { mut(r); return nil }) |
| + } |
| + } |
| + box := func(b offlineBox, f func(*offlineBox)) offlineBox { f(&b); return b } |
| + return []operatorSnapshotCase{ |
| + {name: "ok", boxes: oneEach}, |
| + {name: "ok with no metrics", boxes: []offlineBox{ |
| + box(ieOff("ie-box"), func(b *offlineBox) { b.NoMetrics = true }), |
| + box(ukOff("uk-box"), func(b *offlineBox) { b.NoMetrics = true })}}, |
| + {name: "two source boxes", boxes: []offlineBox{ieOff("ie-a"), ieOff("ie-b"), ukOff("uk-box")}}, |
| + {name: "seeded", boxes: oneEach, setup: func(t *testing.T, w *offlineWorld) { |
| + // A seed changes only through a seed transition, so the fixture |
| + // writes the record's bytes itself. |
| + key := provision.RecordKey("bab", "shop", offlineHost) |
| + raw, etag, err := w.fx.rel.Get(t.Context(), key) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var rec map[string]any |
| + if err := json.Unmarshal(raw, &rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + rec["seed"] = map[string]any{"id": "seed-1", "kind": "replica", "from": "seed.shop.example.com", "sha256": "x", "bytes": 1, "state": "copying", "by": "paul@keymail.dev", "at": "2026-10-22T11:00:00Z"} |
| + body, _ := json.Marshal(rec) |
| + if _, err := w.fx.rel.Put(t.Context(), key, body, etag); err != nil { |
| + t.Fatal(err) |
| + } |
| + }}, |
| + {name: "a replica", boxes: oneEach, setup: edit(func(r *provision.Record) { r.Role = "replica" })}, |
| + {name: "replicas", boxes: oneEach, setup: edit(func(r *provision.Record) { r.Replicas = []string{"uk"} })}, |
| + {name: "steering", boxes: oneEach, setup: edit(func(r *provision.Record) { r.Steering = "geo" })}, |
| + {name: "a unit", boxes: oneEach, setup: edit(func(r *provision.Record) { r.Unit = "app.service" })}, |
| + {name: "a record acquisition", boxes: oneEach, setup: edit(func(r *provision.Record) { r.Acquisition = "acq-1" })}, |
| + {name: "a host claim", boxes: oneEach, setup: func(t *testing.T, w *offlineWorld) { offlineClaimFor(t, w.fx, offlineHost, "ie-box", "ie") }}, |
| + {name: "an admission", boxes: oneEach, setup: func(t *testing.T, w *offlineWorld) { |
| + key, err := hostops.AdmissionKey("bab", "shop") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + offlinePut(t, w.fx.rel, key, []byte(`{}`)) |
| + }}, |
| + {name: "a second move in flight", boxes: oneEach, setup: func(t *testing.T, w *offlineWorld) { |
| + if _, code, out := w.startOther("beta.shop.example.com"); code != http.StatusAccepted { |
| + t.Fatalf("beta start = %d %s", code, out) |
| + } |
| + }}, |
| + {name: "the manifest's regions", boxes: oneEach, setup: func(t *testing.T, w *offlineWorld) { |
| + m := offlineManifest() |
| + m.Pool = "" |
| + enableInstances(t, w.fx, "shop", m) |
| + }}, |
| + {name: "an unreadable edges file", boxes: oneEach, setup: func(t *testing.T, w *offlineWorld) { |
| + offlinePut(t, w.fx.rel, edgesFilePrefix+"broken.json", []byte("{")) |
| + }}, |
| + {name: "no source box", boxes: []offlineBox{ukOff("uk-box")}}, |
| + {name: "no target box", boxes: []offlineBox{ieOff("ie-box")}}, |
| + {name: "two target boxes", boxes: []offlineBox{ieOff("ie-box"), ukOff("uk-a"), ukOff("uk-b")}}, |
| + {name: "a source without the capability", boxes: []offlineBox{box(ieOff("ie-box"), func(b *offlineBox) { b.NoCap = true }), ukOff("uk-box")}}, |
| + {name: "a target without the stage capability", boxes: []offlineBox{ieOff("ie-box"), box(ukOff("uk-box"), func(b *offlineBox) { b.NoStageCap = true })}}, |
| + {name: "a target without the secrets key", boxes: []offlineBox{ieOff("ie-box"), box(ukOff("uk-box"), func(b *offlineBox) { b.FP = "not-it" })}}, |
| + {name: "a target that lists the host", boxes: []offlineBox{ieOff("ie-box"), box(ukOff("uk-box"), func(b *offlineBox) { b.Hosts = []string{offlineHost} })}}, |
| + {name: "a target with no data bucket", boxes: []offlineBox{ieOff("ie-box"), box(ukOff("uk-box"), func(b *offlineBox) { b.Bucket = "" })}}, |
| + {name: "a target with the source's bucket", boxes: []offlineBox{ieOff("ie-box"), box(ukOff("uk-box"), func(b *offlineBox) { b.Bucket = "data-ie" })}}, |
| + {name: "a claims-on box", boxes: []offlineBox{ieOff("ie-box"), box(ukOff("uk-box"), func(b *offlineBox) { b.ClaimsOn = true })}}, |
| + } |
| +} |
| + |
| +// operatorAnswer is one answer as the snapshot keeps it: the status and the |
| +// error sentence, or for a 202 the view's stable fields. |
| +func operatorAnswer(code int, out string) string { |
| + if code == http.StatusAccepted || code == http.StatusOK { |
| + var v offlineView |
| + if err := json.Unmarshal([]byte(out), &v); err != nil { |
| + return fmt.Sprintf("%d unparsed %q", code, out) |
| + } |
| + return fmt.Sprintf("%d state=%s from=%s to=%s stager=%s next=%s waiting=%q can_abort=%v never_offline=%v", |
| + code, v.State, v.From, v.To, v.Stager, v.Next, v.Waiting, v.CanAbort, v.NeverOffline) |
| + } |
| + return fmt.Sprintf("%d %q", code, errorText(out)) |
| +} |
| + |
| +// operatorSnapshot drives every case through both doors, then the staged |
| +// move's own paths, and returns one line per answer. |
| +func operatorSnapshot(t *testing.T) []string { |
| + t.Helper() |
| + var lines []string |
| + note := func(name, what string, code int, out string) { |
| + lines = append(lines, name+" | "+what+" | "+operatorAnswer(code, out)) |
| + } |
| + bodies := []struct{ name, body string }{ |
| + {"good", `{"region":"uk"}`}, {"malformed", `{"region":`}, {"current", `{"region":"ie"}`}, {"invalid", `{"region":"UK!"}`}, |
| + } |
| + for _, c := range operatorSnapshotCases() { |
| + for _, door := range []struct{ name, path string }{{"start", ""}, {"stage", "/stage"}} { |
| + for _, b := range bodies { |
| + t.Run(c.name+"/"+door.name+"/"+b.name, func(t *testing.T) { |
| + w := newOfflineWorld(t, c.boxes...) |
| + if c.setup != nil { |
| + c.setup(t, w) |
| + } |
| + code, out := w.req(http.MethodPost, door.path, b.body) |
| + note(c.name, door.name+" "+b.name, code, out) |
| + if code == http.StatusAccepted { |
| + code, out = w.req(http.MethodGet, "", "") |
| + note(c.name, door.name+" "+b.name+" then status", code, out) |
| + } |
| + }) |
| + } |
| + } |
| + } |
| + // A staged move: restage, a start elsewhere, a start, and the stager's |
| + // and the target's changes after the stage. |
| + staged := func(t *testing.T, boxes ...offlineBox) *offlineWorld { |
| + w := newOfflineWorld(t, boxes...) |
| + if code, out := w.req(http.MethodPost, "/stage", `{"region":"uk"}`); code != http.StatusAccepted { |
| + t.Fatalf("stage = %d %s", code, out) |
| + } |
| + return w |
| + } |
| + seBox := offlineBox{Box: "se-box", Placement: "se", Bucket: "data-se"} |
| + t.Run("staged", func(t *testing.T) { |
| + w := staged(t, ieOff("ie-box"), ukOff("uk-box"), seBox) |
| + code, out := w.req(http.MethodGet, "", "") |
| + note("staged", "status", code, out) |
| + code, out = w.req(http.MethodPost, "/stage", `{"region":"se"}`) |
| + note("staged", "stage elsewhere", code, out) |
| + code, out = w.req(http.MethodPost, "", `{"region":"se"}`) |
| + note("staged", "start elsewhere", code, out) |
| + code, out = w.req(http.MethodPost, "/stage", `{"region":"uk"}`) |
| + note("staged", "restage", code, out) |
| + code, out = w.req(http.MethodPost, "", `{"region":"uk"}`) |
| + note("staged", "start", code, out) |
| + }) |
| + t.Run("staged, the stager gone", func(t *testing.T) { |
| + w := staged(t, oneEach...) |
| + for _, key := range []string{edgesFilePrefix + "ie-box.json", "status/boxes/ie-box.json"} { |
| + if err := w.fx.rel.Delete(t.Context(), key); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + w.fleet(ieOff("ie-other")) |
| + code, out := w.req(http.MethodGet, "", "") |
| + note("staged, the stager gone", "status", code, out) |
| + code, out = w.req(http.MethodPost, "", `{"region":"uk"}`) |
| + note("staged, the stager gone", "start", code, out) |
| + code, out = w.req(http.MethodPost, "/stage", `{"region":"uk"}`) |
| + note("staged, the stager gone", "restage", code, out) |
| + }) |
| + t.Run("staged, the target's bucket changed", func(t *testing.T) { |
| + w := staged(t, oneEach...) |
| + w.fleet(func() offlineBox { b := ukOff("uk-box"); b.Bucket = "data-uk-2"; return b }()) |
| + code, out := w.req(http.MethodGet, "", "") |
| + note("staged, the target's bucket changed", "status", code, out) |
| + code, out = w.req(http.MethodPost, "", `{"region":"uk"}`) |
| + note("staged, the target's bucket changed", "start", code, out) |
| + code, out = w.req(http.MethodPost, "/stage", `{"region":"uk"}`) |
| + note("staged, the target's bucket changed", "restage", code, out) |
| + }) |
| + t.Run("staged, a claim since", func(t *testing.T) { |
| + w := staged(t, oneEach...) |
| + offlineClaimFor(t, w.fx, offlineHost, "ie-box", "ie") |
| + code, out := w.req(http.MethodGet, "", "") |
| + note("staged, a claim since", "status", code, out) |
| + code, out = w.req(http.MethodPost, "", `{"region":"uk"}`) |
| + note("staged, a claim since", "start", code, out) |
| + code, out = w.req(http.MethodPost, "/stage", `{"region":"uk"}`) |
| + note("staged, a claim since", "restage", code, out) |
| + }) |
| + return lines |
| +} |
| + |
| +// TestOperatorDoorAnswersAreUnchanged replays every operator start and |
| +// stage answer against the golden file written from the tree before the |
| +// split. |
| +func TestOperatorDoorAnswersAreUnchanged(t *testing.T) { |
| + lines := operatorSnapshot(t) |
| + got := strings.Join(lines, "\n") + "\n" |
| + if os.Getenv("CARLOS_OPERATOR_SNAPSHOT_WRITE") == "1" { |
| + if err := os.MkdirAll(filepath.Dir(operatorSnapshotGolden), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(operatorSnapshotGolden, []byte(got), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + return |
| + } |
| + want, err := os.ReadFile(operatorSnapshotGolden) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + wl, gl := strings.Split(strings.TrimSuffix(string(want), "\n"), "\n"), lines |
| + if len(wl) != len(gl) { |
| + t.Errorf("%d answers, the golden file has %d", len(gl), len(wl)) |
| + } |
| + for i := 0; i < len(wl) && i < len(gl); i++ { |
| + if wl[i] != gl[i] { |
| + t.Errorf("answer %d changed:\n got %s\n want %s", i, gl[i], wl[i]) |
| + } |
| + } |
| +} |
diff --git a/internal/console/apiinstancemove_offline_test.go b/internal/console/apiinstancemove_offline_test.go| index dae55a041..c37dc9444 100644 |
| --- a/internal/console/apiinstancemove_offline_test.go |
| +++ b/internal/console/apiinstancemove_offline_test.go |
| @@ -73,6 +73,7 @@ type offlineBox struct { |
| NoHibernation bool // hibernation_configured false |
| Hosts []string // hosts its edges file lists |
| FP string // secrets_key_fp; testKeyFP when empty |
| + ID string // box_id; Box when empty |
| } |
| func ieOff(box string) offlineBox { return offlineBox{Box: box, Placement: "ie", Bucket: "data-ie"} } |
| @@ -155,8 +156,12 @@ func (w *offlineWorld) fleet(boxes ...offlineBox) { |
| if fp == "" { |
| fp = testKeyFP |
| } |
| + id := b.ID |
| + if id == "" { |
| + id = b.Box |
| + } |
| body, err := json.Marshal(map[string]any{ |
| - "v": 1, "box": b.Box, "box_id": b.Box, "eip": "1.2.3.4", "region": aws, "placement": b.Placement, |
| + "v": 1, "box": b.Box, "box_id": id, "eip": "1.2.3.4", "region": aws, "placement": b.Placement, |
| "data_bucket": b.Bucket, "data_bucket_region": aws, "hibernation_configured": !b.NoHibernation, "serves_shared_pool": !b.NotSharedPool, |
| "accepts_inbound": true, "secrets_key_fp": fp, "capabilities": caps, "hosts": hosts, |
| }) |
diff --git a/internal/console/offlinereserve.go b/internal/console/offlinereserve.go| index eba573825..954f32c60 100644 |
| --- a/internal/console/offlinereserve.go |
| +++ b/internal/console/offlinereserve.go |
| @@ -35,10 +35,10 @@ import ( |
| // created under the table form released), then the old console. |
| // offlineSelection is a move's participants as the door or the job chose |
| -// them (Task 5's offlineRegionChecks produces it for self-serve; the |
| -// operator start and stage doors build one from offlineSelect's fleet |
| -// today, offlineOperatorSelection). Sentinel is set when any participant's |
| -// InstanceID is empty: such a selection reserves every box. |
| +// them: offlineRegionChecks produces it for the operator start and stage |
| +// doors, the self-serve door and the job, pinned by pinParticipants. |
| +// Sentinel is set when any participant's InstanceID is empty: such a |
| +// selection reserves every box. |
| type offlineSelection struct { |
| From, To string |
| Fleet offlineFleet |
| @@ -73,24 +73,6 @@ func (sel offlineSelection) reservationKeys() []string { |
| return keys |
| } |
| -// offlineOperatorSelection is the operator start and stage doors' |
| -// selection: the source region's boxes and the one target box, as |
| -// offlineSelect found them, labelled and named by the box id each edges file |
| -// publishes. The operator path pins no InstanceID until Task 5's pinning |
| -// lands, so every operator selection is a sentinel selection: one operator |
| -// move at a time across the deployment, as before the table. |
| -func offlineOperatorSelection(f offlineFleet, from, to string, st provision.StageTarget) offlineSelection { |
| - sel := offlineSelection{From: from, To: to, Fleet: f, Stage: st} |
| - for _, b := range f.boxesAt(from) { |
| - sel.Source = append(sel.Source, provision.Participant{BoxID: b.BoxID, Label: b.Box}) |
| - } |
| - if tgt := f.boxesAt(to); len(tgt) > 0 { |
| - sel.Target = provision.Participant{BoxID: tgt[0].BoxID, Label: tgt[0].Box} |
| - } |
| - sel.Sentinel = true |
| - return sel |
| -} |
| - |
| // selfServeRowEvidence is what reclaim rules (c) and (d) read from the |
| // self-serve rows (Task 4's tables): MoveTerminalOrAbsent is true when no |
| // row has the move id, or its row is terminal with release_pending 0; |
diff --git a/internal/console/testdata/operator-door-answers.golden b/internal/console/testdata/operator-door-answers.golden| new file mode 100644 |
| index 000000000..aa8daaf27 |
| --- /dev/null |
| +++ b/internal/console/testdata/operator-door-answers.golden |
| @@ -0,0 +1,214 @@ |
| +ok | start good | 202 state=offline-stopping from=ie to=uk stager= next= waiting="Waiting for ie-box in ie to confirm it has stopped acme.shop.example.com. acme.shop.example.com is offline." can_abort=true never_offline=false |
| +ok | start good then status | 200 state=offline-stopping from=ie to=uk stager= next= waiting="Waiting for ie-box in ie to confirm it has stopped acme.shop.example.com. acme.shop.example.com is offline." can_abort=true never_offline=false |
| +ok | start malformed | 400 "malformed JSON body" |
| +ok | start current | 409 "the instance is already in ie" |
| +ok | start invalid | 400 "invalid region" |
| +ok | stage good | 202 state=offline-staging from=ie to=uk stager=ie-box next=start waiting="" can_abort=true never_offline=true |
| +ok | stage good then status | 200 state=offline-staging from=ie to=uk stager=ie-box next=start waiting="" can_abort=true never_offline=true |
| +ok | stage malformed | 400 "malformed JSON body" |
| +ok | stage current | 409 "the instance is already in ie" |
| +ok | stage invalid | 400 "invalid region" |
| +ok with no metrics | start good | 202 state=offline-stopping from=ie to=uk stager= next= waiting="Waiting for ie-box in ie to confirm it has stopped acme.shop.example.com. acme.shop.example.com is offline." can_abort=true never_offline=false |
| +ok with no metrics | start good then status | 200 state=offline-stopping from=ie to=uk stager= next= waiting="Waiting for ie-box in ie to confirm it has stopped acme.shop.example.com. acme.shop.example.com is offline." can_abort=true never_offline=false |
| +ok with no metrics | start malformed | 400 "malformed JSON body" |
| +ok with no metrics | start current | 409 "the instance is already in ie" |
| +ok with no metrics | start invalid | 400 "invalid region" |
| +ok with no metrics | stage good | 202 state=offline-staging from=ie to=uk stager=ie-box next=start waiting="" can_abort=true never_offline=true |
| +ok with no metrics | stage good then status | 200 state=offline-staging from=ie to=uk stager=ie-box next=start waiting="" can_abort=true never_offline=true |
| +ok with no metrics | stage malformed | 400 "malformed JSON body" |
| +ok with no metrics | stage current | 409 "the instance is already in ie" |
| +ok with no metrics | stage invalid | 400 "invalid region" |
| +two source boxes | start good | 202 state=offline-stopping from=ie to=uk stager= next= waiting="Waiting for ie-a in ie to confirm it has stopped acme.shop.example.com. acme.shop.example.com is offline." can_abort=true never_offline=false |
| +two source boxes | start good then status | 200 state=offline-stopping from=ie to=uk stager= next= waiting="Waiting for ie-a in ie to confirm it has stopped acme.shop.example.com. acme.shop.example.com is offline." can_abort=true never_offline=false |
| +two source boxes | start malformed | 400 "malformed JSON body" |
| +two source boxes | start current | 409 "the instance is already in ie" |
| +two source boxes | start invalid | 400 "invalid region" |
| +two source boxes | stage good | 202 state=offline-staging from=ie to=uk stager=ie-a next=start waiting="" can_abort=true never_offline=true |
| +two source boxes | stage good then status | 200 state=offline-staging from=ie to=uk stager=ie-a next=start waiting="" can_abort=true never_offline=true |
| +two source boxes | stage malformed | 400 "malformed JSON body" |
| +two source boxes | stage current | 409 "the instance is already in ie" |
| +two source boxes | stage invalid | 400 "invalid region" |
| +seeded | start good | 409 "wake the instance once before moving it" |
| +seeded | start malformed | 409 "wake the instance once before moving it" |
| +seeded | start current | 409 "wake the instance once before moving it" |
| +seeded | start invalid | 409 "wake the instance once before moving it" |
| +seeded | stage good | 409 "wake the instance once before moving it" |
| +seeded | stage malformed | 409 "wake the instance once before moving it" |
| +seeded | stage current | 409 "wake the instance once before moving it" |
| +seeded | stage invalid | 409 "wake the instance once before moving it" |
| +a replica | start good | 400 "a replica does not move. delete it and create one in the new region" |
| +a replica | start malformed | 400 "a replica does not move. delete it and create one in the new region" |
| +a replica | start current | 400 "a replica does not move. delete it and create one in the new region" |
| +a replica | start invalid | 400 "a replica does not move. delete it and create one in the new region" |
| +a replica | stage good | 400 "a replica does not move. delete it and create one in the new region" |
| +a replica | stage malformed | 400 "a replica does not move. delete it and create one in the new region" |
| +a replica | stage current | 400 "a replica does not move. delete it and create one in the new region" |
| +a replica | stage invalid | 400 "a replica does not move. delete it and create one in the new region" |
| +replicas | start good | 400 "a home with replicas does not move. remove its replicas first" |
| +replicas | start malformed | 400 "a home with replicas does not move. remove its replicas first" |
| +replicas | start current | 400 "a home with replicas does not move. remove its replicas first" |
| +replicas | start invalid | 400 "a home with replicas does not move. remove its replicas first" |
| +replicas | stage good | 400 "a home with replicas does not move. remove its replicas first" |
| +replicas | stage malformed | 400 "a home with replicas does not move. remove its replicas first" |
| +replicas | stage current | 400 "a home with replicas does not move. remove its replicas first" |
| +replicas | stage invalid | 400 "a home with replicas does not move. remove its replicas first" |
| +steering | start good | 400 "acme.shop.example.com is steered or runs under a unit. offline moves cannot carry either yet" |
| +steering | start malformed | 400 "acme.shop.example.com is steered or runs under a unit. offline moves cannot carry either yet" |
| +steering | start current | 400 "acme.shop.example.com is steered or runs under a unit. offline moves cannot carry either yet" |
| +steering | start invalid | 400 "acme.shop.example.com is steered or runs under a unit. offline moves cannot carry either yet" |
| +steering | stage good | 400 "acme.shop.example.com is steered or runs under a unit. offline moves cannot carry either yet" |
| +steering | stage malformed | 400 "acme.shop.example.com is steered or runs under a unit. offline moves cannot carry either yet" |
| +steering | stage current | 400 "acme.shop.example.com is steered or runs under a unit. offline moves cannot carry either yet" |
| +steering | stage invalid | 400 "acme.shop.example.com is steered or runs under a unit. offline moves cannot carry either yet" |
| +a unit | start good | 400 "acme.shop.example.com is steered or runs under a unit. offline moves cannot carry either yet" |
| +a unit | start malformed | 400 "acme.shop.example.com is steered or runs under a unit. offline moves cannot carry either yet" |
| +a unit | start current | 400 "acme.shop.example.com is steered or runs under a unit. offline moves cannot carry either yet" |
| +a unit | start invalid | 400 "acme.shop.example.com is steered or runs under a unit. offline moves cannot carry either yet" |
| +a unit | stage good | 400 "acme.shop.example.com is steered or runs under a unit. offline moves cannot carry either yet" |
| +a unit | stage malformed | 400 "acme.shop.example.com is steered or runs under a unit. offline moves cannot carry either yet" |
| +a unit | stage current | 400 "acme.shop.example.com is steered or runs under a unit. offline moves cannot carry either yet" |
| +a unit | stage invalid | 400 "acme.shop.example.com is steered or runs under a unit. offline moves cannot carry either yet" |
| +a record acquisition | start good | 409 "acme.shop.example.com has a host claim. offline moves are only for hosts without one" |
| +a record acquisition | start malformed | 409 "acme.shop.example.com has a host claim. offline moves are only for hosts without one" |
| +a record acquisition | start current | 409 "acme.shop.example.com has a host claim. offline moves are only for hosts without one" |
| +a record acquisition | start invalid | 409 "acme.shop.example.com has a host claim. offline moves are only for hosts without one" |
| +a record acquisition | stage good | 409 "acme.shop.example.com has a host claim. offline moves are only for hosts without one" |
| +a record acquisition | stage malformed | 409 "acme.shop.example.com has a host claim. offline moves are only for hosts without one" |
| +a record acquisition | stage current | 409 "acme.shop.example.com has a host claim. offline moves are only for hosts without one" |
| +a record acquisition | stage invalid | 409 "acme.shop.example.com has a host claim. offline moves are only for hosts without one" |
| +a host claim | start good | 409 "acme.shop.example.com has a host claim. offline moves are only for hosts without one" |
| +a host claim | start malformed | 409 "acme.shop.example.com has a host claim. offline moves are only for hosts without one" |
| +a host claim | start current | 409 "acme.shop.example.com has a host claim. offline moves are only for hosts without one" |
| +a host claim | start invalid | 409 "acme.shop.example.com has a host claim. offline moves are only for hosts without one" |
| +a host claim | stage good | 409 "acme.shop.example.com has a host claim. offline moves are only for hosts without one" |
| +a host claim | stage malformed | 409 "acme.shop.example.com has a host claim. offline moves are only for hosts without one" |
| +a host claim | stage current | 409 "acme.shop.example.com has a host claim. offline moves are only for hosts without one" |
| +a host claim | stage invalid | 409 "acme.shop.example.com has a host claim. offline moves are only for hosts without one" |
| +an admission | start good | 409 "acme.shop.example.com has a host claim. offline moves are only for hosts without one" |
| +an admission | start malformed | 409 "acme.shop.example.com has a host claim. offline moves are only for hosts without one" |
| +an admission | start current | 409 "acme.shop.example.com has a host claim. offline moves are only for hosts without one" |
| +an admission | start invalid | 409 "acme.shop.example.com has a host claim. offline moves are only for hosts without one" |
| +an admission | stage good | 409 "acme.shop.example.com has a host claim. offline moves are only for hosts without one" |
| +an admission | stage malformed | 409 "acme.shop.example.com has a host claim. offline moves are only for hosts without one" |
| +an admission | stage current | 409 "acme.shop.example.com has a host claim. offline moves are only for hosts without one" |
| +an admission | stage invalid | 409 "acme.shop.example.com has a host claim. offline moves are only for hosts without one" |
| +a second move in flight | start good | 409 "beta.shop.example.com is moving offline. move one instance at a time" |
| +a second move in flight | start malformed | 400 "malformed JSON body" |
| +a second move in flight | start current | 409 "the instance is already in ie" |
| +a second move in flight | start invalid | 400 "invalid region" |
| +a second move in flight | stage good | 409 "beta.shop.example.com is moving offline. move one instance at a time" |
| +a second move in flight | stage malformed | 400 "malformed JSON body" |
| +a second move in flight | stage current | 409 "the instance is already in ie" |
| +a second move in flight | stage invalid | 400 "invalid region" |
| +the manifest's regions | start good | 400 "region uk is not one of this app's regions: ie" |
| +the manifest's regions | start malformed | 400 "malformed JSON body" |
| +the manifest's regions | start current | 409 "the instance is already in ie" |
| +the manifest's regions | start invalid | 400 "invalid region" |
| +the manifest's regions | stage good | 400 "region uk is not one of this app's regions: ie" |
| +the manifest's regions | stage malformed | 400 "malformed JSON body" |
| +the manifest's regions | stage current | 409 "the instance is already in ie" |
| +the manifest's regions | stage invalid | 400 "invalid region" |
| +an unreadable edges file | start good | 409 "the fleet's edges files could not all be read. try again" |
| +an unreadable edges file | start malformed | 400 "malformed JSON body" |
| +an unreadable edges file | start current | 409 "the instance is already in ie" |
| +an unreadable edges file | start invalid | 400 "invalid region" |
| +an unreadable edges file | stage good | 409 "the fleet's edges files could not all be read. try again" |
| +an unreadable edges file | stage malformed | 400 "malformed JSON body" |
| +an unreadable edges file | stage current | 409 "the instance is already in ie" |
| +an unreadable edges file | stage invalid | 400 "invalid region" |
| +no source box | start good | 400 "no eligible box in ie publishes an edges file" |
| +no source box | start malformed | 400 "malformed JSON body" |
| +no source box | start current | 409 "the instance is already in ie" |
| +no source box | start invalid | 400 "invalid region" |
| +no source box | stage good | 400 "no eligible box in ie publishes an edges file" |
| +no source box | stage malformed | 400 "malformed JSON body" |
| +no source box | stage current | 409 "the instance is already in ie" |
| +no source box | stage invalid | 400 "invalid region" |
| +no target box | start good | 409 "uk has 0 boxes. an offline move needs exactly one box in the region it moves to" |
| +no target box | start malformed | 400 "malformed JSON body" |
| +no target box | start current | 409 "the instance is already in ie" |
| +no target box | start invalid | 400 "invalid region" |
| +no target box | stage good | 409 "uk has 0 boxes. an offline move needs exactly one box in the region it moves to" |
| +no target box | stage malformed | 400 "malformed JSON body" |
| +no target box | stage current | 409 "the instance is already in ie" |
| +no target box | stage invalid | 400 "invalid region" |
| +two target boxes | start good | 409 "uk has 2 boxes. an offline move needs exactly one box in the region it moves to" |
| +two target boxes | start malformed | 400 "malformed JSON body" |
| +two target boxes | start current | 409 "the instance is already in ie" |
| +two target boxes | start invalid | 400 "invalid region" |
| +two target boxes | stage good | 409 "uk has 2 boxes. an offline move needs exactly one box in the region it moves to" |
| +two target boxes | stage malformed | 400 "malformed JSON body" |
| +two target boxes | stage current | 409 "the instance is already in ie" |
| +two target boxes | stage invalid | 400 "invalid region" |
| +a source without the capability | start good | 409 "ie-box cannot take part in an offline move. it runs an older release or has host claims on" |
| +a source without the capability | start malformed | 400 "malformed JSON body" |
| +a source without the capability | start current | 409 "the instance is already in ie" |
| +a source without the capability | start invalid | 400 "invalid region" |
| +a source without the capability | stage good | 409 "ie-box cannot take part in an offline move. it runs an older release or has host claims on" |
| +a source without the capability | stage malformed | 400 "malformed JSON body" |
| +a source without the capability | stage current | 409 "the instance is already in ie" |
| +a source without the capability | stage invalid | 400 "invalid region" |
| +a target without the stage capability | start good | 202 state=offline-stopping from=ie to=uk stager= next= waiting="Waiting for ie-box in ie to confirm it has stopped acme.shop.example.com. acme.shop.example.com is offline." can_abort=true never_offline=false |
| +a target without the stage capability | start good then status | 200 state=offline-stopping from=ie to=uk stager= next= waiting="Waiting for ie-box in ie to confirm it has stopped acme.shop.example.com. acme.shop.example.com is offline." can_abort=true never_offline=false |
| +a target without the stage capability | start malformed | 400 "malformed JSON body" |
| +a target without the stage capability | start current | 409 "the instance is already in ie" |
| +a target without the stage capability | start invalid | 400 "invalid region" |
| +a target without the stage capability | stage good | 409 "uk-box cannot stage an offline move. it runs an older release" |
| +a target without the stage capability | stage malformed | 400 "malformed JSON body" |
| +a target without the stage capability | stage current | 409 "the instance is already in ie" |
| +a target without the stage capability | stage invalid | 400 "invalid region" |
| +a target without the secrets key | start good | 409 "uk-box does not hold this deployment's secrets key" |
| +a target without the secrets key | start malformed | 400 "malformed JSON body" |
| +a target without the secrets key | start current | 409 "the instance is already in ie" |
| +a target without the secrets key | start invalid | 400 "invalid region" |
| +a target without the secrets key | stage good | 409 "uk-box does not hold this deployment's secrets key" |
| +a target without the secrets key | stage malformed | 400 "malformed JSON body" |
| +a target without the secrets key | stage current | 409 "the instance is already in ie" |
| +a target without the secrets key | stage invalid | 400 "invalid region" |
| +a target that lists the host | start good | 409 "uk-box already serves acme.shop.example.com. an offline move needs the region it moves to to be empty of it" |
| +a target that lists the host | start malformed | 400 "malformed JSON body" |
| +a target that lists the host | start current | 409 "the instance is already in ie" |
| +a target that lists the host | start invalid | 400 "invalid region" |
| +a target that lists the host | stage good | 409 "uk-box already serves acme.shop.example.com. an offline move needs the region it moves to to be empty of it" |
| +a target that lists the host | stage malformed | 400 "malformed JSON body" |
| +a target that lists the host | stage current | 409 "the instance is already in ie" |
| +a target that lists the host | stage invalid | 400 "invalid region" |
| +a target with no data bucket | start good | 400 "region uk cannot take a move in this deployment. it publishes no staging bucket. Regions that can: ie" |
| +a target with no data bucket | start malformed | 400 "malformed JSON body" |
| +a target with no data bucket | start current | 409 "the instance is already in ie" |
| +a target with no data bucket | start invalid | 400 "invalid region" |
| +a target with no data bucket | stage good | 400 "region uk cannot take a move in this deployment. it publishes no staging bucket. Regions that can: ie" |
| +a target with no data bucket | stage malformed | 400 "malformed JSON body" |
| +a target with no data bucket | stage current | 409 "the instance is already in ie" |
| +a target with no data bucket | stage invalid | 400 "invalid region" |
| +a target with the source's bucket | start good | 202 state=offline-stopping from=ie to=uk stager= next= waiting="Waiting for ie-box in ie to confirm it has stopped acme.shop.example.com. acme.shop.example.com is offline." can_abort=true never_offline=false |
| +a target with the source's bucket | start good then status | 200 state=offline-stopping from=ie to=uk stager= next= waiting="Waiting for ie-box in ie to confirm it has stopped acme.shop.example.com. acme.shop.example.com is offline." can_abort=true never_offline=false |
| +a target with the source's bucket | start malformed | 400 "malformed JSON body" |
| +a target with the source's bucket | start current | 409 "the instance is already in ie" |
| +a target with the source's bucket | start invalid | 400 "invalid region" |
| +a target with the source's bucket | stage good | 409 "uk publishes the same data bucket as ie. a staged copy there would replace the serving one" |
| +a target with the source's bucket | stage malformed | 400 "malformed JSON body" |
| +a target with the source's bucket | stage current | 409 "the instance is already in ie" |
| +a target with the source's bucket | stage invalid | 400 "invalid region" |
| +a claims-on box | start good | 409 "uk-box cannot take part in an offline move. it runs an older release or has host claims on" |
| +a claims-on box | start malformed | 400 "malformed JSON body" |
| +a claims-on box | start current | 409 "the instance is already in ie" |
| +a claims-on box | start invalid | 400 "invalid region" |
| +a claims-on box | stage good | 409 "uk-box cannot take part in an offline move. it runs an older release or has host claims on" |
| +a claims-on box | stage malformed | 400 "malformed JSON body" |
| +a claims-on box | stage current | 409 "the instance is already in ie" |
| +a claims-on box | stage invalid | 400 "invalid region" |
| +staged | status | 200 state=offline-staging from=ie to=uk stager=ie-box next=start waiting="" can_abort=true never_offline=true |
| +staged | stage elsewhere | 409 "acme.shop.example.com is staged for uk. abort the move to move it to another region" |
| +staged | start elsewhere | 409 "acme.shop.example.com is staged for uk. abort the move to move it to another region" |
| +staged | restage | 202 state=offline-staging from=ie to=uk stager=ie-box next=start waiting="" can_abort=true never_offline=true |
| +staged | start | 202 state=offline-stopping from=ie to=uk stager=ie-box next= waiting="Waiting for ie-box in ie to confirm it has stopped acme.shop.example.com. acme.shop.example.com is offline." can_abort=true never_offline=false |
| +staged, the stager gone | status | 200 state=offline-staging from=ie to=uk stager=ie-box next= waiting="ie-box, which staged acme.shop.example.com, no longer publishes an edges file in ie. bring it back or abort the move" can_abort=true never_offline=true |
| +staged, the stager gone | start | 409 "ie-box, which staged acme.shop.example.com, no longer publishes an edges file in ie. bring it back or abort the move" |
| +staged, the stager gone | restage | 202 state=offline-staging from=ie to=uk stager=ie-box next= waiting="ie-box, which staged acme.shop.example.com, no longer publishes an edges file in ie. bring it back or abort the move" can_abort=true never_offline=true |
| +staged, the target's bucket changed | status | 200 state=offline-staging from=ie to=uk stager=ie-box next= waiting="uk now publishes a different data bucket or region from what was recorded when acme.shop.example.com was staged. abort the move and stage it again" can_abort=true never_offline=true |
| +staged, the target's bucket changed | start | 409 "uk now publishes a different data bucket or region from what was recorded when acme.shop.example.com was staged. abort the move and stage it again" |
| +staged, the target's bucket changed | restage | 409 "uk now publishes a different data bucket or region from what was recorded when acme.shop.example.com was staged. abort the move and stage it again" |
| +staged, a claim since | status | 200 state=offline-staging from=ie to=uk stager=ie-box next=start waiting="" can_abort=true never_offline=true |
| +staged, a claim since | start | 409 "acme.shop.example.com has a host claim. offline moves are only for hosts without one" |
| +staged, a claim since | restage | 409 "acme.shop.example.com has a host claim. offline moves are only for hosts without one" |