console: move door fix round 1 (Task 4 review)
- The resolve route is registered only with CARLOS_MOVES_PHASE1 on, so with it off every request to it answers as on main (D13). - A record write that fails after the claim CAS is settled: reread; the move landed answers success; definitely absent releases the claim (RequireMoveOp + ReleaseMove) and asks for a retry; unknown leaves the claim and promises nothing. - A floor above this console's protocol is refused (§11). - The legacy abort fallback is bound to the legacy move the door read. - Under the switch, transition refusals are their recorded sentences, never err.Error(); legacy bytes are unchanged with it off. - Both boxid test signers wrap internal/boxid/internal/signtest. - Copy rows MV-N33 reworded, MV-N36 to MV-N46 added (pending).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
10 files changed,
+486
−110
docs/superpowers/plans/2026-10-06-moves-phase1-copy.md+12 −1internal/boxid/boxid_test.go+6 −27internal/boxid/boxidtest/boxidtest.go+9 −42internal/boxid/internal/signtest/signtest.go+61 −0internal/console/apiinstancemove.go+133 −11internal/console/apiinstancemove_test.go+226 −13internal/console/authcli.go+7 −6internal/console/boxselect.go+5 −1internal/console/boxselect_test.go+17 −1internal/console/svcroutes_test.go+10 −8
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 871cb2727..99da074d0 100644 |
| --- a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md |
| +++ b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md |
| @@ -72,9 +72,20 @@ Columns: id, file, text, status. |
| | 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-N33 | internal/console/apiinstancemove.go | could not record the move. try again | 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 | |
| +| MV-N36 | internal/console/apiinstancemove.go | could not confirm whether the move was recorded. read its state before trying again | pending | |
| +| MV-N37 | internal/console/apiinstancemove.go | this console is below the moves protocol floor. roll the console first | pending | |
| +| MV-N38 | internal/console/apiinstancemove.go | the move changed since this request read it. read its state and try again | pending | |
| +| MV-N39 | internal/console/apiinstancemove.go | past the point of no return. the cut completes forward | pending | |
| +| MV-N40 | internal/provision/provision.go | cut is only legal from ready | pending | |
| +| MV-N41 | internal/provision/provision.go | already aborting | pending | |
| +| MV-N42 | internal/provision/provision.go | a move in state "<state>" cannot be aborted | pending | |
| +| MV-N43 | internal/provision/provision.go | a move to <to> is already <state> | pending | |
| +| MV-N44 | internal/provision/provision.go | a <role> does not move. delete it and create one in the new region | pending | |
| +| MV-N45 | internal/provision/provision.go | no move in flight | pending | |
| +| MV-N46 | internal/console/apiinstancemove.go | this move's state does not allow that. read its state and try again | pending | |
| ## Plan 2 |
diff --git a/internal/boxid/boxid_test.go b/internal/boxid/boxid_test.go| index cc733fd5c..7aff42c17 100644 |
| --- a/internal/boxid/boxid_test.go |
| +++ b/internal/boxid/boxid_test.go |
| @@ -3,25 +3,22 @@ package boxid |
| import ( |
| "bytes" |
| "context" |
| - "crypto" |
| - "crypto/rand" |
| "crypto/rsa" |
| "crypto/sha256" |
| "crypto/x509" |
| - "crypto/x509/pkix" |
| "encoding/base64" |
| "encoding/hex" |
| "encoding/json" |
| "errors" |
| - "math/big" |
| "os" |
| "testing" |
| - "time" |
| + "github.com/carlosframework/platform/internal/boxid/internal/signtest" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| -// signer stands in for AWS: one key and certificate per test. |
| +// signer stands in for AWS (signtest, shared with boxidtest): one key and |
| +// certificate per test. |
| type signer struct { |
| key *rsa.PrivateKey |
| cert *x509.Certificate |
| @@ -29,31 +26,13 @@ type signer struct { |
| func newSigner(t *testing.T) 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, cert} |
| + s := signtest.New(t) |
| + return signer{s.Key, s.Cert} |
| } |
| func (s signer) record(t *testing.T, box, account, region, instance string) (Record, []byte) { |
| t.Helper() |
| - doc, _ := json.Marshal(map[string]string{"accountId": account, "region": region, "instanceId": instance}) |
| - sum := sha256.Sum256(doc) |
| - sig, err := rsa.SignPKCS1v15(rand.Reader, s.key, crypto.SHA256, sum[:]) |
| - if err != nil { |
| - t.Fatal(err) |
| - } |
| + doc, sig := (&signtest.Signer{Key: s.key, Cert: s.cert}).Sign(t, account, region, instance) |
| rec := Record{V: 1, Box: box, IdentityDocument: base64.StdEncoding.EncodeToString(doc), |
| IdentitySignature: base64.StdEncoding.EncodeToString(sig), WrittenAt: "2026-10-06T00:00:00Z"} |
| raw, _ := json.Marshal(rec) |
diff --git a/internal/boxid/boxidtest/boxidtest.go b/internal/boxid/boxidtest/boxidtest.go| index 31b86a11c..0c690ab29 100644 |
| --- a/internal/boxid/boxidtest/boxidtest.go |
| +++ b/internal/boxid/boxidtest/boxidtest.go |
| @@ -1,70 +1,37 @@ |
| // 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. |
| +// outside package boxid: records signed by one test key, and the |
| +// certificates that verify them. The key, certificate and signing are |
| +// boxid/internal/signtest's, which boxid's own tests wrap too. |
| 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" |
| + "github.com/carlosframework/platform/internal/boxid/internal/signtest" |
| ) |
| // 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 |
| -} |
| +// Signer is one test's AWS. |
| +type Signer struct{ s *signtest.Signer } |
| // 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} |
| + return &Signer{s: signtest.New(t)} |
| } |
| // 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) |
| - } |
| + doc, sig := s.s.Sign(t, account, region, instance) |
| 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) |
| @@ -79,7 +46,7 @@ func (s *Signer) Record(t testing.TB, box, account, region, instance string) (bo |
| func (s *Signer) Certs() map[string]*x509.Certificate { |
| out := make(map[string]*x509.Certificate, len(Regions)) |
| for _, r := range Regions { |
| - out[r] = s.cert |
| + out[r] = s.s.Cert |
| } |
| return out |
| } |
diff --git a/internal/boxid/internal/signtest/signtest.go b/internal/boxid/internal/signtest/signtest.go| new file mode 100644 |
| index 000000000..4aef0af79 |
| --- /dev/null |
| +++ b/internal/boxid/internal/signtest/signtest.go |
| @@ -0,0 +1,61 @@ |
| +// Package signtest stands in for AWS's instance-identity signer: one key, |
| +// one self-signed certificate, and identity documents signed with them. It |
| +// imports nothing from boxid, so both boxid's own tests (package boxid) and |
| +// boxid/boxidtest wrap it without an import cycle. |
| +package signtest |
| + |
| +import ( |
| + "crypto" |
| + "crypto/rand" |
| + "crypto/rsa" |
| + "crypto/sha256" |
| + "crypto/x509" |
| + "crypto/x509/pkix" |
| + "encoding/json" |
| + "math/big" |
| + "testing" |
| + "time" |
| +) |
| + |
| +// Signer is one test's AWS. |
| +type Signer struct { |
| + Key *rsa.PrivateKey |
| + Cert *x509.Certificate |
| +} |
| + |
| +// New makes a fresh key and a certificate for it, valid for an hour either |
| +// side of now. |
| +func New(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} |
| +} |
| + |
| +// Sign returns an identity document for instance in account and region and |
| +// its PKCS #1 v1.5 SHA-256 signature, the pair AWS's IMDS serves. |
| +func (s *Signer) Sign(t testing.TB, account, region, instance string) (doc, sig []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) |
| + } |
| + return doc, sig |
| +} |
diff --git a/internal/console/apiinstancemove.go b/internal/console/apiinstancemove.go| index 5214edbb0..3fd023656 100644 |
| --- a/internal/console/apiinstancemove.go |
| +++ b/internal/console/apiinstancemove.go |
| @@ -3,6 +3,7 @@ package console |
| import ( |
| "crypto/rand" |
| "errors" |
| + "fmt" |
| "log" |
| "net/http" |
| "os" |
| @@ -16,6 +17,7 @@ import ( |
| "github.com/carlosframework/platform/internal/objstore" |
| "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/svcrole" |
| ) |
| // The residence-move doors (issue #331, instance-move spec): the console |
| @@ -156,20 +158,21 @@ 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 |
| + s.moveTransitionSaying(w, r, acct, app, host, mut, okStatus, error.Error, "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. |
| +// moveTransitionP1 is moveTransition for the Phase 1 door: a refusal is its |
| +// recorded sentence (phase1Conflict), never err.Error(), and the 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") |
| + s.moveTransitionSaying(w, r, acct, app, host, mut, okStatus, phase1Conflict, "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) { |
| +func (s *Server) moveTransitionSaying(w http.ResponseWriter, r *http.Request, acct *Account, app, host string, mut func(*provision.Record) error, okStatus int, conflict func(error) string, failed string) { |
| ctx := r.Context() |
| rec, err := provision.SetRecordMove(ctx, s.cfg.Releases, acct.Sqid, app, host, mut) |
| if errors.Is(err, provision.ErrMoveConflict) { |
| - jsonError(w, http.StatusConflict, err.Error()) |
| + jsonError(w, http.StatusConflict, conflict(err)) |
| return |
| } |
| if err != nil { |
| @@ -441,6 +444,10 @@ func (s *Server) handleAPIInstanceMoveRequest(w http.ResponseWriter, r *http.Req |
| jsonError(w, http.StatusBadRequest, "no eligible box in "+from+" publishes an edges file") |
| return |
| } |
| + if sel.ConsoleBelowFloor { |
| + jsonError(w, http.StatusConflict, "this console is below the moves protocol floor. roll the console first") |
| + 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 |
| @@ -487,10 +494,12 @@ func (s *Server) handleAPIInstanceMoveRequest(w http.ResponseWriter, r *http.Req |
| // leaves rec as read. |
| probe := *rec |
| if err := start(&probe); err != nil { |
| - jsonError(w, http.StatusConflict, err.Error()) |
| + jsonError(w, http.StatusConflict, phase1Conflict(err)) |
| return |
| } |
| - if _, err := hostclaim.Update(ctx, s.cfg.Releases, obs, hostclaim.BeginMove(id, addr, obs.Claim.Op.Exec, sel.Target, to, now)); err != nil { |
| + exec := obs.Claim.Op.Exec |
| + moving, err := hostclaim.Update(ctx, s.cfg.Releases, obs, hostclaim.BeginMove(id, addr, exec, sel.Target, to, now)) |
| + if 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") |
| @@ -505,13 +514,68 @@ func (s *Server) handleAPIInstanceMoveRequest(w http.ResponseWriter, r *http.Req |
| 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 |
| + got = s.settleFailedMoveWrite(w, r, acct, app, host, id, exec, moving) |
| + if got == nil { |
| + return |
| + } |
| } |
| s.bumpSerial(ctx) |
| writeJSON(w, http.StatusAccepted, s.moveViewOf(got)) |
| } |
| +// settleFailedMoveWrite decides a record write that failed after the claim |
| +// CAS succeeded. A failed write may still have landed, so the record is |
| +// read again first: |
| +// |
| +// - it carries this move's id: the write landed and only its answer was |
| +// lost. The move is returned and the door answers success; |
| +// - it definitely does not: the claim goes back to serving under exec |
| +// (ReleaseMove, guarded by RequireMoveOp so it can only undo this |
| +// move, before its seal) and the caller is told to try again; |
| +// - the read fails, or the release does: the outcome is unknown, so the |
| +// claim is left as it is and the answer promises nothing. The host and |
| +// move id are logged. Plan 5's recovery pass, which must land before |
| +// the switch is turned on anywhere, finds a moving claim with no |
| +// journaled move and releases it. |
| +// |
| +// It answers w itself and returns nil in every case but the first. |
| +func (s *Server) settleFailedMoveWrite(w http.ResponseWriter, r *http.Request, acct *Account, app, host, id, exec string, moving hostclaim.Observed) *provision.Record { |
| + ctx := r.Context() |
| + unknown := func(why string, err error) { |
| + log.Printf("console: api instance move %s/%s/%s: move %s: %s: %v; the claim is left moving", acct.Sqid, app, host, id, why, err) |
| + jsonError(w, http.StatusBadGateway, "could not confirm whether the move was recorded. read its state before trying again") |
| + } |
| + rec, err := provision.GetRecord(ctx, s.cfg.Releases, acct.Sqid, app, host) |
| + if err != nil { |
| + unknown("record reread", err) |
| + return nil |
| + } |
| + if rec.Move != nil && rec.Move.ID == id { |
| + return rec |
| + } |
| + release := func(c *hostclaim.Claim) error { |
| + if err := hostclaim.RequireMoveOp(id, hostclaim.KindMoving, hostclaim.PhaseMoving)(c); err != nil { |
| + return err |
| + } |
| + return hostclaim.ReleaseMove(id, exec)(c) |
| + } |
| + _, err = hostclaim.Update(ctx, s.cfg.Releases, moving, release) |
| + if errors.Is(err, hostclaim.ErrConflict) { |
| + // Something else wrote the claim since the CAS: read it and try |
| + // once more. The guard refuses unless it is still this move. |
| + var cur hostclaim.Observed |
| + if cur, err = hostclaim.Read(ctx, s.cfg.Releases, host); err == nil { |
| + _, err = hostclaim.Update(ctx, s.cfg.Releases, cur, release) |
| + } |
| + } |
| + if err != nil { |
| + unknown("claim release", err) |
| + return nil |
| + } |
| + jsonError(w, http.StatusBadGateway, "could not record the move. try again") |
| + return nil |
| +} |
| + |
| // 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). |
| @@ -547,7 +611,7 @@ func (s *Server) handleAPIInstanceMoveAbort(w http.ResponseWriter, r *http.Reque |
| return |
| } |
| if rec.Move.Legacy() { |
| - s.moveTransitionP1(w, r, acct, app, host, provision.MoveAbort(), http.StatusAccepted) |
| + s.moveTransitionP1(w, r, acct, app, host, legacyAbortOf(*rec.Move), http.StatusAccepted) |
| return |
| } |
| s.moveTransitionP1(w, r, acct, app, host, provision.MoveGuardedAbort(moveGuardOf(rec)), http.StatusAccepted) |
| @@ -567,3 +631,61 @@ func (s *Server) handleAPIInstanceMoveResolve(w http.ResponseWriter, r *http.Req |
| } |
| jsonError(w, http.StatusConflict, "nothing to resolve yet") |
| } |
| + |
| +// registerMovesPhase1Routes adds the routes that exist only with the switch |
| +// on. The Instance role stays on resolve only so a service token meets the |
| +// family's 404 rather than the role gate's 403. |
| +func (s *Server) registerMovesPhase1Routes() { |
| + s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/move/resolve", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceMoveResolve) |
| +} |
| + |
| +// errMoveChanged refuses a write whose move is no longer the one the door |
| +// read. It wraps ErrMoveConflict so moveTransition answers 409. |
| +var errMoveChanged = fmt.Errorf("%w: %s", provision.ErrMoveConflict, moveChangedSentence) |
| + |
| +const moveChangedSentence = "the move changed since this request read it. read its state and try again" |
| + |
| +// legacyAbortOf is the legacy MoveAbort bound to the legacy move the door |
| +// read (F7(a)). SetRecordMove reads the record again before it writes, and |
| +// MoveAbort alone would abort whatever preparing or ready move it found |
| +// there, so a request delayed past that move's end could abort its |
| +// replacement. A legacy journal has no id; its target and request time |
| +// identify it. |
| +func legacyAbortOf(seen provision.MoveState) func(*provision.Record) error { |
| + return func(rec *provision.Record) error { |
| + m := rec.Move |
| + if m == nil || !m.Legacy() || m.To != seen.To || !m.RequestedAt.Equal(seen.RequestedAt) { |
| + return errMoveChanged |
| + } |
| + return provision.MoveAbort()(rec) |
| + } |
| +} |
| + |
| +// phase1Conflict maps a provision transition refusal to its recorded |
| +// sentence (copy record MV-N12 to MV-N16, MV-N38 to MV-N46): never the |
| +// "provision: move state conflict:" prefix, never an em dash. A refusal |
| +// this table does not know answers a fixed sentence, so a new provision |
| +// sentence cannot reach an operator unreviewed. |
| +func phase1Conflict(err error) string { |
| + msg := strings.TrimPrefix(err.Error(), provision.ErrMoveConflict.Error()+": ") |
| + switch { |
| + case msg == "past the point of no return \u2014 the cut completes forward": |
| + // The legacy MoveAbort's sentence, reworded. |
| + return "past the point of no return. the cut completes forward" |
| + case strings.Contains(msg, "\u2014"): |
| + case msg == "no journaled move in flight", |
| + msg == "malformed move start", |
| + msg == "past the point of no return. the cut completes forward or moves back on its own", |
| + msg == "cut is only legal from ready", |
| + msg == "already aborting", |
| + msg == "no move in flight", |
| + msg == moveChangedSentence, |
| + strings.HasPrefix(msg, "the record's move is ") && strings.Contains(msg, " for incarnation "), |
| + strings.HasPrefix(msg, "a move to ") && strings.Contains(msg, " is already "), |
| + strings.HasPrefix(msg, "the instance is already in "), |
| + strings.HasPrefix(msg, "a move in state ") && strings.HasSuffix(msg, " cannot be aborted"), |
| + strings.HasPrefix(msg, "a ") && strings.HasSuffix(msg, " does not move. delete it and create one in the new region"): |
| + return msg |
| + } |
| + return "this move's state does not allow that. read its state and try again" |
| +} |
diff --git a/internal/console/apiinstancemove_test.go b/internal/console/apiinstancemove_test.go| index 2d58c5a42..9eec22335 100644 |
| --- a/internal/console/apiinstancemove_test.go |
| +++ b/internal/console/apiinstancemove_test.go |
| @@ -3,6 +3,7 @@ package console |
| import ( |
| "context" |
| "encoding/json" |
| + "errors" |
| "fmt" |
| "net/http" |
| "strings" |
| @@ -335,10 +336,28 @@ func operatorInstanceFixture(t *testing.T, code string) (*consoleFixture, string |
| if err := fx.st.SetOperator("alice@keymail.dev", true); err != nil { |
| t.Fatal(err) |
| } |
| - fx.srv.cfg.MovesEnabled = true |
| + enableMovesPhase1(fx) |
| return fx, token |
| } |
| +// enableMovesPhase1 turns the switch on for a fixture built with it off: |
| +// the config field, and the routes NewServer registers only when it is on. |
| +func enableMovesPhase1(fx *consoleFixture) { |
| + fx.srv.cfg.MovesEnabled = true |
| + fx.srv.registerMovesPhase1Routes() |
| +} |
| + |
| +// errorText is a JSON error body's sentence, "" when the body is not one. |
| +func errorText(body string) string { |
| + var e struct { |
| + Error string `json:"error"` |
| + } |
| + if json.Unmarshal([]byte(body), &e) != nil { |
| + return "" |
| + } |
| + return e.Error |
| +} |
| + |
| // 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. |
| @@ -417,14 +436,29 @@ func TestMoveDoorSwitchOffIsLegacy(t *testing.T) { |
| 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) |
| + // The transition refusals keep main's bytes, prefix and all. |
| + if code, _, out := rawReq(t, http.MethodPost, moveURL+"/cut", token, ""); code != http.StatusConflict || errorText(out) != "provision: move state conflict: cut is only legal from ready" { |
| + t.Fatalf("legacy cut from preparing = %d %s, want main's sentence", code, out) |
| + } |
| + // The resolve route does not exist with the switch off: every way in |
| + // answers exactly what an unrouted sibling path answers on main. |
| + unrouted := moveURL + "/no-such-door" |
| + same := func(what, method, bearer, body string) { |
| + t.Helper() |
| + wc, wh, wb := rawReq(t, method, unrouted, bearer, body) |
| + gc, gh, gb := rawReq(t, method, moveURL+"/resolve", bearer, body) |
| + if gc != wc || gb != wb || gh.Get("Content-Type") != wh.Get("Content-Type") || gh.Get("Allow") != wh.Get("Allow") { |
| + t.Errorf("%s: resolve = %d %q %q, an unrouted path = %d %q %q", what, gc, gh.Get("Content-Type"), gb, wc, wh.Get("Content-Type"), wb) |
| + } |
| + } |
| + same("unauthenticated POST", http.MethodPost, "", `{"retry":true}`) |
| + same("member POST", http.MethodPost, token, `{"retry":true}`) |
| + same("member GET", http.MethodGet, token, "") |
| 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) |
| + same("operator POST", http.MethodPost, token, `{"retry":true}`) |
| + same("unauthenticated GET", http.MethodGet, "", "") |
| } |
| // The fleet's refusals (§7.1, §11, D15), each before the claim CAS. |
| @@ -451,6 +485,8 @@ func TestMoveDoorRefusesTheFleet(t *testing.T) { |
| 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"}, |
| + {"a floor above this console", `{"min":2,"min_release":"v0.23.0"}`, []fleetBox{ieBox2("ie-box"), ukBox2("uk-box")}, "", |
| + http.StatusConflict, "this console is below the moves protocol floor. roll the console first"}, |
| {"an unreadable floor", floor, []fleetBox{ieBox("ie-box"), ukBox("uk-box")}, moves.FloorKey, |
| http.StatusServiceUnavailable, "could not read the moves protocol floor. try again"}, |
| } { |
| @@ -491,7 +527,7 @@ func TestMoveDoorRefusesTheFleet(t *testing.T) { |
| // 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 |
| + enableMovesPhase1(fx) |
| m := titoManifest() |
| m.Regions = nil |
| m.Pool = "" |
| @@ -740,7 +776,7 @@ func TestAPIInstanceMoveVerbsPhase1(t *testing.T) { |
| } |
| // 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") { |
| + if code, _, out := rawReq(t, c.method, c.path, token, ""); code != http.StatusConflict || errorText(out) != "no journaled move in flight" { |
| t.Fatalf("%s %s with no move = %d %s", c.method, c.path, code, out) |
| } |
| } |
| @@ -760,15 +796,15 @@ func TestAPIInstanceMoveVerbsPhase1(t *testing.T) { |
| 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 { |
| + if code, _, out := rawReq(t, http.MethodPost, moveURL+"/cut", token, ""); code != http.StatusConflict || errorText(out) != "cut is only legal from ready" { |
| 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, _, out := rawReq(t, http.MethodDelete, moveURL, token, ""); code != http.StatusConflict || errorText(out) != "already aborting" { |
| + t.Fatalf("double abort = %d %s", code, out) |
| } |
| if code, _, _ := rawReq(t, http.MethodPost, moveURL, token, `{"region":"uk"}`); code != http.StatusConflict { |
| t.Fatalf("request while aborting = %d", code) |
| @@ -821,7 +857,7 @@ func TestAPIInstanceMoveVerbsPhase1(t *testing.T) { |
| }); 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") { |
| + if code, _, out := rawReq(t, http.MethodDelete, moveURL, token, ""); code != http.StatusConflict || errorText(out) != "past the point of no return. the cut completes forward or moves back on its own" { |
| t.Fatalf("abort after admission = %d %s, want 409", code, out) |
| } |
| _ = g3 |
| @@ -858,7 +894,7 @@ func TestMoveDoorLegacyRecordUnderTheSwitch(t *testing.T) { |
| 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") { |
| + if code, _, out := rawReq(t, http.MethodPost, moveURL, token, `{"region":"uk"}`); code != http.StatusConflict || errorText(out) != "a move to uk is already ready" { |
| t.Fatalf("request over a legacy move = %d %s, want 409", code, out) |
| } |
| if claimBytes(t, fx, "acme.go.tito.io") != before { |
| @@ -870,4 +906,181 @@ func TestMoveDoorLegacyRecordUnderTheSwitch(t *testing.T) { |
| 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) |
| } |
| + // A legacy move past its cut: the legacy refusal, reworded under the |
| + // switch (no prefix, no em dash). |
| + if _, err := provision.SetRecordMove(ctx, fx.rel, "bab", "titogo", "acme.go.tito.io", provision.MoveAbortComplete()); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, mut := range []func(*provision.Record) error{provision.MoveRequest("uk", time.Now()), provision.MovePromoteReady(time.Now()), provision.MoveMarkCutting()} { |
| + if _, err := provision.SetRecordMove(ctx, fx.rel, "bab", "titogo", "acme.go.tito.io", mut); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if code, _, out := rawReq(t, http.MethodDelete, moveURL, token, ""); code != http.StatusConflict || errorText(out) != "past the point of no return. the cut completes forward" { |
| + t.Fatalf("abort of a cutting legacy move = %d %s", code, out) |
| + } |
| +} |
| + |
| +// interleaveOnGet runs fn once, just before the at'th read of key: a |
| +// deterministic stand-in for another writer landing between two reads. |
| +type interleaveOnGet struct { |
| + release.Store |
| + key string |
| + at, n int |
| + fn func() |
| +} |
| + |
| +func (f *interleaveOnGet) Get(ctx context.Context, key string) ([]byte, string, error) { |
| + if key == f.key { |
| + f.n++ |
| + if f.n == f.at { |
| + f.fn() |
| + } |
| + } |
| + return f.Store.Get(ctx, key) |
| +} |
| + |
| +// F7(a)'s fallback is bound to the legacy move the door read: if that move |
| +// finished and another began before the write, the abort is refused and |
| +// the replacement is left alone. |
| +func TestMoveDoorLegacyAbortRefusesAReplacement(t *testing.T) { |
| + fx, token := operatorInstanceFixture(t, "code-inst-move-legrep") |
| + m := titoManifest() |
| + m.Regions = nil |
| + m.Pool = "" |
| + enableInstances(t, fx, "titogo", m) |
| + u := instancesURL(fx, "bab", "titogo") |
| + ctx := context.Background() |
| + const host = "acme.go.tito.io" |
| + 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) |
| + } |
| + first := time.Now().Add(-time.Hour).UTC() |
| + second := first.Add(time.Minute) |
| + if _, err := provision.SetRecordMove(ctx, fx.rel, "bab", "titogo", host, provision.MoveRequest("uk", first)); err != nil { |
| + t.Fatal(err) |
| + } |
| + rel := fx.rel |
| + // The door's first read sees the first move; before its write reads the |
| + // record again, that move is aborted and cleared and a second begins. |
| + fx.srv.cfg.Releases = &interleaveOnGet{Store: rel, key: provision.RecordKey("bab", "titogo", host), at: 2, fn: func() { |
| + for _, mut := range []func(*provision.Record) error{provision.MoveAbort(), provision.MoveAbortComplete(), provision.MoveRequest("uk", second)} { |
| + if _, err := provision.SetRecordMove(ctx, rel, "bab", "titogo", host, mut); err != nil { |
| + t.Error(err) |
| + } |
| + } |
| + }} |
| + code, _, out := rawReq(t, http.MethodDelete, u+"/"+host+"/move", token, "") |
| + fx.srv.cfg.Releases = rel |
| + if code != http.StatusConflict || errorText(out) != "the move changed since this request read it. read its state and try again" { |
| + t.Fatalf("delayed legacy abort = %d %s, want 409", code, out) |
| + } |
| + rec, err := provision.GetRecord(ctx, rel, "bab", "titogo", host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if rec.Move == nil || rec.Move.State != provision.MovePreparing || !rec.Move.RequestedAt.Equal(second) { |
| + t.Fatalf("the replacement move = %+v, want it preparing and untouched", rec.Move) |
| + } |
| +} |
| + |
| +// recordWriteFault fails the first write of key. With land, the write |
| +// reaches the store first, so only the answer is lost. With blind, every |
| +// later read of key fails too, so the door cannot tell which happened. |
| +type recordWriteFault struct { |
| + release.Store |
| + key string |
| + land, blind bool |
| + failed bool |
| +} |
| + |
| +func (f *recordWriteFault) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| + if key == f.key && !f.failed { |
| + f.failed = true |
| + if f.land { |
| + if _, err := f.Store.Put(ctx, key, body, ifMatch); err != nil { |
| + return "", err |
| + } |
| + } |
| + return "", errors.New("write timeout") |
| + } |
| + return f.Store.Put(ctx, key, body, ifMatch) |
| +} |
| + |
| +func (f *recordWriteFault) Get(ctx context.Context, key string) ([]byte, string, error) { |
| + if key == f.key && f.failed && f.blind { |
| + return nil, "", errors.New("read timeout") |
| + } |
| + return f.Store.Get(ctx, key) |
| +} |
| + |
| +// The claim CAS succeeded and the record write did not: the door rereads |
| +// the record and either releases the claim (the move is definitely not |
| +// journaled), answers success (it is), or, when it cannot tell, leaves the |
| +// claim and promises nothing. |
| +func TestMoveDoorCompensatesAFailedRecordWrite(t *testing.T) { |
| + const host = "acme.go.tito.io" |
| + for _, c := range []struct { |
| + name string |
| + land, blind bool |
| + code int |
| + want string // the error sentence; "" for success |
| + claimKind string |
| + }{ |
| + {name: "not written: the claim is released", code: http.StatusBadGateway, want: "could not record the move. try again", claimKind: hostclaim.KindServing}, |
| + {name: "written, answer lost: success", land: true, code: http.StatusAccepted, claimKind: hostclaim.KindMoving}, |
| + {name: "unknown: the claim is left", blind: true, code: http.StatusBadGateway, |
| + want: "could not confirm whether the move was recorded. read its state before trying again", claimKind: hostclaim.KindMoving}, |
| + } { |
| + t.Run(c.name, func(t *testing.T) { |
| + fx, token := operatorInstanceFixture(t, "code-inst-move-comp") |
| + 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")) |
| + claimFor(t, fx, host, "ie-box", "ie") |
| + rel := fx.rel |
| + fault := &recordWriteFault{Store: rel, key: provision.RecordKey("bab", "titogo", host), land: c.land, blind: c.blind} |
| + fx.srv.cfg.Releases = fault |
| + code, _, out := rawReq(t, http.MethodPost, u+"/"+host+"/move", token, `{"region":"uk"}`) |
| + fx.srv.cfg.Releases = rel |
| + if !fault.failed { |
| + t.Fatal("the fault never fired") |
| + } |
| + if code != c.code || (c.want != "" && errorText(out) != c.want) { |
| + t.Fatalf("request = %d %s, want %d %q", code, out, c.code, c.want) |
| + } |
| + obs, err := hostclaim.Read(ctx, rel, host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if obs.Claim.Op.Kind != c.claimKind || obs.Claim.Op.Exec != "ie-box" { |
| + t.Fatalf("claim op = %+v, want %s under ie-box", obs.Claim.Op, c.claimKind) |
| + } |
| + rec, err := provision.GetRecord(ctx, rel, "bab", "titogo", host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + switch c.claimKind { |
| + case hostclaim.KindServing: |
| + // Released with evidence, and the host can be asked again. |
| + if rec.Move != nil || obs.Claim.Op.Journal["released_move"] == "" { |
| + t.Fatalf("record move %+v, claim op %+v", rec.Move, obs.Claim.Op) |
| + } |
| + if code, _, out := rawReq(t, http.MethodPost, u+"/"+host+"/move", token, `{"region":"uk"}`); code != http.StatusAccepted { |
| + t.Fatalf("retry after a release = %d %s", code, out) |
| + } |
| + default: |
| + if c.land && (rec.Move == nil || rec.Move.ID != obs.Claim.Op.ID) { |
| + t.Fatalf("record move %+v, claim op %+v: want one id", rec.Move, obs.Claim.Op) |
| + } |
| + } |
| + }) |
| + } |
| } |
diff --git a/internal/console/authcli.go b/internal/console/authcli.go| index b2067330d..f71bac5ea 100644 |
| --- a/internal/console/authcli.go |
| +++ b/internal/console/authcli.go |
| @@ -638,12 +638,13 @@ 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) |
| + // With CARLOS_MOVES_PHASE1 on, the three mutations above are |
| + // operator-only (moveOperatorGate) and a resolve door joins them. Off, |
| + // that route is never registered, so every request to it answers |
| + // exactly as it did before Phase 1 (index D13). |
| + if s.cfg.MovesEnabled { |
| + s.registerMovesPhase1Routes() |
| + } |
| 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| index 308631a34..46d49e2b7 100644 |
| --- a/internal/console/boxselect.go |
| +++ b/internal/console/boxselect.go |
| @@ -26,6 +26,10 @@ type moveSelection struct { |
| // 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 |
| + // ConsoleBelowFloor: a floor is published and its min is above |
| + // moves.ProtocolVersion, the protocol this console speaks (§11: the |
| + // console must meet the floor too, whatever the boxes publish). |
| + ConsoleBelowFloor bool |
| } |
| // errEdgesIncomplete is the snapshot (or a box's identity record) that |
| @@ -85,7 +89,7 @@ func (s *Server) selectMoveBoxes(ctx context.Context, host, to, from, app string |
| if err != nil { |
| return moveSelection{}, err |
| } |
| - sel := moveSelection{FloorOK: true} |
| + sel := moveSelection{FloorOK: true, ConsoleBelowFloor: floor != nil && moves.ProtocolVersion < floor.Min} |
| seen := map[string]bool{} |
| for _, f := range snap.Files { |
| code := s.homeRegionIn(f.Placement) |
diff --git a/internal/console/boxselect_test.go b/internal/console/boxselect_test.go| index 85dcccc98..b02039cf7 100644 |
| --- a/internal/console/boxselect_test.go |
| +++ b/internal/console/boxselect_test.go |
| @@ -28,6 +28,7 @@ type fleetBox struct { |
| SignedAWS string |
| Unverified, Stale, FloorUnarmed bool |
| NoProtocol bool |
| + Protocol int // 0: moves.ProtocolVersion |
| } |
| // armMoveFleet makes the fleet production-shaped for the move door: the |
| @@ -55,6 +56,9 @@ func armMoveFleet(t *testing.T, fx *consoleFixture, boxes ...fleetBox) *boxidtes |
| "secrets_key_fp": testKeyFP, "capabilities": []string{"region-ha-1"}, "hosts": map[string]any{}, |
| "moves_protocol": moves.ProtocolVersion, "prev_release": "none", |
| } |
| + if b.Protocol != 0 { |
| + m["moves_protocol"] = b.Protocol |
| + } |
| if b.NoProtocol { |
| delete(m, "moves_protocol") |
| } |
| @@ -104,6 +108,10 @@ func ukBox(box string) fleetBox { |
| return fleetBox{Box: box, Placement: "uk", AWS: "eu-west-2", EIP: "1.2.3.4", Bucket: "data-uk"} |
| } |
| +// ieBox2 and ukBox2 publish protocol 2, above anything this build speaks. |
| +func ieBox2(box string) fleetBox { b := ieBox(box); b.Protocol = 2; return b } |
| +func ukBox2(box string) fleetBox { b := ukBox(box); b.Protocol = 2; return b } |
| + |
| // 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 |
| @@ -165,7 +173,15 @@ func TestSelectMoveBoxesComposesTheCreateDoorsInputs(t *testing.T) { |
| t.Fatal(err) |
| } |
| }, |
| - want: moveSelection{Target: "uk-box", Source: "ie-box"}}, |
| + want: moveSelection{Target: "uk-box", Source: "ie-box", ConsoleBelowFloor: true}}, |
| + {name: "a floor above this console, boxes at it", |
| + boxes: []fleetBox{ieBox2("ie-box"), ukBox2("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", FloorOK: true, ConsoleBelowFloor: true}}, |
| } |
| for _, c := range cases { |
| t.Run(c.name, func(t *testing.T) { |
diff --git a/internal/console/svcroutes_test.go b/internal/console/svcroutes_test.go| index 4544bbdb6..03a47ab71 100644 |
| --- a/internal/console/svcroutes_test.go |
| +++ b/internal/console/svcroutes_test.go |
| @@ -215,6 +215,11 @@ var closedDoors = []string{ |
| // at build time rather than in production. |
| func TestEveryCLIDoorNamesItsRoles(t *testing.T) { |
| s := newConsoleFixture(t).srv |
| + // A door registered only behind a switch (CARLOS_MOVES_PHASE1's resolve) |
| + // is registered here too, so the recorded set is every door the source |
| + // can register. |
| + s.cfg.MovesEnabled = true |
| + s.registerMovesPhase1Routes() |
| var empty []string |
| for pattern, set := range s.cliRoles { |
| @@ -377,14 +382,11 @@ 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, |
| - // 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, |
| + "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 |