carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package console
2
3import (
4 "context"
5 "net/http"
6 "reflect"
7 "strings"
8 "sync/atomic"
9 "testing"
10
11 "github.com/carlosframework/platform/internal/hostactors"
12 "github.com/carlosframework/platform/internal/hostclaim"
13 "github.com/carlosframework/platform/internal/hostops"
14 "github.com/carlosframework/platform/internal/provision"
15 "github.com/carlosframework/platform/internal/release"
16)
17
18// Moves into the home region, part 2, Task 5: the Phase 1 request door
19// admits a home target on a host-claim console (Ruling R1). The caller names
20// the home by its configured name (Ruling R3); the record and the claim keep
21// the code "" and the engine region @home.
22
23const homeMoveHost = "acme.go.tito.io"
24
25// caBox is a named region's box, the source of every move below. Its AWS
26// region is one the test signer has certificates for (boxidtest.Regions).
27func caBox(box string) fleetBox {
28 return fleetBox{Box: box, Placement: "ca", AWS: "us-east-1", EIP: "1.2.3.5", Bucket: "data-ca"}
29}
30
31// homeFleetBox is a box of the home region as it publishes itself: it sets no
32// CARLOS_REGION, so its edges file carries no placement. Its roster entry
33// names the home (homeDoor sets it to "ie").
34func homeFleetBox(box string) fleetBox {
35 return fleetBox{Box: box, AWS: "eu-west-1", EIP: "1.2.3.3", Bucket: "data-home"}
36}
37
38// homeNamedBox is a box that sets CARLOS_REGION to the home's name. The
39// console reads it as home (homeRegionIn), the box does not.
40func homeNamedBox(box string) fleetBox {
41 return fleetBox{Box: box, Placement: "ie", AWS: "eu-west-1", EIP: "1.2.3.6", Bucket: "data-ie"}
42}
43
44// homeDoorOpts shapes homeDoor's fixture.
45type homeDoorOpts struct {
46 recRegion string // the create's region; "ca" when empty
47 source string // the serving claim's box; "ca-box" when empty
48 noDesignation bool // the roster names no home_region
49 regions []string // the manifest's regions after the create (nil: none)
50 chore string // a pending park chore's region on the claim ("": none)
51 plain bool // no host claims: the plain Phase 1 path
52}
53
54// homeDoor is the Phase 1 door with the console's home named ie: the
55// instance created in ca (unless recRegion says otherwise) and claimed by
56// its source box, the fleet armed with boxes, the roster enrolling every
57// box (a placementless box under the home's name) and, unless
58// noDesignation, designating ie as home. Unless plain, the console runs
59// with host claims (armClaimMoves). It returns the move URL.
60func homeDoor(t *testing.T, code string, o homeDoorOpts, boxes ...fleetBox) (fx *consoleFixture, token, moveURL string) {
61 t.Helper()
62 ctx := context.Background()
63 fx, token = operatorInstanceFixture(t, code)
64 fx.srv.cfg.HomeRegion = "ie"
65 m := titoManifest()
66 m.Regions = nil
67 m.Pool = ""
68 enableInstances(t, fx, "titogo", m)
69 u := instancesURL(fx, "bab", "titogo")
70 region, source := o.recRegion, o.source
71 if region == "" {
72 region = "ca"
73 }
74 if source == "" {
75 source = "ca-box"
76 }
77 if c, _, out := rawReq(t, http.MethodPost, u, token, `{"host":"`+homeMoveHost+`","region":"`+region+`"}`); c != http.StatusCreated {
78 t.Fatalf("create = %d %s", c, out)
79 }
80 armMoveFleet(t, fx, boxes...)
81 roster := hostactors.Roster{Boxes: map[string]hostactors.Box{}}
82 for _, b := range boxes {
83 rb := moveRosterBox(b)
84 if b.Placement == "" {
85 rb.Region = "ie"
86 }
87 roster.Boxes[b.Box] = rb
88 }
89 if !o.noDesignation {
90 roster.HomeRegion = "ie"
91 }
92 putMoveRoster(t, fx, roster)
93 claimFor(t, fx, homeMoveHost, source, region)
94 if o.chore != "" {
95 obs, err := hostclaim.Read(ctx, fx.rel, homeMoveHost)
96 if err != nil {
97 t.Fatal(err)
98 }
99 if _, err := hostclaim.Update(ctx, fx.rel, obs, func(c *hostclaim.Claim) error {
100 c.Obligations = append(c.Obligations, hostclaim.Obligation{ID: "chore-1", Op: "mv_earlier", Kind: "park-replica",
101 Region: o.chore, Retirement: "ret-1", Owner: "region:" + o.chore})
102 return nil
103 }); err != nil {
104 t.Fatal(err)
105 }
106 }
107 if o.regions != nil {
108 m.Regions = o.regions
109 enableInstances(t, fx, "titogo", m)
110 }
111 if !o.plain {
112 armClaimMoves(t, fx, true, true, nil)
113 }
114 return fx, token, u + "/" + homeMoveHost + "/move"
115}
116
117// homeMoveRegistrations is the number of registrations on titogo's
118// admission.
119func homeMoveRegistrations(t *testing.T, fx *consoleFixture) int {
120 t.Helper()
121 a, err := hostops.ReadAdmission(context.Background(), fx.rel, "bab", "titogo", claimMoveAppID)
122 if err != nil {
123 t.Fatal(err)
124 }
125 return len(a.Value.Registrations)
126}
127
128func homeMoveRecordBytes(t *testing.T, fx *consoleFixture) string {
129 t.Helper()
130 b, _, err := fx.rel.Get(context.Background(), provision.RecordKey("bab", "titogo", homeMoveHost))
131 if err != nil {
132 t.Fatal(err)
133 }
134 return string(b)
135}
136
137// claimReadSpy counts reads of the host claims.
138type claimReadSpy struct {
139 release.Store
140 reads *atomic.Int32
141}
142
143func (s claimReadSpy) Get(ctx context.Context, key string) ([]byte, string, error) {
144 if strings.HasPrefix(key, "control/hosts/") {
145 s.reads.Add(1)
146 }
147 return s.Store.Get(ctx, key)
148}
149
150// A home target on a host-claim console is admitted: the caller names the
151// home (ie), the record journals the code "", the claim's op targets the
152// engine region @home under the roster's designation, and the view names
153// the home again.
154func TestMoveDoorClaimModeAcceptsHomeTarget(t *testing.T) {
155 ctx := context.Background()
156 fx, token, moveURL := homeDoor(t, "code-home-accept", homeDoorOpts{}, caBox("ca-box"), homeFleetBox("home-box"))
157 code, _, out := rawReq(t, http.MethodPost, moveURL, token, `{"region":"ie","cut":"manual"}`)
158 if code != http.StatusAccepted {
159 t.Fatalf("request = %d %s, want 202", code, out)
160 }
161 if !strings.Contains(out, `"target":"ie"`) {
162 t.Fatalf("view = %s, want target ie", out)
163 }
164 obs, err := hostclaim.Read(ctx, fx.rel, homeMoveHost)
165 if err != nil {
166 t.Fatal(err)
167 }
168 op := obs.Claim.Op
169 if op.Kind != hostclaim.KindMoving || op.Exec != "ca-box" || op.Intent["to"] != "@home" || op.Intent["home_region"] != "ie" ||
170 op.Intent["source"] != "ca-box" || op.Intent["target"] != "home-box" {
171 t.Fatalf("claim op = %+v", op)
172 }
173 rec, err := provision.GetRecord(ctx, fx.rel, "bab", "titogo", homeMoveHost)
174 if err != nil {
175 t.Fatal(err)
176 }
177 if rec.Move == nil || rec.Move.To != "" || rec.Move.From != "ca" || rec.Move.ID != op.ID {
178 t.Fatalf("record move = %+v", rec.Move)
179 }
180}
181
182// Ruling R1: without host claims the Phase 1 door refuses a home target
183// (MV-N510) before it writes anything.
184func TestMoveDoorPlainPathRefusesHomeTarget(t *testing.T) {
185 fx, token, moveURL := homeDoor(t, "code-home-plain", homeDoorOpts{plain: true}, caBox("ca-box"), homeFleetBox("home-box"))
186 claim, rec := claimBytes(t, fx, homeMoveHost), homeMoveRecordBytes(t, fx)
187 code, _, out := rawReq(t, http.MethodPost, moveURL, token, `{"region":"ie"}`)
188 if code != http.StatusBadRequest || errorText(out) != "moves into the home region need host claims. this console runs without them" {
189 t.Fatalf("plain home target = %d %s, want MV-N510", code, out)
190 }
191 if claimBytes(t, fx, homeMoveHost) != claim || homeMoveRecordBytes(t, fx) != rec {
192 t.Fatal("a refused home target wrote the claim or the record")
193 }
194}
195
196// The home's staging comes from its own placementless edges file: without
197// a data bucket the door says so with MV-N20, naming the home and listing
198// no empty code; with one, it passes.
199func TestMoveDoorHomeStagingFromItsEdgesFile(t *testing.T) {
200 bare := homeFleetBox("home-box")
201 bare.Bucket = ""
202 fx, token, moveURL := homeDoor(t, "code-home-staging", homeDoorOpts{}, caBox("ca-box"), bare)
203 code, _, out := rawReq(t, http.MethodPost, moveURL, token, `{"region":"ie"}`)
204 const want = "region ie cannot take a move in this deployment. it publishes no staging bucket. Regions that can: ca"
205 if code != http.StatusBadRequest || errorText(out) != want {
206 t.Fatalf("home without a data bucket = %d %s, want %q", code, out, want)
207 }
208 if homeMoveRegistrations(t, fx) != 0 {
209 t.Fatal("a staging refusal registered a move")
210 }
211
212 fx, token, moveURL = homeDoor(t, "code-home-staging-ok", homeDoorOpts{}, caBox("ca-box"), homeFleetBox("home-box"))
213 if code, _, out := rawReq(t, http.MethodPost, moveURL, token, `{"region":"ie"}`); code != http.StatusAccepted {
214 t.Fatalf("home with a data bucket = %d %s, want 202", code, out)
215 }
216
217 // The list names the home by its name, and never as an empty code.
218 caps := map[string]placementCapability{
219 "": {Reachable: true, Staging: true},
220 "ca": {Reachable: true, Staging: true},
221 "uk": {Reachable: true},
222 }
223 named := &Server{cfg: Config{HomeRegion: "ie"}}
224 if got := named.stagingNames(caps); got != "ca, ie" {
225 t.Fatalf("stagingNames with home ie = %q, want %q", got, "ca, ie")
226 }
227 unnamed := &Server{cfg: Config{}}
228 if got := unnamed.stagingNames(caps); got != "ca" {
229 t.Fatalf("stagingNames with no home named = %q, want %q", got, "ca")
230 }
231 if got := unnamed.stagingNames(map[string]placementCapability{"": {Reachable: true, Staging: true}}); got != "(none)" {
232 t.Fatalf("stagingNames of an unnamed home alone = %q, want (none)", got)
233 }
234 both := map[string]placementCapability{"": {Reachable: true, Staging: true}, "ie": {Reachable: true, Staging: true}}
235 if got := named.stagingNames(both); got != "ie" {
236 t.Fatalf("stagingNames with a placementless and a home-named box = %q, want ie once", got)
237 }
238}
239
240// Review Focus 4: the home fold is the move door's alone. placements(),
241// which feeds the create gate and armedPlacements, reads the same fleet as
242// it did before the fold.
243func TestCreatePlacementsUnchangedByHomeFold(t *testing.T) {
244 ctx := context.Background()
245 fx := newConsoleFixture(t)
246 armMoveFleet(t, fx, caBox("ca-box"), homeFleetBox("home-box"))
247 caps, published := placements(ctx, fx.rel)
248 want := map[string]placementCapability{"ca": {Reachable: true, Hosting: true, Staging: true}}
249 if !published || !reflect.DeepEqual(caps, want) {
250 t.Fatalf("placements = %+v %v, want %+v true", caps, published, want)
251 }
252 set, published := armedPlacements(ctx, fx.rel)
253 if !published || !reflect.DeepEqual(set, map[string]bool{"ca": true}) {
254 t.Fatalf("armedPlacements = %v %v", set, published)
255 }
256 mcaps, homeNamed, mpublished := movePlacements(ctx, fx.rel, "ie")
257 mwant := map[string]placementCapability{
258 "": {Reachable: true, Hosting: true, Staging: true},
259 "ca": {Reachable: true, Hosting: true, Staging: true},
260 }
261 if !mpublished || len(homeNamed) != 0 || !reflect.DeepEqual(mcaps, mwant) {
262 t.Fatalf("movePlacements = %+v %v %v, want %+v, no home-named box, published", mcaps, homeNamed, mpublished, mwant)
263 }
264
265 // A home-only fleet publishes no placement: create's gate stays dormant
266 // and the move door still reads "publishes no armed regions".
267 home := newConsoleFixture(t)
268 armMoveFleet(t, home, homeFleetBox("home-box"))
269 if caps, published := placements(ctx, home.rel); published || len(caps) != 0 {
270 t.Fatalf("placements of a home-only fleet = %+v %v", caps, published)
271 }
272 if caps, _, published := movePlacements(ctx, home.rel, "ie"); published || !caps[""].Staging {
273 t.Fatalf("movePlacements of a home-only fleet = %+v %v, want the home folded and nothing published", caps, published)
274 }
275
276 // Home-named boxes are listed, sorted; they count under their own code.
277 named := newConsoleFixture(t)
278 armMoveFleet(t, named, caBox("ca-box"), homeNamedBox("ie-z"), homeNamedBox("ie-a"), homeFleetBox("home-box"))
279 mcaps, homeNamed, _ = movePlacements(ctx, named.rel, "ie")
280 if !reflect.DeepEqual(homeNamed, []string{"ie-a", "ie-z"}) || !mcaps["ie"].Staging || !mcaps[""].Staging {
281 t.Fatalf("movePlacements home-named = %v caps %+v", homeNamed, mcaps)
282 }
283 if _, homeNamed, _ := movePlacements(ctx, named.rel, ""); len(homeNamed) != 0 {
284 t.Fatalf("with no home named nothing is home-named: %v", homeNamed)
285 }
286}
287
288// Review Focus 5: a box that sets CARLOS_REGION to the home's name is not a
289// home box. The door refuses it with MV-N511, naming it, before the staging
290// check would answer a misleading MV-N20, and before any claim read or
291// registration. With a placementless home box beside it, the answer is the
292// same.
293func TestMoveDoorHomeTargetMustPublishNoPlacement(t *testing.T) {
294 for _, tc := range []struct {
295 name string
296 boxes []fleetBox
297 }{
298 {"only a home-named box", []fleetBox{caBox("ca-box"), homeNamedBox("ie-box")}},
299 {"a home box and a home-named box", []fleetBox{caBox("ca-box"), homeFleetBox("home-box"), homeNamedBox("ie-box")}},
300 } {
301 t.Run(tc.name, func(t *testing.T) {
302 fx, token, moveURL := homeDoor(t, "code-home-named", homeDoorOpts{}, tc.boxes...)
303 var reads atomic.Int32
304 fx.srv.cfg.Releases = claimReadSpy{Store: fx.srv.cfg.Releases, reads: &reads}
305 claim := claimBytes(t, fx, homeMoveHost)
306 code, _, out := rawReq(t, http.MethodPost, moveURL, token, `{"region":"ie"}`)
307 const want = "ie-box sets CARLOS_REGION, so it cannot take a move into the home region"
308 if code != http.StatusConflict || errorText(out) != want {
309 t.Fatalf("home-named target = %d %s, want 409 %q", code, out, want)
310 }
311 if reads.Load() != 0 {
312 t.Fatalf("the refusal read the host claim %d times", reads.Load())
313 }
314 // The spy sees the door's claim read (hostclaim.Read on
315 // Config.Releases), so the zero above means something.
316 if _, err := hostclaim.Read(context.Background(), fx.srv.cfg.Releases, homeMoveHost); err != nil || reads.Load() == 0 {
317 t.Fatalf("the spy missed a claim read: %d %v", reads.Load(), err)
318 }
319 if homeMoveRegistrations(t, fx) != 0 || claimBytes(t, fx, homeMoveHost) != claim {
320 t.Fatal("the refusal registered a move or wrote the claim")
321 }
322 })
323 }
324}
325
326// Without the roster's home designation the claim cannot place a home
327// target: MV-N177, naming the home, and nothing registered.
328func TestMoveDoorHomeTargetNeedsTheDesignation(t *testing.T) {
329 fx, token, moveURL := homeDoor(t, "code-home-designation", homeDoorOpts{noDesignation: true}, caBox("ca-box"), homeFleetBox("home-box"))
330 code, _, out := rawReq(t, http.MethodPost, moveURL, token, `{"region":"ie"}`)
331 const want = "the host roster does not allow this move. the source and target must be active boxes in ca and ie"
332 if code != http.StatusConflict || errorText(out) != want {
333 t.Fatalf("no designation = %d %s, want %q", code, out, want)
334 }
335 if homeMoveRegistrations(t, fx) != 0 {
336 t.Fatal("a roster refusal registered a move")
337 }
338}
339
340// The manifest allows a home target when an entry names the home; one that
341// does not refuses with MV-N2, naming the home.
342func TestMoveDoorManifestNamesHome(t *testing.T) {
343 _, token, moveURL := homeDoor(t, "code-home-manifest", homeDoorOpts{regions: []string{"ie", "uk"}}, caBox("ca-box"), homeFleetBox("home-box"))
344 if code, _, out := rawReq(t, http.MethodPost, moveURL, token, `{"region":"ie"}`); code != http.StatusAccepted {
345 t.Fatalf("regions [ie uk], --to ie = %d %s, want 202", code, out)
346 }
347 _, token, moveURL = homeDoor(t, "code-home-manifest-no", homeDoorOpts{regions: []string{"uk"}}, caBox("ca-box"), homeFleetBox("home-box"))
348 code, _, out := rawReq(t, http.MethodPost, moveURL, token, `{"region":"ie"}`)
349 const want = "region ie is not one of this app's regions: uk"
350 if code != http.StatusBadRequest || errorText(out) != want {
351 t.Fatalf("regions [uk], --to ie = %d %s, want %q", code, out, want)
352 }
353}
354
355// Every refusal a home target can reach names the home by its name, with
356// its approved template unchanged.
357func TestMoveDoorHomeRefusalsNameTheHome(t *testing.T) {
358 below := homeFleetBox("home-box")
359 below.NoProtocol = true
360 bare := homeFleetBox("home-box")
361 bare.Bucket = ""
362 unverified := homeFleetBox("home-box")
363 unverified.Unverified = true
364 for _, tc := range []struct {
365 id string
366 opts homeDoorOpts
367 boxes []fleetBox
368 code int
369 want string
370 }{
371 {"MV-N2", homeDoorOpts{regions: []string{"uk"}}, []fleetBox{caBox("ca-box"), homeFleetBox("home-box")},
372 http.StatusBadRequest, "region ie is not one of this app's regions: uk"},
373 {"MV-N5", homeDoorOpts{}, []fleetBox{caBox("ca-box"), below},
374 http.StatusConflict, "a box in ca or ie is below the moves protocol floor. roll the platform first"},
375 {"MV-N16", homeDoorOpts{recRegion: "ie", source: "home-box"}, []fleetBox{caBox("ca-box"), homeFleetBox("home-box")},
376 http.StatusConflict, "the instance is already in ie"},
377 {"MV-N20", homeDoorOpts{}, []fleetBox{caBox("ca-box"), bare},
378 http.StatusBadRequest, "region ie cannot take a move in this deployment. it publishes no staging bucket. Regions that can: ca"},
379 {"MV-N23", homeDoorOpts{}, []fleetBox{caBox("ca-box"), unverified},
380 http.StatusBadRequest, "no box in ie publishes an edges file yet"},
381 {"MV-N30 under @home", homeDoorOpts{chore: "@home"}, []fleetBox{caBox("ca-box"), homeFleetBox("home-box")},
382 http.StatusConflict, "the instance has unfinished cleanup in ie. wait for it to finish"},
383 {"MV-N30 under ie", homeDoorOpts{chore: "ie"}, []fleetBox{caBox("ca-box"), homeFleetBox("home-box")},
384 http.StatusConflict, "the instance has unfinished cleanup in ie. wait for it to finish"},
385 {"MV-N177", homeDoorOpts{noDesignation: true}, []fleetBox{caBox("ca-box"), homeFleetBox("home-box")},
386 http.StatusConflict, "the host roster does not allow this move. the source and target must be active boxes in ca and ie"},
387 } {
388 t.Run(tc.id, func(t *testing.T) {
389 fx, token, moveURL := homeDoor(t, "code-home-refusal", tc.opts, tc.boxes...)
390 claim := claimBytes(t, fx, homeMoveHost)
391 code, _, out := rawReq(t, http.MethodPost, moveURL, token, `{"region":"ie"}`)
392 got := errorText(out)
393 if code != tc.code || got != tc.want {
394 t.Fatalf("%s = %d %q, want %d %q", tc.id, code, got, tc.code, tc.want)
395 }
396 if strings.Contains(got, " in ") || strings.Contains(got, " or ") || strings.HasSuffix(got, " and ") {
397 t.Fatalf("%s names an empty region: %q", tc.id, got)
398 }
399 if claimBytes(t, fx, homeMoveHost) != claim {
400 t.Fatalf("%s wrote the claim", tc.id)
401 }
402 })
403 }
404}
405
406// moveHomeName, homeRegionIn and homeRegionOut each spell the home-name
407// rule out (homeRegionIn and homeRegionOut inline, to keep the copy gate's
408// derivation shallow), so this pins that they agree, and that every region
409// the door accepts survives the round trip the door relies on: toName, the
410// caller's region, is homeRegionOut(homeRegionIn(region)).
411func TestMoveHomeNameRuleAgrees(t *testing.T) {
412 fallback := Config{HostClaim: true, HibernateBucket: "hibernate-bucket", HibernateRegion: "ie"}
413 claimsOff := fallback
414 claimsOff.HostClaim = false
415 for _, tc := range []struct {
416 name string
417 cfg Config
418 home string // moveHomeName's answer
419 in map[string]string // caller's region -> homeRegionIn's code
420 }{
421 {"explicit home, no host claims", Config{HomeRegion: "ie"}, "ie", map[string]string{"ie": "", "ca": "ca", "home": "home"}},
422 {"explicit home, host claims", Config{HomeRegion: "ie", HostClaim: true}, "ie", map[string]string{"ie": "", "ca": "ca"}},
423 {"claim fallback", fallback, "ie", map[string]string{"ie": "", "ca": "ca"}},
424 {"claims disabled: no fallback", claimsOff, "", map[string]string{"ie": "ie", "ca": "ca"}},
425 {"unnamed home", Config{HostClaim: true}, "", map[string]string{"ie": "ie", "home": "home"}},
426 {"unnamed home, no host claims", Config{}, "", map[string]string{"ie": "ie"}},
427 {"a named region called home", Config{HomeRegion: "ie", HostClaim: true}, "ie", map[string]string{"home": "home", "ie": ""}},
428 } {
429 t.Run(tc.name, func(t *testing.T) {
430 s := &Server{cfg: tc.cfg}
431 if got := s.moveHomeName(); got != tc.home {
432 t.Fatalf("moveHomeName = %q, want %q", got, tc.home)
433 }
434 if got := s.homeRegionOut(""); got != tc.home {
435 t.Fatalf("homeRegionOut(\"\") = %q, moveHomeName = %q", got, tc.home)
436 }
437 for region, code := range tc.in {
438 to := s.homeRegionIn(region)
439 if to != code || to != homePlacementCode(s.moveHomeName(), region) {
440 t.Fatalf("homeRegionIn(%q) = %q, want %q (homePlacementCode under moveHomeName: %q)", region, to, code, homePlacementCode(s.moveHomeName(), region))
441 }
442 // The door accepts a non-empty region whose code is valid.
443 if !provision.ValidRegion(to) {
444 t.Fatalf("fixture: %q is not a region the door accepts", region)
445 }
446 if toName := s.homeRegionOut(to); toName != region {
447 t.Fatalf("round trip of %q: homeRegionOut(%q) = %q", region, to, toName)
448 }
449 }
450 })
451 }
452}
453
454// The legacy door (switch off) is not touched: its home refusal keeps
455// main's sentence, byte for byte.
456func TestLegacyMoveDoorStillRefusesHome(t *testing.T) {
457 fx, token := instanceFixture(t, "code-home-legacy")
458 fx.srv.cfg.HomeRegion = "ie"
459 m := titoManifest()
460 m.Regions = nil
461 m.Pool = ""
462 enableInstances(t, fx, "titogo", m)
463 u := instancesURL(fx, "bab", "titogo")
464 if c, _, out := rawReq(t, http.MethodPost, u, token, `{"host":"`+homeMoveHost+`","region":"uk"}`); c != http.StatusCreated {
465 t.Fatalf("create = %d %s", c, out)
466 }
467 code, _, out := rawReq(t, http.MethodPost, u+"/"+homeMoveHost+"/move", token, `{"region":"ie"}`)
468 if code != http.StatusBadRequest || errorText(out) != "moves to the home region are not supported yet — the home publishes no staging lane" {
469 t.Fatalf("legacy home target = %d %s, want main's sentence", code, out)
470 }
471}
472