console: the witness that every claims-off console consumer ignores a fieldless record beside a retired slot
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 file changed,
+253
−0
internal/console/lifetimes_read_test.go+253 −0
diff --git a/internal/console/lifetimes_read_test.go b/internal/console/lifetimes_read_test.go| new file mode 100644 |
| index 000000000..9a2c8f3e7 |
| --- /dev/null |
| +++ b/internal/console/lifetimes_read_test.go |
| @@ -0,0 +1,253 @@ |
| +package console |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "errors" |
| + "net/http" |
| + "net/url" |
| + "os" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/seeds" |
| +) |
| + |
| +// The console's consumers of instance records under the lifetime judge |
| +// (self-serve moves plan 1, Task 6): a request or worker iteration admitted |
| +// on judges every record it reads, the record then its incarnation slot. The |
| +// scene is the host witness's (TestHostConsumersIgnoreAFieldlessRecordBesideASlot, |
| +// internal/host/lifetime_gating_test.go): a fieldless record beside a slot |
| +// {1, retired}, an old console's late write after the lifetime ended. It |
| +// carries an offline move, a queued run and a seed a box could place, so |
| +// every consumer has something to act on. Each scene has an unjudged control |
| +// on a console still before the cutover. |
| + |
| +const ( |
| + staleHost = ltOld // the fieldless record behind its slot |
| + validHost = ltOne // a legacy record with no slot: valid, the positive control |
| +) |
| + |
| +// staleScene writes the two records of shop and retires staleHost's |
| +// lifetime. Both start on fleet/acme, so a place to the shared fleet would |
| +// stamp each. |
| +func staleScene(t *testing.T, st release.Store) { |
| + t.Helper() |
| + ctx := context.Background() |
| + now := time.Now().UTC() |
| + seedID, err := seeds.NewID() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + stale := provision.Record{Schema: 1, Host: staleHost, Slug: "old", App: ltApp, Account: release.OpsAccount, |
| + Channel: "edge", Region: "fleet/acme", CreatedAt: now, |
| + Move: &provision.MoveState{ID: "mv_aaaaaaaaaaaaaaaaaaaaaaaaaa", Kind: provision.MoveKindOffline, From: "uk", To: "ie", |
| + State: provision.OfflineStaging, RequestedBy: "alice@keymail.dev", RequestedAt: now, UpdatedAt: now}, |
| + ActiveRun: &provision.RunState{ID: "run_aaaaaaaaaaaaaaaaaaaaaaaaaa", State: provision.RunQueued, By: "alice@keymail.dev", Deadline: now.Add(time.Hour)}, |
| + Seed: &provision.SeedSource{ID: seedID, Kind: seeds.KindReplica, State: seeds.StateReady, SHA256: "a", Bytes: 2, By: "alice@keymail.dev", At: now}, |
| + } |
| + valid := provision.Record{Schema: 1, Host: validHost, Slug: "one", App: ltApp, Account: release.OpsAccount, |
| + Channel: "edge", Region: "fleet/acme", CreatedAt: now} |
| + for _, rec := range []provision.Record{stale, valid} { |
| + if err := provision.CreateRecord(ctx, st, rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + retiredSlot(t, st, staleHost) |
| +} |
| + |
| +func recordBytes(t *testing.T, st release.Store, host string) ([]byte, string) { |
| + t.Helper() |
| + b, etag, err := st.Get(context.Background(), provision.RecordKey(release.OpsAccount, ltApp, host)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return b, etag |
| +} |
| + |
| +func recordHostsOf(recs []provision.Record) string { |
| + var out []string |
| + for _, r := range recs { |
| + out = append(out, r.Host) |
| + } |
| + return strings.Join(out, ",") |
| +} |
| + |
| +func TestEveryClaimsOffConsumerIgnoresAFieldlessRecordBesideASlot(t *testing.T) { |
| + // on is a console with lifetimes on over the scene; before is the |
| + // control, a console over the same scene before the cutover. |
| + on := func(t *testing.T) ltOn { |
| + l := newLtOn(t) |
| + staleScene(t, l.fx.rel) |
| + return l |
| + } |
| + before := func(t *testing.T) lifetimesFx { |
| + l := newLifetimesFx(t) |
| + staleScene(t, l.fx.rel) |
| + return l |
| + } |
| + beforeCtx := func(t *testing.T, srv *Server) context.Context { |
| + t.Helper() |
| + u, ok, why := srv.lifetimesAdmit(context.Background(), false) |
| + if !ok || u.mode != cutoverBefore { |
| + t.Fatalf("admitted %v %q, want a unit before", ok, why) |
| + } |
| + t.Cleanup(u.done) |
| + return context.WithValue(srv.recordCtx(context.Background(), u), admittedUnitKey{}, u) |
| + } |
| + |
| + t.Run("ReadRecordJudged and the console's readers", func(t *testing.T) { |
| + l := on(t) |
| + srv := l.fx.srv |
| + if _, _, err := provision.ReadRecordJudged(context.Background(), srv.cfg.Releases, srv.judge, release.OpsAccount, ltApp, staleHost); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("ReadRecordJudged = %v, want not found", err) |
| + } |
| + ctx := onCtx(t, srv) |
| + if provision.JudgeFrom(ctx) != srv.judge || srv.judge == nil { |
| + t.Fatal("a unit admitted on carries no judge") |
| + } |
| + if _, _, err := srv.readRecord(ctx, release.OpsAccount, ltApp, staleHost); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("readRecord = %v, want not found", err) |
| + } |
| + if obs := srv.observeRecord(ctx, release.OpsAccount, ltApp, staleHost); !obs.Present || obs.Verdict != provision.LifetimeStale { |
| + t.Fatalf("observeRecord = present %v, %v; want a present stale body", obs.Present, obs.Verdict) |
| + } |
| + if rec, _, err := srv.readRecord(ctx, release.OpsAccount, ltApp, validHost); err != nil || rec.Host != validHost { |
| + t.Fatalf("readRecord of the valid record = %v", err) |
| + } |
| + |
| + b := before(t) |
| + bctx := beforeCtx(t, b.fx.srv) |
| + if provision.JudgeFrom(bctx) != nil { |
| + t.Fatal("a unit admitted before carries a judge") |
| + } |
| + if rec, _, err := b.fx.srv.readRecord(bctx, release.OpsAccount, ltApp, staleHost); err != nil || rec.ActiveRun == nil || !rec.Move.Offline() || rec.Seed == nil { |
| + t.Fatalf("fixture: unjudged, the stale record does not read: %v", err) |
| + } |
| + }) |
| + |
| + t.Run("the five judged listings", func(t *testing.T) { |
| + l := on(t) |
| + srv := l.fx.srv |
| + ctx := onCtx(t, srv) |
| + recs, _, err := srv.listRecords(ctx, release.OpsAccount, ltApp, instanceListMax) |
| + if err != nil || recordHostsOf(recs) != validHost { |
| + t.Errorf("listRecords = %s, %v", recordHostsOf(recs), err) |
| + } |
| + recs, truncated, skipped, err := srv.listRecordsComplete(ctx, release.OpsAccount, ltApp, instanceListMax) |
| + if err != nil || truncated || len(skipped) != 0 || recordHostsOf(recs) != validHost { |
| + t.Errorf("listRecordsComplete = %s, %v %v %v", recordHostsOf(recs), truncated, skipped, err) |
| + } |
| + recs, unreadable, err := srv.listRecordsStrict(ctx, release.OpsAccount, ltApp, instanceListMax) |
| + if err != nil || len(unreadable) != 0 || recordHostsOf(recs) != validHost { |
| + t.Errorf("listRecordsStrict = %s, %v %v", recordHostsOf(recs), unreadable, err) |
| + } |
| + recs, unreadable, err = srv.listAllRecordsStrict(ctx) |
| + if err != nil || len(unreadable) != 0 || recordHostsOf(recs) != validHost { |
| + t.Errorf("listAllRecordsStrict = %s, %v %v", recordHostsOf(recs), unreadable, err) |
| + } |
| + recs, complete, err := srv.listAllRecordsTolerant(ctx) |
| + if err != nil || !complete || recordHostsOf(recs) != validHost { |
| + t.Errorf("listAllRecordsTolerant = %s, %v %v", recordHostsOf(recs), complete, err) |
| + } |
| + // The judged replica listing the instance API offers likewise. |
| + if recs, err := provision.ListRecordReplicasJudged(ctx, srv.cfg.Releases, provision.JudgeFrom(ctx), release.OpsAccount, ltApp, instanceListMax); err != nil || len(recs) != 0 { |
| + t.Errorf("ListRecordReplicasJudged = %s, %v", recordHostsOf(recs), err) |
| + } |
| + requireListing(t, l.lifetimesFx, validHost) |
| + |
| + // Control: before the cutover every listing has both. |
| + b := before(t) |
| + bctx := beforeCtx(t, b.fx.srv) |
| + want := staleHost + "," + validHost |
| + if got, _, err := b.fx.srv.listRecords(bctx, release.OpsAccount, ltApp, instanceListMax); err != nil || recordHostsOf(got) != want { |
| + t.Errorf("fixture: unjudged listRecords = %s, %v", recordHostsOf(got), err) |
| + } |
| + if got, _, err := b.fx.srv.listAllRecordsStrict(bctx); err != nil || recordHostsOf(got) != want { |
| + t.Errorf("fixture: unjudged listAllRecordsStrict = %s, %v", recordHostsOf(got), err) |
| + } |
| + requireListing(t, b, staleHost, validHost) |
| + }) |
| + |
| + t.Run("the console GET", func(t *testing.T) { |
| + l := on(t) |
| + base := instancesURL(l.fx, release.OpsAccount, ltApp) |
| + if code, _, body := rawReq(t, http.MethodGet, base+"/"+staleHost, l.member, ""); code != http.StatusNotFound { |
| + t.Fatalf("GET of the stale record = %d %s, want 404", code, body) |
| + } |
| + if code, _, body := rawReq(t, http.MethodGet, base+"/"+validHost, l.member, ""); code != http.StatusOK { |
| + t.Fatalf("GET of the valid record = %d %s", code, body) |
| + } |
| + b := before(t) |
| + if code, _, body := rawReq(t, http.MethodGet, instancesURL(b.fx, release.OpsAccount, ltApp)+"/"+staleHost, b.member, ""); code != http.StatusOK { |
| + t.Fatalf("fixture: unjudged GET = %d %s", code, body) |
| + } |
| + }) |
| + |
| + t.Run("the browser placement handler and placeAppTarget", func(t *testing.T) { |
| + for _, door := range []string{"browser", "placeAppTarget"} { |
| + t.Run(door, func(t *testing.T) { |
| + l := on(t) |
| + staleRaw, _ := recordBytes(t, l.fx.rel, staleHost) |
| + switch door { |
| + case "browser": |
| + c := signInPaul(t, l.fx) |
| + csrf := sessionToken(t, l.fx, c) |
| + code, loc, body := postForm(t, c, l.fx.u("/apps/"+ltApp+"/place"), url.Values{"csrf": {csrf}, "target": {""}}) |
| + if code != http.StatusSeeOther || !strings.HasSuffix(loc, "/apps/"+ltApp+"/instances") { |
| + t.Fatalf("place = %d -> %q %s", code, loc, body) |
| + } |
| + case "placeAppTarget": |
| + acct, err := l.fx.st.AccountBySqid(release.OpsAccount) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if st, msg := l.fx.srv.placeAppTarget(onCtx(t, l.fx.srv), acct, ltApp, ""); st != 0 { |
| + t.Fatalf("placeAppTarget = %d %q", st, msg) |
| + } |
| + } |
| + if got, _ := recordBytes(t, l.fx.rel, staleHost); !bytes.Equal(got, staleRaw) { |
| + t.Fatalf("the place rewrote the stale record:\n%s\nwas\n%s", got, staleRaw) |
| + } |
| + // The positive control: the place ran and stamped the valid one. |
| + rec, err := provision.GetRecord(context.Background(), l.fx.rel, release.OpsAccount, ltApp, validHost) |
| + if err != nil || rec.Region != "" { |
| + t.Fatalf("the valid record was not placed: %+v, %v", rec, err) |
| + } |
| + }) |
| + } |
| + // Control: before the cutover the place reads the stale record, and |
| + // its unplaced seed refuses the whole place. |
| + b := before(t) |
| + acct, err := b.fx.st.AccountBySqid(release.OpsAccount) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if st, msg := b.fx.srv.placeAppTarget(beforeCtx(t, b.fx.srv), acct, ltApp, ""); st != http.StatusConflict { |
| + t.Fatalf("fixture: unjudged, the place answered %d %q, want the seed's refusal", st, msg) |
| + } |
| + }) |
| + |
| + t.Run("claimRawUpdateRecord under a judged context", func(t *testing.T) { |
| + l := on(t) |
| + raw, etag := recordBytes(t, l.fx.rel, staleHost) |
| + rec, err := provision.DecodeRecord(raw) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + rec.Gate = "maintenance" |
| + if err := provision.UpdateRecord(onCtx(t, l.fx.srv), l.fx.srv.cfg.Releases, rec, etag); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("UpdateRecord of the stale record = %v, want not found", err) |
| + } |
| + if got, _ := recordBytes(t, l.fx.rel, staleHost); !bytes.Equal(got, raw) { |
| + t.Fatal("the update rewrote the stale record") |
| + } |
| + // Control: unjudged, the same update lands. |
| + if err := provision.UpdateRecord(context.Background(), l.fx.rel, rec, etag); err != nil { |
| + t.Fatalf("fixture: unjudged, the update = %v", err) |
| + } |
| + }) |
| +} |