console: the Phase 1 move door starts a host-claim move through the claim's admitted path (task E-switch)
On a host-claim console the request door no longer refuses (HC5-N1) and never calls a plain BeginMove. It takes the ProductionAuthority from the claim runtime (Config.ClaimAuthority, built by ConfigureClaimRuntime) and: - RegisterMove(ctx, obs, appID, moveID): the exact "move" registration for the serving claim observation, under the app's canonical id; - hostops.BeginClaimMove with the same observation and id: source = exec, both enrollments from the signed roster, the admission reread inside the one claim CAS; the op carries app_id, admission_id and both enrollments, which is what C7's ConvertMovingToRelocating requires; - hostops.StartClaimMoveRecord: the record journal as one effect of that registration. A failed record start is settled as on the plain path, with the release written under the registration's lifecycle grant (settleFailedMoveWrite takes the claim context). The E2 eligibility checks stay as early 409s. A host-claim console with no runtime still answers HC5-N1, and cut and abort keep main's refusal. With the host claim off the door is unchanged (plain BeginMove); with the moves switch off it is the legacy door, byte for byte. New pending copy rows MV-N173 to MV-N177 (app not admitted yet, admission read failure, another operation holds the app, registration failure, roster disagreement). Tests: the admitted start end to end through the production data boundary, the C7 contract from the door (admitted converts, plain does not), every host-claim refusal before the claim CAS, and the failed record start releasing under the grant (mutation-checked).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4 files changed,
+461
−30
approved_moves_copy_test.go+2 −1docs/superpowers/plans/2026-10-06-moves-phase1-copy.md+6 −1internal/console/apiinstancemove.go+111 −25internal/console/apiinstancemove_claim_test.go+342 −3
diff --git a/approved_moves_copy_test.go b/approved_moves_copy_test.go| index 1a8ab2795..6e0229d6a 100644 |
| --- a/approved_moves_copy_test.go |
| +++ b/approved_moves_copy_test.go |
| @@ -68,7 +68,8 @@ var movesCopyIDs = []string{ |
| "MV-N151", "MV-N152", "MV-N153", "MV-N154", "MV-N155", "MV-N156", |
| "MV-N157", "MV-N158", "MV-N159", "MV-N160", "MV-N161", "MV-N162", |
| "MV-N163", "MV-N164", "MV-N165", "MV-N166", "MV-N167", "MV-N168", |
| - "MV-N169", "MV-N170", "MV-N171", "MV-N172", |
| + "MV-N169", "MV-N170", "MV-N171", "MV-N172", "MV-N173", "MV-N174", |
| + "MV-N175", "MV-N176", "MV-N177", |
| } |
| // copyRow is one row of the record. The File cell is the file the sentence is |
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 6b48b11e2..6b56cb561 100644 |
| --- a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md |
| +++ b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md |
| @@ -210,7 +210,7 @@ Columns: id, file, text, status. |
| ## Plan 2 |
| -(Task 10 rows; the rest appended by Plan 2 Task 11) |
| +(Task 10 rows, then task E-switch's MV-N173 to MV-N177; the rest appended by Plan 2 Task 11) |
| | id | file | text | status | |
| |---|---|---|---| |
| @@ -219,6 +219,11 @@ Columns: id, file, text, status. |
| | MV-N170 | internal/provision/provision.go | this box cannot land a move while host claims are on | pending | |
| | MV-N171 | internal/provision/provision.go | the landing row is not at the flat path | pending | |
| | MV-N172 | internal/provision/provision.go | the installed database does not match the build | pending | |
| +| MV-N173 | internal/console/apiinstancemove.go | this app has no host-claim admission yet. try again shortly | pending | |
| +| MV-N174 | internal/console/apiinstancemove.go | could not read this app's admission. try again | pending | |
| +| MV-N175 | internal/console/apiinstancemove.go | another operation holds this app. try again when it finishes | pending | |
| +| MV-N176 | internal/console/apiinstancemove.go | could not register the move. try again | pending | |
| +| MV-N177 | internal/console/apiinstancemove.go | the host roster does not allow this move. the source and target must be active boxes in <from> and <to> | pending | |
| ## Plan 3 |
diff --git a/internal/console/apiinstancemove.go b/internal/console/apiinstancemove.go| index 8f2fef478..40ba5d3c2 100644 |
| --- a/internal/console/apiinstancemove.go |
| +++ b/internal/console/apiinstancemove.go |
| @@ -1,7 +1,9 @@ |
| package console |
| import ( |
| + "context" |
| "crypto/rand" |
| + "database/sql" |
| "errors" |
| "fmt" |
| "log" |
| @@ -15,6 +17,7 @@ import ( |
| "github.com/carlosframework/platform/internal/appconfig" |
| "github.com/carlosframework/platform/internal/hostactors" |
| "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostops" |
| "github.com/carlosframework/platform/internal/moves" |
| "github.com/carlosframework/platform/internal/objstore" |
| "github.com/carlosframework/platform/internal/provision" |
| @@ -402,12 +405,13 @@ func (s *Server) handleAPIInstanceMoveRequest(w http.ResponseWriter, r *http.Req |
| if !ok { |
| return |
| } |
| - // Host-claim consoles: the start below has no app admission. Its claim |
| - // mode form is hostops.BeginClaimMove under a RegisterMove handle, then |
| - // hostops.StartClaimMoveRecord; neither is wired until the Moves |
| - // coordinator owns the effects. Refused before any write, like cut and |
| - // abort (moveTransitionSaying). |
| - if s.cfg.HostClaim { |
| + // Host-claim consoles start the move under the app's admission: the |
| + // claim runtime's authority registers it (RegisterMove), and the start |
| + // is hostops.BeginClaimMove then hostops.StartClaimMoveRecord, never a |
| + // plain BeginMove (whose claim C7 cannot convert). A console with the |
| + // flag on but no runtime cannot admit anything, so it refuses before any |
| + // write, like cut and abort (moveTransitionSaying). |
| + if s.cfg.HostClaim && s.cfg.ClaimAuthority == nil { |
| jsonError(w, http.StatusConflict, claimMoveUnsupportedMsg) |
| return |
| } |
| @@ -561,12 +565,14 @@ func (s *Server) handleAPIInstanceMoveRequest(w http.ResponseWriter, r *http.Req |
| jsonError(w, http.StatusConflict, "the source box "+obs.Claim.Op.Exec+" is not the eligible box of "+from) |
| return |
| } |
| - // Ruling E2. The claim's conversion is pinned to the box incarnation |
| - // that starts the move, so, from ONE roster observation, the source |
| - // must be an active roster entry with an enrollment and must carry no |
| - // termination fence. Anything else is not |
| - // a box to move from (fail closed). A roster or fence that cannot be |
| - // read or trusted is a 503, with the error logged and never sent. |
| + // Ruling E2, kept as the early refusal: from ONE roster observation, |
| + // the source must be an active roster entry with an enrollment and must |
| + // carry no termination fence. Anything else is not a box to move from |
| + // (fail closed). A roster or fence that cannot be read or trusted is a |
| + // 503, with the error logged and never sent. On a host-claim console |
| + // BeginClaimMove checks both boxes again from the roster and records |
| + // both enrollments on the op, which is what the claim's conversion (C7) |
| + // is pinned to. |
| dir := hostactors.Directory{Store: s.cfg.Releases, PublicKey: s.cfg.ReleasePub, Bucket: s.cfg.Bucket} |
| roster, err := dir.Read(ctx) |
| if err != nil { |
| @@ -595,7 +601,8 @@ func (s *Server) handleAPIInstanceMoveRequest(w http.ResponseWriter, r *http.Req |
| return |
| } |
| now := time.Now() |
| - start := provision.MoveStartTransition(provision.MoveStart{ID: id, Incarnation: inc, From: rec.Region, To: to, Cut: cut, By: addr, Now: now}) |
| + ms := provision.MoveStart{ID: id, Incarnation: inc, From: rec.Region, To: to, Cut: cut, By: addr, Now: now} |
| + start := provision.MoveStartTransition(ms) |
| // 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 |
| @@ -606,23 +613,97 @@ func (s *Server) handleAPIInstanceMoveRequest(w http.ResponseWriter, r *http.Req |
| return |
| } |
| exec := obs.Claim.Op.Exec |
| + if s.cfg.HostClaim { |
| + s.startClaimModeMove(w, r, acct, app, host, obs, ms, sel.Target, from) |
| + return |
| + } |
| moving, err := hostclaim.Update(ctx, s.cfg.Releases, obs, hostclaim.BeginMove(id, addr, exec, sel.Target, to, now)) |
| + if err != nil { |
| + moveClaimCASFailed(w, acct, app, host, to, err) |
| + 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) |
| + got = s.settleFailedMoveWrite(w, r, ctx, acct, app, host, id, exec, moving) |
| + if got == nil { |
| + return |
| + } |
| + } |
| + s.bumpSerial(ctx) |
| + writeJSON(w, http.StatusAccepted, s.moveViewOf(got)) |
| +} |
| + |
| +// moveClaimCASFailed answers a refused or failed start CAS, plain or |
| +// admitted, with its recorded sentence. The error itself is only logged. |
| +func moveClaimCASFailed(w http.ResponseWriter, acct *Account, app, host, to string, err error) { |
| + 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") |
| + } |
| +} |
| + |
| +// startClaimModeMove is the request door's start on a host-claim console |
| +// (claim Plan 5 Task 7): the claim runtime's authority registers the move |
| +// under the app's admission (the registration id is the move id), then |
| +// BeginClaimMove CASes the same claim observation from serving to moving |
| +// with the app id, the registration and both roster enrollments on the op, |
| +// and StartClaimMoveRecord journals the move on the record as one effect |
| +// of that registration. The door never builds an authority of its own. |
| +// |
| +// Every refusal before the claim CAS writes nothing but the registration, |
| +// which the admission sweep settles once its TTL passes. A failed record |
| +// start after the CAS is settled as on the plain path, with the release |
| +// written under the registration's lifecycle grant. |
| +func (s *Server) startClaimModeMove(w http.ResponseWriter, r *http.Request, acct *Account, app, host string, obs hostclaim.Observed, ms provision.MoveStart, target, from string) { |
| + ctx := r.Context() |
| + a := s.cfg.ClaimAuthority |
| + to := ms.To |
| + appID, err := s.store.ClaimAppID(acct.ID, app) |
| + if errors.Is(err, sql.ErrNoRows) || errors.Is(err, hostops.ErrIdentity) { |
| + jsonError(w, http.StatusConflict, "this app has no host-claim admission yet. try again shortly") |
| + return |
| + } |
| + if err != nil { |
| + log.Printf("console: api instance move %s/%s/%s: app id: %v", acct.Sqid, app, host, err) |
| + jsonError(w, http.StatusServiceUnavailable, "could not read this app's admission. try again") |
| + return |
| + } |
| + h, err := a.RegisterMove(ctx, obs, appID, ms.ID) |
| 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") |
| - case errors.Is(err, hostclaim.ErrConflict), errors.Is(err, hostclaim.ErrWrongState): |
| - jsonError(w, http.StatusConflict, "the host claim changed under this request. try again") |
| + case errors.Is(err, os.ErrNotExist): |
| + jsonError(w, http.StatusConflict, "this app has no host-claim admission yet. try again shortly") |
| + case errors.Is(err, hostops.ErrPending): |
| + jsonError(w, http.StatusConflict, "another operation holds this app. try again when it finishes") |
| 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") |
| + log.Printf("console: api instance move %s/%s/%s: register move %s: %v", acct.Sqid, app, host, ms.ID, err) |
| + jsonError(w, http.StatusBadGateway, "could not register the move. try again") |
| } |
| return |
| } |
| - got, err := provision.SetRecordMove(ctx, s.cfg.Releases, acct.Sqid, app, host, start) |
| + moving, err := hostops.BeginClaimMove(ctx, s.cfg.Releases, a.Directory, h, obs, ms.ID, ms.By, target, to, ms.Now) |
| 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) |
| - got = s.settleFailedMoveWrite(w, r, acct, app, host, id, exec, moving) |
| + switch { |
| + case errors.Is(err, hostops.ErrIdentity), errors.Is(err, hostactors.ErrRoster): |
| + log.Printf("console: api instance move %s/%s/%s: begin move %s: %v", acct.Sqid, app, host, ms.ID, err) |
| + jsonError(w, http.StatusConflict, "the host roster does not allow this move. the source and target must be active boxes in "+from+" and "+to) |
| + case errors.Is(err, hostops.ErrPending): |
| + jsonError(w, http.StatusConflict, "another operation holds this app. try again when it finishes") |
| + default: |
| + moveClaimCASFailed(w, acct, app, host, to, err) |
| + } |
| + return |
| + } |
| + got, err := hostops.StartClaimMoveRecord(ctx, s.cfg.Releases, h, moving, ms) |
| + if err != nil { |
| + log.Printf("console: api instance move %s/%s/%s: the claim is moving under %s but the record start failed: %v", acct.Sqid, app, host, ms.ID, err) |
| + got = s.settleFailedMoveWrite(w, r, h.Lifecycle(ctx), acct, app, host, ms.ID, obs.Claim.Op.Exec, moving) |
| if got == nil { |
| return |
| } |
| @@ -646,8 +727,13 @@ func (s *Server) handleAPIInstanceMoveRequest(w http.ResponseWriter, r *http.Req |
| // the switch is turned on anywhere, finds a moving claim with no |
| // journaled move and releases it. |
| // |
| +// claimCtx carries the authority the release's claim writes run under: the |
| +// request's own context on the plain path, the move registration's |
| +// lifecycle grant on a host-claim console (whose data boundary refuses an |
| +// unbound claim CAS). |
| +// |
| // 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 { |
| +func (s *Server) settleFailedMoveWrite(w http.ResponseWriter, r *http.Request, claimCtx context.Context, 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) |
| @@ -667,13 +753,13 @@ func (s *Server) settleFailedMoveWrite(w http.ResponseWriter, r *http.Request, a |
| } |
| return hostclaim.ReleaseMove(id, exec)(c) |
| } |
| - _, err = hostclaim.Update(ctx, s.cfg.Releases, moving, release) |
| + _, err = hostclaim.Update(claimCtx, 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) |
| + _, err = hostclaim.Update(claimCtx, s.cfg.Releases, cur, release) |
| } |
| } |
| if err != nil { |
diff --git a/internal/console/apiinstancemove_claim_test.go b/internal/console/apiinstancemove_claim_test.go| index 22521325e..d27eb4482 100644 |
| --- a/internal/console/apiinstancemove_claim_test.go |
| +++ b/internal/console/apiinstancemove_claim_test.go |
| @@ -2,15 +2,29 @@ package console |
| import ( |
| "context" |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "encoding/json" |
| + "errors" |
| "net/http" |
| + "reflect" |
| + "strings" |
| "testing" |
| + "time" |
| + "github.com/carlosframework/platform/internal/boxid" |
| + "github.com/carlosframework/platform/internal/hostactors" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostops" |
| "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/termfence" |
| ) |
| -// With the host claim and the Phase 1 switch both on, every move mutation |
| -// is refused before any write: the Phase 1 request door would otherwise |
| -// CAS the claim to moving with no app admission (claim Plan 5 Task 7). |
| +// With the host claim and the Phase 1 switch both on but no claim runtime |
| +// (no ProductionAuthority to admit the move), every move mutation is |
| +// refused before any write: the request door would otherwise CAS the claim |
| +// to moving with no app admission (claim Plan 5 Task 7). |
| func TestMoveDoorPhase1RefusedUnderHostClaim(t *testing.T) { |
| fx, token := operatorInstanceFixture(t, "code-inst-move-claim") |
| m := titoManifest() |
| @@ -47,3 +61,328 @@ func TestMoveDoorPhase1RefusedUnderHostClaim(t *testing.T) { |
| t.Fatalf("a refused move door journaled a move: %+v", rec.Move) |
| } |
| } |
| + |
| +// claimMoveAppID is the canonical app id armClaimMoves binds titogo to. |
| +const claimMoveAppID = "titogo-canonical" |
| + |
| +// claimMoveWorld is a moveDoorWithClaim fixture switched to a host-claim |
| +// console the way ConfigureClaimRuntime wires one: Config.Releases is the |
| +// production data boundary (the conditional record store under the |
| +// production mutation check, with the typed control writer over the raw |
| +// bucket), and the authority the door uses is Config.ClaimAuthority over |
| +// that boundary. The SQL gate and the remaining runtime fields are not |
| +// needed by the move door and are not built. |
| +type claimMoveWorld struct { |
| + fx *consoleFixture |
| + control *hostops.ControlWriter |
| + data release.Store |
| +} |
| + |
| +// armClaimMoves switches fx to a host-claim console. bind binds the app's |
| +// canonical id in SQL; admission writes the app's open admission object. |
| +// raw is the bucket under the record store (fx.rel unless a test injects |
| +// a failure); every other part of the runtime reads fx.rel. |
| +func armClaimMoves(t *testing.T, fx *consoleFixture, bind, admission bool, raw release.Store) *claimMoveWorld { |
| + t.Helper() |
| + ctx := context.Background() |
| + if err := fx.st.migrateClaimAppIdentity(); err != nil { |
| + t.Fatal(err) |
| + } |
| + if bind { |
| + if err := fx.st.BindClaimAppIdentity(OpsAccountID, "titogo", claimMoveAppID); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if admission { |
| + a := hostops.AppAdmission{V: 1, AppID: claimMoveAppID, Revision: 1, Epoch: 1, State: "open", Registrations: map[string]hostops.Registration{}, Effects: map[string]hostops.Effect{}} |
| + body, err := json.Marshal(a) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := fx.rel.Put(ctx, "apps/bab/titogo/admission.json", body, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if raw == nil { |
| + raw = fx.rel |
| + } |
| + control, err := hostops.NewControlWriter(fx.rel, fx.pub, time.Now) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + check, err := hostops.NewProductionMutationCheck(fx.rel, control) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + records, err := hostops.NewConditionalRecordStore(raw, fx.pub) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + data, err := hostops.NewMutationBoundary(true, records, check) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + fx.srv.cfg.HostClaim = true |
| + fx.srv.cfg.Releases = data |
| + fx.srv.cfg.ClaimAuthority = &hostops.ProductionAuthority{Store: data, Control: control, |
| + Directory: hostactors.Directory{Store: data, PublicKey: fx.pub, Bucket: moveTestBucket}, |
| + ConsoleID: "console-a", Enrollment: strings.Repeat("e", 32), Now: time.Now} |
| + return &claimMoveWorld{fx: fx, control: control, data: data} |
| +} |
| + |
| +// On a host-claim console the request door starts the move under the app's |
| +// admission: the op carries the move's own keys plus the app id, the |
| +// registration (the move id) and both boxes' roster enrollments, the record |
| +// journals the move as one effect of that registration, and the door built |
| +// no authority of its own. Cut and abort keep main's refusal: their record |
| +// writes have no move effect yet. |
| +func TestMoveDoorAdmitsTheMoveUnderTheHostClaim(t *testing.T) { |
| + ctx := context.Background() |
| + fx, token, moveURL := moveDoorWithClaim(t, "code-inst-move-admit") |
| + armClaimMoves(t, fx, true, true, nil) |
| + if c, _, out := rawReq(t, http.MethodPost, moveURL, token, `{"region":"uk","cut":"manual"}`); c != http.StatusAccepted { |
| + t.Fatalf("request = %d %s", c, out) |
| + } |
| + rec, err := provision.GetRecord(ctx, fx.rel, "bab", "titogo", "acme.go.tito.io") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if rec.Move == nil || rec.Move.State != provision.MovePreparing || rec.Move.To != "uk" { |
| + t.Fatalf("record move = %+v", rec.Move) |
| + } |
| + id := rec.Move.ID |
| + obs, err := hostclaim.Read(ctx, fx.rel, "acme.go.tito.io") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + op := obs.Claim.Op |
| + if op.Kind != hostclaim.KindMoving || op.ID != id || op.Exec != "ie-box" { |
| + t.Fatalf("claim op = %+v", op) |
| + } |
| + want := map[string]string{"to": "uk", "source": "ie-box", "target": "uk-box", "app_id": claimMoveAppID, "admission_id": id, |
| + "source_enrollment": moveTestEnrollment("ie-box"), "target_enrollment": moveTestEnrollment("uk-box")} |
| + if !reflect.DeepEqual(op.Intent, want) { |
| + t.Fatalf("claim intent = %v, want %v", op.Intent, want) |
| + } |
| + a, err := hostops.ReadAdmission(ctx, fx.rel, "bab", "titogo", claimMoveAppID) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if reg, ok := a.Value.Registrations[id]; !ok || reg.Kind != hostops.RegistrationMove || reg.Host != "acme.go.tito.io" || reg.AppID != claimMoveAppID { |
| + t.Fatalf("move registration = %+v (present %v)", reg, ok) |
| + } |
| + if e := a.Value.Effects[id]; e.State != "complete" || e.OperationID != id { |
| + t.Fatalf("record start effect = %+v", e) |
| + } |
| + claim := claimBytes(t, fx, "acme.go.tito.io") |
| + 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 || errorText(out) != claimMoveUnsupportedMsg { |
| + t.Fatalf("%s %s under the host claim = %d %s", c.method, c.path, code, out) |
| + } |
| + } |
| + if claimBytes(t, fx, "acme.go.tito.io") != claim { |
| + t.Fatal("a refused cut or abort wrote the claim") |
| + } |
| +} |
| + |
| +// c7MoveAuthority binds every fence to the record's region, as the real |
| +// roster authority would for the source box's own roster entry. |
| +type c7MoveAuthority struct{ region string } |
| + |
| +func (a c7MoveAuthority) ValidateFence(context.Context, termfence.Record) (hostclaim.FenceBinding, error) { |
| + return hostclaim.FenceBinding{Region: a.region, RosterRevision: 1, RosterETag: "roster-etag"}, nil |
| +} |
| + |
| +// c7MoveGuard stands in for the record's durable relocation decision: it |
| +// answers the record's region for this op and the source's enrollment. |
| +type c7MoveGuard struct{ rel release.Store } |
| + |
| +func (g c7MoveGuard) CheckMovingBeforeFlip(ctx context.Context, o hostclaim.Observed, id string, ref hostclaim.FenceRef) (string, error) { |
| + if o.Claim == nil || o.Claim.Op.ID != id || ref.Enrollment != o.Claim.Op.Intent["source_enrollment"] { |
| + return "", hostclaim.ErrFence |
| + } |
| + rec, err := provision.GetRecord(ctx, g.rel, "bab", "titogo", o.Host) |
| + if err != nil { |
| + return "", err |
| + } |
| + return rec.Region, nil |
| +} |
| + |
| +// terminatedSourceFence writes the regional worker's termination fence for |
| +// box and returns its reference. |
| +func terminatedSourceFence(t *testing.T, fx *consoleFixture, box, worker string) hostclaim.FenceRef { |
| + t.Helper() |
| + f := termfence.Record{V: 1, Box: box, Enrollment: moveTestEnrollment(box), Cloud: boxid.Cloud{Provider: "aws", Account: moveTestAccount, Region: "eu-west-1", InstanceID: "i-" + box}, |
| + State: "terminated", ObservedAt: time.Now().UTC().Format(time.RFC3339), Via: "describe", RequestID: "request", |
| + WrittenBy: termfence.WrittenBy{Box: worker, Enrollment: moveTestEnrollment(worker)}} |
| + raw, err := json.Marshal(f) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + sum := sha256.Sum256(raw) |
| + ref := hostclaim.FenceRef{Enrollment: f.Enrollment, Key: "control/box-fences/" + f.Enrollment + ".json", SHA256: hex.EncodeToString(sum[:])} |
| + if _, err := fx.rel.Put(context.Background(), ref.Key, raw, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + return ref |
| +} |
| + |
| +// The C7 contract, end to end from the door: a claim the host-claim door |
| +// started converts through ConvertMovingToRelocating when its source is |
| +// terminated before the flip. A claim the plain door started (main's |
| +// BeginMove, no admission) does not, and the refusal writes nothing. |
| +func TestMoveDoorStartIsWhatC7Converts(t *testing.T) { |
| + for _, admitted := range []bool{true, false} { |
| + t.Run(map[bool]string{true: "host-claim", false: "plain"}[admitted], func(t *testing.T) { |
| + ctx := context.Background() |
| + fx, token, moveURL := moveDoorWithClaim(t, "code-inst-move-c7") |
| + if admitted { |
| + armClaimMoves(t, fx, true, true, nil) |
| + } |
| + if c, _, out := rawReq(t, http.MethodPost, moveURL, token, `{"region":"uk","cut":"manual"}`); c != http.StatusAccepted { |
| + t.Fatalf("request = %d %s", c, out) |
| + } |
| + obs, err := hostclaim.Read(ctx, fx.rel, "acme.go.tito.io") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + id := obs.Claim.Op.ID |
| + rec, err := provision.GetRecord(ctx, fx.rel, "bab", "titogo", "acme.go.tito.io") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if rec.Region != "ie" { |
| + t.Fatalf("record region = %q", rec.Region) |
| + } |
| + ref := terminatedSourceFence(t, fx, "ie-box", "uk-box") |
| + before := claimBytes(t, fx, "acme.go.tito.io") |
| + after, err := hostclaim.ConvertMovingToRelocating(ctx, fx.rel, obs, id, ref, c7MoveAuthority{region: rec.Region}, c7MoveGuard{rel: fx.rel}) |
| + if !admitted { |
| + if !errors.Is(err, hostclaim.ErrWrongState) { |
| + t.Fatalf("a plain door start converted: %v", err) |
| + } |
| + if claimBytes(t, fx, "acme.go.tito.io") != before { |
| + t.Fatal("the refused conversion wrote the claim") |
| + } |
| + return |
| + } |
| + if err != nil { |
| + t.Fatalf("the door's admitted start did not convert: %v", err) |
| + } |
| + op := after.Claim.Op |
| + if op.Kind != hostclaim.KindRelocating || op.Phase != "pinning" || op.ID != id || op.Intent["source_box"] != "ie-box" || op.Intent["region"] != "ie" || |
| + op.Journal["admission_id"] != id || op.Journal["app_id"] != claimMoveAppID { |
| + t.Fatalf("converted op = %+v", op) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// failRecordPut refuses every write of one record key; reads pass. |
| +type failRecordPut struct { |
| + release.Store |
| + key string |
| +} |
| + |
| +func (f failRecordPut) Put(ctx context.Context, key string, body []byte, match string) (string, error) { |
| + if key == f.key { |
| + return "", errors.New("secret-detail: record put failed") |
| + } |
| + return f.Store.Put(ctx, key, body, match) |
| +} |
| + |
| +// Every host-claim refusal answers its recorded sentence before the claim |
| +// CAS: the claim and the record are as they were. |
| +func TestMoveDoorHostClaimRefusals(t *testing.T) { |
| + const roster = "the host roster does not allow this move. the source and target must be active boxes in ie and uk" |
| + for _, tc := range []struct { |
| + name string |
| + bind, admission bool |
| + setup func(t *testing.T, fx *consoleFixture, w *claimMoveWorld) |
| + status int |
| + msg string |
| + }{ |
| + {name: "app-not-bound", admission: true, status: http.StatusConflict, msg: "this app has no host-claim admission yet. try again shortly"}, |
| + {name: "no-admission", bind: true, status: http.StatusConflict, msg: "this app has no host-claim admission yet. try again shortly"}, |
| + {name: "app-busy", bind: true, admission: true, status: http.StatusConflict, msg: "another operation holds this app. try again when it finishes", |
| + setup: func(t *testing.T, fx *consoleFixture, w *claimMoveWorld) { |
| + owner := hostops.RegistrationOwner{Console: "console-a", Enrollment: strings.Repeat("e", 32), Controller: "controller"} |
| + if _, err := w.control.BeginExclusive(context.Background(), "bab", "titogo", claimMoveAppID, hostops.AppOperation{ID: "delete", Kind: hostops.OpDeleting, AppID: claimMoveAppID}, owner); err != nil { |
| + t.Fatal(err) |
| + } |
| + }}, |
| + {name: "target-quarantined", bind: true, admission: true, status: http.StatusConflict, msg: roster, |
| + setup: func(t *testing.T, fx *consoleFixture, _ *claimMoveWorld) { |
| + uk := moveRosterBox(ukBox("uk-box")) |
| + uk.State, uk.QuarantineRevision, uk.LossIDs = "quarantined", 1, []string{"loss"} |
| + putMoveRoster(t, fx, hostactors.Roster{Boxes: map[string]hostactors.Box{"ie-box": moveRosterBox(ieBox("ie-box")), "uk-box": uk}}) |
| + }}, |
| + {name: "target-not-in-roster", bind: true, admission: true, status: http.StatusConflict, msg: roster, |
| + setup: func(t *testing.T, fx *consoleFixture, _ *claimMoveWorld) { |
| + putMoveRoster(t, fx, hostactors.Roster{Boxes: map[string]hostactors.Box{"ie-box": moveRosterBox(ieBox("ie-box"))}}) |
| + }}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + fx, token, moveURL := moveDoorWithClaim(t, "code-inst-move-refuse") |
| + w := armClaimMoves(t, fx, tc.bind, tc.admission, nil) |
| + if tc.setup != nil { |
| + tc.setup(t, fx, w) |
| + } |
| + claim := claimBytes(t, fx, "acme.go.tito.io") |
| + code, _, out := rawReq(t, http.MethodPost, moveURL, token, `{"region":"uk"}`) |
| + if code != tc.status || errorText(out) != tc.msg { |
| + t.Fatalf("request = %d %s, want %d %q", code, out, tc.status, tc.msg) |
| + } |
| + if claimBytes(t, fx, "acme.go.tito.io") != claim { |
| + t.Fatal("a refused request wrote the claim") |
| + } |
| + rec, err := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "acme.go.tito.io") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if rec.Move != nil { |
| + t.Fatalf("a refused request journaled a move: %+v", rec.Move) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// A record start that fails after the admitted claim CAS is settled like |
| +// the plain door's: the record has no move, so the claim goes back to |
| +// serving under the source, and the release is written under the move |
| +// registration's lifecycle grant (the data boundary refuses an unbound |
| +// claim CAS). The error text never reaches the caller. |
| +func TestMoveDoorHostClaimFailedRecordStartReleasesTheClaim(t *testing.T) { |
| + ctx := context.Background() |
| + fx, token, moveURL := moveDoorWithClaim(t, "code-inst-move-release") |
| + armClaimMoves(t, fx, true, true, failRecordPut{Store: fx.rel, key: provision.RecordKey("bab", "titogo", "acme.go.tito.io")}) |
| + code, _, out := rawReq(t, http.MethodPost, moveURL, token, `{"region":"uk"}`) |
| + if code != http.StatusBadGateway || errorText(out) != "could not record the move. try again" || strings.Contains(out, "secret-detail") { |
| + t.Fatalf("request = %d %s", code, out) |
| + } |
| + obs, err := hostclaim.Read(ctx, fx.rel, "acme.go.tito.io") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + a, err := hostops.ReadAdmission(ctx, fx.rel, "bab", "titogo", claimMoveAppID) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(a.Value.Registrations) != 1 { |
| + t.Fatalf("registrations = %+v", a.Value.Registrations) |
| + } |
| + var id string |
| + for k := range a.Value.Registrations { |
| + id = k |
| + } |
| + if obs.Claim.Op.Kind != hostclaim.KindServing || obs.Claim.Op.Exec != "ie-box" || obs.Claim.Op.Journal["released_move"] != id { |
| + t.Fatalf("claim after the failed start = %+v", obs.Claim.Op) |
| + } |
| + rec, err := provision.GetRecord(ctx, fx.rel, "bab", "titogo", "acme.go.tito.io") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if rec.Move != nil { |
| + t.Fatalf("record move = %+v", rec.Move) |
| + } |
| +} |