console: the host-claim move door places a move out of home
claimMoveRosterPlaces compared the source's roster region with the record's literally. The roster refuses an empty region, so a home box is enrolled under the home's name (CONSOLE_HOME_REGION, as boxchoice reads it) while the record keeps "", and every move out of home was refused with the roster sentence. Both boxes' roster regions now go through homeRegionIn, the console's one translation from the home's name to the record's code; named regions compare as before, and with no home named a home source is still refused. Past the door the move registers and then fails closed, writing nothing, at hostops.BeginClaimMove's own literal comparison of the record's region with the roster's (claim side, platform-1e, #236), which the door test pins.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 files changed,
+101
−5
internal/console/apiinstancemove.go+26 −5internal/console/apiinstancemove_claim_test.go+75 −0
diff --git a/internal/console/apiinstancemove.go b/internal/console/apiinstancemove.go| index 9f44c8790..3f6a0af3b 100644 |
| --- a/internal/console/apiinstancemove.go |
| +++ b/internal/console/apiinstancemove.go |
| @@ -250,7 +250,15 @@ func stagingCodes(caps map[string]placementCapability) string { |
| // record's "" (the unconstrained value the fleet's reconcile has always |
| // scoped by). The record NEVER stores the code; the API never shows "". |
| func (s *Server) homeRegionIn(region string) string { |
| - if s.cfg.HomeRegion != "" && region == s.cfg.HomeRegion { |
| + return homePlacementCode(s.cfg.HomeRegion, region) |
| +} |
| + |
| +// homePlacementCode is homeRegionIn's rule for a given home name: the |
| +// home's name is the record's "", any other region is itself, and with no |
| +// home named nothing is the home. claimMoveRosterPlaces reads roster |
| +// regions through it too, so the door and the API translate home one way. |
| +func homePlacementCode(home, region string) string { |
| + if home != "" && region == home { |
| return "" |
| } |
| return region |
| @@ -761,7 +769,7 @@ func (s *Server) startClaimModeMove(w http.ResponseWriter, r *http.Request, acct |
| ctx := r.Context() |
| a := s.cfg.ClaimAuthority |
| to := ms.To |
| - if !claimMoveRosterPlaces(roster.Roster, obs.Claim.Op.Exec, target, recRegion, to) { |
| + if !claimMoveRosterPlaces(roster.Roster, obs.Claim.Op.Exec, target, recRegion, to, s.cfg.HomeRegion) { |
| jsonError(w, http.StatusConflict, "the host roster does not allow this move. the source and target must be active boxes in "+from+" and "+to) |
| return |
| } |
| @@ -1252,11 +1260,24 @@ func phase1Conflict(err error) string { |
| // the door's own observation: both boxes have active, enrolled entries |
| // under their box references, they differ, the source is in the record's |
| // region and the target in to, which is not the source's region. |
| -func claimMoveRosterPlaces(r hostactors.Roster, source, target, recRegion, to string) bool { |
| +// |
| +// The roster and the record name regions differently for one region, the |
| +// home: the roster refuses an empty region, so a home box is enrolled under |
| +// the home's name (CONSOLE_HOME_REGION, as boxchoice reads it), while the |
| +// record keeps "". Both boxes' roster regions are read as record codes |
| +// through homePlacementCode with home, the deployment's home name (the rule |
| +// homeRegionIn applies), so a move out of home is placed and a named region |
| +// is compared as it is. With no home named nothing translates, and a home |
| +// source is refused as before (home-region parks, part 1). |
| +func claimMoveRosterPlaces(r hostactors.Roster, source, target, recRegion, to, home string) bool { |
| src, sourceOK := r.Boxes[source] |
| dst, targetOK := r.Boxes[target] |
| - return sourceOK && targetOK && source != target && src.State == "active" && dst.State == "active" && src.Enrollment != "" && dst.Enrollment != "" && |
| - src.Region != "" && src.Region == recRegion && dst.Region == to && src.Region != to |
| + if !sourceOK || !targetOK { |
| + return false |
| + } |
| + from, into := homePlacementCode(home, src.Region), homePlacementCode(home, dst.Region) |
| + return source != target && src.State == "active" && dst.State == "active" && src.Enrollment != "" && dst.Enrollment != "" && |
| + src.Region != "" && dst.Region != "" && from == recRegion && into == to && from != to |
| } |
| // sourceEnrollment is the enrollment of the box that executes the claim, and |
diff --git a/internal/console/apiinstancemove_claim_test.go b/internal/console/apiinstancemove_claim_test.go| index 809bdf01a..c50af8914 100644 |
| --- a/internal/console/apiinstancemove_claim_test.go |
| +++ b/internal/console/apiinstancemove_claim_test.go |
| @@ -550,3 +550,78 @@ func TestMoveDoorHostClaimRetryAfterAFailedRecordStart(t *testing.T) { |
| t.Fatal("the failed attempt's registration is still held") |
| } |
| } |
| + |
| +// Home-region parks, part 1 (Paul's ruling on concern 2): the host-claim |
| +// door places a move out of home. The record names home "" and the roster |
| +// names the home box by the home's name (CONSOLE_HOME_REGION, "ie" here: |
| +// the roster refuses an empty region); the door compares them through |
| +// homeRegionIn, the one translation between the two, so the roster check |
| +// passes and the move registers. It then fails closed, writing nothing to |
| +// the claim or the record, at hostops.BeginClaimMove's own roster check, |
| +// which compares the record's "" with the roster's "ie" literally (claim |
| +// side, platform-1e, #236). |
| +func TestMoveDoorHostClaimPlacesAHomeSource(t *testing.T) { |
| + ctx := context.Background() |
| + fx, token := operatorInstanceFixture(t, "code-inst-move-home") |
| + fx.srv.cfg.HomeRegion = "ie" |
| + m := titoManifest() |
| + m.Regions = nil |
| + m.Pool = "" |
| + enableInstances(t, fx, "titogo", m) |
| + u := instancesURL(fx, "bab", "titogo") |
| + if c, _, out := rawReq(t, http.MethodPost, u, token, `{"host":"acme.go.tito.io","region":"ie"}`); c != http.StatusCreated { |
| + t.Fatalf("create = %d %s", c, out) |
| + } |
| + armMoveFleet(t, fx, ieBox("ie-box"), ukBox("uk-box")) |
| + claimFor(t, fx, "acme.go.tito.io", "ie-box", "ie") |
| + if rec, err := provision.GetRecord(ctx, fx.rel, "bab", "titogo", "acme.go.tito.io"); err != nil || rec.Region != "" { |
| + t.Fatalf("fixture: the record is not home's: %+v %v", rec, err) |
| + } |
| + armClaimMoves(t, fx, true, true, nil) |
| + claim := claimBytes(t, fx, "acme.go.tito.io") |
| + code, _, out := rawReq(t, http.MethodPost, u+"/acme.go.tito.io/move", token, `{"region":"uk"}`) |
| + if msg := errorText(out); strings.HasPrefix(msg, "the host roster does not allow this move") { |
| + t.Fatalf("the door's roster check refused a home source: %d %s", code, out) |
| + } |
| + if code != http.StatusConflict || errorText(out) != "the instance or its boxes changed under this request. read its state and try again" { |
| + t.Fatalf("request = %d %s, want BeginClaimMove's fail-closed refusal", code, out) |
| + } |
| + a, err := hostops.ReadAdmission(ctx, fx.rel, "bab", "titogo", claimMoveAppID) |
| + if err != nil || len(a.Value.Registrations) != 1 { |
| + t.Fatalf("the move did not register past the door's roster check: %+v %v", a.Value.Registrations, err) |
| + } |
| + if claimBytes(t, fx, "acme.go.tito.io") != claim { |
| + t.Fatal("the refused move wrote the claim") |
| + } |
| + if rec, err := provision.GetRecord(ctx, fx.rel, "bab", "titogo", "acme.go.tito.io"); err != nil || rec.Move != nil { |
| + t.Fatalf("the refused move journaled a move: %+v %v", rec, err) |
| + } |
| +} |
| + |
| +// claimMoveRosterPlaces reads roster regions through the console's home |
| +// translation (homePlacementCode, homeRegionIn's rule): the home's name is |
| +// the record's "", a named region is itself, and with no home named |
| +// nothing is home. |
| +func TestClaimMoveRosterPlacesTranslatesHome(t *testing.T) { |
| + box := func(b fleetBox) hostactors.Box { return moveRosterBox(b) } |
| + r := hostactors.Roster{Boxes: map[string]hostactors.Box{"ie-box": box(ieBox("ie-box")), "uk-box": box(ukBox("uk-box"))}} |
| + home, unnamed := "ie", "" |
| + for _, tc := range []struct { |
| + name string |
| + in string |
| + recRegion string |
| + to string |
| + ok bool |
| + }{ |
| + {"home source, home named", home, "", "uk", true}, |
| + {"home source, no home named", unnamed, "", "uk", false}, |
| + {"named source, home named elsewhere", "de", "ie", "uk", true}, |
| + {"named source, no home named", unnamed, "ie", "uk", true}, |
| + {"the record says ie but ie is home", home, "ie", "uk", false}, |
| + {"target in the source's region", home, "", "", false}, |
| + } { |
| + if got := claimMoveRosterPlaces(r, "ie-box", "uk-box", tc.recRegion, tc.to, tc.in); got != tc.ok { |
| + t.Fatalf("%s: %v, want %v", tc.name, got, tc.ok) |
| + } |
| + } |
| +} |