package console import ( "context" "errors" "net/http" "strings" "testing" "time" "github.com/carlosframework/platform/internal/provision" "github.com/carlosframework/platform/internal/release" "github.com/carlosframework/platform/internal/seeds" ) const seedMoveHost = "canary.example.com" // seedMoveFixture is a console whose ops account runs the shop app with // instances on, and a member's CLI token (an operator's when phase1, with // the Phase 1 switch on). func seedMoveFixture(t *testing.T, code string, phase1 bool) (*consoleFixture, string) { t.Helper() fx := newConsoleFixture(t) now := time.Now() if _, err := fx.st.CreateAppIn(OpsAccountID, "shop", "paul@keymail.dev", now); err != nil { t.Fatal(err) } if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, now); err != nil { t.Fatal(err) } token := cliToken(t, fx, "alice@keymail.dev", code) if phase1 { if err := fx.st.SetOperator("alice@keymail.dev", true); err != nil { t.Fatal(err) } enableMovesPhase1(fx) } enableInstances(t, fx, "shop", provision.Manifest{Schema: 1, App: "shop", Domain: "example.com", Channel: "stable", Regions: []string{"ie"}}) return fx, token } // putSeededRecord creates an instance record of the shop app in region, // holding a seed of kind in state. func putSeededRecord(t *testing.T, st release.Store, host, region, kind, state string) { t.Helper() id, err := seeds.NewID() if err != nil { t.Fatal(err) } rec := provision.Record{Schema: 1, Host: host, Slug: strings.SplitN(host, ".", 2)[0], App: "shop", Account: release.OpsAccount, Region: region, Channel: "stable", CreatedAt: time.Now().UTC(), Seed: &provision.SeedSource{ID: id, Kind: kind, State: state, By: "paul@keymail.dev", At: time.Now().UTC()}} if err := provision.CreateRecord(context.Background(), st, rec); err != nil { t.Fatal(err) } } // Both move doors answer a move of an unplaced --from seed or an // uncommitted import with the deck's sentence (spec §5.8), before any // write: no journal, and on the Phase 1 door no claim. func TestMoveDoorsRefuseAnUnplacedSeed(t *testing.T) { for _, door := range []struct { name string phase1 bool }{{"legacy", false}, {"phase 1", true}} { for _, c := range []struct{ kind, state, want string }{ {seeds.KindReplica, seeds.StateReady, "wake the instance once before moving it"}, {seeds.KindImport, seeds.StateSwapped, "commit or abort the import first"}, } { fx, token := seedMoveFixture(t, "code-seed-move", door.phase1) putSeededRecord(t, fx.rel, seedMoveHost, "ie", c.kind, c.state) code, _, raw := rawReq(t, http.MethodPost, instancesURL(fx, release.OpsAccount, "shop")+"/"+seedMoveHost+"/move", token, `{"region":"uk"}`) if code != http.StatusConflict || errorText(raw) != c.want { t.Fatalf("%s door, %s %s = %d %s", door.name, c.kind, c.state, code, raw) } got, err := provision.GetRecord(context.Background(), fx.rel, release.OpsAccount, "shop", seedMoveHost) if err != nil || got.Move != nil { t.Fatalf("%s door: a refused move left a journal: %+v, %v", door.name, got, err) } if claim := claimBytes(t, fx, seedMoveHost); claim != "" { t.Fatalf("%s door: a refused move wrote a claim: %s", door.name, claim) } } } } // A placed seed is not the helper's to refuse: the legacy door goes on to // its own checks (here, no armed regions). func TestMoveDoorLetsAPlacedSeedThrough(t *testing.T) { fx, token := seedMoveFixture(t, "code-seed-move-placed", false) putSeededRecord(t, fx.rel, seedMoveHost, "ie", seeds.KindReplica, seeds.StatePlaced) code, _, raw := rawReq(t, http.MethodPost, instancesURL(fx, release.OpsAccount, "shop")+"/"+seedMoveHost+"/move", token, `{"region":"uk"}`) if code != http.StatusBadRequest || errorText(raw) != "this deployment publishes no armed regions — nothing to move to" { t.Fatalf("placed seed = %d %s", code, raw) } } // placeSeedFixture is fleetFixture with the shop app, one route and an // owned fleet "pi" to place it on. func placeSeedFixture(t *testing.T, code string) (*consoleFixture, string) { t.Helper() fx, token := fleetFixture(t, code) if _, err := fx.st.CreateAppIn(OpsAccountID, "shop", "paul@keymail.dev", time.Now()); err != nil { t.Fatal(err) } if status, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/new", map[string]string{"name": "pi", "bucket": "pi-fleet-data"}, token); status != http.StatusOK { t.Fatalf("create fleet = %d %v", status, body) } return fx, token } // assertNotPlaced checks a refused place wrote nothing: the app's placement // and every record's region are as before. func assertNotPlaced(t *testing.T, fx *consoleFixture, hosts ...string) { t.Helper() if p, err := fx.st.AppPlacement(OpsAccountID, "shop"); err != nil || p != "" { t.Fatalf("a refused place recorded placement %q, %v", p, err) } for _, h := range hosts { got, err := provision.GetRecord(context.Background(), fx.rel, fx.sqid, "shop", h) if err != nil || got.Region != "ie" { t.Fatalf("a refused place re-homed %s: %+v, %v", h, got, err) } } } // carlos apps place re-homes every record of the app, so it is a move too // (preflight M8(a)): an unplaced --from seed or an uncommitted import // refuses it, with the deck's sentence, before any write. func TestPlaceRefusesAnUnplacedSeed(t *testing.T) { for _, c := range []struct{ kind, state, want string }{ {seeds.KindReplica, seeds.StateReady, "wake the instance once before moving it"}, {seeds.KindImport, seeds.StatePlaced, "commit or abort the import first"}, } { fx, token := placeSeedFixture(t, "code-place-seed") putSeededRecord(t, fx.rel, "plain.example.com", "ie", seeds.KindReplica, seeds.StatePlaced) putSeededRecord(t, fx.rel, seedMoveHost, "ie", c.kind, c.state) code, _, raw := rawReq(t, http.MethodPost, placeURL(fx, "shop"), token, `{"target":"fleet/pi"}`) if code != http.StatusConflict || errorText(raw) != c.want { t.Fatalf("%s %s: place = %d %s", c.kind, c.state, code, raw) } assertNotPlaced(t, fx, "plain.example.com", seedMoveHost) } } // A record the place cannot read could be a seed that may not move, so the // place is refused (it fails closed): a record that will not decode, and a // read that fails. func TestPlaceRefusesWhenARecordCannotBeRead(t *testing.T) { fx, token := placeSeedFixture(t, "code-place-unreadable") putSeededRecord(t, fx.rel, "plain.example.com", "ie", seeds.KindReplica, seeds.StatePlaced) bad := provision.RecordKey(fx.sqid, "shop", "broken.example.com") if _, err := fx.rel.Put(context.Background(), bad, []byte("{not json"), ""); err != nil { t.Fatal(err) } code, _, raw := rawReq(t, http.MethodPost, placeURL(fx, "shop"), token, `{"target":"fleet/pi"}`) if code != http.StatusBadGateway || errorText(raw) != "could not update this app's instances. Try again." { t.Fatalf("an undecodable record: place = %d %s", code, raw) } assertNotPlaced(t, fx, "plain.example.com") if err := fx.rel.Delete(context.Background(), bad); err != nil { t.Fatal(err) } fx.srv.cfg.Releases = &failGetStore{Store: fx.rel, key: provision.RecordKey(fx.sqid, "shop", "plain.example.com")} code, _, raw = rawReq(t, http.MethodPost, placeURL(fx, "shop"), token, `{"target":"fleet/pi"}`) if code != http.StatusBadGateway || errorText(raw) != "could not update this app's instances. Try again." { t.Fatalf("a failed read: place = %d %s", code, raw) } assertNotPlaced(t, fx, "plain.example.com") } // A record deleted between the strict read's listing and its read is the // I4 race one step earlier: the read is taken again, and the place lands. func TestPlaceReadsAgainWhenARecordVanishes(t *testing.T) { fx, token := placeSeedFixture(t, "code-place-seed-vanish") putSeededRecord(t, fx.rel, "plain.example.com", "ie", seeds.KindReplica, seeds.StatePlaced) putSeededRecord(t, fx.rel, "gone.example.com", "ie", seeds.KindReplica, seeds.StatePlaced) fx.srv.cfg.Releases = &vanishOnGetStore{Store: fx.rel, vanish: provision.RecordKey(fx.sqid, "shop", "gone.example.com")} code, _, raw := rawReq(t, http.MethodPost, placeURL(fx, "shop"), token, `{"target":"fleet/pi"}`) if code != http.StatusOK { t.Fatalf("place = %d %s", code, raw) } got, err := provision.GetRecord(context.Background(), fx.rel, fx.sqid, "shop", "plain.example.com") if err != nil || got.Region != "fleet/pi" { t.Fatalf("after the place: %+v, %v", got, err) } } // The backstop: a seeded record created after the place's read is refused // under SetRecordRegion's CAS, and the place answers the same sentence. func TestPlaceAnswersTheBackstopsSentence(t *testing.T) { fx, token := placeSeedFixture(t, "code-place-seed-backstop") putSeededRecord(t, fx.rel, "plain.example.com", "ie", seeds.KindReplica, seeds.StatePlaced) fx.srv.cfg.Releases = &createOnSecondListStore{Store: fx.rel, prefix: provision.RecordsPrefix(fx.sqid, "shop"), create: func(st release.Store) { putSeededRecord(t, st, seedMoveHost, "ie", seeds.KindImport, seeds.StateSwapped) }} code, _, raw := rawReq(t, http.MethodPost, placeURL(fx, "shop"), token, `{"target":"fleet/pi"}`) if code != http.StatusConflict || errorText(raw) != "commit or abort the import first" { t.Fatalf("place = %d %s", code, raw) } got, err := provision.GetRecord(context.Background(), fx.rel, fx.sqid, "shop", seedMoveHost) if err != nil || got.Region != "ie" { t.Fatalf("the late import was re-homed: %+v, %v", got, err) } } // failGetStore fails every Get of one key. type failGetStore struct { release.Store key string } func (f *failGetStore) Get(ctx context.Context, key string) ([]byte, string, error) { if key == f.key { return nil, "", errors.New("injected read failure") } return f.Store.Get(ctx, key) } // createOnSecondListStore runs create once, just before the second List of // prefix: after the place's strict read, before its walk. type createOnSecondListStore struct { release.Store prefix string create func(release.Store) lists int } func (c *createOnSecondListStore) List(ctx context.Context, prefix string) ([]string, error) { if prefix == c.prefix { c.lists++ if c.lists == 2 { c.create(c.Store) } } return c.Store.List(ctx, prefix) }