carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package host
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "os"
8 "slices"
9 "sort"
10 "strings"
11 "sync"
12 "sync/atomic"
13 "testing"
14 "time"
15
16 "github.com/carlosframework/platform/internal/appconfig"
17 "github.com/carlosframework/platform/internal/domains"
18 "github.com/carlosframework/platform/internal/provision"
19 "github.com/carlosframework/platform/internal/registry"
20 "github.com/carlosframework/platform/internal/release"
21 "github.com/carlosframework/platform/internal/telemetry"
22)
23
24// ciCountingStore is a DirStore with the change index on that records every
25// read by method and key. Embedding the concrete store forwards every
26// optional extension (ConditionalGetter and ETagLister included), so the
27// view and the watch see the store production gives them.
28type ciCountingStore struct {
29 *release.DirStore
30 mu sync.Mutex
31 calls []string
32 failList string // List and ListETags of exactly this prefix fail
33 // stall, when set, holds every GetIfChanged of the change index until
34 // the caller's ctx ends or stall is closed: a hung index GET while the
35 // rest of the bucket answers.
36 stall chan struct{}
37}
38
39// stallIndex hangs the index GET until the returned func is called.
40func (s *ciCountingStore) stallIndex() (release func()) {
41 ch := make(chan struct{})
42 s.mu.Lock()
43 s.stall = ch
44 s.mu.Unlock()
45 return func() {
46 s.mu.Lock()
47 s.stall = nil
48 s.mu.Unlock()
49 close(ch)
50 }
51}
52
53func (s *ciCountingStore) setFailList(p string) { s.mu.Lock(); s.failList = p; s.mu.Unlock() }
54
55func (s *ciCountingStore) listFails(prefix string) bool {
56 s.mu.Lock()
57 defer s.mu.Unlock()
58 return s.failList != "" && s.failList == prefix
59}
60
61func newCICountingStore(t *testing.T) *ciCountingStore {
62 t.Helper()
63 d := release.NewDirStore(t.TempDir())
64 d.EnableChangeIndex()
65 return &ciCountingStore{DirStore: d}
66}
67
68func (s *ciCountingStore) note(method, key string) {
69 s.mu.Lock()
70 s.calls = append(s.calls, method+" "+key)
71 s.mu.Unlock()
72}
73
74func (s *ciCountingStore) reset() { s.mu.Lock(); s.calls = nil; s.mu.Unlock() }
75
76func (s *ciCountingStore) got() []string {
77 s.mu.Lock()
78 defer s.mu.Unlock()
79 out := slices.Clone(s.calls)
80 sort.Strings(out)
81 return out
82}
83
84// count is how many recorded calls start with method+" " and name a key
85// under prefix.
86func (s *ciCountingStore) count(method, prefix string) int {
87 n := 0
88 for _, c := range s.got() {
89 if strings.HasPrefix(c, method+" "+prefix) {
90 n++
91 }
92 }
93 return n
94}
95
96func (s *ciCountingStore) Get(ctx context.Context, key string) ([]byte, string, error) {
97 s.note("Get", key)
98 return s.DirStore.Get(ctx, key)
99}
100
101func (s *ciCountingStore) GetIfChanged(ctx context.Context, key, etag string) ([]byte, string, bool, error) {
102 s.note("GetIfChanged", key)
103 s.mu.Lock()
104 stall := s.stall
105 s.mu.Unlock()
106 if stall != nil && key == release.ChangeIndexKey {
107 select {
108 case <-ctx.Done():
109 return nil, "", false, ctx.Err()
110 case <-stall:
111 }
112 }
113 return s.DirStore.GetIfChanged(ctx, key, etag)
114}
115
116func (s *ciCountingStore) List(ctx context.Context, prefix string) ([]string, error) {
117 s.note("List", prefix)
118 if s.listFails(prefix) {
119 return nil, errors.New("list refused")
120 }
121 return s.DirStore.List(ctx, prefix)
122}
123
124func (s *ciCountingStore) ListETags(ctx context.Context, prefix string) ([]release.KeyETag, error) {
125 s.note("ListETags", prefix)
126 if s.listFails(prefix) {
127 return nil, errors.New("list refused")
128 }
129 return s.DirStore.ListETags(ctx, prefix)
130}
131
132func (s *ciCountingStore) ListDirs(ctx context.Context, prefix string) ([]string, error) {
133 s.note("ListDirs", prefix)
134 return s.DirStore.ListDirs(ctx, prefix)
135}
136
137func (s *ciCountingStore) ListMod(ctx context.Context, prefix string) ([]release.KeyMod, error) {
138 s.note("ListMod", prefix)
139 return s.DirStore.ListMod(ctx, prefix)
140}
141
142// ciWorld is a deployment of one account with two apps, shop and cafe, each
143// with one instance record and a config bundle, written through the indexed
144// store.
145type ciWorld struct {
146 live *ciCountingStore
147 acct string
148 now time.Time
149}
150
151func newCIWorld(t *testing.T) *ciWorld {
152 t.Helper()
153 t.Setenv("CARLOS_HOST_CLAIM", "") // the appconfig and provision guards read the process env
154 ctx := context.Background()
155 w := &ciWorld{live: newCICountingStore(t), acct: domains.Sqid(2), now: time.Date(2026, 10, 21, 9, 0, 0, 0, time.UTC)}
156 manifest, err := json.Marshal(map[string]any{"id": 2, "sqid": w.acct, "name": "fixture", "created_at": w.now})
157 if err != nil {
158 t.Fatal(err)
159 }
160 if _, err := w.live.Put(ctx, "accounts/"+w.acct+"/account.json", manifest, ""); err != nil {
161 t.Fatal(err)
162 }
163 for _, app := range []string{"shop", "cafe"} {
164 if err := provision.PutManifest(ctx, w.live, w.acct, app, provision.Manifest{Schema: 1, App: app, Domain: "example.com"}); err != nil {
165 t.Fatal(err)
166 }
167 w.record(t, app, app+".example.com")
168 if err := appconfig.SetEnv(ctx, w.live, w.acct, app, map[string]string{"APP": app}); err != nil {
169 t.Fatal(err)
170 }
171 }
172 w.live.reset()
173 return w
174}
175
176func (w *ciWorld) record(t *testing.T, app, host string) {
177 t.Helper()
178 rec := provision.Record{Schema: 1, Host: host, Slug: strings.SplitN(host, ".", 2)[0], Account: w.acct, App: app, Channel: "stable", CreatedAt: w.now}
179 if err := provision.CreateRecord(context.Background(), w.live, rec); err != nil {
180 t.Fatal(err)
181 }
182}
183
184func (w *ciWorld) scope(app string) string { return "apps/" + w.acct + "/" + app }
185
186// armView arms the change index over live for one test, as RunAgent does,
187// and disarms it afterwards. Tests that call it stay serial.
188func armView(t *testing.T, live release.Store) *provision.RecordView {
189 t.Helper()
190 watch := release.NewChangeWatch(live)
191 v := provision.NewRecordView(live, watch)
192 armedChangeWatch.Store(watch)
193 armedRecordView.Store(v)
194 t.Cleanup(func() {
195 armedChangeWatch.Store(nil)
196 armedRecordView.Store(nil)
197 })
198 return v
199}
200
201// disarmView makes sure no earlier test left the view armed.
202func disarmView(t *testing.T) {
203 t.Helper()
204 armedChangeWatch.Store(nil)
205 armedRecordView.Store(nil)
206}
207
208func ciReconcileConfig(t *testing.T) provision.ReconcileConfig {
209 return provision.ReconcileConfig{DataDir: t.TempDir(), Layout: flatLayout}
210}
211
212// recordFamilyLists counts listings under the record families.
213func recordFamilyLists(s *ciCountingStore) int {
214 n := 0
215 for _, m := range []string{"List", "ListETags", "ListDirs"} {
216 n += s.count(m, "apps/") + s.count(m, "accounts/")
217 }
218 return n
219}
220
221func TestChangeIndexArmedOnlyForRead(t *testing.T) {
222 t.Parallel()
223 for v, want := range map[string]bool{"": false, "read": true, "READ": false, "write": false, "1": false, " read": false} {
224 if got := changeIndexArmed(func(k string) string {
225 if k == "CARLOS_CHANGE_INDEX" {
226 return v
227 }
228 return ""
229 }); got != want {
230 t.Errorf("CARLOS_CHANGE_INDEX=%q armed = %v, want %v", v, got, want)
231 }
232 }
233}
234
235// A fleet box stays disarmed with the variable set: its store door has no
236// conditional read, so every Refresh would walk anyway (spec §3.4).
237func TestChangeIndexStaysDisarmedOnFleetBox(t *testing.T) {
238 disarmView(t)
239 t.Cleanup(func() { disarmView(t) })
240 opened := 0
241 open := func(context.Context) (release.Store, error) { opened++; return newCICountingStore(t), nil }
242 env := func(m map[string]string) func(string) string { return func(k string) string { return m[k] } }
243
244 if v := armChangeIndex(context.Background(), env(map[string]string{"CARLOS_CHANGE_INDEX": "read", "CARLOS_FLEET": "pi"}), open); v != nil || armedRecordView.Load() != nil || armedChangeWatch.Load() != nil {
245 t.Fatal("a fleet box armed the change index")
246 }
247 if v := armChangeIndex(context.Background(), env(nil), open); v != nil || armedRecordView.Load() != nil {
248 t.Fatal("an unset CARLOS_CHANGE_INDEX armed the change index")
249 }
250 if opened != 0 {
251 t.Fatalf("a disarmed box opened the deployment store %d time(s)", opened)
252 }
253 v := armChangeIndex(context.Background(), env(map[string]string{"CARLOS_CHANGE_INDEX": "read"}), open)
254 if v == nil || armedRecordView.Load() != v || armedChangeWatch.Load() == nil {
255 t.Fatal("a platform box with CARLOS_CHANGE_INDEX=read did not arm")
256 }
257}
258
259// Armed, the record walks of a reconcile pass (the offline keep view's
260// listing, Reconcile and ReconcileSidecars) read the view: once it is built,
261// two passes with no writes list nothing on the bucket.
262func TestReconcileUsesViewWhenArmed(t *testing.T) {
263 w := newCIWorld(t)
264 v := armView(t, w.live)
265 if err := v.Rebuild(context.Background()); err != nil {
266 t.Fatal(err)
267 }
268 reg := restartTestRegistry(t)
269 // A capable worker whose keep view has read nothing yet: the first pass
270 // lists records for it (refreshOfflineView), from the bucket, never the
271 // view (final review R1, finding 1: everything a move acts on is live).
272 mw := newMovesWorker(moveCfg{OfflineCapable: true})
273 mw.cfg.Offline = newOfflineView(mw.cfg, nil)
274 cfg := ciReconcileConfig(t)
275 w.live.reset()
276 if err := reconcileSerializedWith(context.Background(), w.live, reg, cfg, mw); err != nil {
277 t.Fatal(err)
278 }
279 if !mw.cfg.Offline.built() || mw.cfg.Offline.failed() {
280 t.Fatal("the offline keep view was not read")
281 }
282 if n := w.live.count("List", "accounts/"); n != 1 {
283 t.Fatalf("the unbuilt offline keep view listed accounts/ %d time(s), want once, from the bucket: %v", n, w.live.got())
284 }
285 w.live.reset()
286 for range 2 {
287 if err := reconcileSerializedWith(context.Background(), w.live, reg, cfg, mw); err != nil {
288 t.Fatal(err)
289 }
290 }
291 if n := w.live.count("List", ""); n != 0 {
292 t.Fatalf("two armed passes with no writes listed the bucket %d time(s): %v", n, w.live.got())
293 }
294 if n := recordFamilyLists(w.live); n != 0 {
295 t.Fatalf("two armed passes listed the record families %d time(s): %v", n, w.live.got())
296 }
297 for _, h := range []string{"shop.example.com", "cafe.example.com"} {
298 if rt, err := reg.Get(h); err != nil || rt == nil {
299 t.Fatalf("no route for %s from the view's records: %v", h, err)
300 }
301 }
302}
303
304// Disarmed, the same passes walk the bucket exactly as before the index.
305func TestReconcileWalksWhenDisarmed(t *testing.T) {
306 disarmView(t)
307 w := newCIWorld(t)
308 reg := restartTestRegistry(t)
309 cfg := ciReconcileConfig(t)
310 for range 2 {
311 if err := reconcileSerializedWith(context.Background(), w.live, reg, cfg, nil); err != nil {
312 t.Fatal(err)
313 }
314 }
315 if n := w.live.count("List", "accounts/"); n < 2 {
316 t.Fatalf("two disarmed passes listed accounts/ %d time(s), want one walk each: %v", n, w.live.got())
317 }
318 if n := w.live.count("GetIfChanged", ""); n != 0 {
319 t.Fatalf("a disarmed pass read the change index: %v", w.live.got())
320 }
321}
322
323// instancesLoop's floor is reconcileSerialized over its own store: armed,
324// with nothing changed, it costs one conditional GET of the index and no
325// listing.
326func TestInstancesLoopFloorIsRefreshWhenArmed(t *testing.T) {
327 w := newCIWorld(t)
328 v := armView(t, w.live)
329 if err := v.Rebuild(context.Background()); err != nil {
330 t.Fatal(err)
331 }
332 reg := restartTestRegistry(t)
333 cfg := ciReconcileConfig(t)
334 if err := reconcileSerialized(context.Background(), w.live, reg, cfg); err != nil {
335 t.Fatal(err)
336 }
337 w.live.reset()
338 if err := reconcileSerialized(context.Background(), w.live, reg, cfg); err != nil {
339 t.Fatal(err)
340 }
341 if n := w.live.count("GetIfChanged", release.ChangeIndexKey); n != 1 {
342 t.Fatalf("a quiet floor read the index %d time(s), want 1: %v", n, w.live.got())
343 }
344 if n := w.live.count("List", "") + w.live.count("ListETags", "") + w.live.count("ListDirs", ""); n != 0 {
345 t.Fatalf("a quiet floor listed the bucket: %v", w.live.got())
346 }
347}
348
349// A view that cannot vouch (its Refresh failed, so there is no Complete
350// snapshot) is never used: the caller walks live, as before the index.
351func TestViewStoreFallsBackWhenTheViewCannotVouch(t *testing.T) {
352 disarmView(t)
353 w := newCIWorld(t)
354 if s, _, ok := viewStore(context.Background(), w.live); ok || s != release.Store(w.live) {
355 t.Fatal("a disarmed viewStore did not hand back live")
356 }
357 armView(t, w.live)
358 w.live.setFailList("accounts/")
359 if s, _, ok := viewStore(context.Background(), w.live); ok || s != release.Store(w.live) {
360 t.Fatal("viewStore used the view after a failed Refresh")
361 }
362 w.live.setFailList("")
363 s, epoch, ok := viewStore(context.Background(), w.live)
364 if !ok || s == release.Store(w.live) || epoch == 0 {
365 t.Fatalf("viewStore after a good Refresh: ok %v epoch %d", ok, epoch)
366 }
367}
368
369// A hung index GET while the rest of the bucket answers (amadan #336 final
370// review, finding 3): the reconcile pass gives up on the index after
371// ChangeIndexPollTimeout, walks, and mints what the walk found; the watch
372// is unknown meanwhile, so a gated read marked before the stall is due
373// again; and the next good poll brings the watch back.
374func TestStalledIndexGetKeepsReconcileWalking(t *testing.T) {
375 w := newCIWorld(t)
376 v := armView(t, w.live)
377 if err := v.Rebuild(context.Background()); err != nil {
378 t.Fatal(err)
379 }
380 reg := restartTestRegistry(t)
381 cfg := ciReconcileConfig(t)
382 if err := reconcileSerialized(context.Background(), w.live, reg, cfg); err != nil {
383 t.Fatal(err)
384 }
385 gate := newChangeGate()
386 clock := newGateClock()
387 gen, _ := gate.Due(w.scope("shop"), clock.now())
388 gate.Mark(w.scope("shop"), gen, clock.now(), true)
389 if _, due := gate.Due(w.scope("shop"), clock.now()); due {
390 t.Fatal("fixture: a marked scope is due before the stall")
391 }
392
393 w.record(t, "shop", "two.shop.example.com")
394 unstall := w.live.stallIndex()
395 defer func() {
396 if unstall != nil {
397 unstall()
398 }
399 }()
400 done := make(chan error, 1)
401 go func() { done <- reconcileSerialized(context.Background(), w.live, reg, cfg) }()
402 select {
403 case err := <-done:
404 if err != nil {
405 t.Fatal(err)
406 }
407 case <-time.After(4 * release.ChangeIndexPollTimeout):
408 t.Fatal("a reconcile pass hung behind a stalled index GET")
409 }
410 if rt, err := reg.Get("two.shop.example.com"); err != nil || rt == nil {
411 t.Fatalf("the pass over a stalled index did not walk to the new record: %v", err)
412 }
413 if armedChangeWatch.Load().Usable() {
414 t.Fatal("the watch vouches after its poll gave up")
415 }
416 if _, due := gate.Due(w.scope("shop"), clock.now()); !due {
417 t.Fatal("a gated read trusted the frozen observation")
418 }
419
420 unstall()
421 unstall = nil
422 if err := reconcileSerialized(context.Background(), w.live, reg, cfg); err != nil {
423 t.Fatal(err)
424 }
425 if !armedChangeWatch.Load().Usable() {
426 t.Fatal("the watch did not recover on the next good poll")
427 }
428}
429
430// ---- moves -----------------------------------------------------------------
431
432// The moves pass decides whether it has work from its own refresh of the
433// view inside w.pass, never from the snapshot the reconcile leg of its pass
434// set read (spec §4.3, codex 1): pass A is paused after its reconcile, a
435// move is requested, and A's moves leg sees it and walks the bucket.
436func TestMovesPassDecidesFromItsOwnRefresh(t *testing.T) {
437 w := newCIWorld(t)
438 armView(t, w.live)
439 mw := newMovesWorker(moveCfg{})
440 armedMovesWorker.Store(mw)
441 t.Cleanup(func() { armedMovesWorker.Store(nil) })
442 reg := restartTestRegistry(t)
443 rcfg := ciReconcileConfig(t)
444 legs := &passLegs{store: w.live, reg: reg, optDir: t.TempDir(), dd: t.TempDir(),
445 cfg: func() provision.ReconcileConfig { return rcfg }}
446 legs.full(context.Background()) // walks once: nothing to move
447 if !mw.walkedIdle {
448 t.Fatal("fixture: a walk with no move left the worker busy")
449 }
450
451 paused, resume := make(chan struct{}), make(chan struct{})
452 var first atomic.Bool
453 passAfterReconcileHook = func() {
454 if first.CompareAndSwap(false, true) {
455 paused <- struct{}{}
456 <-resume
457 }
458 }
459 t.Cleanup(func() { passAfterReconcileHook = nil })
460 done := make(chan struct{})
461 go func() { defer close(done); legs.full(context.Background()) }() // pass A
462 <-paused
463 if _, err := provision.SetRecordMove(context.Background(), w.live, w.acct, "shop", "shop.example.com", provision.MoveRequest("uk", w.now)); err != nil {
464 t.Fatal(err)
465 }
466 w.live.reset()
467 close(resume)
468 <-done
469 if n := w.live.count("List", "accounts/"); n == 0 {
470 t.Fatalf("pass A's moves leg did not walk for a move requested after its reconcile: %v", w.live.got())
471 }
472 if mw.walkedIdle {
473 t.Fatal("a walk that found a move left the worker idle")
474 }
475}
476
477// With nothing to move, the armed moves pass costs no walk: the record view
478// vouches that no record carries a move, and the worker holds no move state.
479// Anything the worker holds (here, a cut's wake fence) makes it walk.
480func TestMovesPassSkipsTheWalkOnlyWithNoMoveWork(t *testing.T) {
481 w := newCIWorld(t)
482 armView(t, w.live)
483 mw := newMovesWorker(moveCfg{})
484 if err := movesPass(context.Background(), w.live, mw); err != nil {
485 t.Fatal(err)
486 }
487 w.live.reset()
488 if err := movesPass(context.Background(), w.live, mw); err != nil {
489 t.Fatal(err)
490 }
491 if n := recordFamilyLists(w.live); n != 0 {
492 t.Fatalf("an idle armed moves pass listed the record families: %v", w.live.got())
493 }
494 mw.fenced.Store("shop.example.com", uint64(1))
495 w.live.reset()
496 if err := movesPass(context.Background(), w.live, mw); err != nil {
497 t.Fatal(err)
498 }
499 if n := w.live.count("List", "accounts/"); n == 0 {
500 t.Fatalf("a worker holding a wake fence skipped its walk: %v", w.live.got())
501 }
502}
503
504// Final review R1 finding 1 (amadan #336): the moves pass acts on the
505// bucket, never on a snapshot older than what a pass already saw there.
506// An abort's completion misses its bump, so the view still holds the move
507// aborting; an unrelated scope then fails to re-read and a pass walks the
508// bucket instead (the live fallback); a new move of the same host is
509// requested (its bump missed too) and staging fills; the unrelated scope
510// recovers, so the view is Complete again with the old aborting state.
511// Acting on that snapshot would delete the new move's staging. The pass
512// walks the bucket, sees the new move preparing, and leaves staging alone.
513func TestMovesPassNeverActsOnAnOlderSnapshot(t *testing.T) {
514 h := newMoveHarness(t)
515 root := t.TempDir()
516 indexed := release.NewDirStore(root)
517 indexed.EnableChangeIndex()
518 live := &ciCountingStore{DirStore: indexed}
519 bypass := release.NewDirStore(root) // writes whose bump failed
520 ctx := context.Background()
521 edges, _, err := h.s.Get(ctx, "control/edges/uk-box.json")
522 if err != nil {
523 t.Fatal(err)
524 }
525 if _, err := live.Put(ctx, "control/edges/uk-box.json", edges, ""); err != nil {
526 t.Fatal(err)
527 }
528 h.s = live
529 host := "a.go.example.com"
530 h.record(t, host, provision.MoveRequest("uk", time.Now()), provision.MoveAbort())
531 other := "b.go.example.com"
532 if err := provision.CreateRecord(ctx, live, provision.Record{Schema: 1, Host: other, Slug: "b", App: "other", Account: "bab",
533 Region: "ie", Channel: "stable", CreatedAt: time.Now().UTC()}); err != nil {
534 t.Fatal(err)
535 }
536 v := armView(t, live)
537 if err := v.Rebuild(ctx); err != nil {
538 t.Fatal(err)
539 }
540 if recs, err := provision.ListAllRecords(ctx, v.Snapshot().Store(live)); err != nil || len(recs) != 2 {
541 t.Fatalf("fixture: the view holds %d record(s), %v; want both", len(recs), err)
542 }
543 // The abort completes, and its bump fails.
544 if _, err := provision.SetRecordMove(ctx, bypass, "bab", "app", host, provision.MoveAbortComplete()); err != nil {
545 t.Fatal(err)
546 }
547 // An unrelated scope moves and will not re-read: the view cannot vouch,
548 // and the pass walks the bucket, where the abort is complete.
549 third := provision.Record{Schema: 1, Host: "c.go.example.com", Slug: "c", App: "other", Account: "bab",
550 Region: "ie", Channel: "stable", CreatedAt: time.Now().UTC()}
551 if err := provision.CreateRecord(ctx, live, third); err != nil {
552 t.Fatal(err)
553 }
554 live.setFailList(provision.RecordsPrefix("bab", "other"))
555 if _, _, ok := viewStore(ctx, live); ok {
556 t.Fatal("fixture: the view vouched with a scope failing to re-read")
557 }
558 wk := h.worker()
559 if err := movesPass(ctx, live, wk); err != nil {
560 t.Fatal(err)
561 }
562 // The host is moved again, the bump failing again, and staging fills.
563 if _, err := provision.SetRecordMove(ctx, bypass, "bab", "app", host, provision.MoveRequest("uk", time.Now())); err != nil {
564 t.Fatal(err)
565 }
566 h.f.put("data-ie", "instances/"+host+"/generations/g/snapshot", "e1")
567 h.f.put("data-uk", "moves/"+host+"/generations/g/snapshot", "e1")
568 // The unrelated scope recovers: Complete again, with the old abort.
569 live.setFailList("")
570 if _, _, ok := viewStore(ctx, live); !ok {
571 t.Fatal("fixture: the view did not recover")
572 }
573 snapRec, err := provision.GetRecord(ctx, v.Snapshot().Store(live), "bab", "app", host)
574 if err != nil || snapRec.Move == nil || snapRec.Move.State != provision.MoveAborting {
575 t.Fatalf("fixture: the recovered snapshot should hold the old abort: %+v %v", snapRec, err)
576 }
577
578 if err := movesPass(ctx, live, wk); err != nil {
579 t.Fatal(err)
580 }
581 for _, op := range h.f.ops {
582 if strings.HasPrefix(op, "delete data-uk/moves/"+host+"/") {
583 t.Fatalf("the pass acted on the snapshot's old abort and deleted the new move's staging: %v", h.f.ops)
584 }
585 }
586 if staged := h.f.bodies("data-uk", "moves/"+host+"/"); len(staged) != 1 {
587 t.Fatalf("the new move's staging after the pass: %v", staged)
588 }
589 if got := h.read(t, host); got.Move == nil || got.Move.State == provision.MoveAborting {
590 t.Fatalf("the record after the pass: %+v", got.Move)
591 }
592}
593
594// ---- fastTick --------------------------------------------------------------
595
596// ciFastDeps is a fastDeps over an indexed store with counting hooks for
597// both pass shapes.
598func ciFastDeps(t *testing.T) (*fastDeps, *release.DirStore, *[]string, *[][]string) {
599 t.Helper()
600 env := newFastFixture(t)
601 ds := release.NewDirStore(t.TempDir())
602 ds.EnableChangeIndex()
603 env.store = ds
604 var full []string
605 var scoped [][]string
606 d := env.deps(func() { full = append(full, "full") })
607 d.watch = release.NewChangeWatch(ds)
608 d.scopedPass = func(_ context.Context, moved []string) { scoped = append(scoped, moved) }
609 return d, ds, &full, &scoped
610}
611
612func TestFastTickIndexOnlyRunsScopedPass(t *testing.T) {
613 t.Parallel()
614 ctx := context.Background()
615 d, ds, full, scoped := ciFastDeps(t)
616 now := time.Unix(1754640000, 0)
617 fastTick(ctx, d, now) // seeds the serial and the index: no pass
618 if len(*full)+len(*scoped) != 0 {
619 t.Fatalf("the seeding tick ran a pass: full %v scoped %v", *full, *scoped)
620 }
621 if _, err := ds.Put(ctx, "apps/bab/shop/instances/one.shop.example.com.json", []byte(`{}`), ""); err != nil {
622 t.Fatal(err)
623 }
624 fastTick(ctx, d, now.Add(10*time.Second))
625 if len(*full) != 0 {
626 t.Fatalf("an index-only change ran the full pass: %v", *full)
627 }
628 if want := [][]string{{"apps/bab/shop"}}; !slices.EqualFunc(*scoped, want, slices.Equal[[]string]) {
629 t.Fatalf("scoped passes %v, want %v", *scoped, want)
630 }
631 // Inside the clamp the change waits, and is acted on at the next tick
632 // past it.
633 if _, err := ds.Put(ctx, "apps/bab/cafe/instances/one.cafe.example.com.json", []byte(`{}`), ""); err != nil {
634 t.Fatal(err)
635 }
636 fastTick(ctx, d, now.Add(12*time.Second))
637 if len(*scoped) != 1 {
638 t.Fatalf("a change inside the clamp ran at once: %v", *scoped)
639 }
640 fastTick(ctx, d, now.Add(16*time.Second))
641 if want := [][]string{{"apps/bab/shop"}, {"apps/bab/cafe"}}; !slices.EqualFunc(*scoped, want, slices.Equal[[]string]) {
642 t.Fatalf("scoped passes %v, want %v", *scoped, want)
643 }
644 fastTick(ctx, d, now.Add(30*time.Second))
645 if len(*scoped) != 2 {
646 t.Fatalf("an unchanged index ran the scoped pass again: %v", *scoped)
647 }
648 // Scopes outside the record view's (control/edges) run no pass.
649 if _, err := ds.Put(ctx, "control/edges/one.json", []byte(`{}`), ""); err != nil {
650 t.Fatal(err)
651 }
652 fastTick(ctx, d, now.Add(40*time.Second))
653 if len(*scoped) != 2 || len(*full) != 0 {
654 t.Fatalf("a control/ scope ran a pass: full %v scoped %v", *full, *scoped)
655 }
656}
657
658// A shared watch: another poller (the record view) taking Poll's moved list
659// first does not hide the movement from fastTick.
660func TestFastTickActsOnObservedNotPollReturn(t *testing.T) {
661 t.Parallel()
662 ctx := context.Background()
663 d, ds, _, scoped := ciFastDeps(t)
664 now := time.Unix(1754640000, 0)
665 fastTick(ctx, d, now)
666 if _, err := ds.Put(ctx, "apps/bab/shop/instances/one.shop.example.com.json", []byte(`{}`), ""); err != nil {
667 t.Fatal(err)
668 }
669 if moved, err := d.watch.Poll(ctx); err != nil || len(moved) == 0 {
670 t.Fatalf("the sibling poll saw %v, %v", moved, err)
671 }
672 fastTick(ctx, d, now.Add(10*time.Second))
673 if want := [][]string{{"apps/bab/shop"}}; !slices.EqualFunc(*scoped, want, slices.Equal[[]string]) {
674 t.Fatalf("scoped passes %v, want %v: a sibling's Poll swallowed the movement", *scoped, want)
675 }
676}
677
678func TestSerialChangeStillRunsFullPass(t *testing.T) {
679 t.Parallel()
680 ctx := context.Background()
681 d, ds, full, scoped := ciFastDeps(t)
682 now := time.Unix(1754640000, 0)
683 fastTick(ctx, d, now)
684 if _, err := ds.Put(ctx, "apps/bab/shop/instances/one.shop.example.com.json", []byte(`{}`), ""); err != nil {
685 t.Fatal(err)
686 }
687 if err := release.BumpSerial(ctx, ds, now); err != nil {
688 t.Fatal(err)
689 }
690 fastTick(ctx, d, now.Add(10*time.Second))
691 if len(*full) != 1 || len(*scoped) != 0 {
692 t.Fatalf("a serial change with the index moved: full %v scoped %v, want one full pass only", *full, *scoped)
693 }
694 // The full pass covered the movement: the next tick runs nothing.
695 fastTick(ctx, d, now.Add(20*time.Second))
696 if len(*full) != 1 || len(*scoped) != 0 {
697 t.Fatalf("the movement the full pass covered ran again: full %v scoped %v", *full, *scoped)
698 }
699}
700
701// The backstop asks for the unrestricted full pass after its rebuild; the
702// loop's own tick runs it, once.
703func TestFastTickRunsTheBackstopsFullPass(t *testing.T) {
704 t.Parallel()
705 ctx := context.Background()
706 d, ds, full, scoped := ciFastDeps(t)
707 var due atomic.Bool
708 d.fullDue = &due
709 now := time.Unix(1754640000, 0)
710 fastTick(ctx, d, now)
711 v := provision.NewRecordView(ds, d.watch)
712 recordViewBackstop(ctx, v, &due)
713 if v.Snapshot() == nil {
714 t.Fatal("the backstop did not rebuild the view")
715 }
716 fastTick(ctx, d, now.Add(10*time.Second))
717 if len(*full) != 1 || len(*scoped) != 0 || due.Load() {
718 t.Fatalf("after the backstop: full %v scoped %v due %v, want one full pass", *full, *scoped, due.Load())
719 }
720 fastTick(ctx, d, now.Add(20*time.Second))
721 if len(*full) != 1 {
722 t.Fatalf("the backstop's full pass ran again: %v", *full)
723 }
724}
725
726func TestBackstopEveryIsJitteredAroundTheBackstop(t *testing.T) {
727 t.Parallel()
728 lo, hi := release.ChangeIndexBackstop*9/10, release.ChangeIndexBackstop*11/10
729 for range 200 {
730 if d := backstopEvery(); d < lo || d >= hi {
731 t.Fatalf("backstopEvery() = %v, want in [%v, %v)", d, lo, hi)
732 }
733 }
734}
735
736// ---- the scoped pass -------------------------------------------------------
737
738// Codex 10's witness: on a box hosting shop and cafe, an index bump for shop
739// runs a scoped pass that reads only shop's keys (its app prefix and its
740// build state), plus the change index, while shop's new config is
741// delivered. Nothing under cafe's prefix, nothing for cafe's build state,
742// and nothing box-wide (edges files, domain claims) is read.
743func TestScopedPassTouchesOnlyMovedApp(t *testing.T) {
744 w := newCIWorld(t)
745 armView(t, w.live)
746 armedMovesWorker.Store(newMovesWorker(moveCfg{}))
747 t.Cleanup(func() { armedMovesWorker.Store(nil) })
748 // A sink, so the telemetry leg runs (telemetryPass returns early without
749 // one): its reads are gated too, so the scoped pass reads only shop's.
750 var pushed []map[string]bool
751 setAnalyticsSink(func(on map[string]bool) { pushed = append(pushed, on) })
752 t.Cleanup(func() { analyticsSink.Store(nil) })
753 raw, _, err := appconfig.GenerateKey()
754 if err != nil {
755 t.Fatal(err)
756 }
757 pk, err := appconfig.ParsePrivateKey(raw, "test")
758 if err != nil {
759 t.Fatal(err)
760 }
761 reg := restartTestRegistry(t)
762 dd := t.TempDir()
763 // Build states live in the same counting store, under inst/<host>/.
764 bs := &buildStates{hibBucket: "hib", prefix: func(h string) string { return "inst/" + h },
765 storeFor: storesOf(map[string]release.Store{"hib": w.live}), reg: reg, optDir: t.TempDir(), bakeDir: t.TempDir(), now: time.Now}
766 legs := &passLegs{store: w.live, reg: reg, optDir: t.TempDir(), priv: pk, dd: dd, bs: bs,
767 cfg: func() provision.ReconcileConfig { return provision.ReconcileConfig{DataDir: dd, Layout: flatLayout} }}
768 w.live.reset()
769 legs.full(context.Background())
770 // The fixture exercises every box-wide leg on the full pass.
771 for _, want := range []string{"inst/cafe.example.com/", "domains/", "control/edges",
772 telemetry.AccountKey(w.acct), telemetry.AppKey(w.acct, "cafe")} {
773 if !slices.ContainsFunc(w.live.got(), func(c string) bool { _, k, _ := strings.Cut(c, " "); return strings.HasPrefix(k, want) }) {
774 t.Fatalf("fixture: the full pass read nothing under %s: %v", want, w.live.got())
775 }
776 }
777 for _, h := range []string{"shop.example.com", "cafe.example.com"} {
778 rt, err := reg.Get(h)
779 if err != nil || rt == nil {
780 t.Fatalf("fixture: no route for %s: %v", h, err)
781 }
782 if !strings.Contains(readConfigEnv(t, dd, *rt), "APP=") {
783 t.Fatalf("fixture: %s got no config from the full pass", h)
784 }
785 }
786
787 if err := appconfig.SetEnv(context.Background(), w.live, w.acct, "shop", map[string]string{"APP": "shop2"}); err != nil {
788 t.Fatal(err)
789 }
790 w.live.reset()
791 legs.scoped(context.Background(), []string{w.scope("shop")})
792
793 if len(pushed) != 2 || len(pushed[1]) != 0 {
794 t.Fatalf("switch maps pushed %v, want one per pass, both all off", pushed)
795 }
796 shop := "apps/" + w.acct + "/shop/"
797 var shopReads int
798 for _, c := range w.live.got() {
799 _, key, _ := strings.Cut(c, " ")
800 switch {
801 case strings.HasPrefix(key, shop):
802 shopReads++
803 case key == release.ChangeIndexKey, strings.HasPrefix(key, "inst/shop.example.com/"):
804 case strings.HasPrefix(key, "control/config-ack/") && strings.HasSuffix(key, "/shop.example.com.json"):
805 // shop's config acknowledgement, written for the delivery
806 default:
807 t.Errorf("the scoped pass for shop read %s, which is not shop's", c)
808 }
809 }
810 if shopReads == 0 {
811 t.Fatalf("the scoped pass read nothing under shop: %v", w.live.got())
812 }
813 rt, _ := reg.Get("shop.example.com")
814 if got := readConfigEnv(t, dd, *rt); !strings.Contains(got, "APP=shop2") {
815 t.Fatalf("shop's config after the scoped pass = %q, want APP=shop2", got)
816 }
817}
818
819func readConfigEnv(t *testing.T, dd string, rt registry.Route) string {
820 t.Helper()
821 b, err := os.ReadFile(appconfig.ConfigEnvPath(dd, rt))
822 if err != nil && !os.IsNotExist(err) {
823 t.Fatal(err)
824 }
825 return string(b)
826}
827