carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package provision
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "strings"
8 "testing"
9 "time"
10
11 "github.com/carlosframework/platform/internal/release"
12 "github.com/carlosframework/platform/internal/seeds"
13)
14
15func seedTestRecord(t *testing.T, s release.Store, seed *SeedSource) Record {
16 t.Helper()
17 rec := Record{
18 Schema: 1, Host: "canary.example.com", Slug: "canary", App: "shop", Account: "bae",
19 Channel: "stable", CreatedAt: time.Date(2026, 10, 8, 9, 0, 0, 0, time.UTC), Seed: seed,
20 }
21 if err := CreateRecord(context.Background(), s, rec); err != nil {
22 t.Fatal(err)
23 }
24 return rec
25}
26
27func testSeed(t *testing.T, kind, state string) *SeedSource {
28 t.Helper()
29 id, err := seeds.NewID()
30 if err != nil {
31 t.Fatal(err)
32 }
33 return &SeedSource{ID: id, Kind: kind, State: state, By: "paul@rslw.com",
34 At: time.Date(2026, 10, 8, 9, 0, 0, 0, time.UTC)}
35}
36
37func TestSeedWireShapeIsAdditive(t *testing.T) {
38 const legacy = `{"schema":1,"host":"acme.example.com","slug":"acme","app":"shop",` +
39 `"account":"bab","channel":"stable","created_at":"2026-08-04T09:00:00Z"}`
40 var rec Record
41 if err := json.Unmarshal([]byte(legacy), &rec); err != nil {
42 t.Fatal(err)
43 }
44 if rec.Seed != nil {
45 t.Fatalf("Seed = %+v, want nil on a record without one", rec.Seed)
46 }
47 out, _ := json.Marshal(rec)
48 if strings.Contains(string(out), `"seed"`) {
49 t.Fatalf("a seedless record encodes a seed key: %s", out)
50 }
51 rec.Seed = &SeedSource{ID: "sd_aaaaaaaaaaaaaaaaaaaaaaaaaa", Kind: seeds.KindImport, State: seeds.StateAwaitingUpload,
52 Replaces: "127.0.0.1:8401", VerifyHash: strings.Repeat("b", 64)}
53 out, _ = json.Marshal(rec)
54 for _, want := range []string{`"seed":{"id":"sd_`, `"kind":"import"`, `"state":"awaiting-upload"`, `"replaces":"127.0.0.1:8401"`, `"verify_hash":"bbbb`} {
55 if !strings.Contains(string(out), want) {
56 t.Errorf("encoded record lacks %s: %s", want, out)
57 }
58 }
59}
60
61func TestOrdinaryWritersCannotChangeTheSeed(t *testing.T) {
62 ctx := context.Background()
63 s := release.NewDirStore(t.TempDir())
64 rec := seedTestRecord(t, s, testSeed(t, seeds.KindReplica, seeds.StateReady))
65 // Through the pointer the mutation is handed: the scaffold's copy must
66 // be deep, or this lands.
67 _, err := updateRecord(ctx, s, rec.Account, rec.App, rec.Host, func(r *Record) error {
68 r.Seed.State = seeds.StatePlaced
69 return nil
70 })
71 if !errors.Is(err, ErrSeedProtected) {
72 t.Fatalf("an in-place seed edit through updateRecord: err = %v, want ErrSeedProtected", err)
73 }
74 _, err = updateRecord(ctx, s, rec.Account, rec.App, rec.Host, func(r *Record) error {
75 r.Seed = nil
76 return nil
77 })
78 if !errors.Is(err, ErrSeedProtected) {
79 t.Fatalf("dropping the seed through updateRecord: err = %v, want ErrSeedProtected", err)
80 }
81 // An ordinary write that leaves the seed alone carries it through. It
82 // keeps the region: re-homing an unplaced seed is refused (Plan 8 Task 3).
83 if err := SetRecordRegion(ctx, s, rec.Account, rec.App, rec.Host, rec.Region); err != nil {
84 t.Fatal(err)
85 }
86 got, err := GetRecord(ctx, s, rec.Account, rec.App, rec.Host)
87 if err != nil || got.Seed == nil || got.Seed.ID != rec.Seed.ID || got.Seed.State != seeds.StateReady {
88 t.Fatalf("after SetRecordRegion: seed = %+v, %v; want it carried through", got.Seed, err)
89 }
90 // The etag-guarded writer refuses too.
91 cur, etag, err := GetRecordETag(ctx, s, rec.Account, rec.App, rec.Host)
92 if err != nil {
93 t.Fatal(err)
94 }
95 cur.Seed.State = seeds.StateAborted
96 if err := claimRawUpdateRecord(ctx, s, *cur, etag); !errors.Is(err, ErrSeedProtected) {
97 t.Fatalf("claimRawUpdateRecord with a changed seed: err = %v, want ErrSeedProtected", err)
98 }
99}
100
101func TestSeedPredicates(t *testing.T) {
102 var none *SeedSource
103 if none.Import() || none.BlocksRun() || none.MoveBlock() != "" || none.Abandoned() {
104 t.Error("a nil seed must block nothing")
105 }
106 for _, c := range []struct {
107 kind, state string
108 blocksRun bool
109 moveBlock string
110 abandoned bool
111 }{
112 {seeds.KindReplica, seeds.StateReserving, false, SeedMoveUnplaced, false},
113 {seeds.KindReplica, seeds.StateReady, false, SeedMoveUnplaced, false},
114 {seeds.KindReplica, seeds.StatePlaced, false, "", false},
115 {seeds.KindReplica, seeds.StateAborted, false, SeedMoveUnplaced, true},
116 {seeds.KindImport, seeds.StateAwaitingUpload, true, SeedMoveUncommitted, false},
117 {seeds.KindImport, seeds.StateSwapped, true, SeedMoveUncommitted, false},
118 {seeds.KindImport, seeds.StatePlaced, true, SeedMoveUncommitted, false},
119 {seeds.KindImport, seeds.StateCommitted, false, "", false},
120 {seeds.KindImport, seeds.StateRolledBack, true, SeedMoveUncommitted, true},
121 } {
122 s := &SeedSource{Kind: c.kind, State: c.state}
123 if s.BlocksRun() != c.blocksRun || s.MoveBlock() != c.moveBlock || s.Abandoned() != c.abandoned {
124 t.Errorf("%s %s: BlocksRun %v MoveBlock %q Abandoned %v; want %v %q %v",
125 c.kind, c.state, s.BlocksRun(), s.MoveBlock(), s.Abandoned(), c.blocksRun, c.moveBlock, c.abandoned)
126 }
127 }
128}
129
130func TestSeedImportWalksItsPath(t *testing.T) {
131 ctx := context.Background()
132 s := release.NewDirStore(t.TempDir())
133 seed := testSeed(t, seeds.KindImport, seeds.StateAwaitingUpload)
134 rec := seedTestRecord(t, s, seed)
135 for _, step := range []struct {
136 mut func(*Record) error
137 want string
138 }{
139 {SeedUploaded(seed.ID), seeds.StateUploaded},
140 {SeedStopped(seed.ID), seeds.StateReady},
141 {SeedSwapped(seed.ID), seeds.StateSwapped},
142 {SeedPlace(seed.ID), seeds.StatePlaced},
143 {SeedCommit(seed.ID), seeds.StateCommitted},
144 } {
145 got, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, step.mut)
146 if err != nil || got.Seed.State != step.want {
147 t.Fatalf("to %s: %+v, %v", step.want, got, err)
148 }
149 }
150 // After commit, abort is refused (spec §5.6).
151 if _, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedAbort(seed.ID)); !errors.Is(err, ErrSeedConflict) {
152 t.Fatalf("abort after commit: err = %v, want ErrSeedConflict", err)
153 }
154}
155
156func TestSeedReplicaPublishesWhatWasRestored(t *testing.T) {
157 ctx := context.Background()
158 s := release.NewDirStore(t.TempDir())
159 seed := testSeed(t, seeds.KindReplica, seeds.StateReserving)
160 seed.From = "acme.example.com"
161 rec := seedTestRecord(t, s, seed)
162 sha := strings.Repeat("c", 64)
163 got, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedPublishReplica(seed.ID, "4211", sha, 8192))
164 if err != nil || got.Seed.State != seeds.StateReady || got.Seed.TXID != "4211" || got.Seed.SHA256 != sha || got.Seed.Bytes != 8192 {
165 t.Fatalf("publish: %+v, %v", got.Seed, err)
166 }
167 if _, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedCommit(seed.ID)); !errors.Is(err, ErrSeedConflict) {
168 t.Fatalf("commit of a replica seed: err = %v, want ErrSeedConflict", err)
169 }
170}
171
172func TestSeedPlaceFollowsTheKind(t *testing.T) {
173 ctx := context.Background()
174 s := release.NewDirStore(t.TempDir())
175 seed := testSeed(t, seeds.KindImport, seeds.StateReady)
176 rec := seedTestRecord(t, s, seed)
177 if _, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedPlace(seed.ID)); !errors.Is(err, ErrSeedConflict) {
178 t.Fatalf("an import placed from ready: err = %v, want ErrSeedConflict", err)
179 }
180}
181
182func TestSeedTransitionsAreScopedToTheirID(t *testing.T) {
183 ctx := context.Background()
184 s := release.NewDirStore(t.TempDir())
185 seed := testSeed(t, seeds.KindImport, seeds.StateAborted)
186 rec := seedTestRecord(t, s, seed)
187 other, _ := seeds.NewID()
188 if _, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedRolledBack(other)); !errors.Is(err, ErrSeedConflict) {
189 t.Fatalf("another id's rollback: err = %v, want ErrSeedConflict", err)
190 }
191 if _, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedRolledBack(seed.ID)); err != nil {
192 t.Fatalf("its own rollback: %v", err)
193 }
194}
195
196func TestSeedAbortAndRollbackBeforeSwap(t *testing.T) {
197 ctx := context.Background()
198 for _, state := range []string{seeds.StateAwaitingUpload, seeds.StateUploaded, seeds.StateRefused} {
199 s := release.NewDirStore(t.TempDir())
200 seed := testSeed(t, seeds.KindImport, state)
201 rec := seedTestRecord(t, s, seed)
202 got, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedRolledBackBeforeSwap(seed.ID))
203 if err != nil || got.Seed.State != seeds.StateRolledBack {
204 t.Fatalf("%s: %+v, %v", state, got, err)
205 }
206 }
207 for _, state := range []string{seeds.StateReady, seeds.StateSwapped, seeds.StatePlaced} {
208 s := release.NewDirStore(t.TempDir())
209 seed := testSeed(t, seeds.KindImport, state)
210 rec := seedTestRecord(t, s, seed)
211 if _, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedRolledBackBeforeSwap(seed.ID)); !errors.Is(err, ErrSeedConflict) {
212 t.Fatalf("%s straight to rolled-back: err = %v, want ErrSeedConflict", state, err)
213 }
214 if _, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedAbort(seed.ID)); err != nil {
215 t.Fatalf("%s abort: %v", state, err)
216 }
217 }
218}
219
220func TestSeedRefuseRecordsItsReason(t *testing.T) {
221 ctx := context.Background()
222 s := release.NewDirStore(t.TempDir())
223 seed := testSeed(t, seeds.KindImport, seeds.StateReady)
224 rec := seedTestRecord(t, s, seed)
225 got, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedRefuse(seed.ID, "the route belongs to app blog"))
226 if err != nil || got.Seed.State != seeds.StateRefused || got.Seed.Reason != "the route belongs to app blog" {
227 t.Fatalf("refuse: %+v, %v", got.Seed, err)
228 }
229}
230
231// A --from seed's abort records why; an empty reason keeps the one a
232// refused import already carries.
233func TestSeedAbortBecauseRecordsItsReason(t *testing.T) {
234 ctx := context.Background()
235 s := release.NewDirStore(t.TempDir())
236 seed := testSeed(t, seeds.KindReplica, seeds.StateReserving)
237 rec := seedTestRecord(t, s, seed)
238 got, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedAbortBecause(seed.ID, "the copy failed its check"))
239 if err != nil || got.Seed.State != seeds.StateAborted || got.Seed.Reason != "the copy failed its check" {
240 t.Fatalf("abort because = %+v, %v", got.Seed, err)
241 }
242 s = release.NewDirStore(t.TempDir())
243 seed = testSeed(t, seeds.KindImport, seeds.StateReady)
244 rec = seedTestRecord(t, s, seed)
245 if _, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedRefuse(seed.ID, "the route belongs to app blog")); err != nil {
246 t.Fatal(err)
247 }
248 got, err = SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedAbort(seed.ID))
249 if err != nil || got.Seed.State != seeds.StateAborted || got.Seed.Reason != "the route belongs to app blog" {
250 t.Fatalf("abort of a refused import = %+v, %v; want its refusal reason kept", got.Seed, err)
251 }
252}
253
254// The upload's abort records why, and refuses an import that has moved
255// on from awaiting-upload: it never aborts another upload's work.
256func TestSeedAbortUploadOnlyWhileAwaiting(t *testing.T) {
257 ctx := context.Background()
258 s := release.NewDirStore(t.TempDir())
259 seed := testSeed(t, seeds.KindImport, seeds.StateAwaitingUpload)
260 rec := seedTestRecord(t, s, seed)
261 got, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedAbortUploadBecause(seed.ID, "the upload failed its check"))
262 if err != nil || got.Seed.State != seeds.StateAborted || got.Seed.Reason != "the upload failed its check" {
263 t.Fatalf("abort upload = %+v, %v", got.Seed, err)
264 }
265 s = release.NewDirStore(t.TempDir())
266 seed = testSeed(t, seeds.KindImport, seeds.StateAwaitingUpload)
267 rec = seedTestRecord(t, s, seed)
268 if _, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedUploaded(seed.ID)); err != nil {
269 t.Fatal(err)
270 }
271 if _, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedAbortUploadBecause(seed.ID, "late")); !errors.Is(err, ErrSeedConflict) {
272 t.Fatalf("abort upload of an uploaded import: err = %v, want ErrSeedConflict", err)
273 }
274 if after, err := GetRecord(ctx, s, rec.Account, rec.App, rec.Host); err != nil || after.Seed.State != seeds.StateUploaded || after.Seed.Reason != "" {
275 t.Fatalf("after a refused upload abort = %+v, %v; want it uploaded", after.Seed, err)
276 }
277}
278
279func TestSeedColumn(t *testing.T) {
280 if SeedColumn(Record{}) != "" {
281 t.Error("a seedless record has a column value")
282 }
283 seed := testSeed(t, seeds.KindReplica, seeds.StateReady)
284 seed.SHA256, seed.Bytes = strings.Repeat("d", 64), 12
285 got, err := seeds.ParseRowSeed(SeedColumn(Record{Seed: seed}))
286 if err != nil || got == nil || got.ID != seed.ID || got.Kind != seeds.KindReplica || got.SHA256 != seed.SHA256 || got.Bytes != 12 || got.State != seeds.StateReady {
287 t.Fatalf("column = %+v, %v", got, err)
288 }
289}
290
291// The whole transition table (spec §3.2, §5.6), checked cell by cell: every
292// transition, for each kind, from every state, under its own id and under
293// another. A cell not in the table is ErrSeedConflict and leaves the seed
294// as it was.
295func TestSeedTransitionTable(t *testing.T) {
296 all := []string{seeds.StateReserving, seeds.StateAwaitingUpload, seeds.StateUploaded, seeds.StateReady,
297 seeds.StateSwapped, seeds.StatePlaced, seeds.StateCommitted, seeds.StateRefused, seeds.StateAborted, seeds.StateRolledBack}
298 type cell struct{ kind, from string }
299 table := map[string]struct {
300 mut func(id string) func(*Record) error
301 to map[cell]string
302 }{
303 "publish": {func(id string) func(*Record) error { return SeedPublishReplica(id, "1", strings.Repeat("e", 64), 1) },
304 map[cell]string{{seeds.KindReplica, seeds.StateReserving}: seeds.StateReady}},
305 "uploaded": {SeedUploaded, map[cell]string{{seeds.KindImport, seeds.StateAwaitingUpload}: seeds.StateUploaded}},
306 "stopped": {SeedStopped, map[cell]string{{seeds.KindImport, seeds.StateUploaded}: seeds.StateReady}},
307 "swapped": {SeedSwapped, map[cell]string{{seeds.KindImport, seeds.StateReady}: seeds.StateSwapped}},
308 "place": {SeedPlace, map[cell]string{
309 {seeds.KindReplica, seeds.StateReady}: seeds.StatePlaced,
310 {seeds.KindImport, seeds.StateSwapped}: seeds.StatePlaced}},
311 "commit": {SeedCommit, map[cell]string{{seeds.KindImport, seeds.StatePlaced}: seeds.StateCommitted}},
312 "refuse": {func(id string) func(*Record) error { return SeedRefuse(id, "no") },
313 map[cell]string{{seeds.KindImport, seeds.StateReady}: seeds.StateRefused}},
314 "abort": {SeedAbort, map[cell]string{
315 {seeds.KindReplica, seeds.StateReserving}: seeds.StateAborted,
316 {seeds.KindReplica, seeds.StateReady}: seeds.StateAborted,
317 {seeds.KindImport, seeds.StateAwaitingUpload}: seeds.StateAborted,
318 {seeds.KindImport, seeds.StateUploaded}: seeds.StateAborted,
319 {seeds.KindImport, seeds.StateReady}: seeds.StateAborted,
320 {seeds.KindImport, seeds.StateSwapped}: seeds.StateAborted,
321 {seeds.KindImport, seeds.StatePlaced}: seeds.StateAborted,
322 {seeds.KindImport, seeds.StateRefused}: seeds.StateAborted}},
323 "abort upload": {func(id string) func(*Record) error { return SeedAbortUploadBecause(id, "no") },
324 map[cell]string{{seeds.KindImport, seeds.StateAwaitingUpload}: seeds.StateAborted}},
325 "rolled back": {SeedRolledBack, map[cell]string{
326 {seeds.KindReplica, seeds.StateAborted}: seeds.StateRolledBack,
327 {seeds.KindImport, seeds.StateAborted}: seeds.StateRolledBack}},
328 "rolled back before swap": {SeedRolledBackBeforeSwap, map[cell]string{
329 {seeds.KindImport, seeds.StateAwaitingUpload}: seeds.StateRolledBack,
330 {seeds.KindImport, seeds.StateUploaded}: seeds.StateRolledBack,
331 {seeds.KindImport, seeds.StateRefused}: seeds.StateRolledBack}},
332 }
333 other, err := seeds.NewID()
334 if err != nil {
335 t.Fatal(err)
336 }
337 for name, tr := range table {
338 for _, kind := range []string{seeds.KindReplica, seeds.KindImport} {
339 for _, from := range all {
340 seed := testSeed(t, kind, from)
341 // Under another id, no cell moves.
342 rec := Record{Seed: cloneSeed(seed)}
343 if err := tr.mut(other)(&rec); !errors.Is(err, ErrSeedConflict) || !seedEqual(rec.Seed, seed) {
344 t.Errorf("%s, %s %s, another id: err %v, seed %+v", name, kind, from, err, rec.Seed)
345 }
346 rec = Record{Seed: cloneSeed(seed)}
347 err := tr.mut(seed.ID)(&rec)
348 want, ok := tr.to[cell{kind, from}]
349 switch {
350 case ok && (err != nil || rec.Seed.State != want):
351 t.Errorf("%s, %s %s: err %v, state %s; want %s", name, kind, from, err, rec.Seed.State, want)
352 case !ok && (!errors.Is(err, ErrSeedConflict) || !seedEqual(rec.Seed, seed)):
353 t.Errorf("%s, %s %s: err %v, seed %+v; want ErrSeedConflict and no change", name, kind, from, err, rec.Seed)
354 }
355 }
356 }
357 // A seedless record has nothing to move.
358 var rec Record
359 if err := tr.mut(other)(&rec); !errors.Is(err, ErrSeedConflict) || rec.Seed != nil {
360 t.Errorf("%s on a seedless record: err %v, seed %+v", name, err, rec.Seed)
361 }
362 }
363}
364
365// An import's abort is refused while a run is open on the instance (spec
366// §5.6, run spec §5.1), although run admission already refuses an
367// uncommitted import. Once the run is terminal the abort goes through.
368func TestSeedAbortOfAnImportWaitsForAnOpenRun(t *testing.T) {
369 ctx := context.Background()
370 s := release.NewDirStore(t.TempDir())
371 seed := testSeed(t, seeds.KindImport, seeds.StatePlaced)
372 rec := Record{
373 Schema: 1, Host: "canary.example.com", Slug: "canary", App: "shop", Account: "bae",
374 Channel: "stable", CreatedAt: time.Date(2026, 10, 8, 9, 0, 0, 0, time.UTC), Seed: seed,
375 ActiveRun: &RunState{ID: "run_a", State: RunQueued, Deadline: time.Now().Add(time.Minute)},
376 }
377 if err := CreateRecord(ctx, s, rec); err != nil {
378 t.Fatal(err)
379 }
380 if _, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedAbort(seed.ID)); !errors.Is(err, ErrRunOpen) {
381 t.Fatalf("abort with a run open: err = %v, want ErrRunOpen", err)
382 }
383 if got, err := GetRecord(ctx, s, rec.Account, rec.App, rec.Host); err != nil || got.Seed.State != seeds.StatePlaced {
384 t.Fatalf("after the refusal: %+v, %v; want the seed still placed", got, err)
385 }
386 if _, err := SetRecordRun(ctx, s, rec.Account, rec.App, rec.Host, RunExpire("run_a")); err != nil {
387 t.Fatal(err)
388 }
389 if got, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedAbort(seed.ID)); err != nil || got.Seed.State != seeds.StateAborted {
390 t.Fatalf("abort with the run expired: %+v, %v", got, err)
391 }
392}
393
394// A --from seed's abort is the door's own failure path (spec §4 step 7): a
395// run admitted on the reserved record must not leave it stuck reserving.
396func TestSeedAbortOfAReplicaIgnoresAnOpenRun(t *testing.T) {
397 ctx := context.Background()
398 s := release.NewDirStore(t.TempDir())
399 seed := testSeed(t, seeds.KindReplica, seeds.StateReserving)
400 rec := seedTestRecord(t, s, seed)
401 if _, err := SetRecordRun(ctx, s, rec.Account, rec.App, rec.Host, RunAdmit(RunAdmission{ID: "run_a", Deadline: time.Now().Add(time.Minute)})); err != nil {
402 t.Fatal(err)
403 }
404 if got, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedAbort(seed.ID)); err != nil || got.Seed.State != seeds.StateAborted {
405 t.Fatalf("abort: %+v, %v", got, err)
406 }
407}
408
409// The guard's other two directions (Task 2): an ordinary writer cannot give
410// a seedless record a seed, and the etag-guarded writer cannot drop one.
411func TestOrdinaryWritersCannotAddOrDropASeed(t *testing.T) {
412 ctx := context.Background()
413 s := release.NewDirStore(t.TempDir())
414 rec := seedTestRecord(t, s, nil)
415 _, err := updateRecord(ctx, s, rec.Account, rec.App, rec.Host, func(r *Record) error {
416 r.Seed = testSeed(t, seeds.KindImport, seeds.StateCommitted)
417 return nil
418 })
419 if !errors.Is(err, ErrSeedProtected) {
420 t.Fatalf("adding a seed through updateRecord: err = %v, want ErrSeedProtected", err)
421 }
422 if got, err := GetRecord(ctx, s, rec.Account, rec.App, rec.Host); err != nil || got.Seed != nil {
423 t.Fatalf("after the refusal: seed = %+v, %v; want none", got.Seed, err)
424 }
425
426 s2 := release.NewDirStore(t.TempDir())
427 seeded := seedTestRecord(t, s2, testSeed(t, seeds.KindReplica, seeds.StatePlaced))
428 cur, etag, err := GetRecordETag(ctx, s2, seeded.Account, seeded.App, seeded.Host)
429 if err != nil {
430 t.Fatal(err)
431 }
432 cur.Seed = nil
433 if err := claimRawUpdateRecord(ctx, s2, *cur, etag); !errors.Is(err, ErrSeedProtected) {
434 t.Fatalf("claimRawUpdateRecord dropping the seed: err = %v, want ErrSeedProtected", err)
435 }
436 if got, err := GetRecord(ctx, s2, seeded.Account, seeded.App, seeded.Host); err != nil || got.Seed == nil || got.Seed.ID != seeded.Seed.ID {
437 t.Fatalf("after the refusal: seed = %+v, %v; want it kept", got.Seed, err)
438 }
439}
440
441// The sweep's abort lands only on the seed exactly as the sweep read it:
442// the same id, in the same state, since the same At. A seed that moved on
443// (published, stopped) or was re-stamped is refused, and so is a state
444// SeedAbort itself refuses.
445func TestSeedAbortStaleOnlyFromWhatWasRead(t *testing.T) {
446 ctx := context.Background()
447 for _, c := range []struct{ kind, state string }{
448 {seeds.KindReplica, seeds.StateReserving},
449 {seeds.KindImport, seeds.StateAwaitingUpload},
450 {seeds.KindImport, seeds.StateUploaded},
451 } {
452 s := release.NewDirStore(t.TempDir())
453 seed := testSeed(t, c.kind, c.state)
454 rec := seedTestRecord(t, s, seed)
455 got, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedAbortStale(seed.ID, c.state, seed.At))
456 if err != nil || got.Seed.State != seeds.StateAborted || got.Seed.Reason != "" {
457 t.Fatalf("%s %s: %+v, %v", c.kind, c.state, got, err)
458 }
459 }
460
461 s := release.NewDirStore(t.TempDir())
462 seed := testSeed(t, seeds.KindImport, seeds.StateUploaded)
463 rec := seedTestRecord(t, s, seed)
464 other, _ := seeds.NewID()
465 for name, mut := range map[string]func(*Record) error{
466 "another id": SeedAbortStale(other, seeds.StateUploaded, seed.At),
467 "another state": SeedAbortStale(seed.ID, seeds.StateAwaitingUpload, seed.At),
468 "another moment": SeedAbortStale(seed.ID, seeds.StateUploaded, seed.At.Add(time.Second)),
469 } {
470 if _, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, mut); !errors.Is(err, ErrSeedConflict) {
471 t.Fatalf("%s: err = %v, want ErrSeedConflict", name, err)
472 }
473 }
474 // The app stops it: the seed moved on, and the abort read before that
475 // is refused.
476 if _, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedStopped(seed.ID)); err != nil {
477 t.Fatal(err)
478 }
479 if _, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedAbortStale(seed.ID, seeds.StateUploaded, seed.At)); !errors.Is(err, ErrSeedConflict) {
480 t.Fatalf("after the stop: err = %v, want ErrSeedConflict", err)
481 }
482 got, err := GetRecord(ctx, s, rec.Account, rec.App, rec.Host)
483 if err != nil || got.Seed.State != seeds.StateReady {
484 t.Fatalf("after a refused stale abort: %+v, %v; want ready", got, err)
485 }
486
487 // A state SeedAbort refuses stays refused: a placed --from seed.
488 s = release.NewDirStore(t.TempDir())
489 seed = testSeed(t, seeds.KindReplica, seeds.StatePlaced)
490 rec = seedTestRecord(t, s, seed)
491 if _, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedAbortStale(seed.ID, seeds.StatePlaced, seed.At)); !errors.Is(err, ErrSeedConflict) {
492 t.Fatalf("placed --from: err = %v, want ErrSeedConflict", err)
493 }
494}
495
496func TestSeedMoveSentence(t *testing.T) {
497 for block, want := range map[string]string{
498 SeedMoveUnplaced: "wake the instance once before moving it",
499 SeedMoveUncommitted: "commit or abort the import first",
500 "": "",
501 "other": "",
502 } {
503 if got := SeedMoveSentence(block); got != want {
504 t.Errorf("SeedMoveSentence(%q) = %q, want %q", block, got, want)
505 }
506 }
507}
508
509// validMoveStart is a start MoveStartTransition accepts on its own: the
510// seed refusal is then the only reason it can refuse (preflight m4).
511func validMoveStart(from string) MoveStart {
512 return MoveStart{ID: "mv_aaaaaaaaaaaaaaaaaaaaaaaaaa", Incarnation: strings.Repeat("a", 64), From: from, To: "uk", Cut: "auto", By: "op", Now: time.Now()}
513}
514
515func TestMoveTransitionsRefuseAnUnplacedSeed(t *testing.T) {
516 ctx := context.Background()
517 for _, c := range []struct {
518 kind, state, block, want string
519 }{
520 {seeds.KindReplica, seeds.StateReady, SeedMoveUnplaced, "wake the instance once before moving it"},
521 {seeds.KindReplica, seeds.StateAborted, SeedMoveUnplaced, "wake the instance once before moving it"},
522 {seeds.KindImport, seeds.StatePlaced, SeedMoveUncommitted, "commit or abort the import first"},
523 {seeds.KindImport, seeds.StateAwaitingUpload, SeedMoveUncommitted, "commit or abort the import first"},
524 } {
525 s := release.NewDirStore(t.TempDir())
526 rec := seedTestRecord(t, s, testSeed(t, c.kind, c.state))
527 for name, mut := range map[string]func(*Record) error{
528 "request": MoveRequest("uk", time.Now()),
529 "start": MoveStartTransition(validMoveStart(rec.Region)),
530 } {
531 _, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, mut)
532 var refusal *SeedMoveError
533 if !errors.Is(err, ErrMoveConflict) || !strings.Contains(err.Error(), c.want) || !errors.As(err, &refusal) || refusal.Block != c.block {
534 t.Fatalf("%s %s %s: err = %v", c.kind, c.state, name, err)
535 }
536 }
537 got, err := GetRecord(ctx, s, rec.Account, rec.App, rec.Host)
538 if err != nil || got.Move != nil {
539 t.Fatalf("%s %s: a refused move left a journal: %+v, %v", c.kind, c.state, got, err)
540 }
541 }
542 for _, c := range []struct{ kind, state string }{
543 {seeds.KindReplica, seeds.StatePlaced},
544 {seeds.KindImport, seeds.StateCommitted},
545 } {
546 for name, mut := range map[string]func(*Record) error{
547 "request": MoveRequest("uk", time.Now()),
548 "start": MoveStartTransition(validMoveStart("")),
549 } {
550 s := release.NewDirStore(t.TempDir())
551 rec := seedTestRecord(t, s, testSeed(t, c.kind, c.state))
552 if _, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, mut); err != nil {
553 t.Fatalf("%s %s %s: %v", c.kind, c.state, name, err)
554 }
555 }
556 }
557}
558
559// SetRecordRegion is carlos apps place's write: it re-homes a record, so it
560// refuses a seed that may not move, under its CAS (preflight M8(a)). A
561// write that leaves the region as it is moves nothing and is let through.
562func TestSetRecordRegionRefusesAnUnplacedSeed(t *testing.T) {
563 ctx := context.Background()
564 for _, c := range []struct{ kind, state, want string }{
565 {seeds.KindReplica, seeds.StateReady, "wake the instance once before moving it"},
566 {seeds.KindImport, seeds.StateSwapped, "commit or abort the import first"},
567 } {
568 s := release.NewDirStore(t.TempDir())
569 rec := seedTestRecord(t, s, testSeed(t, c.kind, c.state))
570 err := SetRecordRegion(ctx, s, rec.Account, rec.App, rec.Host, "fleet/pi")
571 if !errors.Is(err, ErrMoveConflict) || !strings.Contains(err.Error(), c.want) {
572 t.Fatalf("%s %s: err = %v", c.kind, c.state, err)
573 }
574 if got, err := GetRecord(ctx, s, rec.Account, rec.App, rec.Host); err != nil || got.Region != rec.Region {
575 t.Fatalf("%s %s: a refused re-home wrote: %+v, %v", c.kind, c.state, got, err)
576 }
577 if err := SetRecordRegion(ctx, s, rec.Account, rec.App, rec.Host, rec.Region); err != nil {
578 t.Fatalf("%s %s: a write that keeps the region: %v", c.kind, c.state, err)
579 }
580 }
581 s := release.NewDirStore(t.TempDir())
582 rec := seedTestRecord(t, s, testSeed(t, seeds.KindReplica, seeds.StatePlaced))
583 if err := SetRecordRegion(ctx, s, rec.Account, rec.App, rec.Host, "fleet/pi"); err != nil {
584 t.Fatalf("a placed seed: %v", err)
585 }
586 if got, err := GetRecord(ctx, s, rec.Account, rec.App, rec.Host); err != nil || got.Region != "fleet/pi" {
587 t.Fatalf("a placed seed after the re-home: %+v, %v", got, err)
588 }
589}
590
591// Merge review S2: the states claim activation refuses over, exactly.
592func TestSeedUnfinished(t *testing.T) {
593 var none *SeedSource
594 if none.Unfinished() {
595 t.Fatal("a record with no seed is unfinished")
596 }
597 for _, c := range []struct {
598 kind, state string
599 want bool
600 }{
601 {seeds.KindReplica, seeds.StateReserving, true},
602 {seeds.KindReplica, seeds.StateReady, true},
603 {seeds.KindReplica, seeds.StatePlaced, false},
604 {seeds.KindReplica, seeds.StateAborted, true},
605 {seeds.KindReplica, seeds.StateRolledBack, true},
606 {seeds.KindImport, seeds.StateAwaitingUpload, true},
607 {seeds.KindImport, seeds.StateUploaded, true},
608 {seeds.KindImport, seeds.StateReady, true},
609 {seeds.KindImport, seeds.StateSwapped, true},
610 {seeds.KindImport, seeds.StatePlaced, true},
611 {seeds.KindImport, seeds.StateCommitted, false},
612 {seeds.KindImport, seeds.StateRefused, true},
613 {seeds.KindImport, "a state from a later build", true},
614 {seeds.KindImport, seeds.StateAborted, true},
615 {seeds.KindImport, seeds.StateRolledBack, true},
616 {seeds.KindReplica, seeds.StateCommitted, true},
617 {"a kind from a later build", seeds.StatePlaced, true},
618 {"a kind from a later build", seeds.StateCommitted, true},
619 {"", seeds.StateCommitted, true},
620 } {
621 if got := (&SeedSource{Kind: c.kind, State: c.state}).Unfinished(); got != c.want {
622 t.Errorf("%s %s: unfinished %v, want %v", c.kind, c.state, got, c.want)
623 }
624 }
625}
626