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 "errors"
6 "net/http"
7 "strings"
8 "testing"
9 "time"
10
11 "github.com/carlosframework/platform/internal/provision"
12 "github.com/carlosframework/platform/internal/release"
13 "github.com/carlosframework/platform/internal/seeds"
14)
15
16const seedMoveHost = "canary.example.com"
17
18// seedMoveFixture is a console whose ops account runs the shop app with
19// instances on, and a member's CLI token (an operator's when phase1, with
20// the Phase 1 switch on).
21func seedMoveFixture(t *testing.T, code string, phase1 bool) (*consoleFixture, string) {
22 t.Helper()
23 fx := newConsoleFixture(t)
24 now := time.Now()
25 if _, err := fx.st.CreateAppIn(OpsAccountID, "shop", "paul@keymail.dev", now); err != nil {
26 t.Fatal(err)
27 }
28 if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, now); err != nil {
29 t.Fatal(err)
30 }
31 token := cliToken(t, fx, "alice@keymail.dev", code)
32 if phase1 {
33 if err := fx.st.SetOperator("alice@keymail.dev", true); err != nil {
34 t.Fatal(err)
35 }
36 enableMovesPhase1(fx)
37 }
38 enableInstances(t, fx, "shop", provision.Manifest{Schema: 1, App: "shop", Domain: "example.com",
39 Channel: "stable", Regions: []string{"ie"}})
40 return fx, token
41}
42
43// putSeededRecord creates an instance record of the shop app in region,
44// holding a seed of kind in state.
45func putSeededRecord(t *testing.T, st release.Store, host, region, kind, state string) {
46 t.Helper()
47 id, err := seeds.NewID()
48 if err != nil {
49 t.Fatal(err)
50 }
51 rec := provision.Record{Schema: 1, Host: host, Slug: strings.SplitN(host, ".", 2)[0], App: "shop", Account: release.OpsAccount,
52 Region: region, Channel: "stable", CreatedAt: time.Now().UTC(),
53 Seed: &provision.SeedSource{ID: id, Kind: kind, State: state, By: "paul@keymail.dev", At: time.Now().UTC()}}
54 if err := provision.CreateRecord(context.Background(), st, rec); err != nil {
55 t.Fatal(err)
56 }
57}
58
59// Both move doors answer a move of an unplaced --from seed or an
60// uncommitted import with the deck's sentence (spec §5.8), before any
61// write: no journal, and on the Phase 1 door no claim.
62func TestMoveDoorsRefuseAnUnplacedSeed(t *testing.T) {
63 for _, door := range []struct {
64 name string
65 phase1 bool
66 }{{"legacy", false}, {"phase 1", true}} {
67 for _, c := range []struct{ kind, state, want string }{
68 {seeds.KindReplica, seeds.StateReady, "wake the instance once before moving it"},
69 {seeds.KindImport, seeds.StateSwapped, "commit or abort the import first"},
70 } {
71 fx, token := seedMoveFixture(t, "code-seed-move", door.phase1)
72 putSeededRecord(t, fx.rel, seedMoveHost, "ie", c.kind, c.state)
73 code, _, raw := rawReq(t, http.MethodPost, instancesURL(fx, release.OpsAccount, "shop")+"/"+seedMoveHost+"/move", token, `{"region":"uk"}`)
74 if code != http.StatusConflict || errorText(raw) != c.want {
75 t.Fatalf("%s door, %s %s = %d %s", door.name, c.kind, c.state, code, raw)
76 }
77 got, err := provision.GetRecord(context.Background(), fx.rel, release.OpsAccount, "shop", seedMoveHost)
78 if err != nil || got.Move != nil {
79 t.Fatalf("%s door: a refused move left a journal: %+v, %v", door.name, got, err)
80 }
81 if claim := claimBytes(t, fx, seedMoveHost); claim != "" {
82 t.Fatalf("%s door: a refused move wrote a claim: %s", door.name, claim)
83 }
84 }
85 }
86}
87
88// A placed seed is not the helper's to refuse: the legacy door goes on to
89// its own checks (here, no armed regions).
90func TestMoveDoorLetsAPlacedSeedThrough(t *testing.T) {
91 fx, token := seedMoveFixture(t, "code-seed-move-placed", false)
92 putSeededRecord(t, fx.rel, seedMoveHost, "ie", seeds.KindReplica, seeds.StatePlaced)
93 code, _, raw := rawReq(t, http.MethodPost, instancesURL(fx, release.OpsAccount, "shop")+"/"+seedMoveHost+"/move", token, `{"region":"uk"}`)
94 if code != http.StatusBadRequest || errorText(raw) != "this deployment publishes no armed regions — nothing to move to" {
95 t.Fatalf("placed seed = %d %s", code, raw)
96 }
97}
98
99// placeSeedFixture is fleetFixture with the shop app, one route and an
100// owned fleet "pi" to place it on.
101func placeSeedFixture(t *testing.T, code string) (*consoleFixture, string) {
102 t.Helper()
103 fx, token := fleetFixture(t, code)
104 if _, err := fx.st.CreateAppIn(OpsAccountID, "shop", "paul@keymail.dev", time.Now()); err != nil {
105 t.Fatal(err)
106 }
107 if status, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/new",
108 map[string]string{"name": "pi", "bucket": "pi-fleet-data"}, token); status != http.StatusOK {
109 t.Fatalf("create fleet = %d %v", status, body)
110 }
111 return fx, token
112}
113
114// assertNotPlaced checks a refused place wrote nothing: the app's placement
115// and every record's region are as before.
116func assertNotPlaced(t *testing.T, fx *consoleFixture, hosts ...string) {
117 t.Helper()
118 if p, err := fx.st.AppPlacement(OpsAccountID, "shop"); err != nil || p != "" {
119 t.Fatalf("a refused place recorded placement %q, %v", p, err)
120 }
121 for _, h := range hosts {
122 got, err := provision.GetRecord(context.Background(), fx.rel, fx.sqid, "shop", h)
123 if err != nil || got.Region != "ie" {
124 t.Fatalf("a refused place re-homed %s: %+v, %v", h, got, err)
125 }
126 }
127}
128
129// carlos apps place re-homes every record of the app, so it is a move too
130// (preflight M8(a)): an unplaced --from seed or an uncommitted import
131// refuses it, with the deck's sentence, before any write.
132func TestPlaceRefusesAnUnplacedSeed(t *testing.T) {
133 for _, c := range []struct{ kind, state, want string }{
134 {seeds.KindReplica, seeds.StateReady, "wake the instance once before moving it"},
135 {seeds.KindImport, seeds.StatePlaced, "commit or abort the import first"},
136 } {
137 fx, token := placeSeedFixture(t, "code-place-seed")
138 putSeededRecord(t, fx.rel, "plain.example.com", "ie", seeds.KindReplica, seeds.StatePlaced)
139 putSeededRecord(t, fx.rel, seedMoveHost, "ie", c.kind, c.state)
140 code, _, raw := rawReq(t, http.MethodPost, placeURL(fx, "shop"), token, `{"target":"fleet/pi"}`)
141 if code != http.StatusConflict || errorText(raw) != c.want {
142 t.Fatalf("%s %s: place = %d %s", c.kind, c.state, code, raw)
143 }
144 assertNotPlaced(t, fx, "plain.example.com", seedMoveHost)
145 }
146}
147
148// A record the place cannot read could be a seed that may not move, so the
149// place is refused (it fails closed): a record that will not decode, and a
150// read that fails.
151func TestPlaceRefusesWhenARecordCannotBeRead(t *testing.T) {
152 fx, token := placeSeedFixture(t, "code-place-unreadable")
153 putSeededRecord(t, fx.rel, "plain.example.com", "ie", seeds.KindReplica, seeds.StatePlaced)
154 bad := provision.RecordKey(fx.sqid, "shop", "broken.example.com")
155 if _, err := fx.rel.Put(context.Background(), bad, []byte("{not json"), ""); err != nil {
156 t.Fatal(err)
157 }
158 code, _, raw := rawReq(t, http.MethodPost, placeURL(fx, "shop"), token, `{"target":"fleet/pi"}`)
159 if code != http.StatusBadGateway || errorText(raw) != "could not update this app's instances. Try again." {
160 t.Fatalf("an undecodable record: place = %d %s", code, raw)
161 }
162 assertNotPlaced(t, fx, "plain.example.com")
163 if err := fx.rel.Delete(context.Background(), bad); err != nil {
164 t.Fatal(err)
165 }
166
167 fx.srv.cfg.Releases = &failGetStore{Store: fx.rel, key: provision.RecordKey(fx.sqid, "shop", "plain.example.com")}
168 code, _, raw = rawReq(t, http.MethodPost, placeURL(fx, "shop"), token, `{"target":"fleet/pi"}`)
169 if code != http.StatusBadGateway || errorText(raw) != "could not update this app's instances. Try again." {
170 t.Fatalf("a failed read: place = %d %s", code, raw)
171 }
172 assertNotPlaced(t, fx, "plain.example.com")
173}
174
175// A record deleted between the strict read's listing and its read is the
176// I4 race one step earlier: the read is taken again, and the place lands.
177func TestPlaceReadsAgainWhenARecordVanishes(t *testing.T) {
178 fx, token := placeSeedFixture(t, "code-place-seed-vanish")
179 putSeededRecord(t, fx.rel, "plain.example.com", "ie", seeds.KindReplica, seeds.StatePlaced)
180 putSeededRecord(t, fx.rel, "gone.example.com", "ie", seeds.KindReplica, seeds.StatePlaced)
181 fx.srv.cfg.Releases = &vanishOnGetStore{Store: fx.rel, vanish: provision.RecordKey(fx.sqid, "shop", "gone.example.com")}
182 code, _, raw := rawReq(t, http.MethodPost, placeURL(fx, "shop"), token, `{"target":"fleet/pi"}`)
183 if code != http.StatusOK {
184 t.Fatalf("place = %d %s", code, raw)
185 }
186 got, err := provision.GetRecord(context.Background(), fx.rel, fx.sqid, "shop", "plain.example.com")
187 if err != nil || got.Region != "fleet/pi" {
188 t.Fatalf("after the place: %+v, %v", got, err)
189 }
190}
191
192// The backstop: a seeded record created after the place's read is refused
193// under SetRecordRegion's CAS, and the place answers the same sentence.
194func TestPlaceAnswersTheBackstopsSentence(t *testing.T) {
195 fx, token := placeSeedFixture(t, "code-place-seed-backstop")
196 putSeededRecord(t, fx.rel, "plain.example.com", "ie", seeds.KindReplica, seeds.StatePlaced)
197 fx.srv.cfg.Releases = &createOnSecondListStore{Store: fx.rel, prefix: provision.RecordsPrefix(fx.sqid, "shop"), create: func(st release.Store) {
198 putSeededRecord(t, st, seedMoveHost, "ie", seeds.KindImport, seeds.StateSwapped)
199 }}
200 code, _, raw := rawReq(t, http.MethodPost, placeURL(fx, "shop"), token, `{"target":"fleet/pi"}`)
201 if code != http.StatusConflict || errorText(raw) != "commit or abort the import first" {
202 t.Fatalf("place = %d %s", code, raw)
203 }
204 got, err := provision.GetRecord(context.Background(), fx.rel, fx.sqid, "shop", seedMoveHost)
205 if err != nil || got.Region != "ie" {
206 t.Fatalf("the late import was re-homed: %+v, %v", got, err)
207 }
208}
209
210// failGetStore fails every Get of one key.
211type failGetStore struct {
212 release.Store
213 key string
214}
215
216func (f *failGetStore) Get(ctx context.Context, key string) ([]byte, string, error) {
217 if key == f.key {
218 return nil, "", errors.New("injected read failure")
219 }
220 return f.Store.Get(ctx, key)
221}
222
223// createOnSecondListStore runs create once, just before the second List of
224// prefix: after the place's strict read, before its walk.
225type createOnSecondListStore struct {
226 release.Store
227 prefix string
228 create func(release.Store)
229 lists int
230}
231
232func (c *createOnSecondListStore) List(ctx context.Context, prefix string) ([]string, error) {
233 if prefix == c.prefix {
234 c.lists++
235 if c.lists == 2 {
236 c.create(c.Store)
237 }
238 }
239 return c.Store.List(ctx, prefix)
240}
241