console: the move request door is operator-only and CASes the claim to moving (§7.1, §12, K2)
Behind CARLOS_MOVES_PHASE1. Off, every move handler is main's (renamed legacy*, unchanged) and the new resolve route answers the family's 404. On, the three mutations and resolve are operator-only (moveOperatorGate); the request door picks the one eligible box on each side (selectMoveBoxes: live snapshot, identities proven by boxid.Verifier.Enroll into a synthesised roster, the protocol floor, the one-box refusal), binds the source to the claim's executor, tries the record transition first, then CASes the claim with hostclaim.BeginMove and journals the move with its id. Cut and abort carry the journal's guard; a legacy record is abort-only. Adds Config.AWSAccount (CARLOS_AWS_ACCOUNT), Server.boxCerts, the four moves fields on the edges view, internal/boxid/boxidtest, and copy rows MV-N16 to MV-N35 (pending).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
11 files changed,
+1410
−11
cmd/console/main.go+3 −0docs/superpowers/plans/2026-10-06-moves-phase1-copy.md+21 −1internal/boxid/boxidtest/boxidtest.go+85 −0internal/console/apiinstancemove.go+317 −5internal/console/apiinstancemove_test.go+552 −0internal/console/authcli.go+6 −0internal/console/boxselect.go+160 −0internal/console/boxselect_test.go+230 −0internal/console/instancesplacement.go+11 −0internal/console/server.go+17 −0internal/console/svcroutes_test.go+8 −5
diff --git a/cmd/console/main.go b/cmd/console/main.go| index 1ec474ffa..4f1ad4eff 100644 |
| --- a/cmd/console/main.go |
| +++ b/cmd/console/main.go |
| @@ -292,6 +292,9 @@ func serve() { |
| // correctly draws no bucket node. |
| Bucket: os.Getenv("CARLOS_DEPLOYMENT_BUCKET"), |
| HomeRegion: strings.TrimSpace(os.Getenv("CONSOLE_HOME_REGION")), |
| + // The deployment's AWS account id, the trust anchor of the Phase 1 |
| + // move door's box identity check (unset: no box verifies). |
| + AWSAccount: strings.TrimSpace(os.Getenv("CARLOS_AWS_ACCOUNT")), |
| AppRegions: appRegionsSwitch(os.Getenv("CONSOLE_APP_REGIONS")), |
| GeoIP: openGeoIP(strings.TrimSpace(os.Getenv("CONSOLE_GEOIP_DB"))), |
| // The carlos db door's data bucket and this box's placement |
diff --git a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md| index 3095bb7f0..871cb2727 100644 |
| --- a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md |
| +++ b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md |
| @@ -46,7 +46,7 @@ Columns: id, file, text, status. |
| | MV-N4 | internal/console/apiinstancemove.go | the instance is under a <kind> operation. wait for it to finish | pending | |
| | MV-N5 | internal/console/apiinstancemove.go | a box in <from> or <to> is below the moves protocol floor. roll the platform first | pending | |
| | MV-N6 | internal/console/apiinstancemove.go | cut must be "auto" or "manual" | pending | |
| -| MV-N7 | internal/provision/provision.go | this move was started by an older console and has no id. abort it and request it again | pending | |
| +| MV-N7 | internal/console/apiinstancemove.go | this move was started by an older console and has no id. abort it and request it again | pending | |
| | MV-N8 | internal/console/apiinstancemove.go | the fleet's edges files could not all be read. try again | pending | |
| | MV-N9 | internal/host/movespass.go | a staging lane for this move id already exists | pending | |
| | MV-N10 | internal/host/systemapply.go | the moves floor is unknown and this box holds an operation | pending | |
| @@ -55,6 +55,26 @@ Columns: id, file, text, status. |
| | MV-N13 | internal/provision/provision.go | the record's move is <id> for incarnation <incarnation> | pending | |
| | MV-N14 | internal/provision/provision.go | malformed move start | pending | |
| | MV-N15 | internal/provision/provision.go | past the point of no return. the cut completes forward or moves back on its own | pending | |
| +| MV-N16 | internal/console/apiinstancemove.go | the instance is already in <region> | pending | |
| +| MV-N17 | internal/console/apiinstancemove.go | could not read the app's instances manifest. try again | pending | |
| +| MV-N18 | internal/console/apiinstancemove.go | could not read this instance's storage binding. try again | pending | |
| +| MV-N19 | internal/console/apiinstancemove.go | this deployment publishes no armed regions. nothing to move to | pending | |
| +| MV-N20 | internal/console/apiinstancemove.go | region <to> cannot take a move in this deployment. it publishes no staging bucket. Regions that can: <list> | pending | |
| +| MV-N21 | internal/console/apiinstancemove.go | moves to the home region are not supported yet. the home publishes no staging lane | pending | |
| +| MV-N22 | internal/console/apiinstancemove.go | could not read the moves protocol floor. try again | pending | |
| +| MV-N23 | internal/console/apiinstancemove.go | no box in <to> publishes an edges file yet | pending | |
| +| MV-N24 | internal/console/apiinstancemove.go | no eligible box in <from> publishes an edges file | pending | |
| +| MV-N25 | internal/console/apiinstancemove.go | the record's identity is malformed | pending | |
| +| MV-N26 | internal/console/apiinstancemove.go | could not read the host claim | pending | |
| +| MV-N27 | internal/console/apiinstancemove.go | the host claim names another incarnation of this instance | pending | |
| +| MV-N28 | internal/console/apiinstancemove.go | the source box <box> is not the eligible box of <from> | pending | |
| +| MV-N29 | internal/console/apiinstancemove.go | could not mint a move id | pending | |
| +| MV-N30 | internal/console/apiinstancemove.go | the instance has unfinished cleanup in <to>. wait for it to finish | pending | |
| +| MV-N31 | internal/console/apiinstancemove.go | the host claim changed under this request. try again | pending | |
| +| MV-N32 | internal/console/apiinstancemove.go | could not write the host claim | pending | |
| +| MV-N33 | internal/console/apiinstancemove.go | could not record the move after the host claim was taken. the console releases the claim on a later pass | pending | |
| +| MV-N34 | internal/console/apiinstancemove.go | could not write the move. try again | pending | |
| +| MV-N35 | internal/console/apiinstancemove.go | nothing to resolve yet | pending | |
| ## Plan 2 |
diff --git a/internal/boxid/boxidtest/boxidtest.go b/internal/boxid/boxidtest/boxidtest.go| new file mode 100644 |
| index 000000000..31b86a11c |
| --- /dev/null |
| +++ b/internal/boxid/boxidtest/boxidtest.go |
| @@ -0,0 +1,85 @@ |
| +// Package boxidtest stands in for AWS's instance-identity signer in tests |
| +// outside package boxid: one key and one self-signed certificate, and |
| +// records signed with them. It is the exported twin of boxid_test.go's |
| +// signer, which stays as it is because that file is package boxid and |
| +// importing this package from it would be an import cycle. Keep the two in |
| +// step. |
| +package boxidtest |
| + |
| +import ( |
| + "crypto" |
| + "crypto/rand" |
| + "crypto/rsa" |
| + "crypto/sha256" |
| + "crypto/x509" |
| + "crypto/x509/pkix" |
| + "encoding/base64" |
| + "encoding/json" |
| + "math/big" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/boxid" |
| +) |
| + |
| +// Regions are the AWS regions Certs certifies. Every region a fixture |
| +// signs a record for must be here, or its box never verifies. |
| +var Regions = []string{"ap-southeast-2", "eu-north-1", "eu-west-1", "eu-west-2", "us-east-1"} |
| + |
| +// Signer is one test's AWS: a key and its certificate. |
| +type Signer struct { |
| + key *rsa.PrivateKey |
| + cert *x509.Certificate |
| +} |
| + |
| +// NewSigner makes a fresh key and certificate. |
| +func NewSigner(t testing.TB) *Signer { |
| + t.Helper() |
| + key, err := rsa.GenerateKey(rand.Reader, 2048) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + tmpl := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "test"}, |
| + NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour)} |
| + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + cert, err := x509.ParseCertificate(der) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return &Signer{key: key, cert: cert} |
| +} |
| + |
| +// Record signs an identity document for instance in account and region and |
| +// returns the box's record and its exact bytes. |
| +func (s *Signer) Record(t testing.TB, box, account, region, instance string) (boxid.Record, []byte) { |
| + t.Helper() |
| + doc, err := json.Marshal(map[string]string{"accountId": account, "region": region, "instanceId": instance}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + sum := sha256.Sum256(doc) |
| + sig, err := rsa.SignPKCS1v15(rand.Reader, s.key, crypto.SHA256, sum[:]) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + rec := boxid.Record{V: 1, Box: box, IdentityDocument: base64.StdEncoding.EncodeToString(doc), |
| + IdentitySignature: base64.StdEncoding.EncodeToString(sig), WrittenAt: "2026-10-06T00:00:00Z"} |
| + raw, err := json.Marshal(rec) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return rec, raw |
| +} |
| + |
| +// Certs certifies every region in Regions with this signer's certificate, |
| +// the shape boxid.Verifier.Certs takes. |
| +func (s *Signer) Certs() map[string]*x509.Certificate { |
| + out := make(map[string]*x509.Certificate, len(Regions)) |
| + for _, r := range Regions { |
| + out[r] = s.cert |
| + } |
| + return out |
| +} |
diff --git a/internal/console/apiinstancemove.go b/internal/console/apiinstancemove.go| index 0204869e8..5214edbb0 100644 |
| --- a/internal/console/apiinstancemove.go |
| +++ b/internal/console/apiinstancemove.go |
| @@ -1,6 +1,7 @@ |
| package console |
| import ( |
| + "crypto/rand" |
| "errors" |
| "log" |
| "net/http" |
| @@ -9,6 +10,10 @@ import ( |
| "strings" |
| "time" |
| + "github.com/carlosframework/platform/internal/appconfig" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/moves" |
| + "github.com/carlosframework/platform/internal/objstore" |
| "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/registry" |
| ) |
| @@ -151,6 +156,16 @@ func (s *Server) homeRegionOut(region string) string { |
| // ErrMoveConflict → 409 with the transition's own sentence, success → the |
| // flat move view + a serial bump so the source box notices in seconds. |
| func (s *Server) moveTransition(w http.ResponseWriter, r *http.Request, acct *Account, app, host string, mut func(*provision.Record) error, okStatus int) { |
| + s.moveTransitionSaying(w, r, acct, app, host, mut, okStatus, "could not write the move \u2014 try again") // main's bytes |
| +} |
| + |
| +// moveTransitionP1 is moveTransition for the Phase 1 door, whose failure |
| +// sentence has no em dash; the legacy door keeps main's bytes. |
| +func (s *Server) moveTransitionP1(w http.ResponseWriter, r *http.Request, acct *Account, app, host string, mut func(*provision.Record) error, okStatus int) { |
| + s.moveTransitionSaying(w, r, acct, app, host, mut, okStatus, "could not write the move. try again") |
| +} |
| + |
| +func (s *Server) moveTransitionSaying(w http.ResponseWriter, r *http.Request, acct *Account, app, host string, mut func(*provision.Record) error, okStatus int, failed string) { |
| ctx := r.Context() |
| rec, err := provision.SetRecordMove(ctx, s.cfg.Releases, acct.Sqid, app, host, mut) |
| if errors.Is(err, provision.ErrMoveConflict) { |
| @@ -159,7 +174,7 @@ func (s *Server) moveTransition(w http.ResponseWriter, r *http.Request, acct *Ac |
| } |
| if err != nil { |
| log.Printf("console: api instance move %s/%s/%s: %v", acct.Sqid, app, host, err) |
| - jsonError(w, http.StatusBadGateway, "could not write the move — try again") |
| + jsonError(w, http.StatusBadGateway, failed) |
| return |
| } |
| s.bumpSerial(ctx) |
| @@ -176,7 +191,7 @@ func (s *Server) handleAPIInstanceMoveStatus(w http.ResponseWriter, r *http.Requ |
| writeJSON(w, http.StatusOK, s.moveViewOf(rec)) |
| } |
| -func (s *Server) handleAPIInstanceMoveRequest(w http.ResponseWriter, r *http.Request) { |
| +func (s *Server) legacyHandleAPIInstanceMoveRequest(w http.ResponseWriter, r *http.Request) { |
| acct, app, host, rec, ok := s.moveGate(w, r) |
| if !ok { |
| return |
| @@ -236,7 +251,7 @@ func (s *Server) handleAPIInstanceMoveRequest(w http.ResponseWriter, r *http.Req |
| s.moveTransition(w, r, acct, app, host, provision.MoveRequest(to, time.Now()), http.StatusAccepted) |
| } |
| -func (s *Server) handleAPIInstanceMoveCut(w http.ResponseWriter, r *http.Request) { |
| +func (s *Server) legacyHandleAPIInstanceMoveCut(w http.ResponseWriter, r *http.Request) { |
| acct, app, host, _, ok := s.moveGate(w, r) |
| if !ok { |
| return |
| @@ -244,14 +259,311 @@ func (s *Server) handleAPIInstanceMoveCut(w http.ResponseWriter, r *http.Request |
| s.moveTransition(w, r, acct, app, host, provision.MoveMarkCutting(), http.StatusAccepted) |
| } |
| -// Abort is two-phase (review round 1): this door marks MoveAborting and the |
| +// legacyHandleAPIInstanceMoveAbort: abort is two-phase (review round 1): |
| +// this door marks MoveAborting and the |
| // SOURCE box deletes the staged copy before clearing the field — a bare |
| // clear would leave staging behind to win a later landing on the target |
| // (staging-wins). 202, like its siblings: the caller polls the record. |
| -func (s *Server) handleAPIInstanceMoveAbort(w http.ResponseWriter, r *http.Request) { |
| +func (s *Server) legacyHandleAPIInstanceMoveAbort(w http.ResponseWriter, r *http.Request) { |
| acct, app, host, _, ok := s.moveGate(w, r) |
| if !ok { |
| return |
| } |
| s.moveTransition(w, r, acct, app, host, provision.MoveAbort(), http.StatusAccepted) |
| } |
| + |
| +// ---- Phase 1 (CARLOS_MOVES_PHASE1, index D13) ---- |
| +// |
| +// With the switch off each handler below hands the request to its legacy |
| +// twin above, unchanged, and the resolve door answers the family's 404: |
| +// the console behaves exactly as before Phase 1. With it on, every |
| +// mutation is operator-only, the request door CASes the host claim from |
| +// serving to moving before it journals the move (§7.1, K2), and cut and |
| +// abort carry the journal's id and incarnation (MoveGuard). |
| + |
| +// moveOperatorGate is moveGate for the mutations (§12): the caller must be |
| +// an operator presenting their own CLI token, else the family's |
| +// byte-identical 404. A member's token and the Instance-role service token |
| +// keep the status GET only (M18), so the member-minted instance token can |
| +// no longer move anything (codex 11). The routes keep |
| +// svcrole.Allow(svcrole.Instance) so a service token meets this 404 rather |
| +// than the role gate's 403. |
| +func (s *Server) moveOperatorGate(w http.ResponseWriter, r *http.Request) (acct *Account, app, host string, rec *provision.Record, addr string, ok bool) { |
| + acct, app, host, rec, gok := s.moveGate(w, r) |
| + if !gok { |
| + return nil, "", "", nil, "", false |
| + } |
| + addr, ok = s.store.CLITokenAddress(bearer(r), time.Now()) |
| + if !ok { |
| + http.NotFound(w, r) |
| + return nil, "", "", nil, "", false |
| + } |
| + isOp, err := s.store.IsOperator(addr) |
| + if err != nil { |
| + log.Printf("console: api instance move: is-operator %s: %v", addr, err) |
| + } |
| + if err != nil || !isOp { |
| + http.NotFound(w, r) |
| + return nil, "", "", nil, "", false |
| + } |
| + return acct, app, host, rec, addr, true |
| +} |
| + |
| +// moveGuardOf is the guard a door derives from the record it read. A record |
| +// with no move yields the zero guard, which every guarded transition |
| +// refuses with its own 409 ("no journaled move in flight", MV-N12). |
| +func moveGuardOf(rec *provision.Record) provision.MoveGuard { |
| + if rec.Move == nil { |
| + return provision.MoveGuard{} |
| + } |
| + return provision.MoveGuard{ID: rec.Move.ID, Incarnation: rec.Move.Incarnation} |
| +} |
| + |
| +// handleAPIInstanceMoveRequest is the request door (§7.1). Its order: the |
| +// class rails and the operator (moveOperatorGate), the record's own rails, |
| +// the body, the region, the app's regions, the instance's storage binding, |
| +// the armed placements, the fleet (selectMoveBoxes: one eligible box on |
| +// each side, the floor), then the claim (present, this incarnation, |
| +// serving, executed by the source's eligible box). Every refusal comes |
| +// before the claim CAS, so a refused request writes nothing. |
| +// |
| +// If the record write fails after the claim CAS, the door answers 502 and |
| +// logs: the claim is then moving with no journaled move, and the console |
| +// pass of Plan 5 Task 7 releases it. |
| +func (s *Server) handleAPIInstanceMoveRequest(w http.ResponseWriter, r *http.Request) { |
| + if !s.cfg.MovesEnabled { |
| + s.legacyHandleAPIInstanceMoveRequest(w, r) |
| + return |
| + } |
| + acct, app, host, rec, addr, ok := s.moveOperatorGate(w, r) |
| + if !ok { |
| + return |
| + } |
| + ctx := r.Context() |
| + if rec.Role != "" { |
| + jsonError(w, http.StatusBadRequest, "a replica does not move. delete it and create one in the new region") |
| + return |
| + } |
| + if len(rec.Replicas) > 0 { |
| + jsonError(w, http.StatusBadRequest, "a home with replicas does not move. remove its replicas first") |
| + return |
| + } |
| + var req struct { |
| + Region string `json:"region"` |
| + Cut string `json:"cut"` |
| + } |
| + if !readJSON(w, r, &req) { |
| + return |
| + } |
| + cut := req.Cut |
| + if cut == "" { |
| + cut = "auto" |
| + } |
| + if cut != "auto" && cut != "manual" { |
| + jsonError(w, http.StatusBadRequest, `cut must be "auto" or "manual"`) |
| + return |
| + } |
| + region := strings.TrimSpace(req.Region) |
| + to := s.homeRegionIn(region) |
| + if region == "" || !provision.ValidRegion(to) { |
| + jsonError(w, http.StatusBadRequest, "invalid region") |
| + return |
| + } |
| + if to == "" { |
| + jsonError(w, http.StatusBadRequest, "moves to the home region are not supported yet. the home publishes no staging lane") |
| + return |
| + } |
| + if to == rec.Region { |
| + jsonError(w, http.StatusConflict, "the instance is already in "+s.homeRegionOut(to)) |
| + return |
| + } |
| + // Both policy reads fail closed (round-4 finding 13): an unreadable |
| + // manifest or storage binding is a 503, never a pass. |
| + m, err := provision.GetManifest(ctx, s.cfg.Releases, acct.Sqid, app) |
| + if err != nil && !errors.Is(err, os.ErrNotExist) { |
| + log.Printf("console: api instance move %s/%s/%s: manifest: %v", acct.Sqid, app, host, err) |
| + jsonError(w, http.StatusServiceUnavailable, "could not read the app's instances manifest. try again") |
| + return |
| + } |
| + if m != nil && len(m.Regions) > 0 && !slices.Contains(m.Regions, to) { |
| + jsonError(w, http.StatusBadRequest, "region "+to+" is not one of this app's regions: "+strings.Join(m.Regions, ", ")) |
| + return |
| + } |
| + // Private storage is the instance's BINDING, not the app's create-time |
| + // policy (which is mutable and says nothing about an instance that |
| + // already exists): the create door stamps a store per instance under |
| + // its environment, or its slug when it has none (InstanceStoreEnv), and |
| + // turning the policy off later removes no store. A name that cannot be |
| + // an environment can hold no store, so it is not read. |
| + if env := provision.InstanceStoreEnv(rec); env == "" || appconfig.ValidEnvironmentName(env) { |
| + store, _, err := objstore.GetIn(ctx, s.cfg.Releases, acct.Sqid, app, env) |
| + if err != nil { |
| + log.Printf("console: api instance move %s/%s/%s: storage binding: %v", acct.Sqid, app, host, err) |
| + jsonError(w, http.StatusServiceUnavailable, "could not read this instance's storage binding. try again") |
| + return |
| + } |
| + if store != nil { |
| + jsonError(w, http.StatusBadRequest, host+" uses private storage. Instances with private storage cannot move yet.") |
| + return |
| + } |
| + } |
| + caps, published := placements(ctx, s.cfg.Releases) |
| + if !published { |
| + jsonError(w, http.StatusBadRequest, "this deployment publishes no armed regions. nothing to move to") |
| + return |
| + } |
| + if !caps[to].Reachable || !caps[to].Staging { |
| + jsonError(w, http.StatusBadRequest, "region "+to+" cannot take a move in this deployment. it publishes no staging bucket. Regions that can: "+stagingCodes(caps)) |
| + return |
| + } |
| + from := s.homeRegionOut(rec.Region) |
| + sel, err := s.selectMoveBoxes(ctx, host, to, rec.Region, acct.Sqid+"/"+app) |
| + if errors.Is(err, errEdgesIncomplete) { |
| + jsonError(w, http.StatusConflict, "the fleet's edges files could not all be read. try again") |
| + return |
| + } |
| + if err != nil { |
| + log.Printf("console: api instance move %s/%s/%s: protocol floor: %v", acct.Sqid, app, host, err) |
| + jsonError(w, http.StatusServiceUnavailable, "could not read the moves protocol floor. try again") |
| + return |
| + } |
| + // Phase 1 is for one-box regions (spec rev 7.20 §7.1, index D15): a |
| + // region with more than one eligible box, on either side, is refused. |
| + if sel.Many { |
| + jsonError(w, http.StatusBadRequest, sel.ManyRegion+" has more than one box. Moves between regions with several boxes are not supported yet.") |
| + return |
| + } |
| + if sel.Target == "" { |
| + jsonError(w, http.StatusBadRequest, "no box in "+to+" publishes an edges file yet") |
| + return |
| + } |
| + if sel.Source == "" { |
| + jsonError(w, http.StatusBadRequest, "no eligible box in "+from+" publishes an edges file") |
| + return |
| + } |
| + if !sel.FloorOK { |
| + jsonError(w, http.StatusConflict, "a box in "+from+" or "+to+" is below the moves protocol floor. roll the platform first") |
| + return |
| + } |
| + inc, err := hostclaim.Incarnation(acct.Sqid, app, host, rec.CreatedAt) |
| + if err != nil { |
| + jsonError(w, http.StatusBadRequest, "the record's identity is malformed") |
| + return |
| + } |
| + obs, err := hostclaim.Read(ctx, s.cfg.Releases, host) |
| + if err != nil { |
| + log.Printf("console: api instance move %s/%s/%s: claim read: %v", acct.Sqid, app, host, err) |
| + jsonError(w, http.StatusBadGateway, "could not read the host claim") |
| + return |
| + } |
| + if obs.Absent || obs.Claim.Holder == nil { |
| + jsonError(w, http.StatusConflict, "the instance has no host claim yet. it is adopted on its next reconcile pass") |
| + return |
| + } |
| + if obs.Claim.Holder.Incarnation != inc { |
| + jsonError(w, http.StatusConflict, "the host claim names another incarnation of this instance") |
| + return |
| + } |
| + if obs.Claim.Op.Kind != hostclaim.KindServing { |
| + jsonError(w, http.StatusConflict, "the instance is under a "+obs.Claim.Op.Kind+" operation. wait for it to finish") |
| + return |
| + } |
| + // §7.1 binds the source to the claim's executor (round-5 finding 7). |
| + if obs.Claim.Op.Exec != sel.Source { |
| + jsonError(w, http.StatusConflict, "the source box "+obs.Claim.Op.Exec+" is not the eligible box of "+from) |
| + return |
| + } |
| + id, err := moves.NewMoveID(rand.Reader) |
| + if err != nil { |
| + log.Printf("console: api instance move %s/%s/%s: move id: %v", acct.Sqid, app, host, err) |
| + jsonError(w, http.StatusBadGateway, "could not mint a move id") |
| + return |
| + } |
| + now := time.Now() |
| + start := provision.MoveStartTransition(provision.MoveStart{ID: id, Incarnation: inc, From: rec.Region, To: to, Cut: cut, By: addr, Now: now}) |
| + // The record's own refusals (a move already journaled, a legacy move |
| + // in flight) are tried on a copy first, so they too come before the |
| + // claim CAS. The transition only replaces Move, so a shallow copy |
| + // leaves rec as read. |
| + probe := *rec |
| + if err := start(&probe); err != nil { |
| + jsonError(w, http.StatusConflict, err.Error()) |
| + return |
| + } |
| + if _, err := hostclaim.Update(ctx, s.cfg.Releases, obs, hostclaim.BeginMove(id, addr, obs.Claim.Op.Exec, sel.Target, to, now)); err != nil { |
| + switch { |
| + case errors.Is(err, hostclaim.ErrPendingChore): |
| + jsonError(w, http.StatusConflict, "the instance has unfinished cleanup in "+to+". wait for it to finish") |
| + case errors.Is(err, hostclaim.ErrConflict), errors.Is(err, hostclaim.ErrWrongState): |
| + jsonError(w, http.StatusConflict, "the host claim changed under this request. try again") |
| + default: |
| + log.Printf("console: api instance move %s/%s/%s: claim CAS: %v", acct.Sqid, app, host, err) |
| + jsonError(w, http.StatusBadGateway, "could not write the host claim") |
| + } |
| + return |
| + } |
| + got, err := provision.SetRecordMove(ctx, s.cfg.Releases, acct.Sqid, app, host, start) |
| + if err != nil { |
| + log.Printf("console: api instance move %s/%s/%s: the claim is moving under %s but the record write failed: %v", acct.Sqid, app, host, id, err) |
| + jsonError(w, http.StatusBadGateway, "could not record the move after the host claim was taken. the console releases the claim on a later pass") |
| + return |
| + } |
| + s.bumpSerial(ctx) |
| + writeJSON(w, http.StatusAccepted, s.moveViewOf(got)) |
| +} |
| + |
| +// handleAPIInstanceMoveCut is ready → cutting/requested under the guard. A |
| +// move journaled before ids existed (Legacy) has no guard to carry; it can |
| +// only be aborted (MV-N7 says so). |
| +func (s *Server) handleAPIInstanceMoveCut(w http.ResponseWriter, r *http.Request) { |
| + if !s.cfg.MovesEnabled { |
| + s.legacyHandleAPIInstanceMoveCut(w, r) |
| + return |
| + } |
| + acct, app, host, rec, _, ok := s.moveOperatorGate(w, r) |
| + if !ok { |
| + return |
| + } |
| + if rec.Move.Legacy() { |
| + jsonError(w, http.StatusConflict, "this move was started by an older console and has no id. abort it and request it again") |
| + return |
| + } |
| + s.moveTransitionP1(w, r, acct, app, host, provision.MoveGuardedCut(moveGuardOf(rec)), http.StatusAccepted) |
| +} |
| + |
| +// handleAPIInstanceMoveAbort marks the move aborting under the guard |
| +// (legal in preparing, ready and cutting/requested, §7.11). It has no |
| +// claim-side effect: the claim stays moving until the source's interim |
| +// abort deletes staging and releases it (hostclaim.ReleaseMove, Task 14). |
| +// A Legacy() record takes the legacy MoveAbort (F7(a)), the one transition |
| +// that accepts it. |
| +func (s *Server) handleAPIInstanceMoveAbort(w http.ResponseWriter, r *http.Request) { |
| + if !s.cfg.MovesEnabled { |
| + s.legacyHandleAPIInstanceMoveAbort(w, r) |
| + return |
| + } |
| + acct, app, host, rec, _, ok := s.moveOperatorGate(w, r) |
| + if !ok { |
| + return |
| + } |
| + if rec.Move.Legacy() { |
| + s.moveTransitionP1(w, r, acct, app, host, provision.MoveAbort(), http.StatusAccepted) |
| + return |
| + } |
| + s.moveTransitionP1(w, r, acct, app, host, provision.MoveGuardedAbort(moveGuardOf(rec)), http.StatusAccepted) |
| +} |
| + |
| +// handleAPIInstanceMoveResolve is the operator's door out of a stuck move |
| +// (M-C11). This plan gives it only its gate; Plan 4 Task 10 and Plan 6 |
| +// Task 2 give it its body. With the switch off it is the 404 the route |
| +// answered before it existed. |
| +func (s *Server) handleAPIInstanceMoveResolve(w http.ResponseWriter, r *http.Request) { |
| + if !s.cfg.MovesEnabled { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + if _, _, _, _, _, ok := s.moveOperatorGate(w, r); !ok { |
| + return |
| + } |
| + jsonError(w, http.StatusConflict, "nothing to resolve yet") |
| +} |
diff --git a/internal/console/apiinstancemove_test.go b/internal/console/apiinstancemove_test.go| index c493e634d..2d58c5a42 100644 |
| --- a/internal/console/apiinstancemove_test.go |
| +++ b/internal/console/apiinstancemove_test.go |
| @@ -10,7 +10,12 @@ import ( |
| "time" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/moves" |
| + "github.com/carlosframework/platform/internal/objstore" |
| "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/svcrole" |
| ) |
| // The residence-move doors (issue #331): the console writes guarded |
| @@ -319,3 +324,550 @@ func TestAPIInstanceCreateNormalizesHomeRegion(t *testing.T) { |
| t.Fatalf("move to home = %d %s, want the plain refusal", code, out) |
| } |
| } |
| + |
| +// ---- Phase 1 (CARLOS_MOVES_PHASE1): the operator door and the claim CAS ---- |
| + |
| +// operatorInstanceFixture is instanceFixture with the token's address an |
| +// operator and the Phase 1 switch on. |
| +func operatorInstanceFixture(t *testing.T, code string) (*consoleFixture, string) { |
| + t.Helper() |
| + fx, token := instanceFixture(t, code) |
| + if err := fx.st.SetOperator("alice@keymail.dev", true); err != nil { |
| + t.Fatal(err) |
| + } |
| + fx.srv.cfg.MovesEnabled = true |
| + return fx, token |
| +} |
| + |
| +// writeServingClaim writes the host's first claim: serving under box, held |
| +// by the titogo instance of incarnation inc. Update validates what it |
| +// writes, so an invalid fixture dies here and not in the door. |
| +func writeServingClaim(t *testing.T, rel release.Store, host, inc string, created time.Time, box, region string) { |
| + t.Helper() |
| + _, err := hostclaim.Update(context.Background(), rel, hostclaim.Observed{Host: host, Absent: true}, func(c *hostclaim.Claim) error { |
| + c.Holder = &hostclaim.Holder{Family: "instance", Key: provision.RecordKey("bab", "titogo", host), Account: "bab", App: "titogo", |
| + Incarnation: inc, CreatedAt: created, Location: "instances/" + host + "/" + inc, Stamp: 1} |
| + c.Op = hostclaim.Op{Kind: hostclaim.KindServing, Exec: box} |
| + c.Touched = hostclaim.Touched{Complete: true, Regions: []string{region}, Boxes: []string{box}} |
| + return nil |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +// claimFor writes acme's serving claim for the record's own incarnation. |
| +func claimFor(t *testing.T, fx *consoleFixture, host, box, region string) (inc string) { |
| + t.Helper() |
| + rec, err := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + inc, err = hostclaim.Incarnation("bab", "titogo", host, rec.CreatedAt) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + writeServingClaim(t, fx.rel, host, inc, rec.CreatedAt, box, region) |
| + return inc |
| +} |
| + |
| +func claimBytes(t *testing.T, fx *consoleFixture, host string) string { |
| + t.Helper() |
| + b, _, err := fx.rel.Get(context.Background(), "control/hosts/"+host+".json") |
| + if err != nil { |
| + return "" |
| + } |
| + return string(b) |
| +} |
| + |
| +// The switch-off contract (index D13): with CARLOS_MOVES_PHASE1 off the |
| +// door is main's, byte for byte. A member's token still moves, the legacy |
| +// sentences keep their wording, no claim is written, and the resolve route |
| +// answers the family's 404. |
| +func TestMoveDoorSwitchOffIsLegacy(t *testing.T) { |
| + fx, token := instanceFixture(t, "code-inst-move-off") |
| + m := titoManifest() |
| + m.Regions = nil |
| + m.Pool = "" |
| + enableInstances(t, fx, "titogo", m) |
| + u := instancesURL(fx, "bab", "titogo") |
| + if code, _, out := rawReq(t, http.MethodPost, u, token, `{"host":"acme.go.tito.io","region":"ie"}`); code != http.StatusCreated { |
| + t.Fatalf("create = %d %s", code, out) |
| + } |
| + if _, err := fx.rel.Put(context.Background(), "control/edges/uk-box.json", |
| + []byte(`{"v":1,"box":"uk-box","eip":"1.2.3.4","placement":"uk","data_bucket":"data-uk","hosts":{}}`), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + moveURL := u + "/acme.go.tito.io/move" |
| + if code, _, out := rawReq(t, http.MethodPost, moveURL, token, `{"region":"se"}`); code != http.StatusBadRequest || |
| + !strings.Contains(out, "cannot take a move in this deployment \u2014 it publishes no staging bucket") { |
| + t.Fatalf("legacy refusal = %d %s, want main's sentence", code, out) |
| + } |
| + code, _, out := rawReq(t, http.MethodPost, moveURL, token, `{"region":"uk","cut":"manual"}`) |
| + if code != http.StatusAccepted || !strings.Contains(out, provision.MovePreparing) { |
| + t.Fatalf("member request with the switch off = %d %s, want 202", code, out) |
| + } |
| + rec, err := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "acme.go.tito.io") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !rec.Move.Legacy() || rec.Move.Cut != "" { |
| + t.Fatalf("switch-off move = %+v, want main's id-less journal", rec.Move) |
| + } |
| + if claimBytes(t, fx, "acme.go.tito.io") != "" { |
| + t.Fatal("the legacy door wrote a host claim") |
| + } |
| + want := canonicalNotFound(t) |
| + code, h, body := rawReq(t, http.MethodPost, moveURL+"/resolve", token, `{"retry":true}`) |
| + assertNotFound(t, "resolve with the switch off", want, code, h, body) |
| + if err := fx.st.SetOperator("alice@keymail.dev", true); err != nil { |
| + t.Fatal(err) |
| + } |
| + code, h, body = rawReq(t, http.MethodPost, moveURL+"/resolve", token, `{"retry":true}`) |
| + assertNotFound(t, "resolve as an operator with the switch off", want, code, h, body) |
| +} |
| + |
| +// The fleet's refusals (§7.1, §11, D15), each before the claim CAS. |
| +func TestMoveDoorRefusesTheFleet(t *testing.T) { |
| + floor := `{"min":1,"min_release":"v0.23.0"}` |
| + unarmed := ukBox("uk-box") |
| + unarmed.FloorUnarmed = true |
| + unverified := ukBox("uk-box") |
| + unverified.Unverified = true |
| + for _, c := range []struct { |
| + name, floor string |
| + boxes []fleetBox |
| + broken string // a key whose read fails |
| + code int |
| + want string |
| + }{ |
| + {"two boxes in the target", "", []fleetBox{ieBox("ie-box"), ukBox("uk-a"), ukBox("uk-b")}, "", |
| + http.StatusBadRequest, "uk has more than one box. Moves between regions with several boxes are not supported yet."}, |
| + {"a box that has not armed the floor", floor, []fleetBox{ieBox("ie-box"), unarmed}, "", |
| + http.StatusConflict, "a box in ie or uk is below the moves protocol floor. roll the platform first"}, |
| + {"no verified box in the target", "", []fleetBox{ieBox("ie-box"), unverified}, "", |
| + http.StatusBadRequest, "no box in uk publishes an edges file yet"}, |
| + {"no eligible box in the source", "", []fleetBox{ukBox("uk-box"), {Box: "se-box", Placement: "se", AWS: "eu-north-1", EIP: "1.2.3.5", Bucket: "data-se"}}, "", |
| + http.StatusBadRequest, "no eligible box in ie publishes an edges file"}, |
| + {"an unreadable edges file", "", []fleetBox{ieBox("ie-box"), ukBox("uk-box"), {Box: "se-box", Placement: "se", AWS: "eu-north-1", EIP: "1.2.3.5", Bucket: "data-se"}}, edgesFilePrefix + "se-box.json", |
| + http.StatusConflict, "the fleet's edges files could not all be read. try again"}, |
| + {"an unreadable floor", floor, []fleetBox{ieBox("ie-box"), ukBox("uk-box")}, moves.FloorKey, |
| + http.StatusServiceUnavailable, "could not read the moves protocol floor. try again"}, |
| + } { |
| + t.Run(c.name, func(t *testing.T) { |
| + fx, token := operatorInstanceFixture(t, "code-inst-move-fleet") |
| + m := titoManifest() |
| + m.Regions = nil |
| + m.Pool = "" |
| + enableInstances(t, fx, "titogo", m) |
| + u := instancesURL(fx, "bab", "titogo") |
| + if code, _, out := rawReq(t, http.MethodPost, u, token, `{"host":"acme.go.tito.io","region":"ie"}`); code != http.StatusCreated { |
| + t.Fatalf("create = %d %s", code, out) |
| + } |
| + if c.floor != "" { |
| + if _, err := fx.rel.Put(context.Background(), moves.FloorKey, []byte(c.floor), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + armMoveFleet(t, fx, c.boxes...) |
| + claimFor(t, fx, "acme.go.tito.io", "ie-box", "ie") |
| + before := claimBytes(t, fx, "acme.go.tito.io") |
| + if c.broken != "" { |
| + fx.srv.cfg.Releases = failingGet{Store: fx.rel, key: c.broken} |
| + } |
| + code, _, out := rawReq(t, http.MethodPost, u+"/acme.go.tito.io/move", token, `{"region":"uk"}`) |
| + fx.srv.cfg.Releases = fx.rel |
| + if code != c.code || !strings.Contains(out, c.want) { |
| + t.Fatalf("request = %d %s, want %d %q", code, out, c.code, c.want) |
| + } |
| + if claimBytes(t, fx, "acme.go.tito.io") != before { |
| + t.Fatal("a refused request wrote the claim") |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// Mutations are operator-only (§12, codex 11); a member's token and the |
| +// Instance-role token keep the status GET only (M18). |
| +func TestMoveMutationsAreOperatorOnly(t *testing.T) { |
| + fx, token := instanceFixture(t, "code-inst-move-op") |
| + fx.srv.cfg.MovesEnabled = true |
| + m := titoManifest() |
| + m.Regions = nil |
| + m.Pool = "" |
| + enableInstances(t, fx, "titogo", m) |
| + u := instancesURL(fx, "bab", "titogo") |
| + if code, _, out := rawReq(t, http.MethodPost, u, token, `{"host":"acme.go.tito.io","region":"ie"}`); code != http.StatusCreated { |
| + t.Fatalf("create = %d %s", code, out) |
| + } |
| + router := serviceToken(t, fx.st, OpsAccountID, "router", "titogo", svcrole.Instance) |
| + moveURL := u + "/acme.go.tito.io/move" |
| + want := canonicalNotFound(t) |
| + for _, who := range []struct{ name, token string }{{"a member", token}, {"an instance token", router}} { |
| + for _, c := range []struct{ method, path, body string }{ |
| + {http.MethodPost, moveURL, `{"region":"uk"}`}, |
| + {http.MethodPost, moveURL + "/cut", ""}, |
| + {http.MethodDelete, moveURL, ""}, |
| + {http.MethodPost, moveURL + "/resolve", `{"retry":true}`}, |
| + } { |
| + code, h, body := rawReq(t, c.method, c.path, who.token, c.body) |
| + assertNotFound(t, c.method+" "+c.path+" as "+who.name, want, code, h, body) |
| + } |
| + if code, _, out := rawReq(t, http.MethodGet, moveURL, who.token, ""); code != http.StatusOK || !strings.Contains(out, `"state":"none"`) { |
| + t.Fatalf("status as %s = %d %s", who.name, code, out) |
| + } |
| + } |
| + if claimBytes(t, fx, "acme.go.tito.io") != "" { |
| + t.Fatal("a refused mutation wrote a claim") |
| + } |
| + // An operator reaches the resolve door, which has nothing to do yet. |
| + if err := fx.st.SetOperator("alice@keymail.dev", true); err != nil { |
| + t.Fatal(err) |
| + } |
| + if code, _, out := rawReq(t, http.MethodPost, moveURL+"/resolve", token, `{"retry":true}`); code != http.StatusConflict || !strings.Contains(out, "nothing to resolve yet") { |
| + t.Fatalf("resolve as an operator = %d %s", code, out) |
| + } |
| +} |
| + |
| +// Review Focus 4: a refused request writes no claim. |
| +func TestMoveDoorRefusesWithoutClaimWrite(t *testing.T) { |
| + fx, token := operatorInstanceFixture(t, "code-inst-move-ref") |
| + m := titoManifest() |
| + m.Regions = []string{"ie", "uk"} |
| + m.Pool = "" |
| + enableInstances(t, fx, "titogo", m) |
| + u := instancesURL(fx, "bab", "titogo") |
| + if code, _, out := rawReq(t, http.MethodPost, u, token, `{"host":"acme.go.tito.io","region":"ie"}`); code != http.StatusCreated { |
| + t.Fatalf("create = %d %s", code, out) |
| + } |
| + if _, err := fx.rel.Put(context.Background(), moves.FloorKey, []byte(`{"min":1,"min_release":"v0.23.0"}`), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + armMoveFleet(t, fx, ieBox("ie-box"), ukBox("uk-box"), |
| + fleetBox{Box: "se-box", Placement: "se", AWS: "eu-north-1", EIP: "1.2.3.5", Bucket: "data-se"}) |
| + moveURL := u + "/acme.go.tito.io/move" |
| + // No claim yet: 409, no claim written. |
| + if code, _, out := rawReq(t, http.MethodPost, moveURL, token, `{"region":"uk"}`); code != http.StatusConflict || !strings.Contains(out, "no host claim") { |
| + t.Fatalf("claimless = %d %s", code, out) |
| + } |
| + if claimBytes(t, fx, "acme.go.tito.io") != "" { |
| + t.Fatal("a refused request wrote a claim") |
| + } |
| + inc := claimFor(t, fx, "acme.go.tito.io", "ie-box", "ie") |
| + before := claimBytes(t, fx, "acme.go.tito.io") |
| + // Current region, a region outside the app's, a bad cut: refused, and |
| + // none writes. |
| + for _, c := range []struct { |
| + body, want string |
| + code int |
| + }{ |
| + {`{"region":"ie"}`, "already in ie", http.StatusConflict}, |
| + {`{"region":"se"}`, "region se is not one of this app's regions: ie, uk", http.StatusBadRequest}, |
| + {`{"region":"uk","cut":"later"}`, `cut must be \"auto\" or \"manual\"`, http.StatusBadRequest}, |
| + } { |
| + code, _, out := rawReq(t, http.MethodPost, moveURL, token, c.body) |
| + if code != c.code || !strings.Contains(out, c.want) { |
| + t.Fatalf("%s = %d %s, want %d %q", c.body, code, out, c.code, c.want) |
| + } |
| + if claimBytes(t, fx, "acme.go.tito.io") != before { |
| + t.Fatalf("%s wrote the claim", c.body) |
| + } |
| + } |
| + // The real thing: the claim goes moving with the id the record carries. |
| + code, _, out := rawReq(t, http.MethodPost, moveURL, token, `{"region":"uk","cut":"manual"}`) |
| + if code != http.StatusAccepted { |
| + t.Fatalf("request = %d %s", code, out) |
| + } |
| + rec, err := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "acme.go.tito.io") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + obs, err := hostclaim.Read(context.Background(), fx.rel, "acme.go.tito.io") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + op := obs.Claim.Op |
| + if op.Kind != hostclaim.KindMoving || op.ID != rec.Move.ID || !moves.ValidOpID(op.ID) || op.Exec != "ie-box" || op.Intent["target"] != "uk-box" || op.By != "alice@keymail.dev" { |
| + t.Fatalf("claim op %+v", op) |
| + } |
| + if rec.Move.Cut != "manual" || rec.Move.Incarnation != inc || rec.Move.From != "ie" || rec.Move.To != "uk" || rec.Move.RequestedBy != "alice@keymail.dev" { |
| + t.Fatalf("record move %+v", rec.Move) |
| + } |
| + // A second request: the claim is not serving, 409 naming the kind. |
| + if code, _, out := rawReq(t, http.MethodPost, moveURL, token, `{"region":"uk"}`); code != http.StatusConflict || !strings.Contains(out, "under a moving operation") { |
| + t.Fatalf("second request = %d %s", code, out) |
| + } |
| +} |
| + |
| +// §7.1 binds the source to the claim's executor (round-5 finding 7), and |
| +// the claim's holder must be this incarnation. |
| +func TestMoveDoorBindsTheClaimToTheSourceAndTheIncarnation(t *testing.T) { |
| + for _, c := range []struct { |
| + name, exec, want string |
| + otherInc, free bool |
| + }{ |
| + {name: "an executor that is not the source's eligible box", exec: "old-box", want: "the source box old-box is not the eligible box of ie"}, |
| + {name: "another incarnation", exec: "ie-box", otherInc: true, want: "another incarnation"}, |
| + {name: "a free claim with no holder", free: true, want: "the instance has no host claim yet. it is adopted on its next reconcile pass"}, |
| + } { |
| + t.Run(c.name, func(t *testing.T) { |
| + fx, token := operatorInstanceFixture(t, "code-inst-move-bind") |
| + m := titoManifest() |
| + m.Regions = nil |
| + m.Pool = "" |
| + enableInstances(t, fx, "titogo", m) |
| + u := instancesURL(fx, "bab", "titogo") |
| + if code, _, out := rawReq(t, http.MethodPost, u, token, `{"host":"acme.go.tito.io","region":"ie"}`); code != http.StatusCreated { |
| + t.Fatalf("create = %d %s", code, out) |
| + } |
| + armMoveFleet(t, fx, ieBox("ie-box"), ukBox("uk-box")) |
| + rec, err := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "acme.go.tito.io") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + created := rec.CreatedAt |
| + if c.otherInc { |
| + created = created.Add(-time.Hour) |
| + } |
| + inc, err := hostclaim.Incarnation("bab", "titogo", "acme.go.tito.io", created) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if c.free { |
| + if _, err := hostclaim.Update(context.Background(), fx.rel, hostclaim.Observed{Host: "acme.go.tito.io", Absent: true}, func(*hostclaim.Claim) error { return nil }); err != nil { |
| + t.Fatal(err) |
| + } |
| + } else { |
| + writeServingClaim(t, fx.rel, "acme.go.tito.io", inc, created, c.exec, "ie") |
| + } |
| + before := claimBytes(t, fx, "acme.go.tito.io") |
| + code, _, out := rawReq(t, http.MethodPost, u+"/acme.go.tito.io/move", token, `{"region":"uk"}`) |
| + if code != http.StatusConflict || !strings.Contains(out, c.want) { |
| + t.Fatalf("request = %d %s, want 409 %q", code, out, c.want) |
| + } |
| + if claimBytes(t, fx, "acme.go.tito.io") != before { |
| + t.Fatal("a refused request wrote the claim") |
| + } |
| + if rec, _ := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "acme.go.tito.io"); rec.Move != nil { |
| + t.Fatalf("a refused request wrote the record: %+v", rec.Move) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// Private storage is the instance's BINDING (round-4 finding 13), not the |
| +// app's create-time policy, and both policy reads fail closed. |
| +func TestMoveDoorRefusesABoundStoreNotAPolicy(t *testing.T) { |
| + fx, token := operatorInstanceFixture(t, "code-inst-move-store") |
| + m := titoManifest() |
| + m.Regions = nil |
| + m.Pool = "" |
| + enableInstances(t, fx, "titogo", m) |
| + u := instancesURL(fx, "bab", "titogo") |
| + if code, _, out := rawReq(t, http.MethodPost, u, token, `{"host":"acme.go.tito.io","region":"ie"}`); code != http.StatusCreated { |
| + t.Fatalf("create = %d %s", code, out) |
| + } |
| + armMoveFleet(t, fx, ieBox("ie-box"), ukBox("uk-box")) |
| + claimFor(t, fx, "acme.go.tito.io", "ie-box", "ie") |
| + before := claimBytes(t, fx, "acme.go.tito.io") |
| + moveURL := u + "/acme.go.tito.io/move" |
| + ctx := context.Background() |
| + if _, err := objstore.PutIn(ctx, fx.rel, "bab", "titogo", "acme", &objstore.Record{}, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + rel := fx.srv.cfg.Releases |
| + for _, c := range []struct{ name, key string }{ |
| + {"the storage binding", objstore.KeyIn("bab", "titogo", "acme")}, |
| + {"the manifest", provision.ManifestKey("bab", "titogo")}, |
| + } { |
| + fx.srv.cfg.Releases = failingGet{Store: rel, key: c.key} |
| + code, _, out := rawReq(t, http.MethodPost, moveURL, token, `{"region":"uk"}`) |
| + fx.srv.cfg.Releases = rel |
| + if code != http.StatusServiceUnavailable { |
| + t.Fatalf("%s unreadable = %d %s, want 503", c.name, code, out) |
| + } |
| + if claimBytes(t, fx, "acme.go.tito.io") != before { |
| + t.Fatalf("%s unreadable: the claim was written", c.name) |
| + } |
| + } |
| + // A store under the slug with the policy off: M-C17, no claim written. |
| + code, _, out := rawReq(t, http.MethodPost, moveURL, token, `{"region":"uk"}`) |
| + if code != http.StatusBadRequest || !strings.Contains(out, "acme.go.tito.io uses private storage. Instances with private storage cannot move yet.") { |
| + t.Fatalf("bound store = %d %s, want M-C17", code, out) |
| + } |
| + if claimBytes(t, fx, "acme.go.tito.io") != before { |
| + t.Fatal("a refused request wrote the claim") |
| + } |
| + // The policy on and no store: admitted. |
| + if err := fx.rel.Delete(ctx, objstore.KeyIn("bab", "titogo", "acme")); err != nil { |
| + t.Fatal(err) |
| + } |
| + m.PrivateStorage = true |
| + enableInstances(t, fx, "titogo", m) |
| + if code, _, out := rawReq(t, http.MethodPost, moveURL, token, `{"region":"uk"}`); code != http.StatusAccepted { |
| + t.Fatalf("policy on, no store = %d %s, want 202", code, out) |
| + } |
| +} |
| + |
| +// The legacy matrix with the Phase 1 rules: an operator's token, a serving |
| +// claim, a production-shaped fleet, guarded cut and abort. |
| +func TestAPIInstanceMoveVerbsPhase1(t *testing.T) { |
| + fx, token := operatorInstanceFixture(t, "code-inst-move-p1") |
| + m := titoManifest() |
| + m.Regions = nil |
| + m.Pool = "" |
| + enableInstances(t, fx, "titogo", m) |
| + u := instancesURL(fx, "bab", "titogo") |
| + ctx := context.Background() |
| + if code, _, out := rawReq(t, http.MethodPost, u, token, `{"host":"acme.go.tito.io","region":"ie"}`); code != http.StatusCreated { |
| + t.Fatalf("create = %d %s", code, out) |
| + } |
| + armMoveFleet(t, fx, ieBox("ie-box"), ukBox("uk-box")) |
| + inc := claimFor(t, fx, "acme.go.tito.io", "ie-box", "ie") |
| + moveURL := u + "/acme.go.tito.io/move" |
| + guard := func() provision.MoveGuard { |
| + t.Helper() |
| + rec, err := provision.GetRecord(ctx, fx.rel, "bab", "titogo", "acme.go.tito.io") |
| + if err != nil || rec.Move == nil { |
| + t.Fatalf("record move: %v %+v", err, rec) |
| + } |
| + return provision.MoveGuard{ID: rec.Move.ID, Incarnation: rec.Move.Incarnation} |
| + } |
| + |
| + // A target with no staging bucket: 400 naming the set that can take one. |
| + if code, _, out := rawReq(t, http.MethodPost, moveURL, token, `{"region":"se"}`); code != http.StatusBadRequest || !strings.Contains(out, "it publishes no staging bucket. Regions that can: ie, uk") { |
| + t.Fatalf("move to a region with no staging bucket = %d %s", code, out) |
| + } |
| + // Cut and abort before any move: the guarded transitions' own 409. |
| + for _, c := range []struct{ method, path string }{{http.MethodPost, moveURL + "/cut"}, {http.MethodDelete, moveURL}} { |
| + if code, _, out := rawReq(t, c.method, c.path, token, ""); code != http.StatusConflict || !strings.Contains(out, "no journaled move in flight") { |
| + t.Fatalf("%s %s with no move = %d %s", c.method, c.path, code, out) |
| + } |
| + } |
| + if code, _, out := rawReq(t, http.MethodGet, moveURL, token, ""); code != http.StatusOK || !strings.Contains(out, `"state":"none"`) { |
| + t.Fatalf("status with no move = %d %s", code, out) |
| + } |
| + // Request: the flat view. |
| + code, _, out := rawReq(t, http.MethodPost, moveURL, token, `{"region":"uk"}`) |
| + if code != http.StatusAccepted || !strings.Contains(out, `"state":"preparing"`) || !strings.Contains(out, `"target":"uk"`) { |
| + t.Fatalf("request = %d %s", code, out) |
| + } |
| + g := guard() |
| + if g.Incarnation != inc { |
| + t.Fatalf("guard %+v, want incarnation %s", g, inc) |
| + } |
| + // Second request while in flight, cut from preparing: 409. |
| + if code, _, out := rawReq(t, http.MethodPost, moveURL, token, `{"region":"uk"}`); code != http.StatusConflict { |
| + t.Fatalf("second request = %d %s", code, out) |
| + } |
| + if code, _, out := rawReq(t, http.MethodPost, moveURL+"/cut", token, ""); code != http.StatusConflict { |
| + t.Fatalf("cut from preparing = %d %s", code, out) |
| + } |
| + // Abort from preparing: 202 aborting; again: 409; a request: 409. |
| + if code, _, out := rawReq(t, http.MethodDelete, moveURL, token, ""); code != http.StatusAccepted || !strings.Contains(out, provision.MoveAborting) { |
| + t.Fatalf("abort = %d %s", code, out) |
| + } |
| + if code, _, _ := rawReq(t, http.MethodDelete, moveURL, token, ""); code != http.StatusConflict { |
| + t.Fatalf("double abort = %d", code) |
| + } |
| + if code, _, _ := rawReq(t, http.MethodPost, moveURL, token, `{"region":"uk"}`); code != http.StatusConflict { |
| + t.Fatalf("request while aborting = %d", code) |
| + } |
| + // The source's half (Task 14): the record clears, the claim is released. |
| + releaseMove := func(g provision.MoveGuard) { |
| + t.Helper() |
| + if _, err := provision.SetRecordMove(ctx, fx.rel, "bab", "titogo", "acme.go.tito.io", provision.MoveGuardedAbortComplete(g, time.Now())); err != nil { |
| + t.Fatal(err) |
| + } |
| + obs, err := hostclaim.Read(ctx, fx.rel, "acme.go.tito.io") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := hostclaim.Update(ctx, fx.rel, obs, hostclaim.ReleaseMove(g.ID, "ie-box")); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + releaseMove(g) |
| + // A fresh request proceeds with a new id; ready; cut; abort from |
| + // cutting/requested is legal under the guard. |
| + if code, _, out := rawReq(t, http.MethodPost, moveURL, token, `{"region":"uk"}`); code != http.StatusAccepted { |
| + t.Fatalf("re-request = %d %s", code, out) |
| + } |
| + g2 := guard() |
| + if g2.ID == g.ID { |
| + t.Fatal("a second move reused the first move's id") |
| + } |
| + if _, err := provision.SetRecordMove(ctx, fx.rel, "bab", "titogo", "acme.go.tito.io", provision.MoveGuardedPromoteReady(g2, time.Now(), provision.MoveInputs{})); err != nil { |
| + t.Fatal(err) |
| + } |
| + if code, _, out := rawReq(t, http.MethodPost, moveURL+"/cut", token, ""); code != http.StatusAccepted || !strings.Contains(out, provision.MoveCutting) { |
| + t.Fatalf("cut from ready = %d %s", code, out) |
| + } |
| + if rec, _ := provision.GetRecord(ctx, fx.rel, "bab", "titogo", "acme.go.tito.io"); rec.Move.CutPhase != provision.CutRequested { |
| + t.Fatalf("cut phase %q, want requested", rec.Move.CutPhase) |
| + } |
| + if code, _, out := rawReq(t, http.MethodDelete, moveURL, token, ""); code != http.StatusAccepted || !strings.Contains(out, provision.MoveAborting) { |
| + t.Fatalf("abort from cutting/requested = %d %s, want 202", code, out) |
| + } |
| + releaseMove(g2) |
| + // Past the admission: abort is refused, the cut completes forward. |
| + if code, _, out := rawReq(t, http.MethodPost, moveURL, token, `{"region":"uk"}`); code != http.StatusAccepted { |
| + t.Fatalf("third request = %d %s", code, out) |
| + } |
| + g3 := guard() |
| + if _, err := provision.SetRecordMove(ctx, fx.rel, "bab", "titogo", "acme.go.tito.io", func(rec *provision.Record) error { |
| + rec.Move.State, rec.Move.CutPhase = provision.MoveCutting, provision.CutAdmitted |
| + return nil |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + if code, _, out := rawReq(t, http.MethodDelete, moveURL, token, ""); code != http.StatusConflict || !strings.Contains(out, "past the point of no return") { |
| + t.Fatalf("abort after admission = %d %s, want 409", code, out) |
| + } |
| + _ = g3 |
| + if _, _, err := fx.rel.Get(ctx, "control/serial"); err != nil { |
| + t.Fatalf("control/serial not written: %v", err) |
| + } |
| + if code, _, _ := rawReq(t, http.MethodPost, u+"/ghost.go.tito.io/move", token, `{"region":"uk"}`); code != http.StatusNotFound { |
| + t.Fatalf("move on unknown host = %d, want 404", code) |
| + } |
| +} |
| + |
| +// A move journaled by an older console has no id: with the switch on, the |
| +// cut names it (MV-N7) and abort takes the legacy transition (F7(a)). |
| +func TestMoveDoorLegacyRecordUnderTheSwitch(t *testing.T) { |
| + fx, token := operatorInstanceFixture(t, "code-inst-move-leg") |
| + m := titoManifest() |
| + m.Regions = nil |
| + m.Pool = "" |
| + enableInstances(t, fx, "titogo", m) |
| + u := instancesURL(fx, "bab", "titogo") |
| + ctx := context.Background() |
| + if code, _, out := rawReq(t, http.MethodPost, u, token, `{"host":"acme.go.tito.io","region":"ie"}`); code != http.StatusCreated { |
| + t.Fatalf("create = %d %s", code, out) |
| + } |
| + if _, err := provision.SetRecordMove(ctx, fx.rel, "bab", "titogo", "acme.go.tito.io", provision.MoveRequest("uk", time.Now())); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := provision.SetRecordMove(ctx, fx.rel, "bab", "titogo", "acme.go.tito.io", provision.MovePromoteReady(time.Now())); err != nil { |
| + t.Fatal(err) |
| + } |
| + moveURL := u + "/acme.go.tito.io/move" |
| + // A request while the legacy move is in flight is refused by the |
| + // record's own transition, tried before the claim CAS: no claim write. |
| + armMoveFleet(t, fx, ieBox("ie-box"), ukBox("uk-box")) |
| + claimFor(t, fx, "acme.go.tito.io", "ie-box", "ie") |
| + before := claimBytes(t, fx, "acme.go.tito.io") |
| + if code, _, out := rawReq(t, http.MethodPost, moveURL, token, `{"region":"uk"}`); code != http.StatusConflict || !strings.Contains(out, "already ready") { |
| + t.Fatalf("request over a legacy move = %d %s, want 409", code, out) |
| + } |
| + if claimBytes(t, fx, "acme.go.tito.io") != before { |
| + t.Fatal("a request refused by the record wrote the claim") |
| + } |
| + if code, _, out := rawReq(t, http.MethodPost, moveURL+"/cut", token, ""); code != http.StatusConflict || !strings.Contains(out, "this move was started by an older console and has no id. abort it and request it again") { |
| + t.Fatalf("cut of a legacy move = %d %s, want MV-N7", code, out) |
| + } |
| + if code, _, out := rawReq(t, http.MethodDelete, moveURL, token, ""); code != http.StatusAccepted || !strings.Contains(out, provision.MoveAborting) { |
| + t.Fatalf("abort of a legacy move = %d %s, want 202", code, out) |
| + } |
| +} |
diff --git a/internal/console/authcli.go b/internal/console/authcli.go| index e1653a5fa..b2067330d 100644 |
| --- a/internal/console/authcli.go |
| +++ b/internal/console/authcli.go |
| @@ -638,6 +638,12 @@ func (s *Server) registerCLIRoutes() { |
| s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/move", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceMoveRequest) |
| s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/move/cut", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceMoveCut) |
| s.cliRoute("DELETE /api/cli/apps/{account}/{app}/instances/{host}/move", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceMoveAbort) |
| + // The Phase 1 resolve door (CARLOS_MOVES_PHASE1). With the switch on the |
| + // three mutations above and this one are operator-only |
| + // (moveOperatorGate); the Instance role stays on them only so a service |
| + // token meets the family's 404 rather than the role gate's 403. With the |
| + // switch off this answers the 404 it did before it existed. |
| + s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/move/resolve", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceMoveResolve) |
| s.cliRoute("DELETE /api/cli/apps/{account}/{app}/instances/{host}", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceDelete) |
| // The `carlos db` replica door (apidb.go; spec 2026-09-02 §6): an |
| // instance's replicated LTX files, by coordinate, for a member to |
diff --git a/internal/console/boxselect.go b/internal/console/boxselect.go| new file mode 100644 |
| index 000000000..308631a34 |
| --- /dev/null |
| +++ b/internal/console/boxselect.go |
| @@ -0,0 +1,160 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "fmt" |
| + "log" |
| + "os" |
| + |
| + "github.com/carlosframework/platform/internal/boxid" |
| + "github.com/carlosframework/platform/internal/moves" |
| +) |
| + |
| +// moveSelection is the move door's answer about the two regions of a move. |
| +type moveSelection struct { |
| + Target string // the one eligible box in `to`; "" when none |
| + Source string // the one eligible box in `from`; "" when none (the door requires it to equal the claim's Op.Exec) |
| + // Many is set when a region (to, then from) has more than one eligible |
| + // box, and ManyRegion names it as the caller does (the home's name, not |
| + // the record's ""). A flag, because the home's record code is "". |
| + Many bool |
| + ManyRegion string |
| + // FloorOK: both regions have edges files, and every file of either |
| + // region publishes moves_protocol at or above this build's |
| + // moves.ProtocolVersion (and the floor's min, when one is published) |
| + // and, when a floor is published, moves_floor_min_release equal to its |
| + // min_release and prev_release "none" or prev_allowed_for equal to it. |
| + FloorOK bool |
| +} |
| + |
| +// errEdgesIncomplete is the snapshot (or a box's identity record) that |
| +// could not all be read: a box whose evidence is missing may be below the |
| +// floor or may be the second box of a region, so nothing is concluded. |
| +var errEdgesIncomplete = errors.New("console: the fleet's edges files could not all be read") |
| + |
| +// selectMoveBoxes is the first production caller of boxchoice.go's |
| +// eligibleBoxes and chooseBox (region HA's tests exercise them; no door |
| +// calls them yet), so every input is composed here: |
| +// |
| +// - the snapshot is s.liveEdges(ctx) (readLiveEdgesSnapshot, liveness |
| +// joined): eligibleBoxes refuses a snapshot whose complete() is false, |
| +// and only the live read computes BoxesComplete; |
| +// - the roster is synthesised by verifiedRoster from the boxes' own |
| +// signed identity records (boxid.Verifier.Enroll), because the console |
| +// has no reader of control/host-actors.json. Each entry's placement is |
| +// the edges file's own, so eligibleBoxes' placement-agreement check is |
| +// tautological here: what carries the safety is the AWS-signed identity |
| +// (account, and the AWS region expected for the code) plus the one-box |
| +// refusal below (D15). Enroll's roster check (one instance under two |
| +// references) is not run; |
| +// - awsRegion per code is s.regionCands(snap)'s AWSRegion (the majority |
| +// of the code's eligible boxes' AWS regions); a code with none has no |
| +// eligible box; |
| +// - homeName is s.cfg.HomeRegion and expectedFP is s.expectedSecretsFP(), |
| +// as regionCands passes them; lost is nil (Phase 1 consumes no loss |
| +// records, contract C6); |
| +// - running is nil, and chooseBox is unchanged: its spread, load and |
| +// hash tie-break preferences are never reached, because a region with |
| +// two eligible boxes is refused before chooseBox could choose between |
| +// them (D15). Region HA owns that tie-break (L15, P5-D2). |
| +// |
| +// Both regions are selected (round-5 finding 7): §7.1 requires the source |
| +// to be the one eligible box of its region, and the door then requires the |
| +// claim's Op.Exec to be that box. |
| +// |
| +// The floor (§11, K10): see moveSelection.FloorOK. A box's prev verdict is |
| +// its own (Task 13 writes prev_allowed_for after ReleaseAtOrAbove); the |
| +// console does not re-derive it from prev_release, because the box's units |
| +// select by that verdict. Every file of either region counts, fresh or not: |
| +// a box that stopped reporting may come back. |
| +func (s *Server) selectMoveBoxes(ctx context.Context, host, to, from, app string) (moveSelection, error) { |
| + snap, err := s.liveEdges(ctx) |
| + if err != nil || !snap.complete() { |
| + return moveSelection{}, errEdgesIncomplete |
| + } |
| + floor, err := moves.ReadFloor(ctx, s.cfg.Releases) |
| + if err != nil { |
| + return moveSelection{}, err |
| + } |
| + aws := map[string]string{} |
| + for _, c := range s.regionCands(snap) { |
| + aws[c.Code] = c.AWSRegion |
| + } |
| + roster, err := s.verifiedRoster(ctx, snap, aws, to, from) |
| + if err != nil { |
| + return moveSelection{}, err |
| + } |
| + sel := moveSelection{FloorOK: true} |
| + seen := map[string]bool{} |
| + for _, f := range snap.Files { |
| + code := s.homeRegionIn(f.Placement) |
| + if code != to && code != from { |
| + continue |
| + } |
| + seen[code] = true |
| + if f.MovesProtocol < moves.ProtocolVersion || (floor != nil && f.MovesProtocol < floor.Min) { |
| + sel.FloorOK = false |
| + } |
| + if floor != nil { |
| + prevOK := f.PrevRelease == "none" || f.PrevAllowedFor == floor.MinRelease |
| + if f.MovesFloorMinRelease != floor.MinRelease || !prevOK { |
| + sel.FloorOK = false |
| + } |
| + } |
| + } |
| + if !seen[to] || !seen[from] { |
| + sel.FloorOK = false |
| + } |
| + fp := s.expectedSecretsFP() |
| + for _, code := range []string{to, from} { |
| + cands := eligibleBoxes(snap, roster, code, aws[code], s.cfg.HomeRegion, fp, nil) |
| + if len(cands) > 1 { |
| + sel.Many, sel.ManyRegion = true, s.homeRegionOut(code) |
| + return sel, nil |
| + } |
| + if c, ok := chooseBox(host, cands, nil, app); ok { |
| + if code == to { |
| + sel.Target = c.BoxID |
| + } else { |
| + sel.Source = c.BoxID |
| + } |
| + } |
| + } |
| + return sel, nil |
| +} |
| + |
| +// verifiedRoster proves each box of the codes from its boxid record: |
| +// boxid.Load, then Verifier.Enroll against the deployment's account and the |
| +// AWS region the code's boxes publish, with no roster to compare. A box |
| +// with no record, or one that does not verify, is left out, which |
| +// eligibleBoxes reads as not eligible. A record that exists but cannot be |
| +// read is errEdgesIncomplete: that box may be a region's second box. |
| +func (s *Server) verifiedRoster(ctx context.Context, snap edgesSnapshot, aws map[string]string, codes ...string) (map[string]rosterBox, error) { |
| + want := map[string]bool{} |
| + for _, c := range codes { |
| + want[c] = true |
| + } |
| + v := boxid.Verifier{Account: s.cfg.AWSAccount, Certs: s.boxCerts} |
| + out := map[string]rosterBox{} |
| + for _, f := range snap.Files { |
| + code := s.homeRegionIn(f.Placement) |
| + if !want[code] || !boxid.ValidBox(f.BoxID) { |
| + continue |
| + } |
| + raw, err := boxid.Load(ctx, s.cfg.Releases, f.BoxID) |
| + if errors.Is(err, os.ErrNotExist) { |
| + continue |
| + } |
| + if err != nil { |
| + log.Printf("console: move door: read the identity record of %s: %v", f.BoxID, err) |
| + return nil, fmt.Errorf("%w: %v", errEdgesIncomplete, err) |
| + } |
| + ev, err := v.Enroll(raw, boxid.Expect{Box: f.BoxID, AWSRegion: aws[code]}) |
| + if err != nil { |
| + continue |
| + } |
| + out[f.BoxID] = rosterBox{Placement: f.Placement, Cloud: ev.Cloud} |
| + } |
| + return out, nil |
| +} |
diff --git a/internal/console/boxselect_test.go b/internal/console/boxselect_test.go| new file mode 100644 |
| index 000000000..85dcccc98 |
| --- /dev/null |
| +++ b/internal/console/boxselect_test.go |
| @@ -0,0 +1,230 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/boxid" |
| + "github.com/carlosframework/platform/internal/boxid/boxidtest" |
| + "github.com/carlosframework/platform/internal/moves" |
| + "github.com/carlosframework/platform/internal/status" |
| +) |
| + |
| +// moveTestAccount is the AWS account armMoveFleet's boxes are signed in and |
| +// the console is configured to trust. |
| +const moveTestAccount = "111122223333" |
| + |
| +// fleetBox is one box armMoveFleet publishes. PrevRelease defaults to |
| +// "none". Unverified skips the signed identity record, Stale writes an old |
| +// status snapshot, FloorUnarmed omits moves_floor_min_release, NoProtocol |
| +// omits moves_protocol, and SignedAWS (when set) signs the identity record |
| +// for another AWS region than the edges file names. |
| +type fleetBox struct { |
| + Box, Placement, AWS, EIP, Bucket string |
| + PrevRelease, PrevAllowedFor string |
| + SignedAWS string |
| + Unverified, Stale, FloorUnarmed bool |
| + NoProtocol bool |
| +} |
| + |
| +// armMoveFleet makes the fleet production-shaped for the move door: the |
| +// console holds a secrets key, trusts one AWS account and one signer's |
| +// certificates, and every box has an edges file with every eligibility |
| +// field, a status snapshot (fresh unless Stale) and, unless Unverified, a |
| +// signed boxid record. The published floor, if any, is read first so each |
| +// file can say it armed it. |
| +func armMoveFleet(t *testing.T, fx *consoleFixture, boxes ...fleetBox) *boxidtest.Signer { |
| + t.Helper() |
| + ctx := context.Background() |
| + fx.srv.cfg.SecretsPub = testKeyPub |
| + fx.srv.cfg.AWSAccount = moveTestAccount |
| + signer := boxidtest.NewSigner(t) |
| + fx.srv.boxCerts = signer.Certs() |
| + floor, err := moves.ReadFloor(ctx, fx.rel) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, b := range boxes { |
| + m := map[string]any{ |
| + "v": 1, "box": b.Box, "box_id": b.Box, "eip": b.EIP, "region": b.AWS, "placement": b.Placement, |
| + "data_bucket": b.Bucket, "data_bucket_region": b.AWS, |
| + "hibernation_configured": true, "serves_shared_pool": true, "accepts_inbound": true, |
| + "secrets_key_fp": testKeyFP, "capabilities": []string{"region-ha-1"}, "hosts": map[string]any{}, |
| + "moves_protocol": moves.ProtocolVersion, "prev_release": "none", |
| + } |
| + if b.NoProtocol { |
| + delete(m, "moves_protocol") |
| + } |
| + if floor != nil && !b.FloorUnarmed { |
| + m["moves_floor_min_release"] = floor.MinRelease |
| + } |
| + if b.PrevRelease != "" { |
| + m["prev_release"] = b.PrevRelease |
| + } |
| + if b.PrevAllowedFor != "" { |
| + m["prev_allowed_for"] = b.PrevAllowedFor |
| + } |
| + body, err := json.Marshal(m) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := fx.rel.Put(ctx, edgesFilePrefix+b.Box+".json", body, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + at := time.Now() |
| + if b.Stale { |
| + at = at.Add(-time.Hour) |
| + } |
| + if err := status.WriteBox(ctx, fx.rel, status.Box{Label: b.Box, GeneratedAt: at}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if b.Unverified { |
| + continue |
| + } |
| + signed := b.AWS |
| + if b.SignedAWS != "" { |
| + signed = b.SignedAWS |
| + } |
| + rec, _ := signer.Record(t, b.Box, moveTestAccount, signed, "i-"+b.Box) |
| + if err := boxid.Publish(ctx, fx.rel, rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + return signer |
| +} |
| + |
| +func ieBox(box string) fleetBox { |
| + return fleetBox{Box: box, Placement: "ie", AWS: "eu-west-1", EIP: "1.2.3.3", Bucket: "data-ie"} |
| +} |
| + |
| +func ukBox(box string) fleetBox { |
| + return fleetBox{Box: box, Placement: "uk", AWS: "eu-west-2", EIP: "1.2.3.4", Bucket: "data-uk"} |
| +} |
| + |
| +// selectMoveBoxes is the first production caller of eligibleBoxes and |
| +// chooseBox: it composes every input itself (the live snapshot, identities |
| +// proven by boxid.Verifier.Enroll, the secrets fingerprint, each code's AWS |
| +// region) and adds the one-box refusal and the protocol floor. |
| +func TestSelectMoveBoxesComposesTheCreateDoorsInputs(t *testing.T) { |
| + const host, app = "acme.go.tito.io", "bab/titogo" |
| + floorJSON := `{"min":1,"min_release":"v0.23.0"}` |
| + withPrev := func(b fleetBox, prev, allowed string) fleetBox { |
| + b.PrevRelease, b.PrevAllowedFor = prev, allowed |
| + return b |
| + } |
| + cases := []struct { |
| + name string |
| + floor bool |
| + boxes []fleetBox |
| + setup func(fx *consoleFixture) |
| + want moveSelection |
| + }{ |
| + {name: "one verified box each side", boxes: []fleetBox{ieBox("ie-box"), ukBox("uk-box")}, |
| + want: moveSelection{Target: "uk-box", Source: "ie-box", FloorOK: true}}, |
| + {name: "two verified boxes in the target", boxes: []fleetBox{ieBox("ie-box"), ukBox("uk-a"), ukBox("uk-b")}, |
| + want: moveSelection{Many: true, ManyRegion: "uk", FloorOK: true}}, |
| + {name: "two verified boxes in the source", boxes: []fleetBox{ieBox("ie-a"), ieBox("ie-b"), ukBox("uk-box")}, |
| + want: moveSelection{Target: "uk-box", Many: true, ManyRegion: "ie", FloorOK: true}}, |
| + {name: "an unverifiable second box is not eligible", |
| + boxes: []fleetBox{ieBox("ie-box"), ukBox("uk-a"), func() fleetBox { b := ukBox("uk-b"); b.Unverified = true; return b }()}, |
| + want: moveSelection{Target: "uk-a", Source: "ie-box", FloorOK: true}}, |
| + {name: "an identity signed in another AWS region is not eligible", |
| + boxes: []fleetBox{ieBox("ie-box"), ukBox("uk-a"), func() fleetBox { b := ukBox("uk-b"); b.SignedAWS = "eu-north-1"; return b }()}, |
| + want: moveSelection{Target: "uk-a", Source: "ie-box", FloorOK: true}}, |
| + {name: "a stale box is not eligible", |
| + boxes: []fleetBox{ieBox("ie-box"), ukBox("uk-a"), func() fleetBox { b := ukBox("uk-b"); b.Stale = true; return b }()}, |
| + want: moveSelection{Target: "uk-a", Source: "ie-box", FloorOK: true}}, |
| + {name: "no account configured verifies nothing", boxes: []fleetBox{ieBox("ie-box"), ukBox("uk-box")}, |
| + setup: func(fx *consoleFixture) { fx.srv.cfg.AWSAccount = "" }, |
| + want: moveSelection{FloorOK: true}}, |
| + {name: "no file in the source region", boxes: []fleetBox{ukBox("uk-box")}, |
| + want: moveSelection{Target: "uk-box"}}, |
| + {name: "a file without moves_protocol", |
| + boxes: []fleetBox{ieBox("ie-box"), func() fleetBox { b := ukBox("uk-box"); b.NoProtocol = true; return b }()}, |
| + want: moveSelection{Target: "uk-box", Source: "ie-box"}}, |
| + {name: "a published floor, prev none", floor: true, boxes: []fleetBox{ieBox("ie-box"), ukBox("uk-box")}, |
| + want: moveSelection{Target: "uk-box", Source: "ie-box", FloorOK: true}}, |
| + {name: "a published floor, prev judged at it", floor: true, |
| + boxes: []fleetBox{ieBox("ie-box"), withPrev(ukBox("uk-box"), "v0.23.1", "v0.23.0")}, |
| + want: moveSelection{Target: "uk-box", Source: "ie-box", FloorOK: true}}, |
| + {name: "a published floor, prev judged at an older floor", floor: true, |
| + boxes: []fleetBox{ieBox("ie-box"), withPrev(ukBox("uk-box"), "v0.23.1", "v0.22.0")}, |
| + want: moveSelection{Target: "uk-box", Source: "ie-box"}}, |
| + {name: "a published floor, prev never judged", floor: true, |
| + boxes: []fleetBox{withPrev(ieBox("ie-box"), "v0.22.0", ""), ukBox("uk-box")}, |
| + want: moveSelection{Target: "uk-box", Source: "ie-box"}}, |
| + {name: "a published floor the box has not armed", floor: true, |
| + boxes: []fleetBox{ieBox("ie-box"), func() fleetBox { b := ukBox("uk-box"); b.FloorUnarmed = true; return b }()}, |
| + want: moveSelection{Target: "uk-box", Source: "ie-box"}}, |
| + {name: "a floor above this protocol", boxes: []fleetBox{ieBox("ie-box"), ukBox("uk-box")}, |
| + setup: func(fx *consoleFixture) { |
| + if _, err := fx.rel.Put(context.Background(), moves.FloorKey, []byte(`{"min":2}`), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + }, |
| + want: moveSelection{Target: "uk-box", Source: "ie-box"}}, |
| + } |
| + for _, c := range cases { |
| + t.Run(c.name, func(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + if c.floor { |
| + if _, err := fx.rel.Put(context.Background(), moves.FloorKey, []byte(floorJSON), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + armMoveFleet(t, fx, c.boxes...) |
| + if c.setup != nil { |
| + c.setup(fx) |
| + } |
| + got, err := fx.srv.selectMoveBoxes(context.Background(), host, "uk", "ie", app) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got != c.want { |
| + t.Fatalf("selection = %+v, want %+v", got, c.want) |
| + } |
| + }) |
| + } |
| + |
| + t.Run("two boxes in a home source", func(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.srv.cfg.HomeRegion = "ie" |
| + armMoveFleet(t, fx, ieBox("ie-a"), ieBox("ie-b"), ukBox("uk-box")) |
| + got, err := fx.srv.selectMoveBoxes(context.Background(), host, "uk", "", app) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if want := (moveSelection{Target: "uk-box", Many: true, ManyRegion: "ie", FloorOK: true}); got != want { |
| + t.Fatalf("selection = %+v, want %+v", got, want) |
| + } |
| + }) |
| + t.Run("an incomplete snapshot answers nothing", func(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + armMoveFleet(t, fx, ieBox("ie-box"), ukBox("uk-box")) |
| + if _, err := fx.rel.Put(context.Background(), edgesFilePrefix+"broken.json", []byte("{"), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := fx.srv.selectMoveBoxes(context.Background(), host, "uk", "ie", app); !errors.Is(err, errEdgesIncomplete) { |
| + t.Fatalf("err = %v, want errEdgesIncomplete", err) |
| + } |
| + }) |
| + t.Run("an unreadable identity record answers nothing", func(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + armMoveFleet(t, fx, ieBox("ie-box"), ukBox("uk-a"), ukBox("uk-b")) |
| + fx.srv.cfg.Releases = failingGet{Store: fx.rel, key: boxid.Key("uk-b")} |
| + if _, err := fx.srv.selectMoveBoxes(context.Background(), host, "uk", "ie", app); !errors.Is(err, errEdgesIncomplete) { |
| + t.Fatalf("err = %v, want errEdgesIncomplete", err) |
| + } |
| + }) |
| + t.Run("an unreadable floor is an error, never no floor", func(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + armMoveFleet(t, fx, ieBox("ie-box"), ukBox("uk-box")) |
| + fx.srv.cfg.Releases = failingGet{Store: fx.rel, key: moves.FloorKey} |
| + if _, err := fx.srv.selectMoveBoxes(context.Background(), host, "uk", "ie", app); err == nil || errors.Is(err, errEdgesIncomplete) { |
| + t.Fatalf("err = %v, want the floor's read error", err) |
| + } |
| + }) |
| +} |
diff --git a/internal/console/instancesplacement.go b/internal/console/instancesplacement.go| index 7daa23ca3..711005ee9 100644 |
| --- a/internal/console/instancesplacement.go |
| +++ b/internal/console/instancesplacement.go |
| @@ -73,6 +73,17 @@ type edgesFilePlacementView struct { |
| Awake int `json:"awake"` |
| } `json:"headroom"` |
| Hosts map[string]json.RawMessage `json:"hosts"` |
| + // The moves protocol (moves spec §11), read only by the Phase 1 move |
| + // door's floor check (selectMoveBoxes); Task 13 adds the writer's side. |
| + // MovesProtocol is the protocol the box speaks (absent: 0). |
| + // MovesFloorMinRelease is the published floor's min_release the box |
| + // has armed locally. PrevRelease is its carlos.prev's release, "none" |
| + // when it keeps none, and PrevAllowedFor the floor's min_release that |
| + // carlos.prev was judged at or above. |
| + MovesProtocol int `json:"moves_protocol"` |
| + MovesFloorMinRelease string `json:"moves_floor_min_release"` |
| + PrevRelease string `json:"prev_release"` |
| + PrevAllowedFor string `json:"prev_allowed_for"` |
| // Fresh is not on the wire: readLiveEdgesSnapshot sets it when the |
| // box's own status snapshot is still fresh (status.BoxStale). An edges |
| // file is not liveness, see readLiveEdgesSnapshot. |
diff --git a/internal/console/server.go b/internal/console/server.go| index ced3be3bc..cfd653a75 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -9,6 +9,7 @@ import ( |
| "context" |
| "crypto/ed25519" |
| "crypto/subtle" |
| + "crypto/x509" |
| "embed" |
| "encoding/json" |
| "errors" |
| @@ -32,6 +33,7 @@ import ( |
| "github.com/aws/aws-sdk-go-v2/service/costexplorer" |
| "github.com/carlosframework/platform/internal/applog" |
| + "github.com/carlosframework/platform/internal/boxid" |
| "github.com/carlosframework/platform/internal/dnsrec" |
| "github.com/carlosframework/platform/internal/domains" |
| "github.com/carlosframework/platform/internal/economics" |
| @@ -575,6 +577,11 @@ type Config struct { |
| // before Phase 1: the legacy move door answers and nothing new is |
| // reachable. |
| MovesEnabled bool |
| + // AWSAccount (CARLOS_AWS_ACCOUNT) is the deployment's AWS account id, |
| + // the trust anchor the move door checks every box's signed identity |
| + // record against (boxid.Verifier.Account). Unset, no box verifies and |
| + // no region can take a move. Only the Phase 1 move door reads it. |
| + AWSAccount string |
| // DomainProof (CONSOLE_DOMAIN_PROOF) is the opt-in for proof of control |
| // on custom hostnames (#215). With it set, a member's attach is stored |
| // pending until a TXT record at _carlos-challenge.<host> carries the |
| @@ -782,6 +789,11 @@ type Server struct { |
| instances *instanceFetcher |
| secure bool // Secure cookies iff the origin is https |
| cli *cliLimits // rate limits on the CLI-identity endpoints (authcli.go) |
| + // boxCerts are AWS's instance-identity signing certificates by AWS |
| + // region (boxid.EmbeddedCerts), loaded once at construction. The Phase |
| + // 1 move door verifies each box's identity record with them; nil (a |
| + // load failure, logged) verifies nothing. |
| + boxCerts map[string]*x509.Certificate |
| // dbReplica opens the read side of one instance's replica for the |
| // `carlos db` door (apidb.go). NewServer points it at a small cache of |
| // lease.ReadReplicaClient values; tests point it at a file replica. |
| @@ -1180,6 +1192,11 @@ func NewServer(cfg Config, st *Store) (*Server, error) { |
| if s.lookupTXT == nil { |
| s.lookupTXT = net.DefaultResolver.LookupTXT |
| } |
| + if certs, err := boxid.EmbeddedCerts(); err != nil { |
| + log.Printf("console: AWS identity certificates did not load (%v), so no box verifies for a move", err) |
| + } else { |
| + s.boxCerts = certs |
| + } |
| if s.km != nil { |
| s.sendApp = s.km.SendApp |
| } |
diff --git a/internal/console/svcroutes_test.go b/internal/console/svcroutes_test.go| index b9f7ecf30..4544bbdb6 100644 |
| --- a/internal/console/svcroutes_test.go |
| +++ b/internal/console/svcroutes_test.go |
| @@ -377,11 +377,14 @@ var exemptFromLegacyPin = map[string]bool{ |
| "POST /api/cli/apps/{account}/{app}/instances/{host}/move": true, |
| "POST /api/cli/apps/{account}/{app}/instances/{host}/move/cut": true, |
| "DELETE /api/cli/apps/{account}/{app}/instances/{host}/move": true, |
| - "GET /api/cli/apps/{account}/{app}/sidecars": true, |
| - "POST /api/cli/apps/{account}/{app}/sidecars": true, |
| - "GET /api/cli/apps/{account}/{app}/sidecars/{name}": true, |
| - "DELETE /api/cli/apps/{account}/{app}/sidecars/{name}": true, |
| - "POST /api/cli/apps/{account}/{app}/sidecars/{name}/wake": true, |
| + // The Phase 1 resolve door: new with Phase 1, so absent from the legacy |
| + // map, and operator-only behind its handler like the three above. |
| + "POST /api/cli/apps/{account}/{app}/instances/{host}/move/resolve": true, |
| + "GET /api/cli/apps/{account}/{app}/sidecars": true, |
| + "POST /api/cli/apps/{account}/{app}/sidecars": true, |
| + "GET /api/cli/apps/{account}/{app}/sidecars/{name}": true, |
| + "DELETE /api/cli/apps/{account}/{app}/sidecars/{name}": true, |
| + "POST /api/cli/apps/{account}/{app}/sidecars/{name}/wake": true, |
| } |
| // TestInstanceRoleMatchesTodaysAppTokenReach is the compatibility pin of spec |