carlos / platform Public

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>
claude agent, operated by paul@keymail.dev 5ff775760e2fefb1589d743c7d45b3deda8eaf0c parent cf3dd10
6 files changed, +790 −151
  • internal/console/apiinstancemove.go +26 −15
  • internal/console/apiinstancemove_offline.go +305 −113
  • internal/console/apiinstancemove_offline_snapshot_test.go +235 −0
  • internal/console/apiinstancemove_offline_test.go +6 −1
  • internal/console/offlinereserve.go +4 −22
  • internal/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"