provision: close the review's three findings and the comment bundle
ValidateShape refuses addr+hibernate. The edge's activator gate is `Kind == "instance" && Hibernate && !viaTunnel` — Addr does not exclude a route from activator management, so an addr-backed record flagged for hibernation arms a wake for a route with no socket and a process the platform never spawns. Same wedge the static+hibernate rail already names. StatusNamed becomes a tri-state (*bool) and converges. It was expressible, written at mint, and then never compared or converged — a write-only promise plan 3's backfill would have made permanently and invisibly wrong. A plain bool could not be converged safely: an absent field would read as an explicit false and silently revoke an operator's `carlos add -status-named`. Same treatment Hibernate already carries, plus a narrow registry.SetStatusNamed on the SetSeed/SetProvisioned contract. It stays out of shapeMismatch: it is now a column the record OWNS (spec §4.2's exclusion list). Successful converge writes now log. The Environment and Hibernate branches logged only on error and dry-run — and those two are exactly the writes that revert an operator's own `carlos route -environment` / `-hibernate=false`. "The row moved and nothing said so" is the failure class this branch exists to end; it is what #130 looked like from the outside. Also: the mint-site comment now says the whole route shape comes from the record (not just Hibernate and the paths); the removal log is kind-aware like the mint log; routeFor's doc comment recovers the lost note that an explicit DBPath is what keeps ops.FleetDBs' hibernating-exclusion correct; CLAUDE.md and SetProvisioned no longer claim Provisioned is the ONE condition the removal loop keys on (it is the one that GRANTS authority — scope and record-absence are load-bearing too, and the sentence as written invited someone to simplify the loop); and shapeMatches/the removal phase now record that the Backing/Tunnel/Caps refusals cover the adoption INSTANT only, since -unit/-tunnel/-grant still work on a Provisioned row afterwards. Tests: addr+hibernate cases in TestValidateShape's bad table (instance and service); the wire-shape test pins StatusNamed nil and StatusNamedOrDefault false on a legacy record; converge tests for explicit true/false and for an absent field NOT stomping an operator's opt-in (falsified both ways against a plain-bool converge); a positive `kind: service` mint asserting Environment lands on the INSERT path (Upsert's CASE clause makes the conflict path incapable of clearing it); and registry.TestSetStatusNamed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
7 files changed,
+258
−25
CLAUDE.md+4 −2internal/provision/provision.go+31 −1internal/provision/provision_test.go+14 −2internal/provision/reconcile.go+63 −15internal/provision/reconcile_test.go+88 −3internal/registry/registry.go+28 −2internal/registry/registry_test.go+30 −0
diff --git a/CLAUDE.md b/CLAUDE.md| index dcf806114..7299f57e9 100644 |
| --- a/CLAUDE.md |
| +++ b/CLAUDE.md |
| @@ -82,8 +82,10 @@ deliver or verify its result. |
| of the next serial bump — `carlos route` refuses both axes on such a row and |
| names the record. Reconcile also ADOPTS an existing route whose shape matches |
| an appearing record, which is how the fleet gets backfilled with no box |
| - commands. Adoption stamps `Provisioned`, and `Provisioned` is the ONE |
| - condition the removal phase keys on — so adoption is an authority transfer, |
| + commands. Adoption stamps `Provisioned`, and `Provisioned` is the one |
| + condition that GRANTS the removal phase authority over a row (the loop also |
| + gates on region/pool scope and on the record being gone) — so adoption is an |
| + authority transfer, |
| and the shape match is what stands between a backfill and a retired |
| production route. It refuses any row carrying a column the record cannot |
| express (`Backing`, `Tunnel`, `Caps`) or owned by another pass (`Platform`, |
diff --git a/internal/provision/provision.go b/internal/provision/provision.go| index 2e9f97dbe..21297d3d2 100644 |
| --- a/internal/provision/provision.go |
| +++ b/internal/provision/provision.go |
| @@ -71,7 +71,13 @@ type Record struct { |
| // is (design §5). |
| Environment string `json:"environment,omitempty"` |
| // StatusNamed opts this app into being named on the public status page. |
| - StatusNamed bool `json:"status_named,omitempty"` |
| + // A POINTER for the same reason Hibernate is: this column is CONVERGED, |
| + // so absent must mean "this record does not speak to the status page" — |
| + // a plain bool would read every legacy record (and every record whose |
| + // writer left the field out) as an explicit false and silently opt out a |
| + // route an operator opted in with `carlos add -status-named`. nil = |
| + // StatusNamedOrDefault's answer. |
| + StatusNamed *bool `json:"status_named,omitempty"` |
| } |
| // Manifest is the wire shape at apps/<sqid>/<app>/instances.json — the app's |
| @@ -207,6 +213,20 @@ func (r Record) HibernateOrDefault() bool { |
| return r.DerivesPaths() |
| } |
| +// StatusNamedOrDefault resolves the tri-state. An explicit value wins. |
| +// Absent is false — the status page's naming is an opt-in, and false is what |
| +// reconcile minted unconditionally before this field existed, so every legacy |
| +// record is unchanged. Absent and false are still different facts, and the |
| +// difference is load-bearing at the converge site: absent leaves the row's |
| +// column alone (an operator's `carlos add -status-named` survives), while an |
| +// explicit false clears it. |
| +func (r Record) StatusNamedOrDefault() bool { |
| + if r.StatusNamed != nil { |
| + return *r.StatusNamed |
| + } |
| + return false |
| +} |
| + |
| // ValidKind pins the record's route-class vocabulary to the set `carlos |
| // add` documents. "status" is deliberately absent: the fleet status page's |
| // route is the platform's own, not an app's, and nothing should be able to |
| @@ -262,6 +282,16 @@ func ValidateShape(rec Record) error { |
| if rec.KindOrDefault() == "service" && rec.Addr == "" { |
| return errors.New("kind service with no addr: the platform only proxies to a service, so the record must name the upstream it proxies to") |
| } |
| + // Same wedge as static+hibernate, arriving from the other side. Addr does |
| + // NOT exclude a route from activator management: internal/edge's gate is |
| + // `Kind == "instance" && Hibernate && !viaTunnel`, so an addr-backed |
| + // instance flagged for hibernation is armed for an exec-child wake it has |
| + // no socket for and whose process the platform never spawned. |
| + // HibernateOrDefault's own derivation already states the invariant ("an |
| + // addr-backed tenant is always on"); this is the door that enforces it. |
| + if rec.Addr != "" && rec.Hibernate != nil && *rec.Hibernate { |
| + return errors.New("addr with hibernate: an addr-backed route proxies to a process the platform does not spawn and has no socket to wake, so arming the activator for it would wedge the host — drop hibernate") |
| + } |
| return nil |
| } |
diff --git a/internal/provision/provision_test.go b/internal/provision/provision_test.go| index d7b8b4f08..4327c0f32 100644 |
| --- a/internal/provision/provision_test.go |
| +++ b/internal/provision/provision_test.go |
| @@ -38,8 +38,11 @@ func TestRecordWireShapeIsAdditive(t *testing.T) { |
| if rec.Environment != "" { |
| t.Errorf("Environment = %q, want empty", rec.Environment) |
| } |
| - if rec.StatusNamed { |
| - t.Error("StatusNamed = true, want false") |
| + if rec.StatusNamed != nil { |
| + t.Errorf("StatusNamed = %v, want nil — absent and false are different facts here too", rec.StatusNamed) |
| + } |
| + if rec.StatusNamedOrDefault() { |
| + t.Error("StatusNamedOrDefault() = true, want false — reconcile minted StatusNamed:false for these") |
| } |
| if rec.Hibernate != nil { |
| t.Errorf("Hibernate = %v, want nil — absent and false are different facts here", rec.Hibernate) |
| @@ -491,6 +494,15 @@ func TestValidateShape(t *testing.T) { |
| {"static with addr", with(func(r *Record) { r.Kind = "static"; r.Addr = "127.0.0.1:8090" })}, |
| {"static with unit", with(func(r *Record) { r.Kind = "static"; r.Unit = "console.service" })}, |
| {"static hibernating", with(func(r *Record) { r.Kind = "static"; r.Hibernate = &yes })}, |
| + // The same wedge from the other side: edge's activator gate is |
| + // `Kind == "instance" && Hibernate`, which Addr does not exclude, so |
| + // this route would be armed for a wake it has no socket for. |
| + {"addr hibernating", with(func(r *Record) { r.Addr = "127.0.0.1:8090"; r.Hibernate = &yes })}, |
| + {"addr service hibernating", with(func(r *Record) { |
| + r.Kind = "service" |
| + r.Addr = "127.0.0.1:8090" |
| + r.Hibernate = &yes |
| + })}, |
| } |
| for _, c := range bad { |
| if err := ValidateShape(c.rec); err == nil { |
diff --git a/internal/provision/reconcile.go b/internal/provision/reconcile.go| index 2f68aba87..df3d0f866 100644 |
| --- a/internal/provision/reconcile.go |
| +++ b/internal/provision/reconcile.go |
| @@ -59,14 +59,19 @@ type acctApp struct{ acct, app string } |
| // Socket and DBPath are DERIVED, never declared: DefaultSocket and |
| // DefaultDBPath exist so a hand-added route and a provisioned one land on |
| // identical path shapes, and their doc comment records what two names for |
| -// one database cost. |
| +// one database cost. Deriving DBPath EXPLICITLY here — rather than leaving it |
| +// empty on a route that has one — is also what keeps ops.FleetDBs' |
| +// hibernating-exclusion correct: internal/ops/litestream.go builds that |
| +// exclusion set from `r.Hibernate && r.DBPath != ""`, so a hibernating route |
| +// whose row carried no path would be swept up by the dataDir glob and |
| +// replicated box-wide as if it never slept. |
| func routeFor(rec Record, dataDir, seed string) registry.Route { |
| rt := registry.Route{ |
| Host: rec.Host, Slug: rec.Slug, Kind: rec.KindOrDefault(), |
| Addr: rec.Addr, Unit: rec.Unit, |
| App: rec.App, Account: rec.Account, Pool: rec.Pool, Region: rec.Region, |
| Channel: rec.Channel, Environment: rec.Environment, |
| - Hibernate: rec.HibernateOrDefault(), StatusNamed: rec.StatusNamed, |
| + Hibernate: rec.HibernateOrDefault(), StatusNamed: rec.StatusNamedOrDefault(), |
| Provisioned: true, Seed: seed, Status: "ready", |
| } |
| if rec.DerivesPaths() { |
| @@ -105,6 +110,11 @@ func routeFor(rec Record, dataDir, seed string) registry.Route { |
| // A row this pass cannot state the shape of is a row this pass must not |
| // claim to describe, no matter how well everything else lines up. |
| // |
| +// These three refusals guard the adoption INSTANT, not the row forever after: |
| +// `carlos route -backing/-unit/-tunnel/-grant` still work on a Provisioned |
| +// row, so an already-owned route can acquire one of them later. See the |
| +// removal phase's comment in Reconcile for what that costs. |
| +// |
| // Platform and Canary are likewise required false: those flags mark a row |
| // domains.Reconcile minted and re-mints/removes on its own authority |
| // (mintedRoute leaves Slug/Pool/Region/DBPath at their zero values, so a |
| @@ -167,12 +177,24 @@ func shapeMismatch(existing, want registry.Route) string { |
| } |
| } |
| -// convergeOwned applies the record's Channel and Environment onto a route it |
| -// owns — the two columns the record is authoritative for once it owns the |
| -// row, whether ownership was just granted by adoption or was already held |
| +// convergeOwned applies the record's Channel, Environment and StatusNamed onto |
| +// a route it owns — the columns the record is authoritative for once it owns |
| +// the row, whether ownership was just granted by adoption or was already held |
| // by an earlier pass. Both the owned-route converge branch and the |
| -// adoption branch call this, so there is exactly one place these two |
| -// columns get written from a record, never two copies drifting apart. |
| +// adoption branch call this, so there is exactly one place these columns get |
| +// written from a record, never two copies drifting apart. |
| +// |
| +// StatusNamed converges only when the record STATES it (design §3, the same |
| +// tri-state Hibernate carries): absent means "this record does not speak to |
| +// the status page", and stamping a derived false over an operator's `carlos |
| +// add -status-named` would be the silent stomp this design exists to end. |
| +// Unlike Hibernate it is converged at the adoption instant too — it decides |
| +// what a public page prints, not whether a live process is allowed to sleep. |
| +// |
| +// Every write logs on success as well as on failure. These are the writes |
| +// that REVERT an operator's own `carlos route -environment` or `-hibernate`; |
| +// a row that moved with nothing saying so is the exact failure class of the |
| +// production incident behind #130, seen from the outside. |
| // |
| // dryRun is a parameter here, not left to callers, deliberately: this |
| // function is reached from two branches, and a dry-run guard living in only |
| @@ -194,6 +216,17 @@ func convergeOwned(reg *registry.Registry, existing registry.Route, rec Record, |
| log.Printf("provision: dry-run: would converge %s environment %q -> %q", rec.Host, existing.Environment, rec.Environment) |
| } else if err := reg.SetEnvironment(rec.Host, rec.Environment); err != nil { |
| log.Printf("provision: converge environment on %s: %v", rec.Host, err) |
| + } else { |
| + log.Printf("provision: converged %s environment %q -> %q", rec.Host, existing.Environment, rec.Environment) |
| + } |
| + } |
| + if rec.StatusNamed != nil && existing.StatusNamed != *rec.StatusNamed { |
| + if dryRun { |
| + log.Printf("provision: dry-run: would converge %s status-named %v -> %v", rec.Host, existing.StatusNamed, *rec.StatusNamed) |
| + } else if err := reg.SetStatusNamed(rec.Host, *rec.StatusNamed); err != nil { |
| + log.Printf("provision: converge status-named on %s: %v", rec.Host, err) |
| + } else { |
| + log.Printf("provision: converged %s status-named %v -> %v", rec.Host, existing.StatusNamed, *rec.StatusNamed) |
| } |
| } |
| } |
| @@ -400,8 +433,12 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg |
| existing, ok := byHost[rec.Host] |
| switch { |
| case !ok: |
| - // Hibernate and the path derivation now come from the record |
| - // (routeFor); everything else about this branch is unchanged. |
| + // The route's whole shape now comes from the record (routeFor): |
| + // Kind, Addr, Unit, Environment, StatusNamed, Hibernate and the |
| + // path derivation. A record carrying none of them still mints the |
| + // exact route this branch hardcoded before they existed — that is |
| + // the additivity rule, pinned by TestRecordWireShapeIsAdditive and |
| + // TestReconcileMints. |
| nr := routeFor(rec, cfg.DataDir, seed) |
| if cfg.DryRun { |
| log.Printf("provision: dry-run: would mint %s route %s (app %q acct %q channel %q)", |
| @@ -427,6 +464,8 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg |
| log.Printf("provision: dry-run: would converge %s hibernate %v -> %v", rec.Host, existing.Hibernate, *rec.Hibernate) |
| } else if err := reg.SetHibernate(rec.Host, *rec.Hibernate); err != nil { |
| log.Printf("provision: converge hibernate on %s: %v", rec.Host, err) |
| + } else { |
| + log.Printf("provision: converged %s hibernate %v -> %v", rec.Host, existing.Hibernate, *rec.Hibernate) |
| } |
| } |
| // seedIsKnown guards against stamping a transient manifest-read |
| @@ -476,9 +515,9 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg |
| // a fixed "converging channel X -> Y" line here would both |
| // omit environment (the defect this fixes) and claim a |
| // convergence that would not happen when the route is |
| - // already on the record's channel. Sites 1 and 2 (SetChannel, |
| - // SetEnvironment) are already guarded inside convergeOwned, |
| - // so this call writes nothing. |
| + // already on the record's channel. Every write inside |
| + // convergeOwned is dry-run guarded there, so this call |
| + // writes nothing. |
| convergeOwned(reg, existing, rec, true) |
| continue |
| } |
| @@ -505,6 +544,15 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg |
| // foreign box's provisioned route is not this pass's business), and |
| // absence from the record set this pass just read (the bucket's own |
| // decision to retire the instance, not a guess this pass made). |
| + // |
| + // What reaches reg.Delete here may carry Backing, Tunnel or Caps even |
| + // though shapeMismatch refuses to ADOPT a row carrying any of them: those |
| + // refusals cover the adoption instant only. `carlos route -backing/-unit/ |
| + // -tunnel/-grant` all still work on a Provisioned row, so a route can |
| + // acquire an operator column after it was minted or adopted and remain |
| + // one unattended record-deletion away from registry.Delete's grant purge. |
| + // That asymmetry predates this pass's adoption step; it is stated here so |
| + // the adoption guard is not mistaken for a standing guarantee. |
| if !readFailed { |
| for _, rt := range routes { |
| if !rt.Provisioned || rt.Region != cfg.Region || !poolServed(cfg.Pools, rt.Pool) { |
| @@ -518,14 +566,14 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg |
| continue |
| } |
| if cfg.DryRun { |
| - log.Printf("provision: dry-run: would remove retired instance route %s (record gone)", rt.Host) |
| + log.Printf("provision: dry-run: would remove retired %s route %s (record gone)", rt.Kind, rt.Host) |
| continue |
| } |
| if err := reg.Delete(rt.Host); err != nil { |
| - log.Printf("provision: remove retired instance route %s: %v", rt.Host, err) |
| + log.Printf("provision: remove retired %s route %s: %v", rt.Kind, rt.Host, err) |
| continue |
| } |
| - log.Printf("provision: removed retired instance route %s (record gone)", rt.Host) |
| + log.Printf("provision: removed retired %s route %s (record gone)", rt.Kind, rt.Host) |
| } |
| } |
| return nil |
diff --git a/internal/provision/reconcile_test.go b/internal/provision/reconcile_test.go| index 03bba08b3..2080498d5 100644 |
| --- a/internal/provision/reconcile_test.go |
| +++ b/internal/provision/reconcile_test.go |
| @@ -138,13 +138,14 @@ func TestReconcileMints(t *testing.T) { |
| } |
| } |
| -// Every shape the record can now describe, minted in one pass. The three |
| +// Every shape the record can now describe, minted in one pass. The four |
| // cases are the production routes the old vocabulary could not express: |
| // a console (addr-backed, unit-owned, never hibernating), a whole-app TCP |
| -// tenant, and a static site. |
| +// tenant, a static site, and a sibling service. |
| func TestReconcileMintsEveryShape(t *testing.T) { |
| s, sqid := setup(t) |
| off := false |
| + on := true |
| recs := []Record{ |
| {Schema: 1, Host: "console.go.tito.io", Slug: "console", App: "tito", Account: sqid, |
| Pool: "tito", Region: "ie", Channel: "stable", Kind: "instance", |
| @@ -154,7 +155,10 @@ func TestReconcileMintsEveryShape(t *testing.T) { |
| Hibernate: &off, CreatedAt: time.Now()}, |
| {Schema: 1, Host: "site.go.tito.io", Slug: "site", App: "tito", Account: sqid, |
| Pool: "tito", Region: "ie", Channel: "stable", Kind: "static", |
| - StatusNamed: true, CreatedAt: time.Now()}, |
| + StatusNamed: &on, CreatedAt: time.Now()}, |
| + {Schema: 1, Host: "svc.go.tito.io", Slug: "svc", App: "tito", Account: sqid, |
| + Pool: "tito", Region: "ie", Channel: "stable", Kind: "service", |
| + Addr: "127.0.0.1:7000", Environment: "staging", CreatedAt: time.Now()}, |
| } |
| for _, rec := range recs { |
| if err := CreateRecord(ctx, s, rec); err != nil { |
| @@ -200,6 +204,22 @@ func TestReconcileMintsEveryShape(t *testing.T) { |
| if !site.StatusNamed { |
| t.Error("StatusNamed did not reach the row") |
| } |
| + |
| + // A sibling service, and the INSERT path for Environment. On the conflict |
| + // path Upsert deliberately preserves a non-empty environment (`CASE WHEN |
| + // excluded.environment = ''`), so the insert is the only place a mint's |
| + // Environment can land at all — and every other assertion in this file |
| + // would pass just the same if it never did. |
| + svc := *mustGet(t, reg, "svc.go.tito.io") |
| + svc.CreatedAt = 0 |
| + wantSvc := registry.Route{ |
| + Host: "svc.go.tito.io", Slug: "svc", Kind: "service", Addr: "127.0.0.1:7000", |
| + App: "tito", Account: sqid, Pool: "tito", Region: "ie", Channel: "stable", |
| + Environment: "staging", Provisioned: true, Seed: `["seed"]`, Status: "ready", |
| + } |
| + if svc != wantSvc { |
| + t.Errorf("service route = %+v, want %+v", svc, wantSvc) |
| + } |
| } |
| // 2. Scope. |
| @@ -446,6 +466,71 @@ func TestReconcileConvergesHibernateOnlyWhenExplicit(t *testing.T) { |
| } |
| } |
| +// StatusNamed is converged, so it carries Hibernate's tri-state for |
| +// Hibernate's reason: explicit true reaches the row, explicit false CLEARS |
| +// it, and absent is not an answer at all. |
| +func TestReconcileConvergesStatusNamedWhenExplicit(t *testing.T) { |
| + s, sqid := setup(t) |
| + rec := acmeRecord(sqid) |
| + if err := CreateRecord(ctx, s, rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + reg := regWith(t) |
| + cfg := ReconcileConfig{Region: "ie", Pools: []string{"tito"}, DataDir: "/data"} |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + if mustGet(t, reg, "acme.go.tito.io").StatusNamed { |
| + t.Fatal("a record with no status_named minted an opted-in route") |
| + } |
| + |
| + on := true |
| + rec.StatusNamed = &on |
| + putReplace(t, s, RecordKey(sqid, "tito", rec.Host), mustJSON(t, rec)) |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + if !mustGet(t, reg, "acme.go.tito.io").StatusNamed { |
| + t.Error("an explicit status_named:true did not converge onto the row") |
| + } |
| + |
| + off := false |
| + rec.StatusNamed = &off |
| + putReplace(t, s, RecordKey(sqid, "tito", rec.Host), mustJSON(t, rec)) |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + if mustGet(t, reg, "acme.go.tito.io").StatusNamed { |
| + t.Error("an explicit status_named:false did not clear the row's opt-in") |
| + } |
| +} |
| + |
| +// The whole reason StatusNamed is a pointer: an operator's `carlos add |
| +// -status-named` opt-in survives every pass of a record that never mentions |
| +// the field. A plain bool would read absent as an explicit false and quietly |
| +// pull the app's name off the public status page. |
| +func TestReconcileAbsentStatusNamedDoesNotStompOptIn(t *testing.T) { |
| + s, sqid := setup(t) |
| + rec := acmeRecord(sqid) |
| + if err := CreateRecord(ctx, s, rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + reg := regWith(t) |
| + cfg := ReconcileConfig{Region: "ie", Pools: []string{"tito"}, DataDir: "/data"} |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.SetStatusNamed("acme.go.tito.io", true); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + if !mustGet(t, reg, "acme.go.tito.io").StatusNamed { |
| + t.Error("an absent StatusNamed stamped its default over an operator's opt-in") |
| + } |
| +} |
| + |
| // 5. Conflict. |
| func TestReconcileLeavesConflictAlone(t *testing.T) { |
| s, sqid := setup(t) |
diff --git a/internal/registry/registry.go b/internal/registry/registry.go| index bf5d47076..6e9d8eb10 100644 |
| --- a/internal/registry/registry.go |
| +++ b/internal/registry/registry.go |
| @@ -1080,8 +1080,9 @@ func (r *Registry) SetSeed(host, seed string) error { |
| // contract: the pass must notice a row that vanished under it rather than |
| // silently doing nothing. |
| // |
| -// This flag is an authority transfer, not a label: it is the single |
| -// condition the reconcile pass's removal phase keys on. Only that pass's |
| +// This flag is an authority transfer, not a label: it is the condition that |
| +// GRANTS the reconcile pass's removal phase authority over a row (that loop |
| +// also gates on region/pool scope and on the record being gone). Only that pass's |
| // adoption step (provision.Reconcile, design §4.2) should ever set it, and |
| // it does so only after matching the row's shape against the record. |
| func (r *Registry) SetProvisioned(host string, on bool) error { |
| @@ -1103,6 +1104,31 @@ func (r *Registry) SetProvisioned(host string, on bool) error { |
| return nil |
| } |
| +// SetStatusNamed flips a route's public-status-page naming opt-in as a narrow |
| +// UPDATE, same rationale as SetSeed (a read-modify-write Upsert racing a |
| +// deploy's SetVersions or the activator's SetNextDue would clobber those). It |
| +// errors when no row matched, the SetChannel contract: the provisioning |
| +// reconcile pass converges this column from the instance record and must |
| +// notice a row that vanished under it rather than silently doing nothing. |
| +func (r *Registry) SetStatusNamed(host string, on bool) error { |
| + v := 0 |
| + if on { |
| + v = 1 |
| + } |
| + res, err := r.sql.Exec(`UPDATE routes SET status_named = ? WHERE host = ?`, v, host) |
| + if err != nil { |
| + return err |
| + } |
| + n, err := res.RowsAffected() |
| + if err != nil { |
| + return err |
| + } |
| + if n == 0 { |
| + return fmt.Errorf("no route for host %q", host) |
| + } |
| + return nil |
| +} |
| + |
| // DueInstances returns hibernatable instance routes whose next_due has |
| // arrived (0 means no schedule and never matches). |
| func (r *Registry) DueInstances(now int64) ([]Route, error) { |
diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go| index e3927f364..aca8b8bab 100644 |
| --- a/internal/registry/registry_test.go |
| +++ b/internal/registry/registry_test.go |
| @@ -1659,3 +1659,33 @@ func TestSetProvisioned(t *testing.T) { |
| t.Error("SetProvisioned on an unknown host returned nil; want an error (the SetChannel contract)") |
| } |
| } |
| + |
| +func TestSetStatusNamed(t *testing.T) { |
| + r := openTestRegistry(t) |
| + if err := r.Upsert(Route{Host: "a.example.com", Kind: "instance", App: "x", Status: "ready"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := r.SetStatusNamed("a.example.com", true); err != nil { |
| + t.Fatal(err) |
| + } |
| + rt, err := r.Get("a.example.com") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !rt.StatusNamed { |
| + t.Error("SetStatusNamed(true) did not stamp the column") |
| + } |
| + if err := r.SetStatusNamed("a.example.com", false); err != nil { |
| + t.Fatal(err) |
| + } |
| + rt, err = r.Get("a.example.com") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if rt.StatusNamed { |
| + t.Error("SetStatusNamed(false) did not clear the column") |
| + } |
| + if err := r.SetStatusNamed("missing.example.com", true); err == nil { |
| + t.Error("SetStatusNamed on an unknown host returned nil; want an error (the SetChannel contract)") |
| + } |
| +} |