package console import ( "context" "net/http" "reflect" "strings" "sync/atomic" "testing" "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" ) // Moves into the home region, part 2, Task 5: the Phase 1 request door // admits a home target on a host-claim console (Ruling R1). The caller names // the home by its configured name (Ruling R3); the record and the claim keep // the code "" and the engine region @home. const homeMoveHost = "acme.go.tito.io" // caBox is a named region's box, the source of every move below. Its AWS // region is one the test signer has certificates for (boxidtest.Regions). func caBox(box string) fleetBox { return fleetBox{Box: box, Placement: "ca", AWS: "us-east-1", EIP: "1.2.3.5", Bucket: "data-ca"} } // homeFleetBox is a box of the home region as it publishes itself: it sets no // CARLOS_REGION, so its edges file carries no placement. Its roster entry // names the home (homeDoor sets it to "ie"). func homeFleetBox(box string) fleetBox { return fleetBox{Box: box, AWS: "eu-west-1", EIP: "1.2.3.3", Bucket: "data-home"} } // homeNamedBox is a box that sets CARLOS_REGION to the home's name. The // console reads it as home (homeRegionIn), the box does not. func homeNamedBox(box string) fleetBox { return fleetBox{Box: box, Placement: "ie", AWS: "eu-west-1", EIP: "1.2.3.6", Bucket: "data-ie"} } // homeDoorOpts shapes homeDoor's fixture. type homeDoorOpts struct { recRegion string // the create's region; "ca" when empty source string // the serving claim's box; "ca-box" when empty noDesignation bool // the roster names no home_region regions []string // the manifest's regions after the create (nil: none) chore string // a pending park chore's region on the claim ("": none) plain bool // no host claims: the plain Phase 1 path } // homeDoor is the Phase 1 door with the console's home named ie: the // instance created in ca (unless recRegion says otherwise) and claimed by // its source box, the fleet armed with boxes, the roster enrolling every // box (a placementless box under the home's name) and, unless // noDesignation, designating ie as home. Unless plain, the console runs // with host claims (armClaimMoves). It returns the move URL. func homeDoor(t *testing.T, code string, o homeDoorOpts, boxes ...fleetBox) (fx *consoleFixture, token, moveURL string) { t.Helper() ctx := context.Background() fx, token = operatorInstanceFixture(t, code) fx.srv.cfg.HomeRegion = "ie" m := titoManifest() m.Regions = nil m.Pool = "" enableInstances(t, fx, "titogo", m) u := instancesURL(fx, "bab", "titogo") region, source := o.recRegion, o.source if region == "" { region = "ca" } if source == "" { source = "ca-box" } if c, _, out := rawReq(t, http.MethodPost, u, token, `{"host":"`+homeMoveHost+`","region":"`+region+`"}`); c != http.StatusCreated { t.Fatalf("create = %d %s", c, out) } armMoveFleet(t, fx, boxes...) roster := hostactors.Roster{Boxes: map[string]hostactors.Box{}} for _, b := range boxes { rb := moveRosterBox(b) if b.Placement == "" { rb.Region = "ie" } roster.Boxes[b.Box] = rb } if !o.noDesignation { roster.HomeRegion = "ie" } putMoveRoster(t, fx, roster) claimFor(t, fx, homeMoveHost, source, region) if o.chore != "" { obs, err := hostclaim.Read(ctx, fx.rel, homeMoveHost) if err != nil { t.Fatal(err) } if _, err := hostclaim.Update(ctx, fx.rel, obs, func(c *hostclaim.Claim) error { c.Obligations = append(c.Obligations, hostclaim.Obligation{ID: "chore-1", Op: "mv_earlier", Kind: "park-replica", Region: o.chore, Retirement: "ret-1", Owner: "region:" + o.chore}) return nil }); err != nil { t.Fatal(err) } } if o.regions != nil { m.Regions = o.regions enableInstances(t, fx, "titogo", m) } if !o.plain { armClaimMoves(t, fx, true, true, nil) } return fx, token, u + "/" + homeMoveHost + "/move" } // homeMoveRegistrations is the number of registrations on titogo's // admission. func homeMoveRegistrations(t *testing.T, fx *consoleFixture) int { t.Helper() a, err := hostops.ReadAdmission(context.Background(), fx.rel, "bab", "titogo", claimMoveAppID) if err != nil { t.Fatal(err) } return len(a.Value.Registrations) } func homeMoveRecordBytes(t *testing.T, fx *consoleFixture) string { t.Helper() b, _, err := fx.rel.Get(context.Background(), provision.RecordKey("bab", "titogo", homeMoveHost)) if err != nil { t.Fatal(err) } return string(b) } // claimReadSpy counts reads of the host claims. type claimReadSpy struct { release.Store reads *atomic.Int32 } func (s claimReadSpy) Get(ctx context.Context, key string) ([]byte, string, error) { if strings.HasPrefix(key, "control/hosts/") { s.reads.Add(1) } return s.Store.Get(ctx, key) } // A home target on a host-claim console is admitted: the caller names the // home (ie), the record journals the code "", the claim's op targets the // engine region @home under the roster's designation, and the view names // the home again. func TestMoveDoorClaimModeAcceptsHomeTarget(t *testing.T) { ctx := context.Background() fx, token, moveURL := homeDoor(t, "code-home-accept", homeDoorOpts{}, caBox("ca-box"), homeFleetBox("home-box")) code, _, out := rawReq(t, http.MethodPost, moveURL, token, `{"region":"ie","cut":"manual"}`) if code != http.StatusAccepted { t.Fatalf("request = %d %s, want 202", code, out) } if !strings.Contains(out, `"target":"ie"`) { t.Fatalf("view = %s, want target ie", out) } obs, err := hostclaim.Read(ctx, fx.rel, homeMoveHost) if err != nil { t.Fatal(err) } op := obs.Claim.Op if op.Kind != hostclaim.KindMoving || op.Exec != "ca-box" || op.Intent["to"] != "@home" || op.Intent["home_region"] != "ie" || op.Intent["source"] != "ca-box" || op.Intent["target"] != "home-box" { t.Fatalf("claim op = %+v", op) } rec, err := provision.GetRecord(ctx, fx.rel, "bab", "titogo", homeMoveHost) if err != nil { t.Fatal(err) } if rec.Move == nil || rec.Move.To != "" || rec.Move.From != "ca" || rec.Move.ID != op.ID { t.Fatalf("record move = %+v", rec.Move) } } // Ruling R1: without host claims the Phase 1 door refuses a home target // (MV-N510) before it writes anything. func TestMoveDoorPlainPathRefusesHomeTarget(t *testing.T) { fx, token, moveURL := homeDoor(t, "code-home-plain", homeDoorOpts{plain: true}, caBox("ca-box"), homeFleetBox("home-box")) claim, rec := claimBytes(t, fx, homeMoveHost), homeMoveRecordBytes(t, fx) code, _, out := rawReq(t, http.MethodPost, moveURL, token, `{"region":"ie"}`) if code != http.StatusBadRequest || errorText(out) != "moves into the home region need host claims. this console runs without them" { t.Fatalf("plain home target = %d %s, want MV-N510", code, out) } if claimBytes(t, fx, homeMoveHost) != claim || homeMoveRecordBytes(t, fx) != rec { t.Fatal("a refused home target wrote the claim or the record") } } // The home's staging comes from its own placementless edges file: without // a data bucket the door says so with MV-N20, naming the home and listing // no empty code; with one, it passes. func TestMoveDoorHomeStagingFromItsEdgesFile(t *testing.T) { bare := homeFleetBox("home-box") bare.Bucket = "" fx, token, moveURL := homeDoor(t, "code-home-staging", homeDoorOpts{}, caBox("ca-box"), bare) code, _, out := rawReq(t, http.MethodPost, moveURL, token, `{"region":"ie"}`) const want = "region ie cannot take a move in this deployment. it publishes no staging bucket. Regions that can: ca" if code != http.StatusBadRequest || errorText(out) != want { t.Fatalf("home without a data bucket = %d %s, want %q", code, out, want) } if homeMoveRegistrations(t, fx) != 0 { t.Fatal("a staging refusal registered a move") } fx, token, moveURL = homeDoor(t, "code-home-staging-ok", homeDoorOpts{}, caBox("ca-box"), homeFleetBox("home-box")) if code, _, out := rawReq(t, http.MethodPost, moveURL, token, `{"region":"ie"}`); code != http.StatusAccepted { t.Fatalf("home with a data bucket = %d %s, want 202", code, out) } // The list names the home by its name, and never as an empty code. caps := map[string]placementCapability{ "": {Reachable: true, Staging: true}, "ca": {Reachable: true, Staging: true}, "uk": {Reachable: true}, } named := &Server{cfg: Config{HomeRegion: "ie"}} if got := named.stagingNames(caps); got != "ca, ie" { t.Fatalf("stagingNames with home ie = %q, want %q", got, "ca, ie") } unnamed := &Server{cfg: Config{}} if got := unnamed.stagingNames(caps); got != "ca" { t.Fatalf("stagingNames with no home named = %q, want %q", got, "ca") } if got := unnamed.stagingNames(map[string]placementCapability{"": {Reachable: true, Staging: true}}); got != "(none)" { t.Fatalf("stagingNames of an unnamed home alone = %q, want (none)", got) } both := map[string]placementCapability{"": {Reachable: true, Staging: true}, "ie": {Reachable: true, Staging: true}} if got := named.stagingNames(both); got != "ie" { t.Fatalf("stagingNames with a placementless and a home-named box = %q, want ie once", got) } } // Review Focus 4: the home fold is the move door's alone. placements(), // which feeds the create gate and armedPlacements, reads the same fleet as // it did before the fold. func TestCreatePlacementsUnchangedByHomeFold(t *testing.T) { ctx := context.Background() fx := newConsoleFixture(t) armMoveFleet(t, fx, caBox("ca-box"), homeFleetBox("home-box")) caps, published := placements(ctx, fx.rel) want := map[string]placementCapability{"ca": {Reachable: true, Hosting: true, Staging: true}} if !published || !reflect.DeepEqual(caps, want) { t.Fatalf("placements = %+v %v, want %+v true", caps, published, want) } set, published := armedPlacements(ctx, fx.rel) if !published || !reflect.DeepEqual(set, map[string]bool{"ca": true}) { t.Fatalf("armedPlacements = %v %v", set, published) } mcaps, homeNamed, mpublished := movePlacements(ctx, fx.rel, "ie") mwant := map[string]placementCapability{ "": {Reachable: true, Hosting: true, Staging: true}, "ca": {Reachable: true, Hosting: true, Staging: true}, } if !mpublished || len(homeNamed) != 0 || !reflect.DeepEqual(mcaps, mwant) { t.Fatalf("movePlacements = %+v %v %v, want %+v, no home-named box, published", mcaps, homeNamed, mpublished, mwant) } // A home-only fleet publishes no placement: create's gate stays dormant // and the move door still reads "publishes no armed regions". home := newConsoleFixture(t) armMoveFleet(t, home, homeFleetBox("home-box")) if caps, published := placements(ctx, home.rel); published || len(caps) != 0 { t.Fatalf("placements of a home-only fleet = %+v %v", caps, published) } if caps, _, published := movePlacements(ctx, home.rel, "ie"); published || !caps[""].Staging { t.Fatalf("movePlacements of a home-only fleet = %+v %v, want the home folded and nothing published", caps, published) } // Home-named boxes are listed, sorted; they count under their own code. named := newConsoleFixture(t) armMoveFleet(t, named, caBox("ca-box"), homeNamedBox("ie-z"), homeNamedBox("ie-a"), homeFleetBox("home-box")) mcaps, homeNamed, _ = movePlacements(ctx, named.rel, "ie") if !reflect.DeepEqual(homeNamed, []string{"ie-a", "ie-z"}) || !mcaps["ie"].Staging || !mcaps[""].Staging { t.Fatalf("movePlacements home-named = %v caps %+v", homeNamed, mcaps) } if _, homeNamed, _ := movePlacements(ctx, named.rel, ""); len(homeNamed) != 0 { t.Fatalf("with no home named nothing is home-named: %v", homeNamed) } } // Review Focus 5: a box that sets CARLOS_REGION to the home's name is not a // home box. The door refuses it with MV-N511, naming it, before the staging // check would answer a misleading MV-N20, and before any claim read or // registration. With a placementless home box beside it, the answer is the // same. func TestMoveDoorHomeTargetMustPublishNoPlacement(t *testing.T) { for _, tc := range []struct { name string boxes []fleetBox }{ {"only a home-named box", []fleetBox{caBox("ca-box"), homeNamedBox("ie-box")}}, {"a home box and a home-named box", []fleetBox{caBox("ca-box"), homeFleetBox("home-box"), homeNamedBox("ie-box")}}, } { t.Run(tc.name, func(t *testing.T) { fx, token, moveURL := homeDoor(t, "code-home-named", homeDoorOpts{}, tc.boxes...) var reads atomic.Int32 fx.srv.cfg.Releases = claimReadSpy{Store: fx.srv.cfg.Releases, reads: &reads} claim := claimBytes(t, fx, homeMoveHost) code, _, out := rawReq(t, http.MethodPost, moveURL, token, `{"region":"ie"}`) const want = "ie-box sets CARLOS_REGION, so it cannot take a move into the home region" if code != http.StatusConflict || errorText(out) != want { t.Fatalf("home-named target = %d %s, want 409 %q", code, out, want) } if reads.Load() != 0 { t.Fatalf("the refusal read the host claim %d times", reads.Load()) } // The spy sees the door's claim read (hostclaim.Read on // Config.Releases), so the zero above means something. if _, err := hostclaim.Read(context.Background(), fx.srv.cfg.Releases, homeMoveHost); err != nil || reads.Load() == 0 { t.Fatalf("the spy missed a claim read: %d %v", reads.Load(), err) } if homeMoveRegistrations(t, fx) != 0 || claimBytes(t, fx, homeMoveHost) != claim { t.Fatal("the refusal registered a move or wrote the claim") } }) } } // Without the roster's home designation the claim cannot place a home // target: MV-N177, naming the home, and nothing registered. func TestMoveDoorHomeTargetNeedsTheDesignation(t *testing.T) { fx, token, moveURL := homeDoor(t, "code-home-designation", homeDoorOpts{noDesignation: true}, caBox("ca-box"), homeFleetBox("home-box")) code, _, out := rawReq(t, http.MethodPost, moveURL, token, `{"region":"ie"}`) const want = "the host roster does not allow this move. the source and target must be active boxes in ca and ie" if code != http.StatusConflict || errorText(out) != want { t.Fatalf("no designation = %d %s, want %q", code, out, want) } if homeMoveRegistrations(t, fx) != 0 { t.Fatal("a roster refusal registered a move") } } // The manifest allows a home target when an entry names the home; one that // does not refuses with MV-N2, naming the home. func TestMoveDoorManifestNamesHome(t *testing.T) { _, token, moveURL := homeDoor(t, "code-home-manifest", homeDoorOpts{regions: []string{"ie", "uk"}}, caBox("ca-box"), homeFleetBox("home-box")) if code, _, out := rawReq(t, http.MethodPost, moveURL, token, `{"region":"ie"}`); code != http.StatusAccepted { t.Fatalf("regions [ie uk], --to ie = %d %s, want 202", code, out) } _, token, moveURL = homeDoor(t, "code-home-manifest-no", homeDoorOpts{regions: []string{"uk"}}, caBox("ca-box"), homeFleetBox("home-box")) code, _, out := rawReq(t, http.MethodPost, moveURL, token, `{"region":"ie"}`) const want = "region ie is not one of this app's regions: uk" if code != http.StatusBadRequest || errorText(out) != want { t.Fatalf("regions [uk], --to ie = %d %s, want %q", code, out, want) } } // Every refusal a home target can reach names the home by its name, with // its approved template unchanged. func TestMoveDoorHomeRefusalsNameTheHome(t *testing.T) { below := homeFleetBox("home-box") below.NoProtocol = true bare := homeFleetBox("home-box") bare.Bucket = "" unverified := homeFleetBox("home-box") unverified.Unverified = true for _, tc := range []struct { id string opts homeDoorOpts boxes []fleetBox code int want string }{ {"MV-N2", homeDoorOpts{regions: []string{"uk"}}, []fleetBox{caBox("ca-box"), homeFleetBox("home-box")}, http.StatusBadRequest, "region ie is not one of this app's regions: uk"}, {"MV-N5", homeDoorOpts{}, []fleetBox{caBox("ca-box"), below}, http.StatusConflict, "a box in ca or ie is below the moves protocol floor. roll the platform first"}, {"MV-N16", homeDoorOpts{recRegion: "ie", source: "home-box"}, []fleetBox{caBox("ca-box"), homeFleetBox("home-box")}, http.StatusConflict, "the instance is already in ie"}, {"MV-N20", homeDoorOpts{}, []fleetBox{caBox("ca-box"), bare}, http.StatusBadRequest, "region ie cannot take a move in this deployment. it publishes no staging bucket. Regions that can: ca"}, {"MV-N23", homeDoorOpts{}, []fleetBox{caBox("ca-box"), unverified}, http.StatusBadRequest, "no box in ie publishes an edges file yet"}, {"MV-N30 under @home", homeDoorOpts{chore: "@home"}, []fleetBox{caBox("ca-box"), homeFleetBox("home-box")}, http.StatusConflict, "the instance has unfinished cleanup in ie. wait for it to finish"}, {"MV-N30 under ie", homeDoorOpts{chore: "ie"}, []fleetBox{caBox("ca-box"), homeFleetBox("home-box")}, http.StatusConflict, "the instance has unfinished cleanup in ie. wait for it to finish"}, {"MV-N177", homeDoorOpts{noDesignation: true}, []fleetBox{caBox("ca-box"), homeFleetBox("home-box")}, http.StatusConflict, "the host roster does not allow this move. the source and target must be active boxes in ca and ie"}, } { t.Run(tc.id, func(t *testing.T) { fx, token, moveURL := homeDoor(t, "code-home-refusal", tc.opts, tc.boxes...) claim := claimBytes(t, fx, homeMoveHost) code, _, out := rawReq(t, http.MethodPost, moveURL, token, `{"region":"ie"}`) got := errorText(out) if code != tc.code || got != tc.want { t.Fatalf("%s = %d %q, want %d %q", tc.id, code, got, tc.code, tc.want) } if strings.Contains(got, " in ") || strings.Contains(got, " or ") || strings.HasSuffix(got, " and ") { t.Fatalf("%s names an empty region: %q", tc.id, got) } if claimBytes(t, fx, homeMoveHost) != claim { t.Fatalf("%s wrote the claim", tc.id) } }) } } // moveHomeName, homeRegionIn and homeRegionOut each spell the home-name // rule out (homeRegionIn and homeRegionOut inline, to keep the copy gate's // derivation shallow), so this pins that they agree, and that every region // the door accepts survives the round trip the door relies on: toName, the // caller's region, is homeRegionOut(homeRegionIn(region)). func TestMoveHomeNameRuleAgrees(t *testing.T) { fallback := Config{HostClaim: true, HibernateBucket: "hibernate-bucket", HibernateRegion: "ie"} claimsOff := fallback claimsOff.HostClaim = false for _, tc := range []struct { name string cfg Config home string // moveHomeName's answer in map[string]string // caller's region -> homeRegionIn's code }{ {"explicit home, no host claims", Config{HomeRegion: "ie"}, "ie", map[string]string{"ie": "", "ca": "ca", "home": "home"}}, {"explicit home, host claims", Config{HomeRegion: "ie", HostClaim: true}, "ie", map[string]string{"ie": "", "ca": "ca"}}, {"claim fallback", fallback, "ie", map[string]string{"ie": "", "ca": "ca"}}, {"claims disabled: no fallback", claimsOff, "", map[string]string{"ie": "ie", "ca": "ca"}}, {"unnamed home", Config{HostClaim: true}, "", map[string]string{"ie": "ie", "home": "home"}}, {"unnamed home, no host claims", Config{}, "", map[string]string{"ie": "ie"}}, {"a named region called home", Config{HomeRegion: "ie", HostClaim: true}, "ie", map[string]string{"home": "home", "ie": ""}}, } { t.Run(tc.name, func(t *testing.T) { s := &Server{cfg: tc.cfg} if got := s.moveHomeName(); got != tc.home { t.Fatalf("moveHomeName = %q, want %q", got, tc.home) } if got := s.homeRegionOut(""); got != tc.home { t.Fatalf("homeRegionOut(\"\") = %q, moveHomeName = %q", got, tc.home) } for region, code := range tc.in { to := s.homeRegionIn(region) if to != code || to != homePlacementCode(s.moveHomeName(), region) { t.Fatalf("homeRegionIn(%q) = %q, want %q (homePlacementCode under moveHomeName: %q)", region, to, code, homePlacementCode(s.moveHomeName(), region)) } // The door accepts a non-empty region whose code is valid. if !provision.ValidRegion(to) { t.Fatalf("fixture: %q is not a region the door accepts", region) } if toName := s.homeRegionOut(to); toName != region { t.Fatalf("round trip of %q: homeRegionOut(%q) = %q", region, to, toName) } } }) } } // The legacy door (switch off) is not touched: its home refusal keeps // main's sentence, byte for byte. func TestLegacyMoveDoorStillRefusesHome(t *testing.T) { fx, token := instanceFixture(t, "code-home-legacy") 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":"`+homeMoveHost+`","region":"uk"}`); c != http.StatusCreated { t.Fatalf("create = %d %s", c, out) } code, _, out := rawReq(t, http.MethodPost, u+"/"+homeMoveHost+"/move", token, `{"region":"ie"}`) if code != http.StatusBadRequest || errorText(out) != "moves to the home region are not supported yet — the home publishes no staging lane" { t.Fatalf("legacy home target = %d %s, want main's sentence", code, out) } }