Merge pull request #137 from carlosframework/worktree-route-records
route records: a route's desired state lives in the bucket (#123, #130)
12 files changed,
+3672
−16
CLAUDE.md+35 −0README.md+4 −0cli.go+27 −2docs/superpowers/plans/2026-08-10-route-records-vocabulary.md+1504 −0docs/superpowers/specs/2026-08-10-route-records-design.md+413 −0e2e_test.go+90 −0internal/provision/provision.go+170 −0internal/provision/provision_test.go+160 −0internal/provision/reconcile.go+311 −14internal/provision/reconcile_test.go+853 −0internal/registry/registry.go+55 −0internal/registry/registry_test.go+50 −0
diff --git a/CLAUDE.md b/CLAUDE.md| index 55fc89614..fc66ddc9d 100644 |
| --- a/CLAUDE.md |
| +++ b/CLAUDE.md |
| @@ -47,6 +47,26 @@ commands.** The command alone must complete the job — no follow-up SSM |
| session, AWS console visit, or on-box verb may be required for it to |
| deliver or verify its result. |
| +## Every change lands through a PR (Paul's rule, 2026-08-10) |
| + |
| +**Nothing merges to `main` without a pull request.** No local merge to |
| +`main`, no direct commit to `main`, no fast-forward "it's only a docs |
| +change" — every change, however small, goes up as a branch and lands |
| +through a PR. That includes doc-only edits, one-line fixes, and revert |
| +commits. |
| + |
| +Agents: this rule is on you as much as on people. When work is complete, |
| +push the branch and open the PR; never offer merging to `main` as an |
| +option, and never take it if offered. Merging the PR is a human's action. |
| + |
| +The reason is timing, not ceremony. `.github/workflows/ci.yml` triggers on |
| +`pull_request` *and* on `push` to `main`, so a locally merged change does |
| +still meet the gate — but only **after** it is on `main`, where a red run |
| +is an incident to clean up rather than a merge that simply doesn't happen. |
| +The PR is the one place the gate runs against the merge result while the |
| +change can still be turned away, and the one place the branch's diff is |
| +read as a single piece rather than commit by commit. |
| + |
| ## Build & test |
| - `go build ./...`, `go vet ./...`, `go test ./... -count=1` — all clean, |
| @@ -75,6 +95,21 @@ deliver or verify its result. |
| - **Adoption swaps a symlink; it does not restart processes.** Order is: |
| promote → adopt → restart. Long-running units (console, keymail relay) need |
| the restart; hibernating tenants pick it up on wake. |
| +- **A route's Channel and Environment belong to its instance record** once the |
| + route is `Provisioned` (main, 2026-08-10). The provisioning reconciler |
| + converges both from `apps/<sqid>/<app>/instances/<host>.json` on every pass, |
| + so a `carlos route -channel` on the box lands and is reverted within seconds |
| + 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 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`, |
| + `Canary`). |
| - **Console security gates are ordered**: session → CSRF → owner (byte-identical |
| 404) → `requireFreshAuth` LAST. A non-member must never get a redirect that |
| confirms an account exists. |
diff --git a/README.md b/README.md| index 6407b8c61..2de3a42bc 100644 |
| --- a/README.md |
| +++ b/README.md |
| @@ -340,6 +340,10 @@ never silently dropped. App names are opt-in only, via `carlos add |
| -status-named`, and everything else folds into one honest `apps_unlisted` |
| count, so the aggregate never lies by omission. |
| +A route added this way is described by no bucket record, so nothing converges |
| +it: its channel and environment are box-local until an instance record adopts |
| +it. A provisioned route's channel is repointed through the record, not here. |
| + |
| **Config surface** — every env var this feature reads, gathered here since |
| no single reader owns the whole contract: |
diff --git a/cli.go b/cli.go| index d8e809046..62653d8c7 100644 |
| --- a/cli.go |
| +++ b/cli.go |
| @@ -695,6 +695,7 @@ func runRoute(args []string) { |
| vVersionChange // resolved, differs: overridable |
| vUnproven // resolved, but nothing proves what would change: never overridable |
| vPlatformOwned // Platform+Canary: reconciler owns this column, never overridable |
| + vRecordOwned // Provisioned: the instance record owns this column, never overridable |
| ) |
| type verdict struct { |
| rt registry.Route |
| @@ -723,6 +724,12 @@ func runRoute(args []string) { |
| "reconciler's canary-minting pass (internal/domains/reconcile.go), which is idempotent via a\n"+ |
| "check on THIS route's own Channel column; any change here is silently reverted on the next\n"+ |
| "reconcile pass. This verb does not own this column", from)} |
| + case recordOwnedRow(rt): |
| + chanVerdicts[i] = verdict{rt: rt, from: from, kind: vRecordOwned, note: fmt.Sprintf( |
| + "provisioned route on %s — its channel is owned by the instance record at\n"+ |
| + "apps/%s/%s/instances/%s.json, which the provisioning reconciler converges onto this row\n"+ |
| + "on every pass; a change here is reverted within seconds of the next serial bump.", |
| + from, release.RouteAccount(rt), rt.App, rt.Host)} |
| default: |
| chanVerdicts[i] = verdict{rt: rt, from: from, kind: vNeedsCheck} |
| anyNeedsCheck = true |
| @@ -777,6 +784,7 @@ func runRoute(args []string) { |
| eWillWrite // clearing, or the bundle exists — proceed |
| eMissing // named but absent in the bucket — refuse, no override |
| ePlatformOwned // Platform+Canary: the reconciler owns this row, never overridable |
| + eRecordOwned // Provisioned: the instance record owns this column |
| ) |
| type envVerdict struct { |
| rt registry.Route |
| @@ -816,6 +824,12 @@ func runRoute(args []string) { |
| "check goes false, from values it derives itself. This verb does not own this row.\n" + |
| "A binding already on such a route (made before this rail existed) survives re-mints and cannot be\n" + |
| "cleared here either: `carlos remove " + rt.Host + "` and let the next reconcile pass re-mint it clean"} |
| + case recordOwnedRow(rt): |
| + envVerdicts[i] = envVerdict{rt: rt, from: from, kind: eRecordOwned, note: fmt.Sprintf( |
| + "provisioned route — its environment is owned by the instance record at\n"+ |
| + "apps/%s/%s/instances/%s.json, which the provisioning reconciler converges onto this row on every\n"+ |
| + "pass. A binding made here (or cleared here) is reverted within seconds of the next serial bump.", |
| + release.RouteAccount(rt), rt.App, rt.Host)} |
| case envClear: |
| envVerdicts[i] = envVerdict{rt: rt, from: from, kind: eWillWrite} |
| default: |
| @@ -868,7 +882,7 @@ func runRoute(args []string) { |
| case vUnproven: |
| refusals = append(refusals, fmt.Sprintf( |
| "%s: %s — refusing regardless of -allow-version-change: the comparison itself is unproven", v.rt.Host, v.note)) |
| - case vPlatformOwned: |
| + case vPlatformOwned, vRecordOwned: |
| refusals = append(refusals, fmt.Sprintf( |
| "%s: %s — refusing regardless of -allow-version-change: no flag overrides a column this verb does not own", |
| v.rt.Host, v.note)) |
| @@ -883,7 +897,7 @@ func runRoute(args []string) { |
| envToWrite = append(envToWrite, v.rt.Host) |
| case eMissing: |
| refusals = append(refusals, fmt.Sprintf("%s: %s", v.rt.Host, v.note)) |
| - case ePlatformOwned: |
| + case ePlatformOwned, eRecordOwned: |
| refusals = append(refusals, fmt.Sprintf( |
| "%s: %s — no flag overrides a row this verb does not own", v.rt.Host, v.note)) |
| } |
| @@ -1019,6 +1033,17 @@ func platformCanaryOwnedRow(rt registry.Route) bool { |
| return rt.Platform && rt.Canary |
| } |
| +// recordOwnedRow reports whether the instance-provisioning reconciler, not |
| +// this verb, owns this route's Channel and Environment columns. |
| +// |
| +// Provisioned is set only by that pass — on mint, and on adoption after it |
| +// matched the row's shape against the bucket record (internal/provision). |
| +// The pass converges both columns from the record on every tick, so a write |
| +// here lands and is reverted within seconds of the next serial bump, with |
| +// nothing said anywhere. Same structural refusal as platformCanaryOwnedRow, |
| +// one more owner: the door is the record. |
| +func recordOwnedRow(rt registry.Route) bool { return rt.Provisioned } |
| + |
| // runRouteGrant is the operator's door to a transfer of box authority: one |
| // token, onto or off one route, on this box. Everything it can refuse it |
| // refuses before writing, and the write itself is registry.SetCaps — the |
diff --git a/docs/superpowers/plans/2026-08-10-route-records-vocabulary.md b/docs/superpowers/plans/2026-08-10-route-records-vocabulary.md| new file mode 100644 |
| index 000000000..e5234dbc7 |
| --- /dev/null |
| +++ b/docs/superpowers/plans/2026-08-10-route-records-vocabulary.md |
| @@ -0,0 +1,1504 @@ |
| +# Route Records: Record Vocabulary & Reconcile Convergence — Implementation Plan |
| + |
| +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. |
| + |
| +**Goal:** Give `provision.Record` enough vocabulary to describe every route the platform actually runs, make `provision.Reconcile` converge and adopt against it, and stop `carlos route` writing the columns the reconciler now owns. |
| + |
| +**Architecture:** Six additive fields on the bucket record whose zero values reproduce today's mint byte-for-byte; a single `routeFor` builder shared by the mint path and the adoption match test; narrow-UPDATE convergence of `Channel` and `Environment` on records the pass owns; and a `recordOwnedRow` refusal in the CLI modelled exactly on the existing `platformCanaryOwnedRow` one. |
| + |
| +**Tech Stack:** Go, SQLite (`internal/registry`), S3/DirStore (`internal/release`), stdlib `testing`. |
| + |
| +**Spec:** `docs/superpowers/specs/2026-08-10-route-records-design.md` — this plan implements §3, §4 and §5 (the spec's rollout steps 1 and 2, which it calls one coherent PR). |
| + |
| +**Not in this plan** — each gets its own: |
| +- §6 (console `PUT` endpoint, rail moved console-side, §6.2's rail fix) and `carlos route` API mode → plan 2. |
| +- §7 (`carlos add` record mode, `-local` break-glass) and §10 step 5 (fleet backfill) → plan 3. |
| + |
| +## Global Constraints |
| + |
| +- `go build ./...`, `go vet ./...`, `go test ./... -count=1` all clean, every package, before any commit lands. |
| +- `gofmt -l` clean for every file touched. Three pre-existing unformatted files elsewhere are left alone. |
| +- Sandbox note: prefix go commands with `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off`. A "read-only file system" error from the toolchain is the tell that the sandbox needs disabling. |
| +- **Wire-shape changes are additive.** Live records exist in the flagship and tito production buckets. Anything written before this change must still parse and must still materialize the identical route. Task 1 pins this; do not weaken that test. |
| +- **The consumer list is everything that PARSES the shape**, not everything that calls the API. |
| +- Production channel names are `canary/*` and are pinned in `internal/release/laddercheck_test.go`. Nothing here narrows `release.ValidChannel`. |
| +- Any test that shells out to the carlos binary must build its env through `carlosTestEnv` and set `cmd.Dir` to a scratch dir. The `routeTestFixture` in `e2e_test.go` already does both — use it, do not hand-roll. |
| +- `GOOS=darwin` must still compile: `internal/host` links into the CLI that people run on Macs. CI runs a cross-compile; run one locally if you touch anything platform-shaped. |
| + |
| +--- |
| + |
| +### Task 1: Record grows the shape vocabulary |
| + |
| +**Files:** |
| +- Modify: `internal/provision/provision.go` (the `Record` struct at :29-42, and new helpers/validators beside `ValidHost` at :115) |
| +- Test: `internal/provision/provision_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: nothing from earlier tasks. |
| +- Produces, all in package `provision`: |
| + - `Record.Kind string`, `Record.Addr string`, `Record.Unit string`, `Record.Hibernate *bool`, `Record.Environment string`, `Record.StatusNamed bool` |
| + - `func (r Record) KindOrDefault() string` |
| + - `func (r Record) HibernateOrDefault() bool` |
| + - `func (r Record) DerivesPaths() bool` |
| + - `func ValidKind(kind string) bool` |
| + - `func ValidateShape(rec Record) error` |
| + - `func ValidRecordHost(rec Record, domain string) bool` |
| + |
| +- [ ] **Step 1: Write the failing wire-shape test** |
| + |
| +This is the test that protects live production records. Add to `internal/provision/provision_test.go`: |
| + |
| +```go |
| +// A schema-1 record exactly as the flagship and tito buckets hold it today: |
| +// none of the shape fields exist. Every one must default to what reconcile |
| +// hardcoded before they were introduced — a non-nil Hibernate here, or a |
| +// Kind that does not fall back to "instance", rewrites the fleet on the |
| +// first pass after deploy. This is the additive wire-shape rule stated as a |
| +// test; do not relax it. |
| +func TestRecordWireShapeIsAdditive(t *testing.T) { |
| + const legacy = `{"schema":1,"host":"acme.go.tito.io","slug":"acme","app":"tito",` + |
| + `"account":"bab","pool":"tito","region":"ie","channel":"stable",` + |
| + `"created_at":"2026-08-04T09:00:00Z"}` |
| + var rec Record |
| + if err := json.Unmarshal([]byte(legacy), &rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + if rec.Kind != "" { |
| + t.Errorf("Kind = %q, want empty", rec.Kind) |
| + } |
| + if rec.Addr != "" { |
| + t.Errorf("Addr = %q, want empty", rec.Addr) |
| + } |
| + if rec.Unit != "" { |
| + t.Errorf("Unit = %q, want empty", rec.Unit) |
| + } |
| + if rec.Environment != "" { |
| + t.Errorf("Environment = %q, want empty", rec.Environment) |
| + } |
| + if rec.StatusNamed { |
| + t.Error("StatusNamed = true, want false") |
| + } |
| + if rec.Hibernate != nil { |
| + t.Errorf("Hibernate = %v, want nil — absent and false are different facts here", rec.Hibernate) |
| + } |
| + if got := rec.KindOrDefault(); got != "instance" { |
| + t.Errorf("KindOrDefault() = %q, want instance", got) |
| + } |
| + if !rec.DerivesPaths() { |
| + t.Error("DerivesPaths() = false, want true — a legacy record is a socket-backed instance") |
| + } |
| + if !rec.HibernateOrDefault() { |
| + t.Error("HibernateOrDefault() = false, want true — reconcile mints Hibernate:true for these today") |
| + } |
| +} |
| + |
| +// The console rewrites records (plan 2's PUT). A rewrite must not sprout |
| +// keys the reader before it did not know, so every added field is |
| +// omitempty and a record carrying none of them marshals to exactly what it |
| +// marshalled to before. |
| +func TestRecordMarshalOmitsAbsentShapeFields(t *testing.T) { |
| + body, err := json.Marshal(Record{ |
| + Schema: 1, Host: "acme.go.tito.io", Slug: "acme", App: "tito", |
| + Account: "bab", Pool: "tito", Region: "ie", Channel: "stable", |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, key := range []string{"kind", "addr", "unit", "hibernate", "environment", "status_named"} { |
| + if strings.Contains(string(body), `"`+key+`"`) { |
| + t.Errorf("marshalled record carries %q when it was never set: %s", key, body) |
| + } |
| + } |
| +} |
| +``` |
| + |
| +Add `encoding/json` and `strings` to that file's imports if absent. |
| + |
| +- [ ] **Step 2: Run the test to verify it fails** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/provision/ -run 'TestRecordWireShape|TestRecordMarshal' -v` |
| + |
| +Expected: FAIL to compile — `rec.Kind undefined`, `rec.KindOrDefault undefined`. |
| + |
| +- [ ] **Step 3: Add the fields and helpers** |
| + |
| +In `internal/provision/provision.go`, extend the `Record` struct. Keep the existing fields exactly where they are and append: |
| + |
| +```go |
| + // --- shape fields (2026-08-10 route-records design §3) -------------- |
| + // Every one is omitempty with a zero value that reproduces the mint |
| + // reconcile hardcoded before they existed: a record written by an older |
| + // console still materializes a byte-identical route. |
| + |
| + // Kind is the route class: "instance" | "service" | "static". Empty |
| + // means "instance", which is what reconcile minted unconditionally |
| + // before this field existed. |
| + Kind string `json:"kind,omitempty"` |
| + // Addr is a TCP upstream as host:port, for whole-app tenants whose |
| + // process the platform does not spawn (kass, keymail, titogo, the |
| + // consoles). Set means the route proxies to it and derives no socket. |
| + Addr string `json:"addr,omitempty"` |
| + // Unit is the systemd unit that owns this route's process, so adoption |
| + // can cycle it (console.service — issue #110). See registry.Route.Unit |
| + // for why a typo here is a route whose adoption silently restarts |
| + // nothing; ValidateShape is the door that refuses one. |
| + Unit string `json:"unit,omitempty"` |
| + // Hibernate is a POINTER because absent and false are different facts: |
| + // every record already in a production bucket has no such key, and a |
| + // plain bool would read those as false and un-hibernate the fleet on |
| + // the first pass after deploy. nil = HibernateOrDefault's derivation. |
| + Hibernate *bool `json:"hibernate,omitempty"` |
| + // Environment is the named config bundle this route binds to ("" = the |
| + // app's default bundle alone). The second member-facing axis of |
| + // `carlos route`, and owned by this record for the same reason Channel |
| + // 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"` |
| +``` |
| + |
| +Then, beside `ValidHost`: |
| + |
| +```go |
| +// KindOrDefault resolves the record's route class. "" means "instance" — |
| +// the class reconcile minted unconditionally before Kind existed, and so |
| +// the only value that keeps a legacy record materializing unchanged. |
| +func (r Record) KindOrDefault() string { |
| + if r.Kind == "" { |
| + return "instance" |
| + } |
| + return r.Kind |
| +} |
| + |
| +// DerivesPaths reports whether this record's route builds filesystem paths |
| +// out of its host — the <host>.sock and <host>.db segments. It is the hinge |
| +// two separate rules turn on: which host validator applies (ValidRecordHost: |
| +// a host that becomes a path segment stays suffix-pinned, one that does not |
| +// needs only a valid shape), and what hibernation defaults to (only a route |
| +// with a socket has a process for the activator to wake). |
| +func (r Record) DerivesPaths() bool { |
| + return r.KindOrDefault() == "instance" && r.Addr == "" |
| +} |
| + |
| +// HibernateOrDefault resolves the tri-state. An explicit value wins. Absent |
| +// derives: a socket-backed instance hibernates (the activator model, and |
| +// what reconcile minted unconditionally before this field existed, so every |
| +// legacy record is unchanged), and everything else does not — a static |
| +// route has no process to wake, and an addr-backed tenant is always on. |
| +func (r Record) HibernateOrDefault() bool { |
| + if r.Hibernate != nil { |
| + return *r.Hibernate |
| + } |
| + return r.DerivesPaths() |
| +} |
| + |
| +// 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 |
| +// mint one from an app-scoped record. |
| +func ValidKind(kind string) bool { |
| + switch kind { |
| + case "", "instance", "service", "static": |
| + return true |
| + } |
| + return false |
| +} |
| + |
| +// ValidateShape checks the §3 fields at the door, before a record is written |
| +// or materialized. The rails live at the door, exactly as they do for |
| +// registry.SetCaps and SetChannel — Upsert validates no column at all, so a |
| +// value that reaches the row unvalidated is discovered months later as an |
| +// adopt pass that restarted nothing (the `-backing unti` incident, cli.go). |
| +func ValidateShape(rec Record) error { |
| + if !ValidKind(rec.Kind) { |
| + return fmt.Errorf("kind %q: want instance, service or static (empty means instance)", rec.Kind) |
| + } |
| + if rec.Addr != "" { |
| + h, p, err := net.SplitHostPort(rec.Addr) |
| + if err != nil || h == "" || p == "" { |
| + return fmt.Errorf("addr %q: want a TCP upstream as host:port (e.g. 127.0.0.1:8090)", rec.Addr) |
| + } |
| + } |
| + if rec.Unit != "" && !registry.ValidUnitName(rec.Unit) { |
| + return fmt.Errorf("unit %q: want a systemd service unit name (e.g. console.service)", rec.Unit) |
| + } |
| + if rec.KindOrDefault() == "static" { |
| + if rec.Addr != "" { |
| + return errors.New("kind static with addr: a static route serves a file tree and proxies to nothing — drop one of the two") |
| + } |
| + if rec.Unit != "" { |
| + return errors.New("kind static with unit: a static route has no process for a unit to own — drop one of the two") |
| + } |
| + if rec.Hibernate != nil && *rec.Hibernate { |
| + return errors.New("kind static with hibernate: a static route has no process to wake, so arming the activator for it would wedge the host — drop hibernate") |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +// ValidRecordHost applies the host rail this record actually needs (design |
| +// §3.2). ValidHost is path-traversal defence: it is what keeps a hostile |
| +// host from reaching the <host>.sock and <host>.db path segments as anything |
| +// but an opaque, safe component, and it stays exactly where that danger is. |
| +// A record that derives no filesystem path has no such exposure — its host |
| +// reaches a bucket key and the edge's routing table only — so it needs the |
| +// shape check alone, which is what lets an apex (carloku.com) or a console's |
| +// own hostname be expressed at all. |
| +func ValidRecordHost(rec Record, domain string) bool { |
| + if rec.DerivesPaths() { |
| + return ValidHost(rec.Host, domain) |
| + } |
| + return ValidHostShape(rec.Host) |
| +} |
| +``` |
| + |
| +Add `errors`, `net` and `github.com/carlosframework/platform/internal/registry` to the file's imports. `internal/registry` does not import `internal/provision`, so there is no cycle; `reconcile.go` in this same package already imports it. |
| + |
| +- [ ] **Step 4: Run the test to verify it passes** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/provision/ -run 'TestRecordWireShape|TestRecordMarshal' -v` |
| + |
| +Expected: PASS, both tests. |
| + |
| +- [ ] **Step 5: Write the validator tests** |
| + |
| +```go |
| +func TestValidKind(t *testing.T) { |
| + for _, k := range []string{"", "instance", "service", "static"} { |
| + if !ValidKind(k) { |
| + t.Errorf("ValidKind(%q) = false, want true", k) |
| + } |
| + } |
| + // "status" is the platform's own status-page class, not an app's. |
| + for _, k := range []string{"status", "Instance", "unti", "instance "} { |
| + if ValidKind(k) { |
| + t.Errorf("ValidKind(%q) = true, want false", k) |
| + } |
| + } |
| +} |
| + |
| +func TestValidateShape(t *testing.T) { |
| + yes := true |
| + base := Record{Schema: 1, Host: "acme.go.tito.io", Slug: "acme", App: "tito", |
| + Account: "bab", Channel: "stable"} |
| + with := func(f func(*Record)) Record { |
| + r := base |
| + f(&r) |
| + return r |
| + } |
| + ok := []struct { |
| + name string |
| + rec Record |
| + }{ |
| + {"legacy instance", base}, |
| + {"addr tenant", with(func(r *Record) { r.Addr = "127.0.0.1:8090" })}, |
| + {"unit-owned tenant", with(func(r *Record) { r.Addr = "127.0.0.1:8090"; r.Unit = "console.service" })}, |
| + {"static", with(func(r *Record) { r.Kind = "static" })}, |
| + {"explicitly non-hibernating instance", with(func(r *Record) { r.Hibernate = new(bool) })}, |
| + } |
| + for _, c := range ok { |
| + if err := ValidateShape(c.rec); err != nil { |
| + t.Errorf("%s: ValidateShape = %v, want nil", c.name, err) |
| + } |
| + } |
| + bad := []struct { |
| + name string |
| + rec Record |
| + }{ |
| + {"bogus kind", with(func(r *Record) { r.Kind = "unti" })}, |
| + {"status kind", with(func(r *Record) { r.Kind = "status" })}, |
| + {"addr without port", with(func(r *Record) { r.Addr = "127.0.0.1" })}, |
| + {"unit typo", with(func(r *Record) { r.Unit = "console" })}, |
| + {"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 })}, |
| + } |
| + for _, c := range bad { |
| + if err := ValidateShape(c.rec); err == nil { |
| + t.Errorf("%s: ValidateShape = nil, want a refusal", c.name) |
| + } |
| + } |
| +} |
| + |
| +// Design §3.2: the suffix pin stays exactly where the traversal danger is, |
| +// and nowhere else. |
| +func TestValidRecordHostFollowsPathDerivation(t *testing.T) { |
| + socketBacked := Record{Host: "acme.go.tito.io"} |
| + if !ValidRecordHost(socketBacked, "go.tito.io") { |
| + t.Error("a socket-backed instance under its own domain was refused") |
| + } |
| + offDomain := Record{Host: "carloku.com"} |
| + if ValidRecordHost(offDomain, "go.tito.io") { |
| + t.Error("a socket-backed instance escaped its domain suffix — that is the traversal rail") |
| + } |
| + static := Record{Host: "carloku.com", Kind: "static"} |
| + if !ValidRecordHost(static, "go.tito.io") { |
| + t.Error("a static apex was refused; it derives no path and needs shape validation only") |
| + } |
| + addrBacked := Record{Host: "console.platform.tito.io", Addr: "127.0.0.1:8090"} |
| + if !ValidRecordHost(addrBacked, "go.tito.io") { |
| + t.Error("an addr-backed tenant off-domain was refused; it derives no path") |
| + } |
| + hostile := Record{Host: "../../etc/passwd", Kind: "static"} |
| + if ValidRecordHost(hostile, "go.tito.io") { |
| + t.Error("a hostile host passed the shape check") |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 6: Run them** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/provision/ -count=1` |
| + |
| +Expected: PASS, including every pre-existing test in the package unchanged. |
| + |
| +- [ ] **Step 7: Commit** |
| + |
| +```bash |
| +gofmt -l internal/provision/ |
| +go build ./... && go vet ./... && go test ./... -count=1 |
| +git add internal/provision/provision.go internal/provision/provision_test.go |
| +git commit -m "provision: record grows a route-shape vocabulary (additive) |
| + |
| +Kind/Addr/Unit/Hibernate/Environment/StatusNamed, every one omitempty with |
| +a zero value that reproduces today's mint. Hibernate is a pointer because |
| +absent and false are different facts: a plain bool would read every record |
| +already in a production bucket as false and un-hibernate the fleet. |
| + |
| +Design: docs/superpowers/specs/2026-08-10-route-records-design.md §3" |
| +``` |
| + |
| +--- |
| + |
| +### Task 2: Reconcile mints every shape |
| + |
| +**Files:** |
| +- Modify: `internal/provision/reconcile.go` (the `!ok` mint branch at :224-241) |
| +- Test: `internal/provision/reconcile_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `Record.KindOrDefault`, `Record.HibernateOrDefault`, `Record.DerivesPaths` (Task 1). |
| +- Produces: `func routeFor(rec Record, dataDir, seed string) registry.Route` — the single builder for "what route does this record describe". Task 4's match test compares against it, so it must stay the only place that answer is computed. |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +Add to `internal/provision/reconcile_test.go`: |
| + |
| +```go |
| +// Every shape the record can now describe, minted in one pass. The three |
| +// 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. |
| +func TestReconcileMintsEveryShape(t *testing.T) { |
| + s, sqid := setup(t) |
| + off := false |
| + recs := []Record{ |
| + {Schema: 1, Host: "console.go.tito.io", Slug: "console", App: "tito", Account: sqid, |
| + Pool: "tito", Region: "ie", Channel: "stable", Kind: "instance", |
| + Addr: "127.0.0.1:8090", Unit: "console.service", CreatedAt: time.Now()}, |
| + {Schema: 1, Host: "whole.go.tito.io", Slug: "whole", App: "tito", Account: sqid, |
| + Pool: "tito", Region: "ie", Channel: "stable", Addr: "127.0.0.1:9443", |
| + 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()}, |
| + } |
| + for _, rec := range recs { |
| + 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) |
| + } |
| + |
| + console := *mustGet(t, reg, "console.go.tito.io") |
| + console.CreatedAt = 0 |
| + wantConsole := registry.Route{ |
| + Host: "console.go.tito.io", Slug: "console", Kind: "instance", |
| + Addr: "127.0.0.1:8090", Unit: "console.service", |
| + App: "tito", Account: sqid, Pool: "tito", Region: "ie", Channel: "stable", |
| + Provisioned: true, Seed: `["seed"]`, Status: "ready", |
| + } |
| + if console != wantConsole { |
| + t.Errorf("console route = %+v, want %+v", console, wantConsole) |
| + } |
| + |
| + whole := *mustGet(t, reg, "whole.go.tito.io") |
| + if whole.Socket != "" || whole.DBPath != "" { |
| + t.Errorf("an addr-backed tenant derived filesystem paths: socket %q db %q", whole.Socket, whole.DBPath) |
| + } |
| + if whole.Hibernate { |
| + t.Error("an explicitly non-hibernating record minted a hibernating route") |
| + } |
| + |
| + site := *mustGet(t, reg, "site.go.tito.io") |
| + if site.Kind != "static" { |
| + t.Errorf("static route Kind = %q", site.Kind) |
| + } |
| + if site.Socket != "" || site.DBPath != "" { |
| + t.Errorf("a static route derived filesystem paths: socket %q db %q", site.Socket, site.DBPath) |
| + } |
| + if site.Hibernate { |
| + t.Error("a static route was armed for hibernation — it has no process to wake") |
| + } |
| + if !site.StatusNamed { |
| + t.Error("StatusNamed did not reach the row") |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run it to verify it fails** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/provision/ -run TestReconcileMintsEveryShape -v` |
| + |
| +Expected: FAIL — the minted routes carry `Socket`/`DBPath` for every case and ignore `Addr`, `Unit`, `Kind`, `StatusNamed`. |
| + |
| +- [ ] **Step 3: Extract `routeFor` and use it in the mint branch** |
| + |
| +Add to `internal/provision/reconcile.go`, above `Reconcile`: |
| + |
| +```go |
| +// routeFor is the one answer to "what route does this record describe". The |
| +// mint path builds from it and the adoption match test (design §4.2) |
| +// compares against it — one function, so a shape the reconciler would mint |
| +// and a shape it will adopt can never drift apart. |
| +// |
| +// 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. |
| +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, |
| + Provisioned: true, Seed: seed, Status: "ready", |
| + } |
| + if rec.DerivesPaths() { |
| + rt.Socket = DefaultSocket(rec.Host) |
| + rt.DBPath = DefaultDBPath(dataDir, rec.Host) |
| + } |
| + return rt |
| +} |
| +``` |
| + |
| +Replace the `case !ok:` body's route literal with it. The branch becomes: |
| + |
| +```go |
| + case !ok: |
| + // Hibernate and the path derivation now come from the record |
| + // (routeFor); everything else about this branch is unchanged. |
| + nr := routeFor(rec, cfg.DataDir, seed) |
| + if err := reg.Upsert(nr); err != nil { |
| + log.Printf("provision: mint %s: %v", rec.Host, err) |
| + continue |
| + } |
| + log.Printf("provision: minted %s route %s (app %q acct %q)", nr.Kind, rec.Host, rec.App, rec.Account) |
| +``` |
| + |
| +- [ ] **Step 4: Run the whole package** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/provision/ -count=1 -v` |
| + |
| +Expected: PASS, and **`TestReconcileMints` must pass unchanged** — that existing test pins the legacy mint field-by-field and is the second half of the wire-shape guarantee. If it fails, `routeFor` has drifted; fix `routeFor`, never the test. |
| + |
| +- [ ] **Step 5: Commit** |
| + |
| +```bash |
| +gofmt -l internal/provision/ |
| +go build ./... && go vet ./... && go test ./... -count=1 |
| +git add internal/provision/reconcile.go internal/provision/reconcile_test.go |
| +git commit -m "provision: reconcile mints every shape the record describes |
| + |
| +routeFor is now the single builder for what a record materializes; the |
| +adoption match test compares against it, so a shape the pass would mint and |
| +one it will adopt cannot drift apart. |
| + |
| +Design: docs/superpowers/specs/2026-08-10-route-records-design.md §4" |
| +``` |
| + |
| +--- |
| + |
| +### Task 3: Reconcile converges Channel and Environment (#130) |
| + |
| +**Files:** |
| +- Modify: `internal/provision/reconcile.go` (the `existing.Provisioned && …` converge branch at :245-249) |
| +- Test: `internal/provision/reconcile_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `routeFor` (Task 2). |
| +- Produces: no new exported names. Uses `registry.SetChannel(host, channel)`, `registry.SetEnvironment(host, env)` and `registry.SetHibernate(host, on)`, all of which exist. |
| + |
| +- [ ] **Step 1: Write the failing tests** |
| + |
| +```go |
| +// Issue #130, as it happened in production on 2026-08-09: a record created |
| +// on stable, deleted and recreated on edge, and the route row left on |
| +// stable — a route pointing at a channel with no pointer, so every wake |
| +// failed "no adoptable head" while carlos channels cheerfully showed edge |
| +// promoted. Reconcile created from a record and never converged to one. |
| +func TestReconcileConvergesChannel(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) |
| + } |
| + before := *mustGet(t, reg, "acme.go.tito.io") |
| + |
| + rec.Channel = "edge" |
| + putReplace(t, s, RecordKey(sqid, "tito", rec.Host), mustJSON(t, rec)) |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + after := *mustGet(t, reg, "acme.go.tito.io") |
| + if after.Channel != "edge" { |
| + t.Errorf("channel = %q, want edge — the record moved and the row did not", after.Channel) |
| + } |
| + after.Channel = before.Channel |
| + if after != before { |
| + t.Errorf("converge touched more than Channel: before %+v after %+v", before, after) |
| + } |
| + |
| + // The fleet runs on canary/* channels today (pinned in |
| + // internal/release/laddercheck_test.go); a converge that could not carry |
| + // one would stop deploys. |
| + rec.Channel = "canary/rehearsal" |
| + putReplace(t, s, RecordKey(sqid, "tito", rec.Host), mustJSON(t, rec)) |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := mustGet(t, reg, "acme.go.tito.io").Channel; got != "canary/rehearsal" { |
| + t.Errorf("channel = %q, want canary/rehearsal", got) |
| + } |
| +} |
| + |
| +func TestReconcileConvergesEnvironment(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) |
| + } |
| + |
| + rec.Environment = "staging" |
| + putReplace(t, s, RecordKey(sqid, "tito", rec.Host), mustJSON(t, rec)) |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := mustGet(t, reg, "acme.go.tito.io").Environment; got != "staging" { |
| + t.Errorf("environment = %q, want staging", got) |
| + } |
| + |
| + // Clearing must reach the row too. Upsert deliberately PRESERVES a |
| + // non-empty environment (it cannot tell "untouched" from "cleared"), so |
| + // the clear has to go through the narrow setter — which is exactly why |
| + // this converge does not re-Upsert. |
| + rec.Environment = "" |
| + putReplace(t, s, RecordKey(sqid, "tito", rec.Host), mustJSON(t, rec)) |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := mustGet(t, reg, "acme.go.tito.io").Environment; got != "" { |
| + t.Errorf("environment = %q, want it cleared", got) |
| + } |
| +} |
| + |
| +// An explicit Hibernate converges; an absent one never does. Absent means |
| +// "the record does not speak to this", and a pass that stamped its |
| +// derivation over an operator's deliberate SetHibernate would be the silent |
| +// stomp this design exists to end. |
| +func TestReconcileConvergesHibernateOnlyWhenExplicit(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.SetHibernate("acme.go.tito.io", false); 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").Hibernate { |
| + t.Error("an absent Hibernate re-asserted its derivation over an operator's setting") |
| + } |
| + |
| + on := true |
| + rec.Hibernate = &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").Hibernate { |
| + t.Error("an explicit Hibernate did not converge") |
| + } |
| +} |
| +``` |
| + |
| +Add the helper the three tests share, beside `putReplace`: |
| + |
| +```go |
| +func mustJSON(t *testing.T, v any) []byte { |
| + t.Helper() |
| + b, err := json.Marshal(v) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return b |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run to verify they fail** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/provision/ -run TestReconcileConverges -v` |
| + |
| +Expected: FAIL — `channel = "stable", want edge`, and the environment and hibernate equivalents. |
| + |
| +- [ ] **Step 3: Converge in the owned branch** |
| + |
| +Replace the `case existing.Provisioned && …` body. Keep the existing seed converge exactly as it is and add the three columns before it: |
| + |
| +```go |
| + case existing.Provisioned && existing.App == rec.App && existing.Account == rec.Account: |
| + // The columns this record owns (design §4.1, issue #130). Narrow |
| + // UPDATEs, never a re-Upsert: a read-modify-write racing a |
| + // deploy's SetVersions or the activator's SetNextDue would |
| + // clobber those, and Upsert cannot express "clear Environment" |
| + // at all (it preserves a non-empty value by design). |
| + if rec.Channel != "" && existing.Channel != rec.Channel { |
| + if err := reg.SetChannel(rec.Host, rec.Channel); err != nil { |
| + log.Printf("provision: converge channel on %s: %v", rec.Host, err) |
| + } else { |
| + log.Printf("provision: converged %s channel %s -> %s", rec.Host, existing.Channel, rec.Channel) |
| + } |
| + } |
| + if existing.Environment != rec.Environment { |
| + if err := reg.SetEnvironment(rec.Host, rec.Environment); err != nil { |
| + log.Printf("provision: converge environment on %s: %v", rec.Host, err) |
| + } |
| + } |
| + // Absent means "this record does not speak to hibernation" — |
| + // never a derivation stamped over an operator's own setting. |
| + if rec.Hibernate != nil && existing.Hibernate != *rec.Hibernate { |
| + if err := reg.SetHibernate(rec.Host, *rec.Hibernate); err != nil { |
| + log.Printf("provision: converge hibernate on %s: %v", rec.Host, err) |
| + } |
| + } |
| + // seedIsKnown guards against stamping a transient manifest-read |
| + // failure's "" fallback over a working seed — see seedFor. |
| + if seedIsKnown && existing.Seed != seed { |
| + if err := reg.SetSeed(rec.Host, seed); err != nil { |
| + log.Printf("provision: converge seed on %s: %v", rec.Host, err) |
| + } |
| + } |
| +``` |
| + |
| +Note the `rec.Channel != ""` guard: an empty channel would pin the route to nothing, and the create door already refuses one. A malformed record that lost its channel must leave the row alone, not blank it. |
| + |
| +**Deliberately not converged here:** `Kind`, `Addr`, `Unit`, `Slug`, `Pool`, `Region`. A record that changes those describes a structurally different route, and converging them in place would mean re-deriving socket and DB paths under a running process. Log it and leave it — add, immediately after the seed converge: |
| + |
| +```go |
| + if want := routeFor(rec, cfg.DataDir, seed); want.Kind != existing.Kind || |
| + want.Addr != existing.Addr || want.Unit != existing.Unit || |
| + want.Socket != existing.Socket || want.DBPath != existing.DBPath { |
| + log.Printf("provision: %s: record describes a different route SHAPE than the row "+ |
| + "(kind %q/%q addr %q/%q unit %q/%q) — not converged in place; `carlos remove %s` and let the next pass re-mint", |
| + rec.Host, want.Kind, existing.Kind, want.Addr, existing.Addr, want.Unit, existing.Unit, rec.Host) |
| + } |
| +``` |
| + |
| +- [ ] **Step 4: Run to verify they pass** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/provision/ -count=1` |
| + |
| +Expected: PASS, whole package. `TestReconcileConvergesSeed` must still pass — it asserts the converge touches *only* Seed, and it uses a record whose channel and environment never move, so it still holds. |
| + |
| +- [ ] **Step 5: Commit** |
| + |
| +```bash |
| +gofmt -l internal/provision/ |
| +go build ./... && go vet ./... && go test ./... -count=1 |
| +git add internal/provision/reconcile.go internal/provision/reconcile_test.go |
| +git commit -m "provision: reconcile converges Channel, Environment and explicit Hibernate |
| + |
| +Closes the divergence half of #130: a record whose channel moved now moves |
| +the route row, so the member-visible state and the box state cannot |
| +disagree about a column the bucket owns. |
| + |
| +Shape changes (kind/addr/unit) are logged, not converged in place — that |
| +would re-derive socket and DB paths under a running process. |
| + |
| +Design: docs/superpowers/specs/2026-08-10-route-records-design.md §4.1" |
| +``` |
| + |
| +--- |
| + |
| +### Task 4: Reconcile adopts a matching route |
| + |
| +**Files:** |
| +- Modify: `internal/registry/registry.go` (new `SetProvisioned`, beside `SetSeed` at :1062) |
| +- Modify: `internal/provision/reconcile.go` (the `default:` branch at :250-253) |
| +- Test: `internal/registry/registry_test.go`, `internal/provision/reconcile_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `routeFor` (Task 2), the converge branch (Task 3). |
| +- Produces: |
| + - `func (r *Registry) SetProvisioned(host string, on bool) error` |
| + - `func shapeMatches(existing registry.Route, want registry.Route) bool` (package-private, `internal/provision`) |
| + |
| +- [ ] **Step 1: Write the failing tests** |
| + |
| +In `internal/provision/reconcile_test.go`: |
| + |
| +```go |
| +// Design §4.2 — the payoff. Backfilling the fleet is a pure bucket-side |
| +// write per route: describe what is already there, and the next serial bump |
| +// adopts it. No carlos add, no SSM session, no on-box verb. |
| +func TestReconcileAdoptsMatchingHandAddedRoute(t *testing.T) { |
| + s, sqid := setup(t) |
| + rec := acmeRecord(sqid) |
| + rec.Channel = "edge" |
| + if err := CreateRecord(ctx, s, rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + // The route as an operator hand-added it before records existed: the |
| + // same shape, a different channel, and no Provisioned flag. |
| + handAdded := registry.Route{ |
| + Host: "acme.go.tito.io", Slug: "acme", Kind: "instance", |
| + Socket: "/run/carlos/acme.go.tito.io.sock", DBPath: "/data/acme.go.tito.io.db", |
| + App: "tito", Account: sqid, Pool: "tito", Region: "ie", |
| + Channel: "stable", Hibernate: true, Status: "ready", |
| + } |
| + reg := regWith(t, handAdded) |
| + cfg := ReconcileConfig{Region: "ie", Pools: []string{"tito"}, DataDir: "/data"} |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + got := *mustGet(t, reg, "acme.go.tito.io") |
| + if !got.Provisioned { |
| + t.Fatal("a matching hand-added route was not adopted") |
| + } |
| + if got.Channel != "edge" { |
| + t.Errorf("channel = %q, want edge — adoption should converge in the same pass", got.Channel) |
| + } |
| +} |
| + |
| +// The match test EXCLUDES the three columns the record is taking ownership |
| +// of. Requiring them to agree first would make adoption impossible in |
| +// exactly the case it exists for — a hand-added route on the wrong channel |
| +// is the whole reason to adopt it. |
| +func TestReconcileAdoptionIgnoresOwnedColumns(t *testing.T) { |
| + s, sqid := setup(t) |
| + rec := acmeRecord(sqid) |
| + rec.Channel = "edge" |
| + if err := CreateRecord(ctx, s, rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + handAdded := registry.Route{ |
| + Host: "acme.go.tito.io", Slug: "acme", Kind: "instance", |
| + Socket: "/run/carlos/acme.go.tito.io.sock", DBPath: "/data/acme.go.tito.io.db", |
| + App: "tito", Account: sqid, Pool: "tito", Region: "ie", |
| + Channel: "canary/rehearsal", Environment: "staging", Hibernate: false, Status: "ready", |
| + } |
| + reg := regWith(t, handAdded) |
| + 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").Provisioned { |
| + t.Error("adoption refused over a Channel/Environment/Hibernate difference") |
| + } |
| +} |
| + |
| +// A shape that differs is left exactly as alone as it is today. This is the |
| +// guard that stands between "adopt on match" and reconcile's removal phase |
| +// acquiring authority over a route it does not describe. |
| +func TestReconcileRefusesToAdoptMismatchedRoute(t *testing.T) { |
| + s, sqid := setup(t) |
| + if err := CreateRecord(ctx, s, acmeRecord(sqid)); err != nil { |
| + t.Fatal(err) |
| + } |
| + mismatched := registry.Route{ |
| + Host: "acme.go.tito.io", Slug: "acme", Kind: "instance", |
| + Socket: "/run/carlos/acme.go.tito.io.sock", DBPath: "/data/acme.go.tito.io.db", |
| + App: "other", Account: sqid, Pool: "tito", Region: "ie", |
| + Channel: "stable", Hibernate: true, Status: "ready", |
| + } |
| + reg := regWith(t, mismatched) |
| + before := *mustGet(t, reg, "acme.go.tito.io") |
| + cfg := ReconcileConfig{Region: "ie", Pools: []string{"tito"}, DataDir: "/data"} |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + after := *mustGet(t, reg, "acme.go.tito.io") |
| + if after != before { |
| + t.Errorf("a mismatched route was touched: before %+v after %+v", before, after) |
| + } |
| + if after.Provisioned { |
| + t.Error("a mismatched route was adopted — removal authority granted over a route the record does not describe") |
| + } |
| +} |
| + |
| +// An adopted route is removable by the pass that adopted it, on the same |
| +// terms as one it minted: the record disappearing is the bucket's own |
| +// decision to retire the instance. |
| +func TestReconcileRemovesAdoptedRouteWhenRecordGoes(t *testing.T) { |
| + s, sqid := setup(t) |
| + if err := CreateRecord(ctx, s, acmeRecord(sqid)); err != nil { |
| + t.Fatal(err) |
| + } |
| + handAdded := registry.Route{ |
| + Host: "acme.go.tito.io", Slug: "acme", Kind: "instance", |
| + Socket: "/run/carlos/acme.go.tito.io.sock", DBPath: "/data/acme.go.tito.io.db", |
| + App: "tito", Account: sqid, Pool: "tito", Region: "ie", |
| + Channel: "stable", Hibernate: true, Status: "ready", |
| + } |
| + reg := regWith(t, handAdded) |
| + cfg := ReconcileConfig{Region: "ie", Pools: []string{"tito"}, DataDir: "/data"} |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := DeleteRecord(ctx, s, sqid, "tito", "acme.go.tito.io"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + rt, err := reg.Get("acme.go.tito.io") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if rt != nil { |
| + t.Errorf("adopted route survived its record's deletion: %+v", rt) |
| + } |
| +} |
| + |
| +// The other side of that coin, and the one that matters more: a route no |
| +// record ever described is never removed, no matter how many passes run. |
| +// Adoption is the ONLY way this pass acquires removal authority, and this |
| +// test is what stands between a backfill and a retired production route. |
| +func TestReconcileNeverRemovesUnadoptedRoute(t *testing.T) { |
| + s, sqid := setup(t) |
| + // A record for some OTHER host, so the pass runs a full removal phase |
| + // with a non-empty record set rather than bailing early. |
| + if err := CreateRecord(ctx, s, acmeRecord(sqid)); err != nil { |
| + t.Fatal(err) |
| + } |
| + handAdded := registry.Route{ |
| + Host: "console.go.tito.io", Kind: "instance", App: "tito", Account: sqid, |
| + Pool: "tito", Region: "ie", Channel: "stable", Addr: "127.0.0.1:8090", |
| + Unit: "console.service", Status: "ready", |
| + } |
| + reg := regWith(t, handAdded) |
| + cfg := ReconcileConfig{Region: "ie", Pools: []string{"tito"}, DataDir: "/data"} |
| + for i := 0; i < 3; i++ { |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + got, err := reg.Get("console.go.tito.io") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got == nil { |
| + t.Fatal("a hand-added route with no record was removed") |
| + } |
| + if got.Provisioned { |
| + t.Error("a hand-added route with no record was adopted") |
| + } |
| + if *got != handAdded { |
| + t.Errorf("a hand-added route with no record was modified: %+v", got) |
| + } |
| +} |
| +``` |
| + |
| +The final `*got != handAdded` comparison will need `handAdded.CreatedAt` filled from the row before comparing (Upsert stamps it) — read it back once after `regWith` and assign, exactly as `TestReconcileMints` zeroes `CreatedAt` for the same reason. |
| + |
| +In `internal/registry/registry_test.go`: |
| + |
| +```go |
| +func TestSetProvisioned(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.SetProvisioned("a.example.com", true); err != nil { |
| + t.Fatal(err) |
| + } |
| + rt, err := r.Get("a.example.com") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !rt.Provisioned { |
| + t.Error("SetProvisioned(true) did not stamp the column") |
| + } |
| + if err := r.SetProvisioned("missing.example.com", true); err == nil { |
| + t.Error("SetProvisioned on an unknown host returned nil; want an error (the SetChannel contract)") |
| + } |
| +} |
| +``` |
| + |
| +`openTestRegistry` is the helper that file already defines at `internal/registry/registry_test.go:263`; use it, do not add another. |
| + |
| +- [ ] **Step 2: Run to verify they fail** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/provision/ ./internal/registry/ -run 'Adopt|SetProvisioned|RemovesAdopted' -v` |
| + |
| +Expected: FAIL — `SetProvisioned undefined`, and the adoption tests report the route untouched. |
| + |
| +- [ ] **Step 3: Add `SetProvisioned`** |
| + |
| +In `internal/registry/registry.go`, beside `SetSeed`: |
| + |
| +```go |
| +// SetProvisioned stamps (or clears) the flag that marks a route as the |
| +// instance-provisioning reconcile pass's own — a narrow UPDATE, same |
| +// rationale as SetSeed. It errors when no row matched, the SetChannel |
| +// 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 |
| +// 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 { |
| + v := 0 |
| + if on { |
| + v = 1 |
| + } |
| + res, err := r.sql.Exec(`UPDATE routes SET provisioned = ? 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 |
| +} |
| +``` |
| + |
| +- [ ] **Step 4: Add `shapeMatches` and the adoption branch** |
| + |
| +In `internal/provision/reconcile.go`, beside `routeFor`: |
| + |
| +```go |
| +// shapeMatches reports whether an existing route is the same ROUTE the |
| +// record describes — the gate on adoption (design §4.2), and so the gate on |
| +// this pass acquiring removal authority over a row it did not mint. |
| +// |
| +// Channel, Environment and Hibernate are deliberately absent: those are the |
| +// columns the record is taking ownership OF, and a hand-added route on the |
| +// wrong channel is the whole reason to adopt one. Requiring them to agree |
| +// would make adoption impossible in exactly the case it exists for. |
| +// |
| +// Everything else that identifies the route is compared, including the |
| +// derived Socket and DBPath — two rows agreeing on kind and app but not on |
| +// where the database lives are not the same route, and adopting across that |
| +// difference would point a running process at a second copy of its own data. |
| +func shapeMatches(existing, want registry.Route) bool { |
| + return existing.App == want.App && |
| + existing.Account == want.Account && |
| + existing.Kind == want.Kind && |
| + existing.Addr == want.Addr && |
| + existing.Unit == want.Unit && |
| + existing.Slug == want.Slug && |
| + existing.Pool == want.Pool && |
| + existing.Region == want.Region && |
| + existing.Socket == want.Socket && |
| + existing.DBPath == want.DBPath |
| +} |
| +``` |
| + |
| +Replace the `default:` branch: |
| + |
| +```go |
| + default: |
| + // A route exists that this pass did not mint. If it is the same |
| + // route the record describes, adopt it — that is what makes |
| + // backfilling the fleet a bucket-side write rather than a box |
| + // session (design §4.2). If it is not, leave it exactly as alone |
| + // as before: adoption grants this pass's removal phase authority |
| + // over the row, so the match test is the only thing standing |
| + // between a backfill and a retired production route. |
| + want := routeFor(rec, cfg.DataDir, seed) |
| + if !shapeMatches(existing, want) { |
| + log.Printf("provision: %s exists but is not the route this record describes "+ |
| + "(app %q/%q kind %q/%q addr %q/%q) — leaving it alone", |
| + rec.Host, existing.App, want.App, existing.Kind, want.Kind, existing.Addr, want.Addr) |
| + continue |
| + } |
| + if err := reg.SetProvisioned(rec.Host, true); err != nil { |
| + log.Printf("provision: adopt %s: %v", rec.Host, err) |
| + continue |
| + } |
| + log.Printf("provision: adopted existing route %s (app %q acct %q) — this record now owns its channel and environment", |
| + rec.Host, rec.App, rec.Account) |
| + if rec.Channel != "" && existing.Channel != rec.Channel { |
| + if err := reg.SetChannel(rec.Host, rec.Channel); err != nil { |
| + log.Printf("provision: converge channel on newly adopted %s: %v", rec.Host, err) |
| + } |
| + } |
| + if existing.Environment != rec.Environment { |
| + if err := reg.SetEnvironment(rec.Host, rec.Environment); err != nil { |
| + log.Printf("provision: converge environment on newly adopted %s: %v", rec.Host, err) |
| + } |
| + } |
| +``` |
| + |
| +Hibernate is deliberately NOT converged at adoption even when explicit: the first pass over a hand-added route should change what it serves and who owns the column, not whether a live process is allowed to sleep. It converges on the next pass through the owned branch (Task 3), by which point the adoption is visible in the log. |
| + |
| +- [ ] **Step 5: Run the tests** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/provision/ ./internal/registry/ -count=1` |
| + |
| +Expected: PASS. `TestReconcileLeavesConflictAlone` must still pass — its hand-added route has `App: "other"` and `Addr: "127.0.0.1:9000"`, so `shapeMatches` is false and the route stays untouched. |
| + |
| +- [ ] **Step 6: Commit** |
| + |
| +```bash |
| +gofmt -l internal/provision/ internal/registry/ |
| +go build ./... && go vet ./... && go test ./... -count=1 |
| +git add internal/registry/registry.go internal/registry/registry_test.go internal/provision/reconcile.go internal/provision/reconcile_test.go |
| +git commit -m "provision: adopt an existing route whose shape matches an appearing record |
| + |
| +Backfilling the fleet becomes a pure bucket-side write per route — describe |
| +what is already there and the next serial bump adopts it, with no box |
| +commands. The match test excludes Channel/Environment/Hibernate, which are |
| +the columns the record is taking ownership of. |
| + |
| +Adoption grants the removal phase authority over the row, so shapeMatches |
| +compares the derived Socket and DBPath too. |
| + |
| +Design: docs/superpowers/specs/2026-08-10-route-records-design.md §4.2" |
| +``` |
| + |
| +--- |
| + |
| +### Task 5: Reconcile dry-run |
| + |
| +**Files:** |
| +- Modify: `internal/provision/reconcile.go` (`ReconcileConfig`, and every write site) |
| +- Test: `internal/provision/reconcile_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: everything from Tasks 2-4. |
| +- Produces: `ReconcileConfig.DryRun bool`. |
| + |
| +**Scope note:** §4.3 also asks for a tool that prints the record it *would write* for each existing route. That is record generation from routes — a backfill authoring aid, not a reconcile concern — and it belongs with plan 3's backfill along with the operator-facing verb that invokes it. What this task delivers is the half that lives here: a pass that reports what it would do and writes nothing. |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +```go |
| +// A dry-run pass reports and writes nothing — the mitigation that lets a |
| +// backfill be checked before adoption grants the removal phase authority |
| +// over a production route (design §4.3). |
| +func TestReconcileDryRunWritesNothing(t *testing.T) { |
| + s, sqid := setup(t) |
| + if err := CreateRecord(ctx, s, acmeRecord(sqid)); err != nil { |
| + t.Fatal(err) |
| + } |
| + // One route to adopt, one record to mint, one route to remove. |
| + adoptable := registry.Route{ |
| + Host: "acme.go.tito.io", Slug: "acme", Kind: "instance", |
| + Socket: "/run/carlos/acme.go.tito.io.sock", DBPath: "/data/acme.go.tito.io.db", |
| + App: "tito", Account: sqid, Pool: "tito", Region: "ie", |
| + Channel: "stable", Hibernate: true, Status: "ready", |
| + } |
| + retired := registry.Route{ |
| + Host: "gone.go.tito.io", Kind: "instance", App: "tito", Account: sqid, |
| + Pool: "tito", Region: "ie", Status: "ready", Provisioned: true, |
| + } |
| + reg := regWith(t, adoptable, retired) |
| + before, err := reg.List() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + cfg := ReconcileConfig{Region: "ie", Pools: []string{"tito"}, DataDir: "/data", DryRun: true} |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + after, err := reg.List() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !reflect.DeepEqual(before, after) { |
| + t.Errorf("dry-run wrote to the registry:\nbefore %+v\nafter %+v", before, after) |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run to verify it fails** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/provision/ -run TestReconcileDryRun -v` |
| + |
| +Expected: FAIL to compile — `unknown field DryRun`. |
| + |
| +- [ ] **Step 3: Add the field and guard every write** |
| + |
| +In `ReconcileConfig`: |
| + |
| +```go |
| + // DryRun reports what the pass would do and writes nothing — not to the |
| + // registry, not anywhere. The backfill mitigation (design §4.3): a |
| + // production route is adopted only after someone has read what adopting |
| + // it would mean, because adoption is what grants the removal phase |
| + // authority over that row. |
| + DryRun bool |
| +``` |
| + |
| +Guard all five write sites. Each keeps its log line, prefixed, and skips its write: |
| + |
| +```go |
| + case !ok: |
| + 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)", |
| + nr.Kind, rec.Host, rec.App, rec.Account, rec.Channel) |
| + continue |
| + } |
| +``` |
| + |
| +Converge (Task 3), each of the three: |
| + |
| +```go |
| + if rec.Channel != "" && existing.Channel != rec.Channel { |
| + if cfg.DryRun { |
| + log.Printf("provision: dry-run: would converge %s channel %s -> %s", rec.Host, existing.Channel, rec.Channel) |
| + } else if err := reg.SetChannel(rec.Host, rec.Channel); err != nil { |
| + log.Printf("provision: converge channel on %s: %v", rec.Host, err) |
| + } else { |
| + log.Printf("provision: converged %s channel %s -> %s", rec.Host, existing.Channel, rec.Channel) |
| + } |
| + } |
| +``` |
| + |
| +…and the same shape for Environment, Hibernate and Seed. |
| + |
| +Adoption (Task 4): |
| + |
| +```go |
| + if cfg.DryRun { |
| + log.Printf("provision: dry-run: would adopt existing route %s (app %q acct %q), converging channel %s -> %s", |
| + rec.Host, rec.App, rec.Account, existing.Channel, rec.Channel) |
| + continue |
| + } |
| +``` |
| + |
| +Removal: |
| + |
| +```go |
| + if cfg.DryRun { |
| + log.Printf("provision: dry-run: would remove retired instance route %s (record gone)", rt.Host) |
| + continue |
| + } |
| +``` |
| + |
| +- [ ] **Step 4: Run** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/provision/ -count=1` |
| + |
| +Expected: PASS, whole package. |
| + |
| +- [ ] **Step 5: Commit** |
| + |
| +```bash |
| +gofmt -l internal/provision/ |
| +go build ./... && go vet ./... && go test ./... -count=1 |
| +git add internal/provision/reconcile.go internal/provision/reconcile_test.go |
| +git commit -m "provision: ReconcileConfig.DryRun reports the pass and writes nothing |
| + |
| +The backfill mitigation from design §4.3: a production route is adopted |
| +only after someone has read what adopting it would mean, because adoption |
| +is what grants the removal phase authority over that row." |
| +``` |
| + |
| +--- |
| + |
| +### Task 6: `carlos route` refuses the columns the record now owns |
| + |
| +**Files:** |
| +- Modify: `cli.go` (`platformCanaryOwnedRow` at :1006-1020; the `verdictKind` const block at :690-698; the channel switch at :715-728; the `envVerdictKind` const block at :774-782; the environment switch at :805-818; the refusal print loops at :855-880) |
| +- Test: `e2e_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `registry.Route.Provisioned` (already exists). |
| +- Produces: `func recordOwnedRow(rt registry.Route) bool` in `package main`. |
| + |
| +**Why this ships in the same commit range as Tasks 3-4:** a `Provisioned` route whose channel the CLI will still happily write is precisely the window where the silent stomp is possible — the operator's write lands, and the next serial bump reverts it with no message anywhere. |
| + |
| +- [ ] **Step 1: Write the failing tests** |
| + |
| +In `e2e_test.go`, beside `TestRoutePlatformCanaryNeverLeavesCanary`: |
| + |
| +```go |
| +// Design §5: once a route is Provisioned, its Channel and Environment are |
| +// the instance record's. A write here lands and is reverted within seconds |
| +// of the next serial bump, silently — the same class of bug as #130, |
| +// arriving from the other side. The refusal names the record as the door, |
| +// and no flag overrides it: -allow-version-change answers "what would this |
| +// change", not "who owns this column". |
| +func TestRouteChannelRefusedOnProvisionedRoute(t *testing.T) { |
| + f := newRouteTestFixture(t) |
| + f.ship(release.OpsAccount, "site", "v1") |
| + f.promote(release.OpsAccount, "site", "stable", "v1") |
| + f.promote(release.OpsAccount, "site", "canary/preview", "v1") |
| + f.addRoute(registry.Route{Host: "acme.go.tito.io", Kind: "instance", App: "site", |
| + Channel: "canary/preview", Status: "ready", Provisioned: true}) |
| + |
| + out, err := f.run(f.fullEnv(), "route", "-registry", f.regPath, |
| + "-host", "acme.go.tito.io", "-channel", "stable", "-allow-version-change") |
| + if err == nil { |
| + t.Fatalf("route -channel on a Provisioned route was accepted:\n%s", out) |
| + } |
| + if !strings.Contains(out, "record") { |
| + t.Errorf("refusal does not name the instance record as the door:\n%s", out) |
| + } |
| + if got := f.channelOf("acme.go.tito.io"); got != "canary/preview" { |
| + t.Fatalf("channel = %q, want it unwritten after the refusal", got) |
| + } |
| +} |
| + |
| +func TestRouteEnvironmentRefusedOnProvisionedRoute(t *testing.T) { |
| + f := newRouteTestFixture(t) |
| + f.ship(release.OpsAccount, "site", "v1") |
| + f.promote(release.OpsAccount, "site", "stable", "v1") |
| + f.addRoute(registry.Route{Host: "acme.go.tito.io", Kind: "instance", App: "site", |
| + Channel: "stable", Status: "ready", Provisioned: true}) |
| + f.saveEnvironment(release.OpsAccount, "site", "demo") |
| + |
| + out, err := f.run(f.fullEnv(), "route", "-registry", f.regPath, |
| + "-host", "acme.go.tito.io", "-environment", "demo") |
| + if err == nil { |
| + t.Fatalf("route -environment on a Provisioned route was accepted:\n%s", out) |
| + } |
| + if got := f.environmentOf("acme.go.tito.io"); got != "" { |
| + t.Fatalf("environment = %q, want it unwritten after the refusal", got) |
| + } |
| + // Clearing is equally not this verb's to do — the refusal is on the ROW, |
| + // not on one direction of travel, exactly as the Platform+Canary one is. |
| + f.addRoute(registry.Route{Host: "bound.go.tito.io", Kind: "instance", App: "site", |
| + Channel: "stable", Status: "ready", Provisioned: true, Environment: "demo"}) |
| + out, err = f.run(f.fullEnv(), "route", "-registry", f.regPath, |
| + "-host", "bound.go.tito.io", "-environment", "-") |
| + if err == nil { |
| + t.Fatalf("clearing an environment on a Provisioned route was accepted:\n%s", out) |
| + } |
| +} |
| + |
| +// The box-scoped axes are untouched: they name resources that exist on one |
| +// machine, the record does not own them, and an operator must still be able |
| +// to grant a port or stamp a unit on a provisioned route. |
| +func TestRouteBoxScopedAxesStillWorkOnProvisionedRoute(t *testing.T) { |
| + f := newRouteTestFixture(t) |
| + f.addRoute(registry.Route{Host: "acme.go.tito.io", Kind: "instance", App: "site", |
| + Channel: "stable", Status: "ready", Provisioned: true, Addr: "127.0.0.1:8090"}) |
| + |
| + out, err := f.run(f.fullEnv(), "route", "-registry", f.regPath, |
| + "-host", "acme.go.tito.io", "-unit", "console.service") |
| + if err != nil { |
| + t.Fatalf("route -unit on a Provisioned route was refused:\n%s", out) |
| + } |
| + if got := f.routeOf("acme.go.tito.io").Unit; got != "console.service" { |
| + t.Errorf("unit = %q, want console.service", got) |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run to verify they fail** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test . -run 'TestRouteChannelRefusedOnProvisioned|TestRouteEnvironmentRefusedOnProvisioned|TestRouteBoxScopedAxes' -count=1 -v` |
| + |
| +Expected: the two refusal tests FAIL (the writes are accepted); the box-scoped test PASSES already and is there to stay passing. |
| + |
| +- [ ] **Step 3: Add `recordOwnedRow` and the two verdict kinds** |
| + |
| +In `cli.go`, beside `platformCanaryOwnedRow`: |
| + |
| +```go |
| +// recordOwnedRow reports whether the instance-provisioning reconciler, not |
| +// this verb, owns this route's Channel and Environment columns. |
| +// |
| +// Provisioned is set only by that pass — on mint, and on adoption after it |
| +// matched the row's shape against the bucket record (internal/provision). |
| +// The pass converges both columns from the record on every tick, so a write |
| +// here lands and is reverted within seconds of the next serial bump, with |
| +// nothing said anywhere. Same structural refusal as platformCanaryOwnedRow, |
| +// one more owner: the door is the record. |
| +func recordOwnedRow(rt registry.Route) bool { return rt.Provisioned } |
| +``` |
| + |
| +Add to the `verdictKind` const block: |
| + |
| +```go |
| + vRecordOwned // Provisioned: the instance record owns this column, never overridable |
| +``` |
| + |
| +Add to the `envVerdictKind` const block: |
| + |
| +```go |
| + eRecordOwned // Provisioned: the instance record owns this column |
| +``` |
| + |
| +- [ ] **Step 4: Add the switch cases** |
| + |
| +In the channel switch, immediately after `case platformCanaryOwnedRow(rt):`: |
| + |
| +```go |
| + case recordOwnedRow(rt): |
| + chanVerdicts[i] = verdict{rt: rt, from: from, kind: vRecordOwned, note: fmt.Sprintf( |
| + "provisioned route on %s — its channel is owned by the instance record at\n"+ |
| + "apps/<account>/%s/instances/%s.json, which the provisioning reconciler converges onto this row\n"+ |
| + "on every pass; a change here is reverted within seconds of the next serial bump.\n"+ |
| + "Repoint the RECORD instead: `carlos route -host %s -channel <channel>` against the console", |
| + from, rt.App, rt.Host, rt.Host)} |
| +``` |
| + |
| +In the environment switch, immediately after `case platformCanaryOwnedRow(rt):` and **before** `case envClear:` — so clearing is refused too, for the reason the platform case already documents (a half-owned column is the worst of both): |
| + |
| +```go |
| + case recordOwnedRow(rt): |
| + envVerdicts[i] = envVerdict{rt: rt, from: from, kind: eRecordOwned, note: "provisioned route — its environment is owned by the\n" + |
| + "instance record, which the provisioning reconciler converges onto this row on every pass. A binding\n" + |
| + "made here (or cleared here) is reverted within seconds of the next serial bump.\n" + |
| + "Bind the RECORD instead: `carlos route -host " + rt.Host + " -environment <name>` against the console"} |
| +``` |
| + |
| +- [ ] **Step 5: Add the refusal print cases** |
| + |
| +In the channel refusal loop, beside `case vPlatformOwned:`: |
| + |
| +```go |
| + case vRecordOwned: |
| + refusals = append(refusals, fmt.Sprintf( |
| + "%s: %s — refusing regardless of -allow-version-change: no flag overrides a column this verb does not own", |
| + v.rt.Host, v.note)) |
| +``` |
| + |
| +In the environment refusal loop (`cli.go:886`), beside `case ePlatformOwned:` — which reads `refusals = append(refusals, fmt.Sprintf("%s: %s — no flag overrides a row this verb does not own", v.rt.Host, v.note))`. Mirror it exactly, so the two refusals read alike at the terminal: |
| + |
| +```go |
| + case eRecordOwned: |
| + refusals = append(refusals, fmt.Sprintf( |
| + "%s: %s — no flag overrides a row this verb does not own", v.rt.Host, v.note)) |
| +``` |
| + |
| +Leave the `-tunnel` axis alone. It carries the same `platformCanaryOwnedRow` refusal a few lines below, but a tunnel name is box-scoped (design §5) and the record does not own it. |
| + |
| +- [ ] **Step 6: Run the tests** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test . -run TestRoute -count=1` |
| + |
| +Expected: PASS, every `TestRoute*` test in the package. The `-app` bulk form is covered by the same code path (the verdict loop runs over every candidate), so a provisioned route inside an `-app` sweep is refused too — and because any refusal aborts the whole batch, one provisioned route in a sweep now blocks it. That is intended: a partially-applied bulk repoint is the failure mode pass 1 exists to prevent. |
| + |
| +- [ ] **Step 7: Commit** |
| + |
| +```bash |
| +gofmt -l cli.go e2e_test.go |
| +go build ./... && go vet ./... && go test ./... -count=1 |
| +GOOS=darwin go build ./... |
| +git add cli.go e2e_test.go |
| +git commit -m "route: refuse -channel and -environment on a provisioned route |
| + |
| +The reconciler converges both columns from the instance record on every |
| +pass, so a write here lands and is reverted within seconds of the next |
| +serial bump, silently. Same structural refusal as platformCanaryOwnedRow, |
| +one more owner; the message names the record as the door. |
| + |
| +The box-scoped axes (-grant/-revoke/-backing/-unit/-tunnel) are untouched. |
| + |
| +Design: docs/superpowers/specs/2026-08-10-route-records-design.md §5" |
| +``` |
| + |
| +--- |
| + |
| +### Task 7: Documentation and the deploy note |
| + |
| +**Files:** |
| +- Modify: `CLAUDE.md` (the "Rules this repo has paid for" section) |
| +- Modify: `README.md:339` area (the `carlos add` description) |
| + |
| +- [ ] **Step 1: Add the ownership rule to CLAUDE.md** |
| + |
| +Under "Rules this repo has paid for", after the "Adoption swaps a symlink" bullet: |
| + |
| +```markdown |
| +- **A route's Channel and Environment belong to its instance record** once |
| + the route is `Provisioned` (main, 2026-08-10). The provisioning reconciler |
| + converges both from `apps/<sqid>/<app>/instances/<host>.json` on every |
| + pass, so a `carlos route -channel` on the box lands and is reverted within |
| + seconds of the next serial bump. The CLI refuses it 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, and |
| + also how a sloppy record acquires removal authority over a live route. |
| +``` |
| + |
| +- [ ] **Step 2: Note the record path in README** |
| + |
| +In the `carlos add` description around `README.md:339`, append exactly: |
| + |
| +```markdown |
| +A route added this way is described by no bucket record, so nothing |
| +converges it: its channel and environment are box-local until an instance |
| +record adopts it. A provisioned route's channel is repointed through the |
| +record, not here. |
| +``` |
| + |
| +- [ ] **Step 3: Commit** |
| + |
| +```bash |
| +git add CLAUDE.md README.md |
| +git commit -m "docs: record ownership of a route's channel and environment" |
| +``` |
| + |
| +--- |
| + |
| +## Verification before handing back |
| + |
| +- [ ] `go build ./... && go vet ./... && go test ./... -count=1` — clean, every package. |
| +- [ ] `GOOS=darwin go build ./...` — clean. |
| +- [ ] `gofmt -l` clean for: `internal/provision/provision.go`, `internal/provision/provision_test.go`, `internal/provision/reconcile.go`, `internal/provision/reconcile_test.go`, `internal/registry/registry.go`, `internal/registry/registry_test.go`, `cli.go`, `e2e_test.go`. |
| +- [ ] `TestReconcileMints` passes **unchanged** — it is the legacy-mint pin. |
| +- [ ] `TestRecordWireShapeIsAdditive` passes — it is the live-record pin. |
| +- [ ] Nothing in this plan writes a record. The console does not yet emit the new fields, no production record carries one, and reconcile's behaviour against every record that exists today is byte-identical. That is what makes this deployable ahead of plan 2. |
| + |
| +## Deviations from the spec, for the reviewer |
| + |
| +Three places where this plan decided something the spec left open. Each is |
| +called out in a code comment at the site. |
| + |
| +1. **Hibernate's default derives from the route shape** (`DerivesPaths`), |
| + not a flat `true`. The spec says nil means `true`; a flat `true` would arm |
| + the activator for static and addr-backed routes, which have no process to |
| + wake. Legacy records are unaffected — they all derive paths — so the |
| + wire-shape guarantee holds either way. |
| +2. **Shape fields are not converged in place** on an already-provisioned |
| + route (Task 3). A record that changes `Kind`/`Addr`/`Unit` describes a |
| + structurally different route, and converging it would re-derive socket and |
| + DB paths under a running process. It is logged with the repair. |
| +3. **§4.3's record-generation tool is not in this plan.** Generating a record |
| + from an existing route is a backfill authoring aid; it ships with plan 3's |
| + backfill and the operator-facing verb that runs it. The half that belongs |
| + here — a pass that reports and writes nothing — is Task 5. |
diff --git a/docs/superpowers/specs/2026-08-10-route-records-design.md b/docs/superpowers/specs/2026-08-10-route-records-design.md| new file mode 100644 |
| index 000000000..85cc6bdee |
| --- /dev/null |
| +++ b/docs/superpowers/specs/2026-08-10-route-records-design.md |
| @@ -0,0 +1,413 @@ |
| +# Route records — a route's desired state lives in the bucket |
| + |
| +**Date:** 2026-08-10 · **Status:** draft (design agreed in conversation |
| +2026-08-10; approach 1 of two chosen — extend the existing record additively |
| +rather than mint a new primitive) · **Requested by:** Paul |
| + |
| +**Closes:** #123 (`carlos route` is box-local only), #130 (instance-record |
| +channel change never reaches the route row, and the repair is refused), |
| +and the write-converge half of #64 (`carlos routes`: a registry-mode |
| +listing verb). |
| + |
| +**Extends:** the instance provisioning design (2026-08-04 — the record and |
| +reconcile pass this generalises), the CLI console API design (2026-08-03 — |
| +the verb family the new endpoint joins), and `carlos deploy` (2026-08-08 — |
| +the serial bump that makes convergence seconds rather than an hour). |
| + |
| +**Follows on:** #82 (console-driven custom domain attach) builds on this |
| +primitive rather than beside it — see §9. |
| + |
| +## 1. Why |
| + |
| +`carlos route` has exactly one source of truth: `-registry`, defaulting to |
| +`/data/registry.db` (cli.go's `runRoute`). It has no `-console` flag, unlike |
| +`channels`, `ship`, `promote`, `deploy` and `instances list`. Changing which |
| +channel a route follows therefore requires a shell on the box, which breaks |
| +the rule recorded in CLAUDE.md: |
| + |
| +> every `carlos` command is designed for a user with no access to the |
| +> underlying infra and no box commands. The command alone must complete the |
| +> job. |
| + |
| +The obvious reading of #123 is "give `carlos route` an API mode". That |
| +reading is wrong in a way worth stating, because it decides the whole shape: |
| +an API mode implies the console reaching into a registry, and the console |
| +today can only read registries it is colocated with (`view.go`'s |
| +`openRegistry` opens the DB path directly). Generalising that to more than |
| +one box means inventing a fleet RPC, which is #73, and which is unbuilt. |
| + |
| +The platform already has the answer, and it is the platform's whole premise: |
| +**the bucket is the control plane**. Desired state goes in the bucket, boxes |
| +converge. Instance records at `apps/<sqid>/<app>/instances/<host>.json` |
| +already carry a `Channel` and `provision.Reconcile` already materializes |
| +them into route rows. The gap is not a missing API. It is that the record |
| +vocabulary describes exactly one kind of route, and that reconcile creates |
| +from a record but never converges to it. |
| + |
| +### 1.1 Why "there shouldn't be hand-added routes" is the destination, not the state |
| + |
| +There are four doors that mint a route: `carlos add` (cli.go), the two |
| +`domains.Reconcile` alias mints, and `provision.Reconcile`. The record door |
| +mints one shape, hardcoded: |
| + |
| +```go |
| +Host: rec.Host, Slug: rec.Slug, Kind: "instance", |
| +Socket: DefaultSocket(rec.Host), DBPath: DefaultDBPath(cfg.DataDir, rec.Host), |
| +App: rec.App, Account: rec.Account, Pool: rec.Pool, Region: rec.Region, |
| +Channel: rec.Channel, Hibernate: true, Provisioned: true, |
| +Seed: seed, Status: "ready", |
| +``` |
| + |
| +Single-label subdomain of the app's declared `Domain`, exec-child socket, |
| +hibernating, `kind=instance`. The `Record` struct has no field for kind, |
| +addr, unit or hibernate — reconcile supplies all of them by convention. |
| + |
| +Compare what `carlos add` accepts: `-kind static`, `-addr`, `-unit`, `-slug`, |
| +`-db`, `-status-named`, `-hibernate`. Against the deployments named in |
| +CLAUDE.md, the record shape cannot express **either console** (unit-backed |
| +`console.service`), **keymail, kass or titogo** (`-addr` whole-app TCP |
| +tenants), or **the static sites** (carloku.com, platform-site). Those are not |
| +routes somebody lazily hand-added. They are routes the control plane has no |
| +words for. |
| + |
| +So the work is not "stop hand-adding routes". It is: give the record enough |
| +vocabulary to describe every route the platform actually runs, then make |
| +the bucket the normal door and the registry write the documented exception. |
| + |
| +## 2. The model |
| + |
| +Three changes, each additive, each following an existing pattern: |
| + |
| +1. **`provision.Record` grows the missing shape fields** (§3), every one |
| + `omitempty` with a zero value that reproduces today's mint exactly. |
| +2. **`provision.Reconcile` converges and adopts** (§4): it converges |
| + `Channel` and `Environment` on records it owns, and it adopts an existing |
| + route whose shape already matches an appearing record. |
| +3. **A member-facing record edit endpoint** (§6), with the version safety |
| + rail moved console-side where the pubkey and the store actually are. |
| + |
| +`carlos add` becomes a record writer in bucket mode, with its registry write |
| +surviving behind an explicit break-glass flag (§7). |
| + |
| +## 3. The record grows a shape vocabulary (all additive) |
| + |
| +`provision.Record` gains six optional fields. The rule every one obeys: the |
| +zero value is what reconcile does today, so a schema-1 record written before |
| +this change parses and materializes a byte-identical route. |
| + |
| +| Field | JSON | Zero value means | Covers | |
| +|---|---|---|---| |
| +| `Kind` | `kind` | `instance` | static sites | |
| +| `Addr` | `addr` | derive socket via `DefaultSocket` | whole-app TCP tenants | |
| +| `Unit` | `unit` | no owning unit | `console.service` | |
| +| `Hibernate` | `hibernate` | `true` | long-running units that must not sleep | |
| +| `Environment` | `environment` | the app's default bundle | the second member-facing axis of `carlos route` | |
| +| `StatusNamed` (`*bool`) | `status_named` | unstated — mints false, never converges | `carlos add -status-named` | |
| + |
| +### 3.1 Field notes |
| + |
| +**`Hibernate` is `*bool`, not `bool`.** Reconcile mints `Hibernate: true` |
| +today, and every record already in the flagship and tito buckets has no such |
| +field. A plain bool would read those as `false` and un-hibernate the fleet on |
| +the first pass after deploy. `nil` must mean `true`. This is the same |
| +tri-state honesty `runRoute` already buys with `fs.Visit`, for the same |
| +reason: absent and false are different facts. |
| + |
| +**`StatusNamed` is `*bool` for the same reason** (added during implementation, |
| +2026-08-10). It is converged like `Channel` and `Environment`, and a plain |
| +bool cannot be: a record omitting the field would read as `false` and |
| +silently opt a route out of the status page that an operator opted in with |
| +`carlos add -status-named`. So `nil` means "this record does not speak to the |
| +status page" — it mints `false`, matching every record already in a |
| +production bucket, and it never converges. A backfill record that wants a |
| +route named on the status page must say `status_named: true` explicitly; |
| +omitting it leaves the row's opt-in box-local and undescribed. |
| + |
| +**`Socket` and `DBPath` stay derived, never declared.** `DefaultSocket` and |
| +`DefaultDBPath` exist precisely so a hand-added route and a provisioned one |
| +land on identical path shapes, and the `filepath.Join` note in their doc |
| +comment records what two names for one database cost. Declaring them would |
| +reintroduce that. The upstream rule is therefore positional: |
| + |
| +- `Addr` set → TCP upstream, no socket, no DB path. |
| +- `Addr` empty, `Kind` instance → derived socket and DB path (today). |
| +- `Kind` static → neither. |
| + |
| +**`Kind` is validated against the registry's own vocabulary** |
| +(`instance | service | static`), not accepted verbatim. The `-backing unti` |
| +incident recorded in cli.go — an unvalidated value written straight to a |
| +column, leaving a route neither unit-backed nor visibly wrong — is the |
| +failure mode this avoids at the other door. |
| + |
| +**`Unit` is validated with `registry.ValidUnitName`** at the console, before |
| +the record is written, for the reason both existing doors already state: a |
| +route created with a typo'd unit is a route whose adoption silently restarts |
| +nothing. |
| + |
| +**`Unit` and `Kind: instance` + unit-backing remain mutually exclusive**, the |
| +same refusal `runAdd` and `unitRail` already carry, at this third door. |
| + |
| +### 3.2 Host validation |
| + |
| +`provision.ValidHost` requires exactly one DNS label plus the app manifest's |
| +`Domain`, and its doc comment states why: the host becomes a bucket key |
| +segment and, on the box, the `<host>.sock` and `<host>.db` filesystem path |
| +segments. That is path-traversal defence, and it is not loosened casually. |
| + |
| +It also cannot express an apex (`carloku.com`) or a console's own hostname, |
| +which is most of what needs backfilling. |
| + |
| +The resolution follows the derivation rule in §3.1: **a record validates with |
| +`ValidHost` when it derives filesystem paths, and with the looser |
| +`ValidHostShape` when it does not.** Concretely: |
| + |
| +- `Kind: instance` with no `Addr` — derives `.sock` and `.db` paths → |
| + `ValidHost`, suffix-pinned, unchanged. |
| +- `Kind: static`, or any record with `Addr` set — derives no filesystem path |
| + at all → `ValidHostShape`. |
| + |
| +The traversal safety comes from not building a path out of the host, rather |
| +than from the suffix. The suffix pin remains exactly where the danger is. |
| + |
| +The sibling-apex check in `handleAPIInstanceCreate` (one app quietly serving |
| +another's public URL) is unaffected: it is scoped to |
| +`m.Domain == acct.Sqid+"."+s.cfg.AppsDomain`, which a custom-domain or apex |
| +record never enters. |
| + |
| +## 4. Reconcile converges and adopts |
| + |
| +Two changes to `provision.Reconcile`. |
| + |
| +### 4.1 Converge (closes #130) |
| + |
| +The `existing.Provisioned && existing.App == rec.App && existing.Account == |
| +rec.Account` branch converges `Seed` and nothing else today. It should also |
| +converge `Channel` and `Environment`, and the §3 shape fields. |
| + |
| +This is #130 closed structurally rather than patched. That incident — a |
| +record created on `stable`, deleted and recreated on `edge`, with the route |
| +row left on `stable` and every wake failing `no adoptable head` while |
| +`carlos channels` cheerfully showed `edge` promoted — becomes |
| +unrepresentable. Member-visible state and box state cannot disagree about a |
| +column the bucket owns. |
| + |
| +`Seed`'s existing `seedIsKnown` guard is the pattern to copy: a transient |
| +manifest-read failure must never stamp a `""` fallback over a working value. |
| +Channel has no manifest dependency, so it converges directly from the record. |
| + |
| +### 4.2 Adopt on match |
| + |
| +The `default:` branch today logs "exists but was not minted by this pass — |
| +leaving it alone" and moves on. It becomes: |
| + |
| +- If the existing route's shape **matches** the record's declared shape, |
| + stamp `Provisioned` and take ownership. Log the adoption. |
| +- If it **differs**, leave it alone exactly as today, and log loudly enough |
| + that the mismatch is diagnosable without a box session. |
| + |
| +This is what makes the migration cheap, and it is the design's main payoff. |
| +Backfilling the fleet becomes a pure bucket-side write per route — no |
| +`carlos add`, no SSM session, no on-box verb. Write the record that describes |
| +what is already there; the next serial bump adopts it. Get the shape wrong |
| +and nothing happens, with the log saying so. |
| + |
| +"Matches" is: `App`, `Account`, `Kind`, `Addr`, `Unit`, `Slug`, `Pool`, |
| +`Region` all equal, and the derived socket/DB paths equal what the record |
| +would derive. `Channel`, `Environment` and `Hibernate` are deliberately |
| +**excluded** from the match test — those are the columns the record is taking |
| +ownership of, and requiring them to agree first would make adoption |
| +impossible in exactly the case it is for. |
| + |
| +### 4.3 The risk this creates, stated plainly |
| + |
| +Adoption grants reconcile's removal phase authority over the route, because |
| +that phase keys on `Provisioned`. A sloppy shape-match check could retire a |
| +live route. |
| + |
| +The existing guards are real and stay: `readFailed` disables removal |
| +pass-wide, `protect[]` covers a record that exists but could not be read |
| +cleanly, and scope is region/pool-bound. On top of them: |
| + |
| +- A `-dry-run` mode that prints the record it would write for every route on |
| + a box, and for each, whether it would adopt, mint or leave alone. |
| +- Backfill applied one route at a time, verified through the serial, with |
| + the two consoles **last** — a console that retires its own route cannot be |
| + used to put it back. |
| + |
| +## 5. Reconcile owns `Channel` and `Environment`, so the CLI must stop writing them |
| + |
| +Once a route is `Provisioned`, `carlos route -channel` or `-environment` on |
| +the box is a write that gets stomped within seconds of the next serial bump. |
| +Silently. That is the same class of bug as #130, arriving from the other |
| +side. |
| + |
| +Both are refused, with a message naming the record as the door. |
| + |
| +The precedent to copy is exact: `platformCanaryOwnedRow` already refuses |
| +`-environment` on platform and canary routes, and the `Environment` doc |
| +comment in registry.go states the reason — "the reconciler owns those rows |
| +and re-Upserts them whenever its `followed` check goes false … a column the |
| +reconciler rebuilds is not one this verb should be handing an operator." |
| +One more owner, same rail, same voice. |
| + |
| +`-grant`, `-revoke`, `-backing`, `-unit` and `-tunnel` are untouched by this: |
| +they are box-scoped columns the record does not own. |
| + |
| +## 6. The API |
| + |
| +``` |
| +PUT /api/cli/apps/{account}/{app}/instances/{host} |
| +``` |
| + |
| +Joining `registerCLIRoutes` beside the existing `POST`/`GET`/`DELETE` on the |
| +same path family, with that family's gate ordering and byte-identical 404 |
| +refusal. |
| + |
| +**Body** carries the member-mutable subset only: |
| + |
| +```json |
| +{ "channel": "edge", "environment": "-", "allow_version_change": false } |
| +``` |
| + |
| +`environment: "-"` is the clear sentinel, the same one `carlos route` and |
| +`carlos env` already use, for the same reason (a bare hyphen fits |
| +`[a-z0-9-]{1,32}` and would otherwise name a real bundle). |
| + |
| +**Not member-mutable:** `kind`, `addr`, `unit`, `hibernate`, `slug`, `pool`, |
| +`region`. The record can *express* them so a route can be declared |
| +declaratively; editing them is operator domain. A body naming one is refused |
| +rather than ignored — a flag that is silently dropped is how an operator |
| +comes to believe a change landed. |
| + |
| +**Concurrency:** read-modify-write CAS'd on the record's etag via a new |
| +`provision.PutRecordIfMatch`, mirroring the `PutManifestIfMatch` that already |
| +exists. A lost race is a 409, never a silent overwrite — the same discipline |
| +`CreateRecord`'s create-only write carries. |
| + |
| +**Serial:** the handler calls `s.bumpSerial(ctx)` after the write, exactly as |
| +`handleAPIInstanceCreate` does, because this is a box-consumed write. The |
| +repoint is live in seconds. |
| + |
| +**Audit:** `s.audit(acct, app, principal, "instance-repoint", …)`. |
| + |
| +### 6.1 The version rail moves console-side |
| + |
| +`routeVersionCheck` (cli.go) resolves whether a repoint changes what gets |
| +served. It needs `CARLOS_RELEASE_PUBKEY` and bucket access — an API-mode |
| +caller on a laptop has neither, by design. The console has both |
| +(`s.cfg.Releases`), so the check runs in the handler, refusing 409 with the |
| +same note text the CLI prints today. `allow_version_change` is the request |
| +field standing in for the flag. |
| + |
| +The three outcomes are unchanged: resolved-and-agreeing proceeds; resolved- |
| +and-disagreeing is the override's intended door; unprovable is never |
| +overridable. |
| + |
| +### 6.2 One targeted fix inside the rail (closes #130's second ask) |
| + |
| +Today the branch where the route has no recorded `Version` **and** its |
| +current channel does not resolve returns `overridable: false` — refused |
| +regardless of the flag. #130 hit exactly this trying to repair the route it |
| +describes, and had to fall back to `carlos remove` and a re-mint. |
| + |
| +That branch is only reached **after the target channel has already resolved |
| +and verified**. The route has never served anything, and the stale channel is |
| +unresolvable because it was always wrong. There is no comparison to protect, |
| +only a stuck route. It becomes overridable. |
| + |
| +The genuinely unprovable cases stay unoverridable, unchanged: no pubkey, an |
| +unresolvable target, a bad signature. The distinction is whether the |
| +*target* is proven, not whether the comparison is complete. |
| + |
| +## 7. `carlos add` becomes a record writer |
| + |
| +In bucket mode (`CARLOS_DEPLOYMENT_BUCKET` / `CARLOS_DEPLOYMENT_DIR` set — |
| +`resolveMode`'s direct mode, which is what pinfra scripts and box-side |
| +bootstrap already run with), `carlos add` writes a record instead of a |
| +registry row. Its existing flags map onto §3's fields one for one, which is |
| +the point of having chosen that vocabulary. |
| + |
| +The registry write survives behind an explicit `-local` flag, documented as |
| +break-glass: standing up a box before it has a control plane, or repairing |
| +one that cannot reach the bucket. |
| + |
| +That is the operational form of "no hand-added routes" — not removing the |
| +door, but making the normal path go through the bucket and making the |
| +exception announce itself. The alternative, deleting the local path outright, |
| +fails the first time a box is partitioned and someone needs a route back. |
| + |
| +## 8. Testing |
| + |
| +**Wire shape (the rule this repo has paid for three times).** A fixture of a |
| +schema-1 record carrying none of the §3 fields, asserted to mint a route |
| +byte-identical to today's — field by field, not `reflect.DeepEqual` against a |
| +freshly built expectation, so the assertion still means something if the mint |
| +changes. This is the test that protects the live records in the flagship and |
| +tito buckets. |
| + |
| +**Reconcile.** |
| +- Converge: a record whose `Channel` changed moves the route's column; |
| + `Environment` likewise. |
| +- Adopt on match: an existing hand-added route plus an appearing record whose |
| + shape matches → `Provisioned` stamped, columns converged. |
| +- Refuse on mismatch: shape differs → route untouched, mismatch logged. |
| +- Removal authority unchanged: a hand-added route with no record is never |
| + removed; a `readFailed` pass removes nothing. |
| +- Adoption does not depend on `Channel`/`Environment`/`Hibernate` agreeing |
| + (§4.2), pinned directly — it is the property most likely to be "tidied" |
| + into a full-shape comparison by a later reader. |
| + |
| +**Console.** |
| +- The member gate and byte-identical 404 for a non-member (the ordering rule |
| + in CLAUDE.md: session → CSRF → owner → `requireFreshAuth` last). |
| +- CAS conflict → 409. |
| +- A body naming a non-mutable field → refused, not ignored. |
| +- The version rail's three outcomes, including §6.2's newly-overridable case. |
| +- Serial bumped after a successful write. |
| + |
| +**CLI.** |
| +- API mode: `-channel` and `-environment` reach the endpoint. |
| +- API mode: `-grant`/`-revoke`/`-backing`/`-unit`/`-tunnel` refused as |
| + box-scoped, naming why. |
| +- Direct mode on a `Provisioned` route: `-channel` refused, naming the record |
| + (§5). |
| +- Direct mode on a hand-added route: unchanged. |
| +- Every test shelling out to the binary builds its env through |
| + `carlosTestEnv` and sets `cmd.Dir` to a scratch dir — non-negotiable, and |
| + the reason is recorded in CLAUDE.md. |
| + |
| +**Channel names.** Production runs on `canary/*`; `laddercheck_test.go` pins |
| +them. Nothing here narrows `release.ValidChannel`, and a test asserts a |
| +`canary/*` repoint is accepted end to end. |
| + |
| +## 9. Out of scope |
| + |
| +- **#82, custom domain attach.** This design makes it buildable — a record |
| + may already name a host outside the app's declared domain when it derives |
| + no filesystem path (§3.2). What #82 still owes is **ownership |
| + verification**: proving the member controls the domain before a record |
| + naming it is honoured. That is a genuinely separate concern and does not |
| + belong in this primitive. |
| +- **#73, the fleet API.** Nothing here needs box RPC; that is the point. |
| +- **`-grant`/`-revoke`/`-backing`/`-tunnel` as record fields.** Capabilities |
| + are operator grants scoped to one box, and registry.go's `Caps` comment |
| + records why the column lives where no tenant can write. They stay there. |
| +- **Migrating the alias routes** minted by `domains.Reconcile`. Those already |
| + have a declarative owner; they are simply a different one. |
| + |
| +## 10. Rollout |
| + |
| +1. Record fields + validation, with the §8 wire-shape test. No behaviour |
| + change: nothing writes the new fields yet. |
| +2. Reconcile converge + adopt, with `-dry-run`, **plus §5's refusal in the |
| + same roll**. The two must land together: a `Provisioned` route whose |
| + channel the CLI will still happily write is precisely the window where the |
| + silent stomp is possible. Still no behaviour change in production at this |
| + point, because no record carries a new field and no new record exists. |
| +3. Console endpoint + rail move + §6.2's fix. `carlos route` API mode. |
| +4. `carlos add` record mode, `-local` break-glass. |
| +5. Backfill, one route at a time, consoles last (§4.3). |
| + |
| +Steps 1–2 are one coherent PR and step 3 is another. Step 5 is an operational |
| +task, not a code change, and wants its own plan with per-route verification |
| +through `X-Carlos-Version` on the URL — never a cached answer. |
diff --git a/e2e_test.go b/e2e_test.go| index 97539e2c4..27a7fa66e 100644 |
| --- a/e2e_test.go |
| +++ b/e2e_test.go |
| @@ -2822,6 +2822,96 @@ func TestRoutePlatformCanaryNeverLeavesCanary(t *testing.T) { |
| } |
| } |
| +// Design §5: once a route is Provisioned, its Channel and Environment are |
| +// the instance record's. A write here lands and is reverted within seconds |
| +// of the next serial bump, silently — the same class of bug as #130, |
| +// arriving from the other side. The refusal names the record as the door, |
| +// and no flag overrides it: -allow-version-change answers "what would this |
| +// change", not "who owns this column". |
| +func TestRouteChannelRefusedOnProvisionedRoute(t *testing.T) { |
| + f := newRouteTestFixture(t) |
| + f.ship(release.OpsAccount, "site", "v1") |
| + f.promote(release.OpsAccount, "site", "stable", "v1") |
| + f.promote(release.OpsAccount, "site", "canary/preview", "v1") |
| + f.addRoute(registry.Route{Host: "acme.go.tito.io", Kind: "instance", App: "site", |
| + Channel: "canary/preview", Status: "ready", Provisioned: true}) |
| + |
| + out, err := f.run(f.fullEnv(), "route", "-registry", f.regPath, |
| + "-host", "acme.go.tito.io", "-channel", "stable", "-allow-version-change") |
| + if err == nil { |
| + t.Fatalf("route -channel on a Provisioned route was accepted:\n%s", out) |
| + } |
| + if !strings.Contains(out, "record") { |
| + t.Errorf("refusal does not name the instance record as the door:\n%s", out) |
| + } |
| + if got := f.channelOf("acme.go.tito.io"); got != "canary/preview" { |
| + t.Fatalf("channel = %q, want it unwritten after the refusal", got) |
| + } |
| +} |
| + |
| +func TestRouteEnvironmentRefusedOnProvisionedRoute(t *testing.T) { |
| + f := newRouteTestFixture(t) |
| + f.ship(release.OpsAccount, "site", "v1") |
| + f.promote(release.OpsAccount, "site", "stable", "v1") |
| + f.addRoute(registry.Route{Host: "acme.go.tito.io", Kind: "instance", App: "site", |
| + Channel: "stable", Status: "ready", Provisioned: true}) |
| + f.saveEnvironment(release.OpsAccount, "site", "demo") |
| + |
| + out, err := f.run(f.fullEnv(), "route", "-registry", f.regPath, |
| + "-host", "acme.go.tito.io", "-environment", "demo") |
| + if err == nil { |
| + t.Fatalf("route -environment on a Provisioned route was accepted:\n%s", out) |
| + } |
| + if got := f.environmentOf("acme.go.tito.io"); got != "" { |
| + t.Fatalf("environment = %q, want it unwritten after the refusal", got) |
| + } |
| + // Clearing is equally not this verb's to do — the refusal is on the ROW, |
| + // not on one direction of travel, exactly as the Platform+Canary one is. |
| + f.addRoute(registry.Route{Host: "bound.go.tito.io", Kind: "instance", App: "site", |
| + Channel: "stable", Status: "ready", Provisioned: true, Environment: "demo"}) |
| + out, err = f.run(f.fullEnv(), "route", "-registry", f.regPath, |
| + "-host", "bound.go.tito.io", "-environment", "-") |
| + if err == nil { |
| + t.Fatalf("clearing an environment on a Provisioned route was accepted:\n%s", out) |
| + } |
| + if got := f.environmentOf("bound.go.tito.io"); got != "demo" { |
| + t.Fatalf("environment = %q, want it still bound after the refusal", got) |
| + } |
| +} |
| + |
| +// The box-scoped axes are untouched: they name resources that exist on one |
| +// machine, the record does not own them, and an operator must still be able |
| +// to grant a port or stamp a unit on a provisioned route. |
| +func TestRouteBoxScopedAxesStillWorkOnProvisionedRoute(t *testing.T) { |
| + f := newRouteTestFixture(t) |
| + f.addRoute(registry.Route{Host: "acme.go.tito.io", Kind: "instance", App: "site", |
| + Channel: "stable", Status: "ready", Provisioned: true, Addr: "127.0.0.1:8090"}) |
| + |
| + out, err := f.run(f.fullEnv(), "route", "-registry", f.regPath, |
| + "-host", "acme.go.tito.io", "-unit", "console.service") |
| + if err != nil { |
| + t.Fatalf("route -unit on a Provisioned route was refused:\n%s", out) |
| + } |
| + if got := f.routeOf("acme.go.tito.io").Unit; got != "console.service" { |
| + t.Errorf("unit = %q, want console.service", got) |
| + } |
| + |
| + // -unit short-circuits before the verdict machinery this change touched |
| + // (the grantAxis/hibernateSet/backingSet/unitSet block returns early, well |
| + // above chanVerdicts/envVerdicts). -tunnel does not: it runs through the |
| + // same candidates slice and sits downstream of the refusal-collection |
| + // loops recordOwnedRow feeds, so it is the axis a future recordOwnedRow |
| + // case could plausibly catch by mistake. Exercise it directly. |
| + out, err = f.run(f.fullEnv(), "route", "-registry", f.regPath, |
| + "-host", "acme.go.tito.io", "-tunnel", "relay1") |
| + if err != nil { |
| + t.Fatalf("route -tunnel on a Provisioned route was refused:\n%s", out) |
| + } |
| + if got := f.routeOf("acme.go.tito.io").Tunnel; got != "relay1" { |
| + t.Errorf("tunnel = %q, want relay1", got) |
| + } |
| +} |
| + |
| // re-review NEW 1, the reachable gap: moving a Platform+Canary route BETWEEN |
| // two canary channels (not off canary/* at all) was previously accepted, |
| // because the round-1 guard only fired when the target left the canary/* |
diff --git a/internal/provision/provision.go b/internal/provision/provision.go| index 87a4a0256..b2ed5342d 100644 |
| --- a/internal/provision/provision.go |
| +++ b/internal/provision/provision.go |
| @@ -11,13 +11,17 @@ import ( |
| "encoding/json" |
| "errors" |
| "fmt" |
| + "net" |
| "os" |
| "path/filepath" |
| "regexp" |
| "sort" |
| + "strconv" |
| "strings" |
| "time" |
| + "github.com/carlosframework/platform/internal/appconfig" |
| + "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| @@ -37,6 +41,43 @@ type Record struct { |
| Channel string `json:"channel"` |
| CreatedAt time.Time `json:"created_at"` |
| CreatedBy string `json:"created_by,omitempty"` |
| + |
| + // --- shape fields (2026-08-10 route-records design §3) -------------- |
| + // Every one is omitempty with a zero value that reproduces the mint |
| + // reconcile hardcoded before they existed: a record written by an older |
| + // console still materializes a byte-identical route. |
| + |
| + // Kind is the route class: "instance" | "service" | "static". Empty |
| + // means "instance", which is what reconcile minted unconditionally |
| + // before this field existed. |
| + Kind string `json:"kind,omitempty"` |
| + // Addr is a TCP upstream as host:port, for whole-app tenants whose |
| + // process the platform does not spawn (kass, keymail, titogo, the |
| + // consoles). Set means the route proxies to it and derives no socket. |
| + Addr string `json:"addr,omitempty"` |
| + // Unit is the systemd unit that owns this route's process, so adoption |
| + // can cycle it (console.service — issue #110). See registry.Route.Unit |
| + // for why a typo here is a route whose adoption silently restarts |
| + // nothing; ValidateShape is the door that refuses one. |
| + Unit string `json:"unit,omitempty"` |
| + // Hibernate is a POINTER because absent and false are different facts: |
| + // every record already in a production bucket has no such key, and a |
| + // plain bool would read those as false and un-hibernate the fleet on |
| + // the first pass after deploy. nil = HibernateOrDefault's derivation. |
| + Hibernate *bool `json:"hibernate,omitempty"` |
| + // Environment is the named config bundle this route binds to ("" = the |
| + // app's default bundle alone). The second member-facing axis of |
| + // `carlos route`, and owned by this record for the same reason Channel |
| + // is (design §5). |
| + Environment string `json:"environment,omitempty"` |
| + // StatusNamed opts this app into being named on the public status page. |
| + // 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 |
| @@ -140,6 +181,135 @@ func ValidHostShape(host string) bool { |
| return true |
| } |
| +// KindOrDefault resolves the record's route class. "" means "instance" — |
| +// the class reconcile minted unconditionally before Kind existed, and so |
| +// the only value that keeps a legacy record materializing unchanged. |
| +func (r Record) KindOrDefault() string { |
| + if r.Kind == "" { |
| + return "instance" |
| + } |
| + return r.Kind |
| +} |
| + |
| +// DerivesPaths reports whether this record's route builds filesystem paths |
| +// out of its host — the <host>.sock and <host>.db segments. It is the hinge |
| +// two separate rules turn on: which host validator applies (ValidRecordHost: |
| +// a host that becomes a path segment stays suffix-pinned, one that does not |
| +// needs only a valid shape), and what hibernation defaults to (only a route |
| +// with a socket has a process for the activator to wake). |
| +func (r Record) DerivesPaths() bool { |
| + return r.KindOrDefault() == "instance" && r.Addr == "" |
| +} |
| + |
| +// HibernateOrDefault resolves the tri-state. An explicit value wins. Absent |
| +// derives: a socket-backed instance hibernates (the activator model, and |
| +// what reconcile minted unconditionally before this field existed, so every |
| +// legacy record is unchanged), and everything else does not — a static |
| +// route has no process to wake, and an addr-backed tenant is always on. |
| +func (r Record) HibernateOrDefault() bool { |
| + if r.Hibernate != nil { |
| + return *r.Hibernate |
| + } |
| + 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 |
| +// mint one from an app-scoped record. |
| +func ValidKind(kind string) bool { |
| + switch kind { |
| + case "", "instance", "service", "static": |
| + return true |
| + } |
| + return false |
| +} |
| + |
| +// ValidateShape checks the §3 fields at the door, before a record is written |
| +// or materialized. The rails live at the door, exactly as they do for |
| +// registry.SetCaps and SetChannel — Upsert validates no column at all, so a |
| +// value that reaches the row unvalidated is discovered months later as an |
| +// adopt pass that restarted nothing (the `-backing unti` incident, cli.go). |
| +func ValidateShape(rec Record) error { |
| + if !ValidKind(rec.Kind) { |
| + return fmt.Errorf("kind %q: want instance, service or static (empty means instance)", rec.Kind) |
| + } |
| + if rec.Addr != "" { |
| + h, p, err := net.SplitHostPort(rec.Addr) |
| + if err != nil || h == "" || p == "" { |
| + return fmt.Errorf("addr %q: want a TCP upstream as host:port (e.g. 127.0.0.1:8090)", rec.Addr) |
| + } |
| + // Parse port as integer and validate range 1..65535. net.SplitHostPort |
| + // accepts garbage ports (e.g. "127.0.0.1:abc") — we need to refuse them |
| + // here at the door, not discover them months later as a route that never |
| + // connects (the -backing unti class of incident). |
| + portNum, err := strconv.ParseUint(p, 10, 16) |
| + if err != nil || portNum == 0 { |
| + return fmt.Errorf("addr %q: port must be an integer in the range 1..65535", rec.Addr) |
| + } |
| + } |
| + if rec.Unit != "" && !registry.ValidUnitName(rec.Unit) { |
| + return fmt.Errorf("unit %q: want a systemd service unit name (e.g. console.service)", rec.Unit) |
| + } |
| + if rec.Environment != "" && !appconfig.ValidEnvironmentName(rec.Environment) { |
| + return fmt.Errorf("environment %q: want [a-z0-9-]{1,32}", rec.Environment) |
| + } |
| + if rec.KindOrDefault() == "static" { |
| + if rec.Addr != "" { |
| + return errors.New("kind static with addr: a static route serves a file tree and proxies to nothing — drop one of the two") |
| + } |
| + if rec.Unit != "" { |
| + return errors.New("kind static with unit: a static route has no process for a unit to own — drop one of the two") |
| + } |
| + if rec.Hibernate != nil && *rec.Hibernate { |
| + return errors.New("kind static with hibernate: a static route has no process to wake, so arming the activator for it would wedge the host — drop hibernate") |
| + } |
| + } |
| + 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 |
| +} |
| + |
| +// ValidRecordHost applies the host rail this record actually needs (design |
| +// §3.2). ValidHost is path-traversal defence: it is what keeps a hostile |
| +// host from reaching the <host>.sock and <host>.db path segments as anything |
| +// but an opaque, safe component, and it stays exactly where that danger is. |
| +// A record that derives no filesystem path has no such exposure — its host |
| +// reaches a bucket key and the edge's routing table only — so it needs the |
| +// shape check alone, which is what lets an apex (carloku.com) or a console's |
| +// own hostname be expressed at all. |
| +func ValidRecordHost(rec Record, domain string) bool { |
| + if rec.DerivesPaths() { |
| + return ValidHost(rec.Host, domain) |
| + } |
| + return ValidHostShape(rec.Host) |
| +} |
| + |
| // regionPoolRe is the shared charset for a region or pool code: lowercase |
| // a-z, 0-9, hyphen, up to 16 characters. Empty is deliberately valid — the |
| // unconstrained region and the shared (default) pool are both "". |
diff --git a/internal/provision/provision_test.go b/internal/provision/provision_test.go| index 21c00a6ae..94f6fc1b4 100644 |
| --- a/internal/provision/provision_test.go |
| +++ b/internal/provision/provision_test.go |
| @@ -12,6 +12,71 @@ import ( |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| +// A schema-1 record exactly as the flagship and tito buckets hold it today: |
| +// none of the shape fields exist. Every one must default to what reconcile |
| +// hardcoded before they were introduced — a non-nil Hibernate here, or a |
| +// Kind that does not fall back to "instance", rewrites the fleet on the |
| +// first pass after deploy. This is the additive wire-shape rule stated as a |
| +// test; do not relax it. |
| +func TestRecordWireShapeIsAdditive(t *testing.T) { |
| + const legacy = `{"schema":1,"host":"acme.go.tito.io","slug":"acme","app":"tito",` + |
| + `"account":"bab","pool":"tito","region":"ie","channel":"stable",` + |
| + `"created_at":"2026-08-04T09:00:00Z"}` |
| + var rec Record |
| + if err := json.Unmarshal([]byte(legacy), &rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + if rec.Kind != "" { |
| + t.Errorf("Kind = %q, want empty", rec.Kind) |
| + } |
| + if rec.Addr != "" { |
| + t.Errorf("Addr = %q, want empty", rec.Addr) |
| + } |
| + if rec.Unit != "" { |
| + t.Errorf("Unit = %q, want empty", rec.Unit) |
| + } |
| + if rec.Environment != "" { |
| + t.Errorf("Environment = %q, want empty", rec.Environment) |
| + } |
| + 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) |
| + } |
| + if got := rec.KindOrDefault(); got != "instance" { |
| + t.Errorf("KindOrDefault() = %q, want instance", got) |
| + } |
| + if !rec.DerivesPaths() { |
| + t.Error("DerivesPaths() = false, want true — a legacy record is a socket-backed instance") |
| + } |
| + if !rec.HibernateOrDefault() { |
| + t.Error("HibernateOrDefault() = false, want true — reconcile mints Hibernate:true for these today") |
| + } |
| +} |
| + |
| +// The console rewrites records (plan 2's PUT). A rewrite must not sprout |
| +// keys the reader before it did not know, so every added field is |
| +// omitempty and a record carrying none of them marshals to exactly what it |
| +// marshalled to before. |
| +func TestRecordMarshalOmitsAbsentShapeFields(t *testing.T) { |
| + body, err := json.Marshal(Record{ |
| + Schema: 1, Host: "acme.go.tito.io", Slug: "acme", App: "tito", |
| + Account: "bab", Pool: "tito", Region: "ie", Channel: "stable", |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, key := range []string{"kind", "addr", "unit", "hibernate", "environment", "status_named"} { |
| + if strings.Contains(string(body), `"`+key+`"`) { |
| + t.Errorf("marshalled record carries %q when it was never set: %s", key, body) |
| + } |
| + } |
| +} |
| + |
| func TestValidHost(t *testing.T) { |
| cases := []struct { |
| host, domain string |
| @@ -423,3 +488,98 @@ func TestPutManifestIfMatch(t *testing.T) { |
| t.Fatalf("manifest after stale write = %+v %v, want the guarded update's beta", final, err) |
| } |
| } |
| + |
| +func TestValidKind(t *testing.T) { |
| + for _, k := range []string{"", "instance", "service", "static"} { |
| + if !ValidKind(k) { |
| + t.Errorf("ValidKind(%q) = false, want true", k) |
| + } |
| + } |
| + // "status" is the platform's own status-page class, not an app's. |
| + for _, k := range []string{"status", "Instance", "unti", "instance "} { |
| + if ValidKind(k) { |
| + t.Errorf("ValidKind(%q) = true, want false", k) |
| + } |
| + } |
| +} |
| + |
| +func TestValidateShape(t *testing.T) { |
| + yes := true |
| + base := Record{Schema: 1, Host: "acme.go.tito.io", Slug: "acme", App: "tito", |
| + Account: "bab", Channel: "stable"} |
| + with := func(f func(*Record)) Record { |
| + r := base |
| + f(&r) |
| + return r |
| + } |
| + ok := []struct { |
| + name string |
| + rec Record |
| + }{ |
| + {"legacy instance", base}, |
| + {"addr tenant", with(func(r *Record) { r.Addr = "127.0.0.1:8090" })}, |
| + {"unit-owned tenant", with(func(r *Record) { r.Addr = "127.0.0.1:8090"; r.Unit = "console.service" })}, |
| + {"service with addr", with(func(r *Record) { r.Kind = "service"; r.Addr = "127.0.0.1:8090" })}, |
| + {"static", with(func(r *Record) { r.Kind = "static" })}, |
| + {"explicitly non-hibernating instance", with(func(r *Record) { r.Hibernate = new(bool) })}, |
| + } |
| + for _, c := range ok { |
| + if err := ValidateShape(c.rec); err != nil { |
| + t.Errorf("%s: ValidateShape = %v, want nil", c.name, err) |
| + } |
| + } |
| + bad := []struct { |
| + name string |
| + rec Record |
| + }{ |
| + {"bogus kind", with(func(r *Record) { r.Kind = "unti" })}, |
| + {"status kind", with(func(r *Record) { r.Kind = "status" })}, |
| + {"addr without port", with(func(r *Record) { r.Addr = "127.0.0.1" })}, |
| + {"addr with garbage port", with(func(r *Record) { r.Addr = "127.0.0.1:abc" })}, |
| + {"unit typo", with(func(r *Record) { r.Unit = "console" })}, |
| + {"bad environment name", with(func(r *Record) { r.Environment = "Demo" })}, |
| + {"service with no addr", with(func(r *Record) { r.Kind = "service" })}, |
| + {"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 { |
| + t.Errorf("%s: ValidateShape = nil, want a refusal", c.name) |
| + } |
| + } |
| +} |
| + |
| +// Design §3.2: the suffix pin stays exactly where the traversal danger is, |
| +// and nowhere else. |
| +func TestValidRecordHostFollowsPathDerivation(t *testing.T) { |
| + socketBacked := Record{Host: "acme.go.tito.io"} |
| + if !ValidRecordHost(socketBacked, "go.tito.io") { |
| + t.Error("a socket-backed instance under its own domain was refused") |
| + } |
| + offDomain := Record{Host: "carloku.com"} |
| + if ValidRecordHost(offDomain, "go.tito.io") { |
| + t.Error("a socket-backed instance escaped its domain suffix — that is the traversal rail") |
| + } |
| + static := Record{Host: "carloku.com", Kind: "static"} |
| + if !ValidRecordHost(static, "go.tito.io") { |
| + t.Error("a static apex was refused; it derives no path and needs shape validation only") |
| + } |
| + addrBacked := Record{Host: "console.platform.tito.io", Addr: "127.0.0.1:8090"} |
| + if !ValidRecordHost(addrBacked, "go.tito.io") { |
| + t.Error("an addr-backed tenant off-domain was refused; it derives no path") |
| + } |
| + hostile := Record{Host: "../../etc/passwd", Kind: "static"} |
| + if ValidRecordHost(hostile, "go.tito.io") { |
| + t.Error("a hostile host passed the shape check") |
| + } |
| +} |
diff --git a/internal/provision/reconcile.go b/internal/provision/reconcile.go| index 7928ca80d..df3d0f866 100644 |
| --- a/internal/provision/reconcile.go |
| +++ b/internal/provision/reconcile.go |
| @@ -38,6 +38,12 @@ type ReconcileConfig struct { |
| DataDir string |
| // Now is the clock seam; nil = time.Now. |
| Now func() time.Time |
| + // DryRun reports what the pass would do and writes nothing — not to the |
| + // registry, not anywhere. The backfill mitigation (design §4.3): a |
| + // production route is adopted only after someone has read what adopting |
| + // it would mean, because adoption is what grants the removal phase |
| + // authority over that row. |
| + DryRun bool |
| } |
| // acctApp identifies one app within one account — the unit both the record |
| @@ -45,6 +51,186 @@ type ReconcileConfig struct { |
| // only within an account, never globally. |
| type acctApp struct{ acct, app string } |
| +// routeFor is the one answer to "what route does this record describe". The |
| +// mint path builds from it and the adoption match test (design §4.2) |
| +// compares against it — one function, so a shape the reconciler would mint |
| +// and a shape it will adopt can never drift apart. |
| +// |
| +// 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. 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.StatusNamedOrDefault(), |
| + Provisioned: true, Seed: seed, Status: "ready", |
| + } |
| + if rec.DerivesPaths() { |
| + rt.Socket = DefaultSocket(rec.Host) |
| + rt.DBPath = DefaultDBPath(dataDir, rec.Host) |
| + } |
| + return rt |
| +} |
| + |
| +// shapeMatches reports whether an existing route is the same ROUTE the |
| +// record describes — the gate on adoption (design §4.2), and so the gate on |
| +// this pass acquiring removal authority over a row it did not mint. |
| +// |
| +// Channel, Environment and Hibernate are deliberately absent: those are the |
| +// columns the record is taking ownership OF, and a hand-added route on the |
| +// wrong channel is the whole reason to adopt one. Requiring them to agree |
| +// would make adoption impossible in exactly the case it exists for. |
| +// |
| +// Backing, Tunnel and Caps are deliberately required to be empty (unset) on |
| +// the existing row — registry.Route's own doc comments name these three as |
| +// the box-scoped operator columns, and a record has a field for none of |
| +// them, so a record can never fully describe a row that carries one: |
| +// - Backing "unit" names a carlos-app@<host> systemd tenant; adopting |
| +// across it would claim a process shape the record says nothing about. |
| +// - Tunnel is routing-decisive and OUTRANKS the Socket/Addr a record can |
| +// state: internal/edge picks the tunnel proxy before it ever looks at |
| +// Socket, so a tunnel-backed route can agree with a record on every |
| +// other compared column while every byte it actually serves goes down |
| +// a channel the record cannot see or reclaim. |
| +// - Caps is the operator-granted, box-local capability grant column |
| +// (port:25, host-role, ...). registry.Delete's purge of a route's |
| +// grants is already treated as a human-gated, last-resort action |
| +// elsewhere in this file (the shape-drift log below); adoption must not |
| +// hand that purge to an unattended bucket-record deletion. |
| +// |
| +// 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 |
| +// platform apex on an unconstrained-region box can otherwise agree with a |
| +// record on every other compared column). Adoption must not hand this |
| +// pass's removal authority to a row another pass already owns and |
| +// periodically rebuilds — nor let it write Channel/Environment onto a row |
| +// the CLI itself refuses (platformCanaryOwnedRow, cli.go) for the same |
| +// reason. |
| +// |
| +// Everything else that identifies the route is compared, including the |
| +// derived Socket and DBPath — two rows agreeing on kind and app but not on |
| +// where the database lives are not the same route, and adopting across that |
| +// difference would point a running process at a second copy of its own data. |
| +func shapeMatches(existing, want registry.Route) bool { |
| + return shapeMismatch(existing, want) == "" |
| +} |
| + |
| +// shapeMismatch names the first column on which existing disagrees with |
| +// want — including the box-scoped and pass-ownership guards shapeMatches |
| +// documents — or "" when every compared column agrees. It backs both |
| +// shapeMatches and the adoption refusal log: printing only a couple of |
| +// columns there (the original message did app/kind/addr) leaves every |
| +// refusal over any OTHER column looking identical and undiagnosable to the |
| +// operator the refusal message exists for. |
| +func shapeMismatch(existing, want registry.Route) string { |
| + switch { |
| + case existing.Backing != "": |
| + return fmt.Sprintf("backing %q (box-scoped, not expressible in a record)", existing.Backing) |
| + case existing.Tunnel != "": |
| + return fmt.Sprintf("tunnel %q (box-scoped, not expressible in a record)", existing.Tunnel) |
| + case existing.Caps != "": |
| + return fmt.Sprintf("caps %q (box-scoped, not expressible in a record)", existing.Caps) |
| + case existing.Platform: |
| + return "platform (owned by the domains reconcile pass)" |
| + case existing.Canary: |
| + return "canary (owned by the domains reconcile pass)" |
| + case existing.App != want.App: |
| + return fmt.Sprintf("app %q/%q", existing.App, want.App) |
| + case existing.Account != want.Account: |
| + return fmt.Sprintf("account %q/%q", existing.Account, want.Account) |
| + case existing.Kind != want.Kind: |
| + return fmt.Sprintf("kind %q/%q", existing.Kind, want.Kind) |
| + case existing.Addr != want.Addr: |
| + return fmt.Sprintf("addr %q/%q", existing.Addr, want.Addr) |
| + case existing.Unit != want.Unit: |
| + return fmt.Sprintf("unit %q/%q", existing.Unit, want.Unit) |
| + case existing.Slug != want.Slug: |
| + return fmt.Sprintf("slug %q/%q", existing.Slug, want.Slug) |
| + case existing.Pool != want.Pool: |
| + return fmt.Sprintf("pool %q/%q", existing.Pool, want.Pool) |
| + case existing.Region != want.Region: |
| + return fmt.Sprintf("region %q/%q", existing.Region, want.Region) |
| + case existing.Socket != want.Socket: |
| + return fmt.Sprintf("socket %q/%q", existing.Socket, want.Socket) |
| + case existing.DBPath != want.DBPath: |
| + return fmt.Sprintf("db %q/%q", existing.DBPath, want.DBPath) |
| + default: |
| + return "" |
| + } |
| +} |
| + |
| +// 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 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 |
| +// one of the two callers is exactly the single-unguarded-write failure mode |
| +// the pass exists to prevent (design §4.3). Guarding here means both call |
| +// sites are safe by construction, not by each one remembering to check. |
| +func convergeOwned(reg *registry.Registry, existing registry.Route, rec Record, dryRun bool) { |
| + if rec.Channel != "" && existing.Channel != rec.Channel { |
| + if dryRun { |
| + log.Printf("provision: dry-run: would converge %s channel %s -> %s", rec.Host, existing.Channel, rec.Channel) |
| + } else if err := reg.SetChannel(rec.Host, rec.Channel); err != nil { |
| + log.Printf("provision: converge channel on %s: %v", rec.Host, err) |
| + } else { |
| + log.Printf("provision: converged %s channel %s -> %s", rec.Host, existing.Channel, rec.Channel) |
| + } |
| + } |
| + if existing.Environment != rec.Environment { |
| + if dryRun { |
| + 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) |
| + } |
| + } |
| +} |
| + |
| // Reconcile materializes bucket instance records into registry routes for |
| // THIS agent's region and pools: mint on appearance, converge seed, remove |
| // on record deletion — removal only for routes this pass (or an earlier one |
| @@ -122,6 +308,29 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg |
| protect[host] = true |
| continue |
| } |
| + // ValidateShape is the console create door's policy check — but a |
| + // door is not a guarantee about what is already in the bucket (the |
| + // same reasoning as the host-shape check just above). This pass |
| + // materializes whatever it finds, so a record whose Kind/Addr/Unit/ |
| + // Environment combination is malformed must be caught here too, or |
| + // a bad value becomes a live route. Protected like a corrupted |
| + // body: the record is there, it just cannot be used. |
| + if err := ValidateShape(rec); err != nil { |
| + log.Printf("provision: skipping record %s: invalid shape: %v", key, err) |
| + protect[host] = true |
| + continue |
| + } |
| + // ValidateShape deliberately does not inspect Channel (it validates |
| + // shape fields, and Channel is not one), but a channel-less record |
| + // must not become — or converge — a route pinned to no channel: |
| + // that is issue #130's failure mode arriving through a different |
| + // door than the one Task 3 closed. Protected like a corrupted body: |
| + // the record is there, it just cannot be used. |
| + if rec.Channel == "" { |
| + log.Printf("provision: skipping record %s: empty channel", key) |
| + protect[host] = true |
| + continue |
| + } |
| if rec.Region != cfg.Region || !poolServed(cfg.Pools, rec.Pool) { |
| continue // another agent's jurisdiction, not this pass's business |
| } |
| @@ -224,32 +433,107 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg |
| existing, ok := byHost[rec.Host] |
| switch { |
| case !ok: |
| - // Hibernate true is the activator model — and what hands the DB |
| - // to the lease replicator from birth; DBPath explicit is what |
| - // keeps ops.FleetDBs' hibernating-exclusion correct. |
| - nr := registry.Route{ |
| - Host: rec.Host, Slug: rec.Slug, Kind: "instance", |
| - Socket: DefaultSocket(rec.Host), DBPath: DefaultDBPath(cfg.DataDir, rec.Host), |
| - App: rec.App, Account: rec.Account, Pool: rec.Pool, Region: rec.Region, |
| - Channel: rec.Channel, Hibernate: true, Provisioned: true, |
| - Seed: seed, Status: "ready", |
| + // 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)", |
| + nr.Kind, rec.Host, rec.App, rec.Account, rec.Channel) |
| + continue |
| } |
| if err := reg.Upsert(nr); err != nil { |
| log.Printf("provision: mint %s: %v", rec.Host, err) |
| continue |
| } |
| - log.Printf("provision: minted instance route %s (app %q acct %q)", rec.Host, rec.App, rec.Account) |
| + log.Printf("provision: minted %s route %s (app %q acct %q)", nr.Kind, rec.Host, rec.App, rec.Account) |
| case existing.Provisioned && existing.App == rec.App && existing.Account == rec.Account: |
| + // The columns this record owns (design §4.1, issue #130). Narrow |
| + // UPDATEs, never a re-Upsert: a read-modify-write racing a |
| + // deploy's SetVersions or the activator's SetNextDue would |
| + // clobber those, and Upsert cannot express "clear Environment" |
| + // at all (it preserves a non-empty value by design). |
| + convergeOwned(reg, existing, rec, cfg.DryRun) |
| + // Absent means "this record does not speak to hibernation" — |
| + // never a derivation stamped over an operator's own setting. |
| + if rec.Hibernate != nil && existing.Hibernate != *rec.Hibernate { |
| + if cfg.DryRun { |
| + 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 |
| // failure's "" fallback over a working seed — see seedFor. |
| if seedIsKnown && existing.Seed != seed { |
| - if err := reg.SetSeed(rec.Host, seed); err != nil { |
| + if cfg.DryRun { |
| + log.Printf("provision: dry-run: would converge %s seed", rec.Host) |
| + } else if err := reg.SetSeed(rec.Host, seed); err != nil { |
| log.Printf("provision: converge seed on %s: %v", rec.Host, err) |
| } |
| } |
| + // Unit is compared only when the RECORD states one: `carlos |
| + // route -unit` (issue #110) is the supported way to stamp the |
| + // unit owning a route's process out of band, and a record that |
| + // never spoke to Unit must not treat that stamp as drift — or |
| + // this would fire on a healthy, correctly-stamped route every |
| + // pass, forever. |
| + if want := routeFor(rec, cfg.DataDir, seed); want.Kind != existing.Kind || |
| + want.Addr != existing.Addr || (rec.Unit != "" && want.Unit != existing.Unit) || |
| + want.Socket != existing.Socket || want.DBPath != existing.DBPath { |
| + log.Printf("provision: %s: record describes a different route SHAPE than the row "+ |
| + "(kind %q/%q addr %q/%q unit %q/%q socket %q/%q db %q/%q) — not converged in place; "+ |
| + "`carlos remove %s` purges this route's capability grants (registry.Delete's contract, "+ |
| + "not a bug) before the next pass re-mints it, so treat that as a last resort, not a routine fix", |
| + rec.Host, want.Kind, existing.Kind, want.Addr, existing.Addr, want.Unit, existing.Unit, |
| + want.Socket, existing.Socket, want.DBPath, existing.DBPath, rec.Host) |
| + } |
| default: |
| - log.Printf("provision: %s exists but was not minted by this pass (app %q acct %q) — leaving it alone", |
| + // A route exists that this pass did not mint. If it is the same |
| + // route the record describes, adopt it — that is what makes |
| + // backfilling the fleet a bucket-side write rather than a box |
| + // session (design §4.2). If it is not, leave it exactly as alone |
| + // as before: adoption grants this pass's removal phase authority |
| + // over the row, so the match test is the only thing standing |
| + // between a backfill and a retired production route. |
| + want := routeFor(rec, cfg.DataDir, seed) |
| + if reason := shapeMismatch(existing, want); reason != "" { |
| + log.Printf("provision: %s exists but is not the route this record describes (%s) — leaving it alone", |
| + rec.Host, reason) |
| + continue |
| + } |
| + if cfg.DryRun { |
| + log.Printf("provision: dry-run: would adopt existing route %s (app %q acct %q) — this record would own its channel and environment", |
| + rec.Host, rec.App, rec.Account) |
| + // convergeOwned reports channel and environment together, |
| + // and only the columns that actually differ (design §4.1) — |
| + // 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. Every write inside |
| + // convergeOwned is dry-run guarded there, so this call |
| + // writes nothing. |
| + convergeOwned(reg, existing, rec, true) |
| + continue |
| + } |
| + if err := reg.SetProvisioned(rec.Host, true); err != nil { |
| + log.Printf("provision: adopt %s: %v", rec.Host, err) |
| + continue |
| + } |
| + log.Printf("provision: adopted existing route %s (app %q acct %q) — this record now owns its channel and environment", |
| rec.Host, rec.App, rec.Account) |
| + // Hibernate is deliberately NOT converged here even when |
| + // explicit: this first pass over a hand-added route should |
| + // change what it serves and who owns the column, not whether a |
| + // live process is allowed to sleep. It converges on the next |
| + // pass through the owned branch above, by which point the |
| + // adoption is already visible in the log. |
| + convergeOwned(reg, existing, rec, cfg.DryRun) |
| } |
| } |
| @@ -260,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) { |
| @@ -272,11 +565,15 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg |
| log.Printf("provision: not removing %s: its record exists but could not be read cleanly this pass", rt.Host) |
| continue |
| } |
| + if cfg.DryRun { |
| + 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 5fe3bfb1d..4153d849d 100644 |
| --- a/internal/provision/reconcile_test.go |
| +++ b/internal/provision/reconcile_test.go |
| @@ -85,6 +85,15 @@ func putReplace(t *testing.T, s release.Store, key string, body []byte) { |
| } |
| } |
| +func mustJSON(t *testing.T, v any) []byte { |
| + t.Helper() |
| + b, err := json.Marshal(v) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return b |
| +} |
| + |
| // setup writes a valid account manifest (id acctID) and that account's |
| // "tito" app instances manifest (domain go.tito.io, pool tito, seed |
| // ["seed"]) — the fixture every test case below builds records on top of. |
| @@ -129,6 +138,90 @@ func TestReconcileMints(t *testing.T) { |
| } |
| } |
| +// 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, 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", |
| + Addr: "127.0.0.1:8090", Unit: "console.service", CreatedAt: time.Now()}, |
| + {Schema: 1, Host: "whole.go.tito.io", Slug: "whole", App: "tito", Account: sqid, |
| + Pool: "tito", Region: "ie", Channel: "stable", Addr: "127.0.0.1:9443", |
| + 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: &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 { |
| + 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) |
| + } |
| + |
| + console := *mustGet(t, reg, "console.go.tito.io") |
| + console.CreatedAt = 0 |
| + wantConsole := registry.Route{ |
| + Host: "console.go.tito.io", Slug: "console", Kind: "instance", |
| + Addr: "127.0.0.1:8090", Unit: "console.service", |
| + App: "tito", Account: sqid, Pool: "tito", Region: "ie", Channel: "stable", |
| + Provisioned: true, Seed: `["seed"]`, Status: "ready", |
| + } |
| + if console != wantConsole { |
| + t.Errorf("console route = %+v, want %+v", console, wantConsole) |
| + } |
| + |
| + whole := *mustGet(t, reg, "whole.go.tito.io") |
| + if whole.Socket != "" || whole.DBPath != "" { |
| + t.Errorf("an addr-backed tenant derived filesystem paths: socket %q db %q", whole.Socket, whole.DBPath) |
| + } |
| + if whole.Hibernate { |
| + t.Error("an explicitly non-hibernating record minted a hibernating route") |
| + } |
| + |
| + site := *mustGet(t, reg, "site.go.tito.io") |
| + if site.Kind != "static" { |
| + t.Errorf("static route Kind = %q", site.Kind) |
| + } |
| + if site.Socket != "" || site.DBPath != "" { |
| + t.Errorf("a static route derived filesystem paths: socket %q db %q", site.Socket, site.DBPath) |
| + } |
| + if site.Hibernate { |
| + t.Error("a static route was armed for hibernation — it has no process to wake") |
| + } |
| + 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. |
| func TestReconcileScope(t *testing.T) { |
| s, sqid := setup(t) |
| @@ -266,6 +359,219 @@ func TestReconcileConvergesSeed(t *testing.T) { |
| } |
| } |
| +// Issue #130, as it happened in production on 2026-08-09: a record created |
| +// on stable, deleted and recreated on edge, and the route row left on |
| +// stable — a route pointing at a channel with no pointer, so every wake |
| +// failed "no adoptable head" while carlos channels cheerfully showed edge |
| +// promoted. Reconcile created from a record and never converged to one. |
| +func TestReconcileConvergesChannel(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) |
| + } |
| + before := *mustGet(t, reg, "acme.go.tito.io") |
| + |
| + rec.Channel = "edge" |
| + putReplace(t, s, RecordKey(sqid, "tito", rec.Host), mustJSON(t, rec)) |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + after := *mustGet(t, reg, "acme.go.tito.io") |
| + if after.Channel != "edge" { |
| + t.Errorf("channel = %q, want edge — the record moved and the row did not", after.Channel) |
| + } |
| + after.Channel = before.Channel |
| + if after != before { |
| + t.Errorf("converge touched more than Channel: before %+v after %+v", before, after) |
| + } |
| + |
| + // The fleet runs on canary/* channels today (pinned in |
| + // internal/release/laddercheck_test.go); a converge that could not carry |
| + // one would stop deploys. |
| + rec.Channel = "canary/rehearsal" |
| + putReplace(t, s, RecordKey(sqid, "tito", rec.Host), mustJSON(t, rec)) |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := mustGet(t, reg, "acme.go.tito.io").Channel; got != "canary/rehearsal" { |
| + t.Errorf("channel = %q, want canary/rehearsal", got) |
| + } |
| +} |
| + |
| +// The converge guard is load-bearing: an empty Channel means a malformed |
| +// record, not "clear the channel" — an empty channel would pin the route to |
| +// nothing, exactly the #130 failure mode. A route already provisioned on a |
| +// real channel must keep it when its record goes channel-less, not have it |
| +// blanked. |
| +func TestReconcileConvergeGuardEmptyChannelLeavesRouteAlone(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) |
| + } |
| + before := *mustGet(t, reg, "acme.go.tito.io") |
| + if before.Channel != "stable" { |
| + t.Fatalf("fixture's route didn't channel as expected: %+v", before) |
| + } |
| + |
| + rec.Channel = "" |
| + putReplace(t, s, RecordKey(sqid, "tito", rec.Host), mustJSON(t, rec)) |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + after := *mustGet(t, reg, "acme.go.tito.io") |
| + if after != before { |
| + t.Errorf("channel-less record changed the row: before %+v after %+v", before, after) |
| + } |
| +} |
| + |
| +func TestReconcileConvergesEnvironment(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) |
| + } |
| + |
| + rec.Environment = "staging" |
| + putReplace(t, s, RecordKey(sqid, "tito", rec.Host), mustJSON(t, rec)) |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := mustGet(t, reg, "acme.go.tito.io").Environment; got != "staging" { |
| + t.Errorf("environment = %q, want staging", got) |
| + } |
| + |
| + // Clearing must reach the row too. Upsert deliberately PRESERVES a |
| + // non-empty environment (it cannot tell "untouched" from "cleared"), so |
| + // the clear has to go through the narrow setter — which is exactly why |
| + // this converge does not re-Upsert. |
| + rec.Environment = "" |
| + putReplace(t, s, RecordKey(sqid, "tito", rec.Host), mustJSON(t, rec)) |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := mustGet(t, reg, "acme.go.tito.io").Environment; got != "" { |
| + t.Errorf("environment = %q, want it cleared", got) |
| + } |
| +} |
| + |
| +// An explicit Hibernate converges; an absent one never does. Absent means |
| +// "the record does not speak to this", and a pass that stamped its |
| +// derivation over an operator's deliberate SetHibernate would be the silent |
| +// stomp this design exists to end. |
| +func TestReconcileConvergesHibernateOnlyWhenExplicit(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.SetHibernate("acme.go.tito.io", false); 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").Hibernate { |
| + t.Error("an absent Hibernate re-asserted its derivation over an operator's setting") |
| + } |
| + |
| + on := true |
| + rec.Hibernate = &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").Hibernate { |
| + t.Error("an explicit Hibernate did not converge") |
| + } |
| +} |
| + |
| +// 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) |
| @@ -477,6 +783,100 @@ func TestReconcileSkipsRecordWithInvalidHostShape(t *testing.T) { |
| mustGet(t, reg, bad) |
| } |
| +// ValidateShape is a safety net, not just the console door's policy check: |
| +// a record can reach the bucket by paths other than the console (an older |
| +// or future writer, or direct bucket access), and this pass must not |
| +// materialize one whose Kind/Addr/Unit/Environment combination is |
| +// malformed. Skipped loudly, and protected from removal for the same |
| +// reason a corrupted body or an invalid host shape is — an unusable record |
| +// is not a retired one. |
| +func TestReconcileSkipsRecordWithInvalidShape(t *testing.T) { |
| + s, sqid := setup(t) |
| + // kind "service" with no addr: ValidateShape refuses this combination |
| + // (the platform only proxies to a service, so it must name its |
| + // upstream), but it agrees with its own key path and its host shape is |
| + // fine — the only thing wrong is the Kind/Addr combination itself. |
| + bad := Record{Schema: 1, Host: "bad-service.go.tito.io", Slug: "bad-service", App: "tito", |
| + Account: sqid, Pool: "tito", Region: "ie", Channel: "stable", Kind: "service", |
| + CreatedAt: time.Now()} |
| + body, err := json.Marshal(bad) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := s.Put(ctx, RecordKey(sqid, "tito", bad.Host), body, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := CreateRecord(ctx, s, acmeRecord(sqid)); 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 rt, err := reg.Get(bad.Host); err != nil || rt != nil { |
| + t.Errorf("minted a route from a shape-invalid record: %+v %v", rt, err) |
| + } |
| + // The good neighbour in the same pass is untouched by the skip. |
| + mustGet(t, reg, "acme.go.tito.io") |
| + |
| + // And a route that somehow already carries that host is not treated as |
| + // retired: the record is there, it just cannot be used. |
| + if err := reg.Upsert(registry.Route{Host: bad.Host, Kind: "instance", App: "tito", |
| + Status: "ready", Provisioned: true, Region: "ie", Pool: "tito", Account: sqid}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + mustGet(t, reg, bad.Host) |
| +} |
| + |
| +// ValidateShape deliberately does not inspect Channel (it validates shape |
| +// fields, Channel is not one), but a channel-less record must not become a |
| +// route pinned to no channel — that is issue #130's failure mode (a route |
| +// whose channel resolves to nothing, waking with "no adoptable head") |
| +// arriving through the MINT door instead of the converge door Task 3 |
| +// closed. Skipped loudly at the read-path gate, and protected from removal |
| +// for the same reason a corrupted body is — an unusable record is not a |
| +// retired one. |
| +func TestReconcileSkipsRecordWithEmptyChannel(t *testing.T) { |
| + s, sqid := setup(t) |
| + bad := acmeRecord(sqid) |
| + bad.Host, bad.Slug, bad.Channel = "no-channel.go.tito.io", "no-channel", "" |
| + body, err := json.Marshal(bad) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := s.Put(ctx, RecordKey(sqid, "tito", bad.Host), body, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := CreateRecord(ctx, s, acmeRecord(sqid)); 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 rt, err := reg.Get(bad.Host); err != nil || rt != nil { |
| + t.Errorf("minted a route from a channel-less record: %+v %v", rt, err) |
| + } |
| + // The good neighbour in the same pass is untouched by the skip. |
| + mustGet(t, reg, "acme.go.tito.io") |
| + |
| + // And a route that somehow already carries that host is not treated as |
| + // retired: the record is there, it just cannot be used. |
| + if err := reg.Upsert(registry.Route{Host: bad.Host, Kind: "instance", App: "tito", |
| + Status: "ready", Provisioned: true, Region: "ie", Pool: "tito", Account: sqid}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + mustGet(t, reg, bad.Host) |
| +} |
| + |
| // CRITICAL regression (code review finding): an existing route's record |
| // going unparseable — not gone, just unreadable this pass — must never read |
| // as "the bucket retired it". Before the fix, json.Unmarshal failure and |
| @@ -643,3 +1043,456 @@ func TestReconcileDeclinesUnreadableTrashMarker(t *testing.T) { |
| t.Fatalf("an unreadable trash marker still minted a route: %+v %v", rt, err) |
| } |
| } |
| + |
| +// Design §4.2 — the payoff. Backfilling the fleet is a pure bucket-side |
| +// write per route: describe what is already there, and the next serial bump |
| +// adopts it. No carlos add, no SSM session, no on-box verb. |
| +func TestReconcileAdoptsMatchingHandAddedRoute(t *testing.T) { |
| + s, sqid := setup(t) |
| + rec := acmeRecord(sqid) |
| + rec.Channel = "edge" |
| + if err := CreateRecord(ctx, s, rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + // The route as an operator hand-added it before records existed: the |
| + // same shape, a different channel, and no Provisioned flag. |
| + handAdded := registry.Route{ |
| + Host: "acme.go.tito.io", Slug: "acme", Kind: "instance", |
| + Socket: "/run/carlos/acme.go.tito.io.sock", DBPath: "/data/acme.go.tito.io.db", |
| + App: "tito", Account: sqid, Pool: "tito", Region: "ie", |
| + Channel: "stable", Hibernate: true, Status: "ready", |
| + } |
| + reg := regWith(t, handAdded) |
| + cfg := ReconcileConfig{Region: "ie", Pools: []string{"tito"}, DataDir: "/data"} |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + got := *mustGet(t, reg, "acme.go.tito.io") |
| + if !got.Provisioned { |
| + t.Fatal("a matching hand-added route was not adopted") |
| + } |
| + if got.Channel != "edge" { |
| + t.Errorf("channel = %q, want edge — adoption should converge in the same pass", got.Channel) |
| + } |
| +} |
| + |
| +// The match test EXCLUDES the three columns the record is taking ownership |
| +// of. Requiring them to agree first would make adoption impossible in |
| +// exactly the case it exists for — a hand-added route on the wrong channel |
| +// is the whole reason to adopt it. |
| +func TestReconcileAdoptionIgnoresOwnedColumns(t *testing.T) { |
| + s, sqid := setup(t) |
| + rec := acmeRecord(sqid) |
| + rec.Channel = "edge" |
| + if err := CreateRecord(ctx, s, rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + handAdded := registry.Route{ |
| + Host: "acme.go.tito.io", Slug: "acme", Kind: "instance", |
| + Socket: "/run/carlos/acme.go.tito.io.sock", DBPath: "/data/acme.go.tito.io.db", |
| + App: "tito", Account: sqid, Pool: "tito", Region: "ie", |
| + Channel: "canary/rehearsal", Environment: "staging", Hibernate: false, Status: "ready", |
| + } |
| + reg := regWith(t, handAdded) |
| + 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").Provisioned { |
| + t.Error("adoption refused over a Channel/Environment/Hibernate difference") |
| + } |
| +} |
| + |
| +// A shape that differs is left exactly as alone as it is today. This is the |
| +// guard that stands between "adopt on match" and reconcile's removal phase |
| +// acquiring authority over a route it does not describe. |
| +func TestReconcileRefusesToAdoptMismatchedRoute(t *testing.T) { |
| + s, sqid := setup(t) |
| + if err := CreateRecord(ctx, s, acmeRecord(sqid)); err != nil { |
| + t.Fatal(err) |
| + } |
| + mismatched := registry.Route{ |
| + Host: "acme.go.tito.io", Slug: "acme", Kind: "instance", |
| + Socket: "/run/carlos/acme.go.tito.io.sock", DBPath: "/data/acme.go.tito.io.db", |
| + App: "other", Account: sqid, Pool: "tito", Region: "ie", |
| + Channel: "stable", Hibernate: true, Status: "ready", |
| + } |
| + reg := regWith(t, mismatched) |
| + before := *mustGet(t, reg, "acme.go.tito.io") |
| + cfg := ReconcileConfig{Region: "ie", Pools: []string{"tito"}, DataDir: "/data"} |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + after := *mustGet(t, reg, "acme.go.tito.io") |
| + if after != before { |
| + t.Errorf("a mismatched route was touched: before %+v after %+v", before, after) |
| + } |
| + if after.Provisioned { |
| + t.Error("a mismatched route was adopted — removal authority granted over a route the record does not describe") |
| + } |
| +} |
| + |
| +// A record has no field for Backing — registry.Route.Backing's own doc |
| +// comment calls it a box-scoped operator column, alongside capability |
| +// grants and tunnels — so a record can never describe a carlos-app@<host> |
| +// unit tenant at all. Even a row that otherwise matches in every column |
| +// must not be adopted: adoption would hand this pass's removal phase |
| +// authority over a row it cannot even state the shape of. |
| +func TestReconcileRefusesToAdoptBackedRoute(t *testing.T) { |
| + s, sqid := setup(t) |
| + if err := CreateRecord(ctx, s, acmeRecord(sqid)); err != nil { |
| + t.Fatal(err) |
| + } |
| + handAdded := registry.Route{ |
| + Host: "acme.go.tito.io", Slug: "acme", Kind: "instance", |
| + Socket: "/run/carlos/acme.go.tito.io.sock", DBPath: "/data/acme.go.tito.io.db", |
| + App: "tito", Account: sqid, Pool: "tito", Region: "ie", |
| + Channel: "stable", Hibernate: true, Status: "ready", |
| + Backing: registry.BackingUnit, |
| + } |
| + reg := regWith(t, handAdded) |
| + before := *mustGet(t, reg, "acme.go.tito.io") |
| + cfg := ReconcileConfig{Region: "ie", Pools: []string{"tito"}, DataDir: "/data"} |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + after := *mustGet(t, reg, "acme.go.tito.io") |
| + if after != before { |
| + t.Errorf("a Backing-owned route was touched: before %+v after %+v", before, after) |
| + } |
| + if after.Provisioned { |
| + t.Error("a carlos-app@ unit tenant was adopted — a record cannot state its Backing") |
| + } |
| +} |
| + |
| +// Tunnel is routing-decisive and OUTRANKS the Socket/Addr a record can |
| +// state: internal/edge picks the tunnel proxy before it ever looks at |
| +// Socket (edge.go's route-selection switch), so a tunnel-backed route can |
| +// otherwise agree with a record on every other compared column while every |
| +// byte it actually serves goes down a channel the record cannot see or |
| +// reclaim (2026-08-10 review, Critical). |
| +func TestReconcileRefusesToAdoptTunnelBoundRoute(t *testing.T) { |
| + s, sqid := setup(t) |
| + if err := CreateRecord(ctx, s, acmeRecord(sqid)); err != nil { |
| + t.Fatal(err) |
| + } |
| + handAdded := registry.Route{ |
| + Host: "acme.go.tito.io", Slug: "acme", Kind: "instance", |
| + Socket: "/run/carlos/acme.go.tito.io.sock", DBPath: "/data/acme.go.tito.io.db", |
| + App: "tito", Account: sqid, Pool: "tito", Region: "ie", |
| + Channel: "stable", Hibernate: true, Status: "ready", |
| + Tunnel: "pi-fleet", |
| + } |
| + reg := regWith(t, handAdded) |
| + before := *mustGet(t, reg, "acme.go.tito.io") |
| + cfg := ReconcileConfig{Region: "ie", Pools: []string{"tito"}, DataDir: "/data"} |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + after := *mustGet(t, reg, "acme.go.tito.io") |
| + if after != before { |
| + t.Errorf("a tunnel-bound route was touched: before %+v after %+v", before, after) |
| + } |
| + if after.Provisioned { |
| + t.Error("a tunnel-bound route was adopted — a record cannot state or reclaim its Tunnel binding") |
| + } |
| +} |
| + |
| +// Caps is the operator-granted, box-local capability-grant column, and |
| +// registry.Delete's purge of a route's grants is already a human-gated, |
| +// last-resort action elsewhere in this file (the shape-drift log). Adoption |
| +// must not hand that purge to an unattended bucket-record deletion |
| +// (2026-08-10 review, Critical). |
| +func TestReconcileRefusesToAdoptCapsBearingRoute(t *testing.T) { |
| + s, sqid := setup(t) |
| + if err := CreateRecord(ctx, s, acmeRecord(sqid)); err != nil { |
| + t.Fatal(err) |
| + } |
| + handAdded := registry.Route{ |
| + Host: "acme.go.tito.io", Slug: "acme", Kind: "instance", |
| + Socket: "/run/carlos/acme.go.tito.io.sock", DBPath: "/data/acme.go.tito.io.db", |
| + App: "tito", Account: sqid, Pool: "tito", Region: "ie", |
| + Channel: "stable", Hibernate: true, Status: "ready", |
| + Caps: "port:25", |
| + } |
| + reg := regWith(t, handAdded) |
| + before := *mustGet(t, reg, "acme.go.tito.io") |
| + cfg := ReconcileConfig{Region: "ie", Pools: []string{"tito"}, DataDir: "/data"} |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + after := *mustGet(t, reg, "acme.go.tito.io") |
| + if after != before { |
| + t.Errorf("a caps-bearing route was touched: before %+v after %+v", before, after) |
| + } |
| + if after.Provisioned { |
| + t.Error("a caps-bearing route was adopted — its grants would now be one record deletion from purged") |
| + } |
| +} |
| + |
| +// domains.mintedRoute builds platform routes with empty Slug/Pool/Region/ |
| +// DBPath, copying Kind/Socket/Addr from the source route — so a record |
| +// whose Slug/Pool/Region are also empty (an unconstrained-region/pool box) |
| +// can otherwise agree with a platform-minted row on every other compared |
| +// column. Adoption must not hand this pass's removal authority to a row |
| +// domains.Reconcile already owns and periodically re-Upserts/removes on its |
| +// own authority (2026-08-10 review, Important 1). |
| +func TestReconcileRefusesToAdoptPlatformRoute(t *testing.T) { |
| + s, sqid := setup(t) |
| + rec := Record{Schema: 1, Host: "acme.go.tito.io", App: "tito", Account: sqid, |
| + Channel: "stable", CreatedAt: time.Now()} |
| + if err := CreateRecord(ctx, s, rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + handAdded := registry.Route{ |
| + Host: "acme.go.tito.io", Kind: "instance", |
| + Socket: "/run/carlos/acme.go.tito.io.sock", DBPath: "/data/acme.go.tito.io.db", |
| + App: "tito", Account: sqid, Status: "ready", Platform: true, |
| + } |
| + reg := regWith(t, handAdded) |
| + before := *mustGet(t, reg, "acme.go.tito.io") |
| + cfg := ReconcileConfig{DataDir: "/data"} // unconstrained region, default pool only |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + after := *mustGet(t, reg, "acme.go.tito.io") |
| + if after != before { |
| + t.Errorf("a platform-owned route was touched: before %+v after %+v", before, after) |
| + } |
| + if after.Provisioned { |
| + t.Error("a platform-owned route was adopted — removal authority granted to a second pass") |
| + } |
| +} |
| + |
| +// Same guard, the other flag domains.Reconcile also uses for its own |
| +// removal authority (2026-08-10 review, Important 1). |
| +func TestReconcileRefusesToAdoptCanaryRoute(t *testing.T) { |
| + s, sqid := setup(t) |
| + rec := Record{Schema: 1, Host: "acme.go.tito.io", App: "tito", Account: sqid, |
| + Channel: "stable", CreatedAt: time.Now()} |
| + if err := CreateRecord(ctx, s, rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + handAdded := registry.Route{ |
| + Host: "acme.go.tito.io", Kind: "instance", |
| + Socket: "/run/carlos/acme.go.tito.io.sock", DBPath: "/data/acme.go.tito.io.db", |
| + App: "tito", Account: sqid, Status: "ready", Canary: true, |
| + } |
| + reg := regWith(t, handAdded) |
| + before := *mustGet(t, reg, "acme.go.tito.io") |
| + cfg := ReconcileConfig{DataDir: "/data"} |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + after := *mustGet(t, reg, "acme.go.tito.io") |
| + if after != before { |
| + t.Errorf("a canary-owned route was touched: before %+v after %+v", before, after) |
| + } |
| + if after.Provisioned { |
| + t.Error("a canary-owned route was adopted — removal authority granted to a second pass") |
| + } |
| +} |
| + |
| +// Nothing currently locked the derived Socket/DBPath comparison specifically |
| +// — the only prior mismatch test differed on App, so a regression that |
| +// dropped the DBPath comparison from shapeMismatch would leave the suite |
| +// green (2026-08-10 review, test gap called out alongside the Important |
| +// findings). |
| +func TestReconcileRefusesToAdoptOnDBPathMismatch(t *testing.T) { |
| + s, sqid := setup(t) |
| + if err := CreateRecord(ctx, s, acmeRecord(sqid)); err != nil { |
| + t.Fatal(err) |
| + } |
| + handAdded := registry.Route{ |
| + Host: "acme.go.tito.io", Slug: "acme", Kind: "instance", |
| + Socket: "/run/carlos/acme.go.tito.io.sock", DBPath: "/data/other.db", |
| + App: "tito", Account: sqid, Pool: "tito", Region: "ie", |
| + Channel: "stable", Hibernate: true, Status: "ready", |
| + } |
| + reg := regWith(t, handAdded) |
| + before := *mustGet(t, reg, "acme.go.tito.io") |
| + cfg := ReconcileConfig{Region: "ie", Pools: []string{"tito"}, DataDir: "/data"} |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + after := *mustGet(t, reg, "acme.go.tito.io") |
| + if after != before { |
| + t.Errorf("a DBPath-mismatched route was touched: before %+v after %+v", before, after) |
| + } |
| + if after.Provisioned { |
| + t.Error("a route was adopted despite a DBPath mismatch") |
| + } |
| +} |
| + |
| +// An adopted route is removable by the pass that adopted it, on the same |
| +// terms as one it minted: the record disappearing is the bucket's own |
| +// decision to retire the instance. |
| +func TestReconcileRemovesAdoptedRouteWhenRecordGoes(t *testing.T) { |
| + s, sqid := setup(t) |
| + if err := CreateRecord(ctx, s, acmeRecord(sqid)); err != nil { |
| + t.Fatal(err) |
| + } |
| + handAdded := registry.Route{ |
| + Host: "acme.go.tito.io", Slug: "acme", Kind: "instance", |
| + Socket: "/run/carlos/acme.go.tito.io.sock", DBPath: "/data/acme.go.tito.io.db", |
| + App: "tito", Account: sqid, Pool: "tito", Region: "ie", |
| + Channel: "stable", Hibernate: true, Status: "ready", |
| + } |
| + reg := regWith(t, handAdded) |
| + cfg := ReconcileConfig{Region: "ie", Pools: []string{"tito"}, DataDir: "/data"} |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := DeleteRecord(ctx, s, sqid, "tito", "acme.go.tito.io"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + rt, err := reg.Get("acme.go.tito.io") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if rt != nil { |
| + t.Errorf("adopted route survived its record's deletion: %+v", rt) |
| + } |
| +} |
| + |
| +// A dry-run pass reports what it would do and writes nothing — the backfill |
| +// mitigation (design §4.3): a production route is adopted only after someone |
| +// has read what adopting it would mean, because adoption is what grants the |
| +// removal phase authority over that row. This exercises all four write |
| +// shapes in the same pass — mint (acme.go.tito.io has no existing route), |
| +// adopt (a hand-added route matching a record's shape but on the wrong |
| +// channel), converge (an already-provisioned route whose channel, |
| +// environment, hibernate and seed all disagree with its record), and remove |
| +// (a provisioned route whose record is gone) — because a test that only |
| +// covers minting would pass while a removal wrote through. |
| +func TestReconcileDryRunWritesNothing(t *testing.T) { |
| + s, sqid := setup(t) |
| + |
| + // Mint: no existing route for this host. |
| + if err := CreateRecord(ctx, s, acmeRecord(sqid)); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + // Adopt: a hand-added route matching the record's shape, on a |
| + // different channel AND environment — adoption would converge both in |
| + // the same pass. Two differing columns, deliberately: a dry-run report |
| + // that only ever mentioned channel here would leave environment's |
| + // change invisible to the operator reading it (design §4.3 review). |
| + adoptRec := Record{Schema: 1, Host: "adopt.go.tito.io", Slug: "adopt", App: "tito", |
| + Account: sqid, Pool: "tito", Region: "ie", Channel: "edge", Environment: "staging", CreatedAt: time.Now()} |
| + if err := CreateRecord(ctx, s, adoptRec); err != nil { |
| + t.Fatal(err) |
| + } |
| + adoptable := registry.Route{ |
| + Host: "adopt.go.tito.io", Slug: "adopt", Kind: "instance", |
| + Socket: "/run/carlos/adopt.go.tito.io.sock", DBPath: "/data/adopt.go.tito.io.db", |
| + App: "tito", Account: sqid, Pool: "tito", Region: "ie", |
| + Channel: "stable", Hibernate: true, Status: "ready", |
| + } |
| + |
| + // Converge: already provisioned (this pass or an earlier one owns it), |
| + // but channel, environment, hibernate and seed all disagree. |
| + onHibernate := true |
| + ownedRec := Record{Schema: 1, Host: "owned.go.tito.io", Slug: "owned", App: "tito", |
| + Account: sqid, Pool: "tito", Region: "ie", Channel: "edge", Environment: "staging", |
| + Hibernate: &onHibernate, CreatedAt: time.Now()} |
| + if err := CreateRecord(ctx, s, ownedRec); err != nil { |
| + t.Fatal(err) |
| + } |
| + owned := registry.Route{ |
| + Host: "owned.go.tito.io", Slug: "owned", Kind: "instance", |
| + Socket: "/run/carlos/owned.go.tito.io.sock", DBPath: "/data/owned.go.tito.io.db", |
| + App: "tito", Account: sqid, Pool: "tito", Region: "ie", |
| + Channel: "stable", Provisioned: true, Status: "ready", |
| + } |
| + |
| + // Remove: provisioned, in scope, and its record is gone. |
| + retired := registry.Route{Host: "gone.go.tito.io", Kind: "instance", App: "tito", Account: sqid, |
| + Pool: "tito", Region: "ie", Status: "ready", Provisioned: true} |
| + |
| + reg := regWith(t, adoptable, owned, retired) |
| + before, err := reg.List() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + cfg := ReconcileConfig{Region: "ie", Pools: []string{"tito"}, DataDir: "/data", DryRun: true} |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + after, err := reg.List() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !reflect.DeepEqual(before, after) { |
| + t.Errorf("dry-run wrote to the registry:\nbefore %+v\nafter %+v", before, after) |
| + } |
| + if rt, err := reg.Get("acme.go.tito.io"); err != nil || rt != nil { |
| + t.Errorf("dry-run minted a route: %+v %v", rt, err) |
| + } |
| + |
| + // Positive control (reviewer-flagged): the assertion above — total |
| + // state unchanged — would also pass if this fixture simply never |
| + // exercised the mint/adopt/converge/remove branches at all, e.g. if a |
| + // future validation change caused ownedRec to be skipped and |
| + // protect-ed, silently dropping the converge branch from coverage while |
| + // the test still reported green. Re-running the identical fixture for |
| + // real proves the fixture DOES drive every branch: if state came out |
| + // unchanged here too, the dry-run assertion above would have been |
| + // vacuous, not a proof of anything. |
| + cfg.DryRun = false |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + live, err := reg.List() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if reflect.DeepEqual(before, live) { |
| + t.Fatal("a real pass over the identical fixture left the registry unchanged — " + |
| + "the dry-run assertion above proves nothing if nothing was ever going to write") |
| + } |
| +} |
| + |
| +// The other side of that coin, and the one that matters more: a route no |
| +// record ever described is never removed, no matter how many passes run. |
| +// Adoption is the ONLY way this pass acquires removal authority, and this |
| +// test is what stands between a backfill and a retired production route. |
| +func TestReconcileNeverRemovesUnadoptedRoute(t *testing.T) { |
| + s, sqid := setup(t) |
| + // A record for some OTHER host, so the pass runs a full removal phase |
| + // with a non-empty record set rather than bailing early. |
| + if err := CreateRecord(ctx, s, acmeRecord(sqid)); err != nil { |
| + t.Fatal(err) |
| + } |
| + handAdded := registry.Route{ |
| + Host: "console.go.tito.io", Kind: "instance", App: "tito", Account: sqid, |
| + Pool: "tito", Region: "ie", Channel: "stable", Addr: "127.0.0.1:8090", |
| + Unit: "console.service", Status: "ready", |
| + } |
| + reg := regWith(t, handAdded) |
| + // Upsert stamps CreatedAt; read the row back so the final comparison |
| + // isn't comparing against a zero value the row never actually had. |
| + handAdded = *mustGet(t, reg, "console.go.tito.io") |
| + cfg := ReconcileConfig{Region: "ie", Pools: []string{"tito"}, DataDir: "/data"} |
| + for i := 0; i < 3; i++ { |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + got, err := reg.Get("console.go.tito.io") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got == nil { |
| + t.Fatal("a hand-added route with no record was removed") |
| + } |
| + if got.Provisioned { |
| + t.Error("a hand-added route with no record was adopted") |
| + } |
| + if *got != handAdded { |
| + t.Errorf("a hand-added route with no record was modified: %+v", got) |
| + } |
| +} |
diff --git a/internal/registry/registry.go b/internal/registry/registry.go| index 250ca1721..1ac4ede6e 100644 |
| --- a/internal/registry/registry.go |
| +++ b/internal/registry/registry.go |
| @@ -1176,6 +1176,61 @@ func (r *Registry) SetSeed(host, seed string) error { |
| return nil |
| } |
| +// SetProvisioned stamps (or clears) the flag that marks a route as the |
| +// instance-provisioning reconcile pass's own — a narrow UPDATE, same |
| +// rationale as SetSeed. It errors when no row matched, the SetChannel |
| +// 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 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 { |
| + v := 0 |
| + if on { |
| + v = 1 |
| + } |
| + res, err := r.sql.Exec(`UPDATE routes SET provisioned = ? 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 |
| +} |
| + |
| +// 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 0e0ab87ec..1721cdef4 100644 |
| --- a/internal/registry/registry_test.go |
| +++ b/internal/registry/registry_test.go |
| @@ -1640,6 +1640,56 @@ func TestValidTunnelNameLengthBoundary(t *testing.T) { |
| } |
| } |
| +func TestSetProvisioned(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.SetProvisioned("a.example.com", true); err != nil { |
| + t.Fatal(err) |
| + } |
| + rt, err := r.Get("a.example.com") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !rt.Provisioned { |
| + t.Error("SetProvisioned(true) did not stamp the column") |
| + } |
| + if err := r.SetProvisioned("missing.example.com", true); err == nil { |
| + 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)") |
| + } |
| +} |
| + |
| // TestSetFleetBoxRoundTrips mirrors TestSetTunnel's shape: SetFleetBox is a |
| // narrow UPDATE on the new fleet_box column, which records which fleet box |
| // is CURRENTLY serving a placed route (distinct from Region/placement |