carlos / platform Public

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>
Paul Campbell 1383f12ec326f7ce030a7bb3460761d965a46d39 parent 13e5368
7 files changed, +258 −25
  • CLAUDE.md +4 −2
  • internal/provision/provision.go +31 −1
  • internal/provision/provision_test.go +14 −2
  • internal/provision/reconcile.go +63 −15
  • internal/provision/reconcile_test.go +88 −3
  • internal/registry/registry.go +28 −2
  • internal/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)")
+ }
+}