carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Package registry is the host's control DB — the set of routes a deployment
2// host serves and how to reach each. Deliberately separate from any app's own
3// database: it holds routing + lifecycle state, never app data.
4//
5// Ported from titogo internal/carlos/registry.go (which carries three fixes
6// over Eleven's registry.go: busy_timeout before WAL, List on a fresh
7// connection, and — in the edge — first-fingerprint reload), with Eleven's
8// activator columns (hibernate, canary, replication, next_due) and the
9// platform's placement columns (app, pool, region, channel, addr) restored.
10package registry
11
12import (
13 "context"
14 "crypto/sha256"
15 "database/sql"
16 "database/sql/driver"
17 "errors"
18 "fmt"
19 "os"
20 "regexp"
21 "strings"
22 "time"
23
24 "modernc.org/sqlite"
25
26 "github.com/carlosframework/platform/internal/controlname"
27)
28
29type Registry struct {
30 remote *remoteClient
31 sql *sql.DB
32 path string
33}
34
35// Route is one host a deployment serves: an app instance the platform
36// lifecycle-manages (kind "instance"), a sibling service it only proxies
37// (kind "service"), a served file tree (kind "static"), or a process that
38// serves no requests and that nothing can dial (kind "sidecar" — its
39// next_due wake, via DueInstances, is its only timer wake source).
40type Route struct {
41 ClaimOwnership
42
43 // WakeIncarnation is transient request state and is never persisted.
44 WakeIncarnation string `json:"-"`
45 Host string
46 // Slug is the app-account short name usable on a root domain. Empty =
47 // subdomain-only.
48 Slug string
49 Kind string // instance | service | static | sidecar
50 Socket string // unix socket upstream; wins over Addr when both set
51 Addr string // TCP upstream host:port — whole-app tenants (Kass v1)
52 // Addrs is a route's multi-upstream set (HA design, issue #152): a
53 // comma-joined list of TCP upstream host:port entries, "" = none. The
54 // comma-join is safe because every element is validated by
55 // provision.validAddr, whose shape (host:port) cannot itself contain a
56 // comma. Addr above remains the legacy single-upstream field — it is
57 // untouched by multi-upstream tenants and AddrList folds it into a
58 // one-element list when Addrs is empty, so a single-upstream caller sees
59 // no difference.
60 Addrs string
61 // Health is the health-probe config JSON, verbatim, from the record that
62 // minted this route — the canonical json.Marshal of provision.Health
63 // (wire key "probe" on the record; the rename there was JSON-only, so
64 // this column keeps the internal name). "" means unprobed, not a zero or
65 // failing probe.
66 Health string
67 Status string // pending|creating|starting|issuing|ready|failed
68 // HoldOp is the operation holding Status (moves spec §5.2); "" when not
69 // held. A held row (Status in HoldStatuses) belongs to that operation: the
70 // edge never serves it, ticks refuse it and no ordinary wake reaches it.
71 // Only SetHold, ReleaseHold and DeleteIfHold change a hold; Upsert's
72 // conflict path never touches it. IN Fingerprint() and SnapshotFingerprint,
73 // folded in only when non-empty so an unheld registry hashes exactly as it
74 // did before the column existed.
75 HoldOp string
76 // SeedSource is the record's seed as this row needs it
77 // (seeds.RowSeed's JSON, "" for none), written by the provision pass
78 // (seeded-instances spec §3.4): the activator's restore fallback reads
79 // it, never the record. Upsert keeps a non-empty value over an empty
80 // one; SetSeedSource converges it. Not in Fingerprint.
81 SeedSource string
82 // ImportGate is an uncommitted import's gate (seeded-instances spec
83 // §5.4): the sha256 of its verify token, or ImportGateClosed during a
84 // rollback. Set: the edge answers 503 to every request without the
85 // matching token, and ticks skip the row. Only the import transactions
86 // (importjournal.go) write it; Upsert's conflict path never changes it.
87 // In Fingerprint (the edge must reload on a change), folded only when
88 // non-empty.
89 ImportGate string
90
91 LastError string
92 DBPath string
93 Version string
94 PrevVersion string
95 // GoodVersion, GoodDigest and GoodDemand are the good build (zero-downtime
96 // spec §8.5): the last build that served for this instance after passing
97 // its health check, the sha256 of its staged executable, and the
98 // release's own `swap` demand as verified then. Separate from
99 // PrevVersion, which records install history whether or not the build
100 // ever ran. Written only by SetGoodBuild, with an expected-current
101 // condition; Upsert inserts them on a new row and never changes them on
102 // an existing one.
103 GoodVersion string
104 GoodDigest string
105 GoodDemand string
106 // ServingVersion is the build of the generation that last took new
107 // connections (spec §3), written by the agent for status and the CLI.
108 // It is not cleared when the instance sleeps.
109 ServingVersion string
110
111 App string // which app's releases this route runs
112 Pool string // placement pool ("" = shared)
113 Region string // residency region code ("" = unconstrained); ALSO doubles
114 // as a route's fleet placement target ("fleet/<name>" — see
115 // FleetPlacement/PlacementTarget, fleets.go) written by SetPlacement, not
116 // this comment's original meaning alone.
117 Channel string // release channel this route follows ("" = stable)
118 // Environment is the named config bundle this route is wired to ("" =
119 // the app's default bundle, config.json, alone — see
120 // internal/appconfig.LoadMerged). Independent of Channel: channel is
121 // how mature a build is, environment is what glue it's wired to
122 // (2026-08-02-pipelines-design.md §2/§4a).
123 //
124 // Written INTENTIONALLY only by `carlos route -environment` (cli.go's
125 // runRoute), through the narrow setters below (SetEnvironment,
126 // SetEnvironments, SetChannelsAndEnvironments). An earlier revision of
127 // this comment claimed that was the only writer at all; it was wrong —
128 // every full-row Upsert (`carlos add`, reconcile.go's two mint paths)
129 // also writes this column, and did so with the zero value, silently
130 // unbinding a route on re-add or re-mint (2026-08-02 review, IMPORTANT
131 // 3). Upsert now preserves a non-empty existing value instead; see its
132 // doc comment. Clearing therefore has exactly one door: `-environment -`.
133 //
134 // Platform+Canary routes are refused by `carlos route -environment`
135 // (platformCanaryOwnedRow, cli.go) for the same structural reason
136 // Channel is: the reconciler owns those rows and re-Upserts them
137 // whenever its `followed` check goes false. Upsert's preservation makes
138 // that survivable rather than silent, but a column the reconciler
139 // rebuilds is not one this verb should be handing an operator.
140 Environment string
141 // Tunnel is the name of the reverse channel backing this route at the
142 // edge ("" = not tunnel-backed — routed to a Socket/Addr upstream as
143 // today). Written only by SetTunnel / carlos route -tunnel; Upsert
144 // preserves non-empty values — see Environment's comment for why.
145 Tunnel string
146 // FleetBox is the label of the fleet box CURRENTLY bound to serve this
147 // route ("" = no binding) — distinct from Region, which carries the
148 // placement TARGET (see FleetPlacement/PlacementTarget, fleets.go); a
149 // route can want "fleet/pi" and not yet have a box assigned. Written only
150 // by SetFleetBox; Upsert preserves non-empty values — see Environment's
151 // comment for why.
152 FleetBox string
153
154 // Caps is the OPERATOR-granted capability tokens for this route ON THIS
155 // BOX: comma-separated and canonically sorted, so "port:25,host-role"
156 // normalises to "host-role,port:25" (the vocabulary and the formatter are
157 // internal/caps' Grants — this column's only reader and writer). Default
158 // '' = nothing granted = today's behaviour for every row already on every
159 // box.
160 //
161 // Written ONLY by SetCaps, behind `carlos route -grant/-revoke`. A
162 // manifest DECLARES `ports`/`host_role`; nothing materializes until an
163 // operator has granted it here, on this box — which is the whole point of
164 // the column living in the registry, where no tenant can write (see
165 // docs/superpowers/specs/2026-08-03-app-capabilities-design.md §2, §7).
166 // Upsert preserves a non-empty existing value for the same reason it
167 // preserves Environment; see its doc comment.
168 //
169 // Deliberately NOT in Fingerprint(): grants change unit shape, not
170 // routing, and the edge has no reason to reload for one.
171 Caps string
172
173 Hibernate bool
174 // Gate is the bcrypt hash of the host's shared password ("" = open),
175 // converged from provision.Record.Gate by SetGate and read by the edge
176 // on every request to the host (internal/gate). Validated at the
177 // console door, never here — SetCaps's rule. IN Fingerprint(): a gate
178 // set or cleared changes what the edge serves, and must reload it.
179 // Upsert preserves a non-empty value against an empty one (the
180 // caps/tunnel/unit idiom), so a full-row writer that predates the
181 // column can never silently open a gated host.
182 Gate string
183
184 Canary bool
185 // Replication is the route's litestream sync class — the rung of the
186 // replication ladder its database is replicated on. The vocabulary is
187 // the four Replication* constants below, and internal/activator maps it
188 // to a real sync interval on every wake. "" is not a fifth rung: it is
189 // the pre-ladder default, and it deliberately buys what stream buys, so
190 // a registry written before the column existed keeps its behaviour.
191 //
192 // Upsert PRESERVES a non-empty value (see its doc comment), which is
193 // what stops the provisioning reconciler — which mints routes from
194 // instance records, and records carry no replication class — from
195 // silently returning an operator's `daily` route to the default within
196 // seconds of the next serial bump.
197 Replication string
198 NextDue int64
199 StatusNamed bool // publish this route's app by name on the status page — the app's own opt-in
200
201 // Backing is who owns the instance's process: "" = an exec child of the
202 // agent (the activator spawns live/<host> itself — the Eleven shape), or
203 // BackingUnit = a carlos-app@<host> systemd unit (whole-app tenants:
204 // DynamicUser, StateDirectory) that the activator drives via systemctl.
205 Backing string
206
207 // Unit is the systemd unit that owns this route's process when it is
208 // neither an exec child nor a carlos-app@ tenant — an operator-run
209 // bespoke service (console.service is the case that forced the column:
210 // an addr-backed route, `-addr 127.0.0.1:8090`, whose process no
211 // backing-dispatched restart could reach). Empty for everything else,
212 // which is every route on every box today.
213 //
214 // The adopt pass's restart dispatch tries this unit FIRST when it is set
215 // (internal/host's makeRestartRoute): before the change, adoption
216 // repointed the symlink, stamped Version — and therefore the edge's
217 // X-Carlos-Version header — and cycled nothing, so the old process went
218 // on serving a build the header already claimed was live (issue #110).
219 //
220 // Written by SetUnit, which validates the name, and by Upsert, which does
221 // not — Upsert validates no column at all (SetCaps's rule: the rails live
222 // at the door), so the convention is that a full-row caller has already
223 // run ValidUnitName. `carlos add -unit` is the only such caller today and
224 // does exactly that, at the flag, before the registry is opened. Upsert
225 // preserves a non-empty existing value — see Environment's comment for
226 // why.
227 //
228 // Deliberately NOT in Fingerprint(): unit changes who restarts the
229 // process, not what the edge serves.
230 Unit string
231
232 // Platform marks a route the domains reconcile pass minted
233 // (<app>.<sqid>.<apps-domain>). Only platform routes are ever
234 // auto-removed by reconciliation; custom domains are operator/owner
235 // property and never touched.
236 Platform bool
237 // Provisioned marks a route the instance-provisioning reconcile pass
238 // (internal/provision) minted from a bucket instance record. Only
239 // provisioned routes are ever auto-removed by that pass — the same
240 // authority rule Platform carries for the domains pass, kept as a
241 // SEPARATE flag because Platform means "alias to another route's
242 // upstream" to three consumers (config sync, upstream picking, apex
243 // candidacy) and a provisioned instance is the opposite: it owns a
244 // process.
245 //
246 // Because it carries AUTHORITY, false is not a value a full-row Upsert
247 // may impose: `carlos add` on a provisioned host would otherwise revoke
248 // the pass's removal right permanently (the environment precedent, with
249 // worse stakes — see Upsert). Upsert preserves an existing true; the
250 // only clear is the row's own deletion, which is what the removal phase
251 // does anyway.
252 Provisioned bool
253 // Seed is the app's first-boot init argv as a JSON array (e.g.
254 // ["seed"]), materialized from the app's instances.json by the
255 // provision pass; "" = no seed. Run by the activator only on a wake
256 // where restore-on-miss found no replica (backend_exec.go).
257 //
258 // Same preservation rule as Provisioned and for the same reason: a
259 // full-row Upsert from a caller that knows nothing about seeding must
260 // not erase one, so Upsert keeps a non-empty existing value and SetSeed
261 // (the provision pass's narrow converge) is the only door that clears
262 // it — which it must be able to do, for a manifest whose seed was
263 // removed.
264 Seed string
265 // Args is a sidecar's argv as a JSON array, materialized from the app's
266 // sidecars manifest on every reconcile pass (truth stays in the bucket).
267 // Empty for every other kind of route.
268 Args string
269 // IdleSeconds and MaxRunSeconds are a sidecar worker's ceilings, in
270 // seconds, materialized from the app's manifest exactly as Args and Seed
271 // are — 0 means "use the platform default". The sweeper reads them
272 // locally on every tick to decide whether a worker has outrun its
273 // ceiling; a bucket read per wake would put the control plane in the hot
274 // path of a timer.
275 IdleSeconds int
276 MaxRunSeconds int
277 // DNSStatus is the last DNS health verdict for this host: "pointing"
278 // (resolves to a deployment edge IP), "elsewhere", or "" — unknown, the
279 // honest answer for a resolver failure, never a guess.
280 DNSStatus string
281 DNSCheckedAt int64 // unix seconds of the last DNS check; 0 = never checked
282 // CertExpiresAt is the NotAfter of the host's cached ACME cert, unix
283 // seconds; 0 = no cert known (absent or unparseable — unknown, not "bad").
284 CertExpiresAt int64
285 // Account is the app→account linkage carried on the route rows
286 // (consistent with the registry's route-centric shape); "" displays as
287 // the built-in "ops" account.
288 Account string
289
290 // Steering is this route's DNS steering opt-in (multi-region ingress
291 // design Part 4): "" (default — today's static per-host answer) or
292 // "latency" (a Route53 latency record per armed edge, converged by the
293 // steering housekeeper duty). A route's own value, set from
294 // provision.Record.Steering via routeFor's mint and SetSteering's
295 // converge — provision.ValidSteering is the rail; this column validates
296 // nothing, the same discipline every other provision-owned column
297 // follows (SetCaps's rule: the rails live at the door).
298 //
299 // SINGLE-BOX INERT as of this column's introduction: nothing reads it
300 // yet. See provision.Record.Steering's doc comment for the task-by-task
301 // rollout that gives it effect.
302 //
303 // Deliberately NOT in Fingerprint(): steering changes what DNS answers,
304 // not what the edge serves; the converger is a host housekeeper duty
305 // reading the registry directly (Tasks 4-6), never gated on the edge's
306 // reload poll. SnapshotFingerprint still covers it, automatically, via
307 // routeCols — the restore surface and the reload surface answer
308 // different questions (SnapshotFingerprint's own doc comment).
309 Steering string
310
311 // UpdateSettings is the instance's effective update settings
312 // (release.UpdateSettings' canonical JSON, "" = today's adoption),
313 // converged by provision.Reconcile from the record and the app policy.
314 // Root reads it (plan 4). Deliberately NOT in Fingerprint(): it
315 // changes what root installs, not what the edge serves.
316 UpdateSettings string
317
318 // BuildState is the agent's copy of the build-state object (JSON of
319 // release.BuildStateCopy; "" = never read), refreshed at reconcile and
320 // before every wake. Root reads it. Deliberately NOT in Fingerprint():
321 // it changes what root decides, not what the edge serves. The registry
322 // holds it as an opaque string (release imports registry, not the other
323 // way round).
324 BuildState string
325
326 // Replicas is the account's replica placements, comma-joined placement
327 // codes in canonical (sorted, deduplicated) order — the registry half of
328 // #162. "" on every row that predates the column = no replicas =
329 // today's routing, byte for byte. Written by SetReplicas (the provision
330 // pass's converge — the value is the record's, and this column must
331 // track it, including down to "") and carried by routeFor at the mint.
332 //
333 // Read by the edges-file pass (a replica placement is where the account
334 // is SERVED) and the steering converger (a replica placement earns a
335 // latency record); never by the edge's own dispatch. A replica host's
336 // own row is a separate route whose Region names its placement, exactly
337 // as a home's row does — this column lives on the HOME's row, naming
338 // where its replicas sit.
339 //
340 // Deliberately NOT in Fingerprint(), in Steering's exact shadow: it
341 // changes what DNS answers and what the edges file publishes, not what
342 // this box's routing map dials. SnapshotFingerprint covers it
343 // automatically via routeCols.
344 Replicas string
345
346 // Replica marks the row as itself a replica — an edge-checkout shard of
347 // the account (#162), holding a one-way mirror of it, selling against a
348 // capacity lease from home. Written by routeFor at the mint from
349 // Record.Role and converged by SetReplica; never set by an operator
350 // verb. Read by the edges-file pass (the row's entries publish with the
351 // replica mark, so steering's home-vs-replica fold can tell them apart)
352 // and by the reconciler's removal-scope test — never by the edge's own
353 // dispatch, which serves the row exactly as any other and never
354 // hairpins it.
355 //
356 // Deliberately NOT in Fingerprint(), Steering's shadow again: a mark
357 // changes what the edges file publishes, not what this box dials.
358 Replica bool
359
360 CreatedAt int64
361}
362
363// AddrList resolves a route's upstream set for a multi-upstream-aware caller:
364// Addrs split on the comma when it is set, else Addr folded into a
365// one-element list for the legacy single-upstream shape, else nil when the
366// route names no TCP upstream at all (a socket-backed or static route). The
367// nil case matters to callers that branch on "has upstreams" — an empty
368// non-nil slice would read as "has zero upstreams", a different fact.
369func (t Route) AddrList() []string {
370 if t.Addrs != "" {
371 return strings.Split(t.Addrs, ",")
372 }
373 if t.Addr != "" {
374 return []string{t.Addr}
375 }
376 return nil
377}
378
379// IsSidecar reports whether t is a standalone worker (Kind "sidecar") — a
380// process that serves no requests and that nothing can dial. A method
381// rather than a bare `Kind == "sidecar"` literal at each call site because
382// most of those sites are security EXCLUSIONS (the edge's dispatch, a
383// fleet's advertised set): a typo in a literal does not fail loudly, it
384// routes to or serves a sidecar. A method is typo-proof by construction.
385func (t Route) IsSidecar() bool { return t.Kind == "sidecar" }
386
387// BackingUnit marks a route whose process is a carlos-app@<host> systemd unit
388// rather than an exec child of the agent. The empty string stays the exec
389// default so pre-existing rows keep today's behavior byte-identical.
390const BackingUnit = "unit"
391
392// tunnelNameRe: lowercase letters, digits and hyphens, 1-32 characters — the
393// same shape as appconfig.ValidEnvironmentName, for the same reason: this
394// name travels through a CLI argument an operator types by hand, and later
395// work in the remote-host-agent feature (issue #53) matches it against a
396// dialed-in tunnel identity and may build keys from it — a permissive shape
397// (path separators, spaces, "..") would let a bad value reach the column
398// verbatim with nothing downstream able to retroactively fix it.
399var tunnelNameRe = regexp.MustCompile(`^[a-z0-9-]{1,32}$`)
400
401// ValidTunnelName reports whether name is usable as a route's Tunnel
402// binding. "" is refused as a NAME here — it is how callers ask for "no
403// tunnel" (Route.Tunnel's zero value), never a tunnel to bind to; the CLI's
404// "-tunnel -" clear sentinel resolves to "" before this is ever called on
405// it, the same way -environment's envClear does for ValidEnvironmentName.
406// Lives here rather than in a CLI or edge package because Tunnel is this
407// package's column — same precedent as BackingUnit above it.
408func ValidTunnelName(name string) bool {
409 return tunnelNameRe.MatchString(name)
410}
411
412// unitNameRe: a systemd unit name that must END in .service — the only unit
413// type this column means anything for (a route's process). The character
414// class is systemd's own alphabet for the part before the suffix, including
415// the '@' of a template instance, so `console.service` and
416// `titogo-canary@demo.service` both pass and a typo does not.
417//
418// The FIRST character is narrower than the rest, and bounded like
419// ValidTunnelName's 32 (64 here, because unit names legitimately carry a
420// prefix and an instance): a leading '-' is how a name gets mistaken for a
421// flag by anything that ever builds an argv from this column, and a leading
422// '.' admits `..service`, `..`-shaped names and other things that read as
423// paths rather than units. Neither is a name anybody means to type, and both
424// are cheaper to refuse than to reason about later.
425//
426// Lives here rather than in the CLI for the same reason ValidTunnelName does:
427// `unit` is this package's column, and this is the shape SetUnit enforces on
428// every write, not merely the shape one door happens to check.
429var unitNameRe = regexp.MustCompile(`^[A-Za-z0-9@_][A-Za-z0-9@_.\-]{0,63}\.service$`)
430
431// ValidUnitName reports whether name is usable as a route's Unit binding.
432// Unlike ValidTunnelName, "" is VALID here: it is the column's default and
433// its only "no unit" value, so every caller that can clear a binding
434// (`carlos route -unit -`, SetUnit(host, "")) passes it through this same
435// gate rather than around it.
436//
437// The rail is about a typo, not an injection: these strings reach systemctl
438// as one argv element of an exec.Command, so there is no shell to escape.
439// What there IS, without the rail, is `console.servcie` written silently to
440// the column and discovered only as a restart that quietly did nothing —
441// exactly the failure the column exists to end.
442func ValidUnitName(name string) bool {
443 return name == "" || unitNameRe.MatchString(name)
444}
445
446// The replication ladder — the vocabulary of the routes.replication column,
447// and the ONE place it is written down. internal/activator maps these to
448// litestream sync intervals; `carlos route --replication` is the door that
449// sets one. They lived only in the activator, unexported, for as long as
450// nothing could set the column (discussion #10): three named rungs, one of
451// them reachable, and it was the most expensive one.
452const (
453 // ReplicationDefault is the unset column. It buys what ReplicationStream
454 // buys — change-driven ~1s sync — so a registry written before the ladder
455 // existed behaves exactly as it did. It is the pre-ladder default rather
456 // than a rung anyone chooses.
457 ReplicationDefault = ""
458 // ReplicationStream syncs on change, ~1s. Loss window: seconds.
459 ReplicationStream = "stream"
460 // ReplicationBatched syncs at most every 5m. Loss window: minutes.
461 ReplicationBatched = "batched"
462 // ReplicationDaily syncs at wake and every 24h. Loss window: up to a day.
463 ReplicationDaily = "daily"
464)
465
466// ReplicationClasses lists the rungs an operator may set, in order of
467// decreasing durability. ReplicationDefault is absent on purpose: it is what
468// a route has before anyone chooses, and the way back to it is the CLI's
469// clear sentinel, not a name to type.
470func ReplicationClasses() []string {
471 return []string{ReplicationStream, ReplicationBatched, ReplicationDaily}
472}
473
474// ValidReplication reports whether class is a value this column may hold.
475// "" is valid: it is the column's default and the state a cleared route
476// returns to, exactly as ValidUnitName accepts "".
477func ValidReplication(class string) bool {
478 switch class {
479 case ReplicationDefault, ReplicationStream, ReplicationBatched, ReplicationDaily:
480 return true
481 }
482 return false
483}
484
485// registryPragmas run on every connection the pool opens, not once on the
486// first. busy_timeout FIRST: two processes open this DB concurrently (the
487// edge starting while `carlos add` runs), and journal_mode=WAL takes a lock —
488// with no timeout set yet it fails immediately with SQLITE_BUSY. A claim
489// transaction discards its connection when ROLLBACK or the NORMAL restore
490// fails (6b-N1); the replacement must not run with SQLite's defaults
491// (busy_timeout 0, synchronous FULL).
492var registryPragmas = []string{
493 "PRAGMA busy_timeout=5000",
494 "PRAGMA journal_mode=WAL",
495 "PRAGMA synchronous=NORMAL",
496}
497
498func applyRegistryPragmas(conn sqlite.ExecQuerierContext, _ string) error {
499 for _, p := range registryPragmas {
500 if _, err := conn.ExecContext(context.Background(), p, nil); err != nil {
501 return err
502 }
503 }
504 return nil
505}
506
507// registryConnector opens every pooled connection through a driver whose
508// connection hook applies registryPragmas.
509type registryConnector struct {
510 d *sqlite.Driver
511 path string
512}
513
514func (c registryConnector) Connect(context.Context) (driver.Conn, error) { return c.d.Open(c.path) }
515func (c registryConnector) Driver() driver.Driver { return c.d }
516
517func Open(path string) (*Registry, error) {
518 drv := &sqlite.Driver{}
519 drv.RegisterConnectionHook(applyRegistryPragmas)
520 d := sql.OpenDB(registryConnector{d: drv, path: path})
521 // One writer at a time keeps WAL happy.
522 d.SetMaxOpenConns(1)
523 // Open the first connection now, so a pragma failure is Open's error as
524 // it was when the pragmas ran here.
525 if err := d.Ping(); err != nil {
526 d.Close()
527 return nil, err
528 }
529 r := &Registry{sql: d, path: path}
530 if err := r.migrate(); err != nil {
531 d.Close()
532 return nil, err
533 }
534 return r, nil
535}
536
537// Path returns the file the registry was opened from.
538func (r *Registry) Path() string { return r.path }
539
540// Close releases the long-lived handle. Long-running processes (edge, agent,
541// console) deliberately never call this — they hold one handle for the
542// process lifetime — but a test that rebuilds a registry file from scratch
543// must be able to let go of it first.
544func (r *Registry) Close() error {
545 if r.remote != nil {
546 return r.remote.close()
547 }
548 return r.sql.Close()
549}
550
551// migrate is additive (CREATE TABLE IF NOT EXISTS), so deploying new code
552// over an existing registry is always safe. New columns go in the ALTER list —
553// the "duplicate column" error is swallowed so the list stays idempotent.
554func (r *Registry) migrate() error {
555 if _, err := r.sql.Exec(`
556CREATE TABLE IF NOT EXISTS routes (
557 host TEXT PRIMARY KEY,
558 kind TEXT NOT NULL,
559 socket TEXT NOT NULL DEFAULT '',
560 status TEXT NOT NULL DEFAULT 'ready',
561 last_error TEXT NOT NULL DEFAULT '',
562 db_path TEXT NOT NULL DEFAULT '',
563 version TEXT NOT NULL DEFAULT '',
564 prev_version TEXT NOT NULL DEFAULT '',
565 created_at INTEGER NOT NULL
566);`); err != nil {
567 return err
568 }
569 for _, stmt := range []string{
570 `ALTER TABLE routes ADD COLUMN slug TEXT NOT NULL DEFAULT ''`,
571 `ALTER TABLE routes ADD COLUMN addr TEXT NOT NULL DEFAULT ''`,
572 `ALTER TABLE routes ADD COLUMN app TEXT NOT NULL DEFAULT ''`,
573 `ALTER TABLE routes ADD COLUMN pool TEXT NOT NULL DEFAULT ''`,
574 `ALTER TABLE routes ADD COLUMN region TEXT NOT NULL DEFAULT ''`,
575 `ALTER TABLE routes ADD COLUMN channel TEXT NOT NULL DEFAULT ''`,
576 `ALTER TABLE routes ADD COLUMN hibernate INTEGER NOT NULL DEFAULT 0`,
577 `ALTER TABLE routes ADD COLUMN canary INTEGER NOT NULL DEFAULT 0`,
578 `ALTER TABLE routes ADD COLUMN replication TEXT NOT NULL DEFAULT ''`,
579 `ALTER TABLE routes ADD COLUMN next_due INTEGER NOT NULL DEFAULT 0`,
580 `ALTER TABLE routes ADD COLUMN backing TEXT NOT NULL DEFAULT ''`,
581 `ALTER TABLE routes ADD COLUMN status_named INTEGER NOT NULL DEFAULT 0`,
582 `ALTER TABLE routes ADD COLUMN platform INTEGER NOT NULL DEFAULT 0`,
583 `ALTER TABLE routes ADD COLUMN dns_status TEXT NOT NULL DEFAULT ''`,
584 `ALTER TABLE routes ADD COLUMN dns_checked_at INTEGER NOT NULL DEFAULT 0`,
585 `ALTER TABLE routes ADD COLUMN cert_expires_at INTEGER NOT NULL DEFAULT 0`,
586 `ALTER TABLE routes ADD COLUMN account TEXT NOT NULL DEFAULT ''`,
587 `ALTER TABLE routes ADD COLUMN environment TEXT NOT NULL DEFAULT ''`,
588 `ALTER TABLE routes ADD COLUMN caps TEXT NOT NULL DEFAULT ''`,
589 `ALTER TABLE routes ADD COLUMN tunnel TEXT NOT NULL DEFAULT ''`,
590 `ALTER TABLE routes ADD COLUMN provisioned INTEGER NOT NULL DEFAULT 0`,
591 `ALTER TABLE routes ADD COLUMN seed TEXT NOT NULL DEFAULT ''`,
592 `ALTER TABLE routes ADD COLUMN unit TEXT NOT NULL DEFAULT ''`,
593 `ALTER TABLE routes ADD COLUMN fleet_box TEXT NOT NULL DEFAULT ''`,
594 `ALTER TABLE routes ADD COLUMN addrs TEXT NOT NULL DEFAULT ''`,
595 `ALTER TABLE routes ADD COLUMN health TEXT NOT NULL DEFAULT ''`,
596 `ALTER TABLE routes ADD COLUMN steering TEXT NOT NULL DEFAULT ''`,
597 `ALTER TABLE routes ADD COLUMN gate TEXT NOT NULL DEFAULT ''`,
598 `ALTER TABLE routes ADD COLUMN args TEXT NOT NULL DEFAULT ''`,
599 `ALTER TABLE routes ADD COLUMN idle_seconds INTEGER NOT NULL DEFAULT 0`,
600 `ALTER TABLE routes ADD COLUMN max_run_seconds INTEGER NOT NULL DEFAULT 0`,
601 `ALTER TABLE routes ADD COLUMN replicas TEXT NOT NULL DEFAULT ''`,
602 `ALTER TABLE routes ADD COLUMN replica INTEGER NOT NULL DEFAULT 0`,
603 `ALTER TABLE routes ADD COLUMN hold_op TEXT NOT NULL DEFAULT ''`,
604 `ALTER TABLE routes ADD COLUMN good_version TEXT NOT NULL DEFAULT ''`,
605 `ALTER TABLE routes ADD COLUMN good_digest TEXT NOT NULL DEFAULT ''`,
606 `ALTER TABLE routes ADD COLUMN good_demand TEXT NOT NULL DEFAULT ''`,
607 `ALTER TABLE routes ADD COLUMN serving_version TEXT NOT NULL DEFAULT ''`,
608 `ALTER TABLE routes ADD COLUMN update_settings TEXT NOT NULL DEFAULT ''`,
609 `ALTER TABLE routes ADD COLUMN build_state TEXT NOT NULL DEFAULT ''`,
610 `ALTER TABLE routes ADD COLUMN seed_source TEXT NOT NULL DEFAULT ''`,
611 `ALTER TABLE routes ADD COLUMN import_gate TEXT NOT NULL DEFAULT ''`,
612 } {
613 if _, err := r.sql.Exec(stmt); err != nil && !strings.Contains(err.Error(), "duplicate column") {
614 return err
615 }
616 }
617 // route_aliases is a derived table (a later reconcile pass owns every
618 // row, materializing domains.Claim records into it) — a plain CREATE
619 // TABLE IF NOT EXISTS, no legacy shape to carry forward.
620 if _, err := r.sql.Exec(`
621CREATE TABLE IF NOT EXISTS route_aliases (
622 alias TEXT PRIMARY KEY,
623 host TEXT NOT NULL,
624 account TEXT NOT NULL DEFAULT '',
625 app TEXT NOT NULL DEFAULT '',
626 dns_status TEXT NOT NULL DEFAULT '',
627 dns_checked_at INTEGER NOT NULL DEFAULT 0,
628 cert_expires_at INTEGER NOT NULL DEFAULT 0,
629 created_at INTEGER NOT NULL DEFAULT 0
630);`); err != nil {
631 return err
632 }
633 // wildcard_domains is a derived table (a later reconcile pass owns every
634 // row, materializing domains.Claim records whose Wildcard capability is
635 // set into it) — a plain CREATE TABLE IF NOT EXISTS, same shape as
636 // route_aliases above: additive, so existing DBs migrate on open.
637 if _, err := r.sql.Exec(`
638CREATE TABLE IF NOT EXISTS wildcard_domains (
639 host TEXT PRIMARY KEY,
640 account TEXT NOT NULL DEFAULT '',
641 app TEXT NOT NULL DEFAULT '',
642 created_at INTEGER NOT NULL DEFAULT 0
643);`); err != nil {
644 return err
645 }
646 // route_paths is a derived table (the domains reconcile pass owns every
647 // row, materializing a claim's Paths map into it) — a plain CREATE TABLE
648 // IF NOT EXISTS, the same additive shape as route_aliases and
649 // wildcard_domains above, so existing DBs migrate on open with zero rows
650 // and no behaviour change.
651 //
652 // The key is the PAIR: `routes` has host as its primary key and must
653 // stay one row per hostname (that row owns the cert, the home box, the
654 // DNS state and the aliases), so a hostname's prefixes live here instead.
655 // prefix is a BARE segment — "paulca", never "/paulca".
656 if _, err := r.sql.Exec(`
657CREATE TABLE IF NOT EXISTS route_paths (
658 host TEXT NOT NULL,
659 prefix TEXT NOT NULL,
660 target TEXT NOT NULL,
661 account TEXT NOT NULL DEFAULT '',
662 app TEXT NOT NULL DEFAULT '',
663 PRIMARY KEY (host, prefix)
664);`); err != nil {
665 return err
666 }
667 if _, err := r.sql.Exec(`CREATE TABLE IF NOT EXISTS timers (
668 host TEXT NOT NULL,
669 name TEXT NOT NULL,
670 kind TEXT NOT NULL,
671 spec TEXT NOT NULL,
672 path TEXT NOT NULL,
673 next_at INTEGER NOT NULL,
674 last_at INTEGER NOT NULL DEFAULT 0,
675 last_status TEXT NOT NULL DEFAULT '',
676 last_ms INTEGER NOT NULL DEFAULT 0,
677 attempts INTEGER NOT NULL DEFAULT 0,
678 created_at INTEGER NOT NULL,
679 PRIMARY KEY (host, name)
680);
681CREATE INDEX IF NOT EXISTS timers_due ON timers (next_at) WHERE next_at > 0;`); err != nil {
682 return err
683 }
684 // Additive, same rule as the routes list above: a registry created by a
685 // build before due_at existed gains the column on open, and re-opening
686 // swallows "duplicate column". Rows carry 0 until the runner next
687 // advances them; readers fall back to next_at (registry.Timer.DueAt).
688 if _, err := r.sql.Exec(`ALTER TABLE timers ADD COLUMN due_at INTEGER NOT NULL DEFAULT 0`); err != nil && !strings.Contains(err.Error(), "duplicate column") {
689 return err
690 }
691 if _, err := r.sql.Exec(`ALTER TABLE timers ADD COLUMN registration TEXT NOT NULL DEFAULT ''`); err != nil && !strings.Contains(err.Error(), "duplicate column") {
692 return err
693 }
694 if err := r.migrateImportJournal(); err != nil {
695 return err
696 }
697 if err := r.migrateFenceGenerations(); err != nil {
698 return err
699 }
700 if err := r.migrateAccounts(); err != nil {
701 return err
702 }
703 if err := r.migrateClaimOwnership(); err != nil {
704 return err
705 }
706 return r.migrateFleets()
707}
708
709// migrateAccounts brings the accounts table to its multi-account shape:
710//
711// id INTEGER PRIMARY KEY (supplied by the caller, NOT autoincrement)
712// sqid TEXT (the frozen encoder over that id — the account's public identity)
713// name TEXT (free text, deliberately NOT unique — see the spec: identity is
714// the sqid, so there is nothing to squat)
715//
716// The pre-multi-account shape was `(id INTEGER PRIMARY KEY AUTOINCREMENT,
717// name TEXT UNIQUE NOT NULL)`, and its rows are exactly the bug this change
718// fixes: ids assigned in bucket-listing order, so a rebuild re-homed every
719// platform domain. That table is also a pure MATERIALIZATION — every row
720// except the pinned ops row is re-derived from the bucket's own
721// accounts/<sqid>/account.json manifests on the next reconcile pass, and it
722// holds no truth of its own. So the legacy shape is dropped and rebuilt
723// rather than ALTERed: carrying the rows forward would preserve wrong ids,
724// and carrying the UNIQUE(name) constraint forward would refuse two accounts
725// that are both legitimately called "Blog". This is the one deliberate
726// exception to the additive-only migration rule, and it is safe precisely
727// because there is nothing here to lose.
728func (r *Registry) migrateAccounts() error {
729 legacy, err := r.accountsTableIsLegacy()
730 if err != nil {
731 return err
732 }
733 if legacy {
734 if _, err := r.sql.Exec(`DROP TABLE accounts`); err != nil {
735 return err
736 }
737 }
738 if _, err := r.sql.Exec(`
739CREATE TABLE IF NOT EXISTS accounts (
740 id INTEGER PRIMARY KEY,
741 sqid TEXT NOT NULL DEFAULT '',
742 name TEXT NOT NULL DEFAULT ''
743);`); err != nil {
744 return err
745 }
746 // PARTIAL unique index: real sqids are unique, but a row that predates the
747 // sqid column (sqid '') must not collide with another such row — an
748 // unmigrated registry has to open before it can be re-materialized.
749 if _, err := r.sql.Exec(
750 `CREATE UNIQUE INDEX IF NOT EXISTS accounts_sqid ON accounts (sqid) WHERE sqid != ''`); err != nil {
751 return err
752 }
753 // Account #1 is "ops" — the deployment operator's built-in account, created
754 // by migration with a PINNED id so its sqid ("bab") is identical on every
755 // deployment ever bootstrapped. INSERT OR IGNORE keeps re-runs (every Open
756 // migrates) idempotent, and never overwrites a rename.
757 _, err = r.sql.Exec(`INSERT OR IGNORE INTO accounts (id, sqid, name) VALUES (1, 'bab', 'ops')`)
758 return err
759}
760
761// accountsTableIsLegacy reports whether an accounts table exists WITHOUT the
762// sqid column — i.e. the pre-multi-account shape.
763func (r *Registry) accountsTableIsLegacy() (bool, error) {
764 rows, err := r.sql.Query(`PRAGMA table_info(accounts)`)
765 if err != nil {
766 return false, err
767 }
768 defer rows.Close()
769 exists, hasSqid := false, false
770 for rows.Next() {
771 var cid int
772 var name, typ string
773 var notNull int
774 var dflt sql.NullString
775 var pk int
776 if err := rows.Scan(&cid, &name, &typ, &notNull, &dflt, &pk); err != nil {
777 return false, err
778 }
779 exists = true
780 if name == "sqid" {
781 hasSqid = true
782 }
783 }
784 if err := rows.Err(); err != nil {
785 return false, err
786 }
787 return exists && !hasSqid, nil
788}
789
790// Account is one tenancy root: a console-minted id, its frozen sqid (the
791// public identity in every platform domain and every bucket key), and a
792// human name that is deliberately not unique.
793type Account struct {
794 ID int64
795 Sqid string
796 Name string
797}
798
799// PutAccount materializes an account the BUCKET already decided on —
800// idempotent upsert BY ID. The registry never assigns an account id: the id
801// arrives from accounts/<sqid>/account.json (or, for ops, from migration),
802// which is what makes account identity survive a registry rebuild and be
803// identical on every box in a deployment.
804func (r *Registry) PutAccount(id int64, sqid, name string) error {
805 if r.remote != nil {
806 _, err := r.remote.call(nil, remoteRequest{Method: "PutAccount", IdValue: id, Sqid: sqid, Name: name}, nil)
807 return err
808 }
809
810 _, err := r.sql.Exec(`INSERT INTO accounts (id, sqid, name) VALUES (?, ?, ?)
811ON CONFLICT(id) DO UPDATE SET sqid = excluded.sqid, name = excluded.name`, id, sqid, name)
812 return err
813}
814
815// DeleteAccount drops a retired account's row. Called only by the reconcile
816// pass, for a manifest the bucket has tombstoned — the bucket decides, this
817// follows. Idempotent: an account already gone is a success, because the
818// caller's goal is "not here", not "I removed it".
819//
820// Routes are deliberately untouched. An account cannot be deleted while it
821// holds apps, so there should be none; if some route still names this sqid,
822// silently unhooking it would take a live site down to tidy up a listing.
823// The ops account is pinned and never deletable. The id is duplicated here
824// rather than imported from domains.ReservedAccountID because the dependency
825// runs the other way — domains imports registry — and a constant is a cheaper
826// thing to keep in step than a cycle is to break.
827func (r *Registry) DeleteAccount(sqid string) error {
828 if r.remote != nil {
829 _, err := r.remote.call(nil, remoteRequest{Method: "DeleteAccount", Sqid: sqid}, nil)
830 return err
831 }
832
833 _, err := r.sql.Exec(`DELETE FROM accounts WHERE sqid = ? AND id != ?`, sqid, opsAccountID)
834 return err
835}
836
837const opsAccountID = 1
838
839// AccountBySqid resolves an account's public identity to its id and name, or
840// (0, "", nil) when absent — "no such account" is an answer, not an error.
841func (r *Registry) AccountBySqid(sqid string) (int64, string, error) {
842 if r.remote != nil {
843 out, err := r.remote.call(nil, remoteRequest{Method: "AccountBySqid", Sqid: sqid}, nil)
844 return out.Number64, out.Text, err
845 }
846
847 var id int64
848 var name string
849 err := r.sql.QueryRow(`SELECT id, name FROM accounts WHERE sqid = ?`, sqid).Scan(&id, &name)
850 if err == sql.ErrNoRows {
851 return 0, "", nil
852 }
853 if err != nil {
854 return 0, "", err
855 }
856 return id, name, nil
857}
858
859// Accounts returns every materialized account, ordered by id (so ops is
860// always first).
861func (r *Registry) Accounts() ([]Account, error) {
862 if r.remote != nil {
863 out, err := r.remote.call(nil, remoteRequest{Method: "Accounts"}, nil)
864 return out.Accounts, err
865 }
866
867 rows, err := r.sql.Query(`SELECT id, sqid, name FROM accounts ORDER BY id`)
868 if err != nil {
869 return nil, err
870 }
871 defer rows.Close()
872 var out []Account
873 for rows.Next() {
874 var a Account
875 if err := rows.Scan(&a.ID, &a.Sqid, &a.Name); err != nil {
876 return nil, err
877 }
878 out = append(out, a)
879 }
880 return out, rows.Err()
881}
882
883// routeCols is the column list every route read and every route insert
884// share: routeColCount names, in this exact order. scanRoute's destinations
885// and routeArgs's values count from here, and every insert (Upsert, the
886// import swap, coldhandover.go's restore) binds through routeArgs. Adding a
887// column means adding it in SIX places: the Route struct, the migration
888// list, routeCols with routeColCount and scanRoute, routeArgs, Upsert's CASE
889// arm, and remote_dispatch.go's remoteRequest and dispatch case for its
890// narrow setter. A miscount is a silent column shift rather than an error
891// (TestUpsertRoundTripsAllColumns is what catches one; an insert whose
892// values miss a column fails on column count).
893const routeCols = `host, slug, kind, socket, addr, status, last_error, db_path,
894version, prev_version, app, pool, region, channel, hibernate, canary,
895replication, next_due, backing, status_named, platform, dns_status,
896dns_checked_at, cert_expires_at, account, environment, caps, tunnel,
897provisioned, seed, unit, fleet_box, addrs, health, steering, gate, created_at,
898args, idle_seconds, max_run_seconds, replicas, replica, claim_incarnation, claim_stamp, claim_owner, claim_row_id, hold_op,
899good_version, good_digest, good_demand, serving_version, update_settings, build_state,
900seed_source, import_gate`
901
902// routeColCount is the number of names in routeCols.
903const routeColCount = 55
904
905// placeholders is n comma-joined bind parameters.
906func placeholders(n int) string {
907 return strings.TrimSuffix(strings.Repeat("?,", n), ",")
908}
909
910func scanRoute(scan func(...any) error) (Route, error) {
911 var t Route
912 var hib, can, sn, plat, prov, rep int
913 err := scan(&t.Host, &t.Slug, &t.Kind, &t.Socket, &t.Addr, &t.Status,
914 &t.LastError, &t.DBPath, &t.Version, &t.PrevVersion, &t.App, &t.Pool,
915 &t.Region, &t.Channel, &hib, &can, &t.Replication, &t.NextDue, &t.Backing,
916 &sn, &plat, &t.DNSStatus, &t.DNSCheckedAt, &t.CertExpiresAt, &t.Account,
917 &t.Environment, &t.Caps, &t.Tunnel, &prov, &t.Seed, &t.Unit, &t.FleetBox,
918 &t.Addrs, &t.Health, &t.Steering, &t.Gate, &t.CreatedAt,
919 &t.Args, &t.IdleSeconds, &t.MaxRunSeconds, &t.Replicas, &rep, &t.ClaimIncarnation, &t.ClaimStamp, &t.ClaimOwner, &t.ClaimRowID, &t.HoldOp,
920 &t.GoodVersion, &t.GoodDigest, &t.GoodDemand, &t.ServingVersion, &t.UpdateSettings, &t.BuildState,
921 &t.SeedSource, &t.ImportGate)
922 t.Hibernate = hib != 0
923 t.Canary = can != 0
924 t.StatusNamed = sn != 0
925 t.Platform = plat != 0
926 t.Provisioned = prov != 0
927 t.Replica = rep != 0
928 return t, err
929}
930
931// List returns every route, ordered by host, over a FRESH connection — not
932// the long-lived pool. modernc/sqlite's long-lived connections can serve a
933// stale WAL snapshot for commits made by OTHER processes; a Reload built on a
934// stale List sticks permanently because the fingerprint watcher, already
935// matching, never retriggers. (Observed live in titogo, 2026-07-25.) List is
936// reload-rare, so the ~1ms open cost is nothing.
937func (r *Registry) List() ([]Route, error) {
938 if r.remote != nil {
939 out, err := r.remote.call(nil, remoteRequest{Method: "List"}, nil)
940 return out.Routes, err
941 }
942
943 db, err := sql.Open("sqlite", r.path)
944 if err != nil {
945 return nil, err
946 }
947 defer db.Close()
948 // busy_timeout only: the DB is already WAL, and this connection only reads.
949 if _, err := db.Exec("PRAGMA busy_timeout=5000"); err != nil {
950 return nil, err
951 }
952 rows, err := db.Query(`SELECT ` + routeCols + ` FROM routes ORDER BY host`)
953 if err != nil {
954 return nil, err
955 }
956 defer rows.Close()
957 var out []Route
958 for rows.Next() {
959 t, err := scanRoute(rows.Scan)
960 if err != nil {
961 return nil, err
962 }
963 out = append(out, t)
964 }
965 return out, rows.Err()
966}
967
968// Get returns the route for host, or (nil, nil) if there is none.
969func (r *Registry) Get(host string) (*Route, error) {
970 if r.remote != nil {
971 out, err := r.remote.call(nil, remoteRequest{Method: "Get", Host: host}, nil)
972 return out.Route, err
973 }
974
975 row := r.sql.QueryRow(`SELECT `+routeCols+` FROM routes WHERE host = ?`, host)
976 t, err := scanRoute(row.Scan)
977 if err == sql.ErrNoRows {
978 return nil, nil
979 }
980 if err != nil {
981 return nil, err
982 }
983 return &t, nil
984}
985
986// Delete removes a route by host (data-safe: it stops routing the host but
987// never touches its DB).
988func (r *Registry) Delete(host string) error {
989 if r.remote != nil {
990 _, err := r.remote.call(nil, remoteRequest{Method: "Delete", Host: host}, nil)
991 return err
992 }
993
994 _, err := r.sql.Exec(`DELETE FROM routes WHERE host = ?`, host)
995 return err
996}
997
998// Upsert inserts or updates a route by host. On conflict it NEVER changes
999// hold_op, and a row that is held keeps its status too, whatever the incoming
1000// Route carries: only SetHold, ReleaseHold and DeleteIfHold change a hold
1001// (moves spec §5.2; a stale Upsert carrying a finished operation's HoldOp must
1002// not hand the row back to it). A fresh INSERT does write the incoming HoldOp,
1003// so a held row can be materialised.
1004// Twelve columns are deliberately
1005// not plain overwrites — `environment`, `caps`, `tunnel`, `provisioned`,
1006// `seed`, `unit`, `fleet_box`, `replication`, `gate`, `args`, `idle_seconds`
1007// and `max_run_seconds` all keep the existing row's value when the incoming
1008// Route carries the zero one.
1009//
1010// The reason is the same for all twelve, and it is structural: a full-row
1011// Upsert caller (`carlos add`, the domains reconciler's two mint paths, the
1012// provision pass's mint) builds its Route from what IT knows, and none of
1013// them knows about the columns another feature owns. `environment` was the
1014// first to be caught (2026-08-02 review, IMPORTANT 3): a plain
1015// excluded.environment silently unbound a route on any re-add or re-mint,
1016// and the next config sync rendered the default bundle over its env file and
1017// restarted its unit. `caps` is the same shape with a worse blast radius —
1018// a plain excluded.caps would silently REVOKE an operator's `port:25` the
1019// next time `carlos add` re-ran or the reconciler re-minted the row, hours
1020// later, when the converge pass next ran. `provisioned` and `seed` raise
1021// the stakes again: `carlos add` on a provisioned host would write
1022// provisioned=0, and the reconcile pass would then lose removal authority
1023// over that route FOREVER — deleting the bucket record could no longer
1024// retire it, and only a human noticing would. `fleet_box` is the same shape
1025// as `tunnel`: a placement pass stamps which box is CURRENTLY serving a
1026// route, and a full-row Upsert from provisioning (which does not know about
1027// fleet placement at all) must not un-stamp it mid-lifecycle.
1028//
1029// `unit` is the same shape as `tunnel` with the blast radius of `caps`: a
1030// re-`carlos add` on the console's row would drop the stamp that is the only
1031// thing making adoption cycle that process, and nothing would report it —
1032// the next promote would stamp X-Carlos-Version onto a console still serving
1033// the old build, which is issue #110 returning silently.
1034//
1035// `args`, `idle_seconds` and `max_run_seconds` are the same shape as `seed`
1036// and for the same reason (2026-08-17 fix round 1): they are materialized
1037// from a sidecar's manifest by the reconcile pass, and a full-row Upsert
1038// from a caller that knows nothing about sidecars (`carlos add`) must not
1039// blank a live argv or reset a worker's ceilings to the platform default.
1040// Omitting them from this clause entirely — the original shape of this
1041// change — was tried and rejected: omission is not preserve-if-empty, it
1042// silently ignores even a genuine NEW value, so a sidecar whose manifest
1043// argv changed would keep stale argv on the row until a narrow SetArgs
1044// happened to run.
1045//
1046// `replication` joined them when the ladder got a door (discussion #10) —
1047// the same CASE as `environment`, but NOT, as an earlier draft of this
1048// paragraph claimed, because the provisioning reconciler re-Upserts owned
1049// rows. It does not: its only reg.Upsert is the mint branch (reconcile.go,
1050// `case !ok`), and the owned branch converges through narrow setters
1051// (convergeOwned, SetHibernate, SetSeed), exactly as the addrs/health
1052// paragraph below already says. The domains reconciler's two Upserts are
1053// mint-only too, guarded by byHost/hasApex. A reader who checks the old
1054// claim finds it false and reasonably concludes the protection is
1055// unnecessary, so it is worth stating what is actually true.
1056//
1057// The caller that really does Upsert over an EXISTING row is `carlos add`
1058// (cli.go's runAdd — it prints "updated <host> (existing route)" when it
1059// does). It builds its Route literal from flags alone, and it has no
1060// replication flag: without this CASE, a re-add to restamp a unit or
1061// repoint an addr would return an operator's `daily` route to the
1062// every-second default, silently, at roughly 100x the S3 traffic the
1063// setting was chosen to avoid. That is the same gap `environment` was
1064// caught in and the same gap Addrs/Health/Steering are closed against at
1065// the CLI's own door below.
1066//
1067// Against the two mint-only reconcilers the CASE is defensive rather than
1068// load-bearing, and it stays that way deliberately: the rule for this
1069// column is then identical to the seven beside it, so the NEXT full-row
1070// Upsert caller — which will know nothing about replication either — is
1071// covered before it is written rather than after it is diagnosed.
1072//
1073// An explicit non-zero value still wins in every case; clearing keeps
1074// exactly one door per column, none of which goes through Upsert:
1075// SetEnvironment (`carlos route -environment -`), SetCaps (`carlos route
1076// -revoke`), SetTunnel (`carlos route -tunnel -`), SetUnit (`carlos route
1077// -unit -`), SetReplication (`carlos route -replication -`), SetSeed,
1078// SetFleetBox, SetArgs, SetWorkerLimits, SetUpdateSettings, SetBuildState, and for
1079// `provisioned` the row's own deletion.
1080//
1081// `replication` is the one column with a SECOND clearing door, and it is not
1082// an operator's: SetHibernate clears it when a route stops hibernating,
1083// because a rung is read only on the activator's wake path and describes
1084// nothing on a route that no longer sleeps. This statement's own
1085// `excluded.hibernate = 0` arm is that same rule reaching the full-row
1086// callers. See SetHibernate for the whole of it.
1087//
1088// `addrs` and `health` (multi-upstream routing, issue #152) are, by
1089// contrast, PLAIN overwrites — see their ON CONFLICT clause below — same as
1090// `addr`, `status` and the rest of the un-preserved majority. That is safe
1091// against the provision reconcile pass specifically: provision.routeFor
1092// (reconcile.go, func routeFor) now carries Record.Addrs/Health onto every
1093// route it mints, and the mint branch (reconcile.go, routeFor → reg.Upsert)
1094// is the only reconcile branch that calls Upsert at all — the owned-route
1095// branch (reconcile.go, the `existing.Provisioned && ...` case) converges
1096// through narrow setters (convergeOwned, SetHibernate, SetSeed), never a
1097// full-row Upsert, so a reconcile tick on an already-owned host cannot blank
1098// what SetUpstreams wrote. The one remaining plain-overwrite risk was
1099// `carlos add` re-run over an existing host: its Upsert builds a Route
1100// literal from flags alone, none of which name Addrs/Health, so re-adding a
1101// host with an established multi-upstream set would blank it, the same way a
1102// plain excluded.environment once silently unbound a route. Closed at the
1103// CLI's own door (cli.go's runAdd, Task 8): when a route already exists for
1104// the host, its Addrs/Health (and Addr, when the invocation named none of its
1105// own) are copied onto the literal before this call, the same
1106// preserve-what-you-don't-manage shape as the CASE columns above, just done
1107// by the caller instead of the ON CONFLICT clause.
1108//
1109// `steering` (multi-region ingress design Part 4) joins `addrs`/`health` as a
1110// PLAIN overwrite: provision.routeFor carries Record.Steering onto every
1111// route it mints, and the owned-route branch converges it through
1112// SetSteering rather than a full-row Upsert. `carlos add` carries no
1113// `-steering` flag, but it IS a caller that can reach this Upsert with an
1114// unrelated Route literal — the same gap Addrs/Health had — so it is closed
1115// the same way, at the CLI's own door: a re-add over an existing host copies
1116// existing.Steering onto the literal before this call, beside Addrs/Health.
1117// Without that copy, a bare re-add during the self-heal window after the
1118// steering housekeeper duty (Task 6) is armed would silently clear a route's
1119// Route53 latency records — a real incident this repo caught in review before
1120// it shipped, not a hypothetical.
1121// `update_settings` and `build_state` keep an existing value only while the
1122// row's tenant (effective account, app) stays the same. An Upsert that moves
1123// the hostname to another tenant takes the incoming values, empty included:
1124// one tenant's manual or major hold, or its build state, is never another
1125// tenant's (settings-5 task 8). The account compared is the effective one
1126// (EffectiveAccount, as release.RouteAccount reads it), so a legacy row's
1127// empty account re-added as the ops account is the same tenant and keeps
1128// both (Astra r4 finding 2). SQLite evaluates every SET expression against
1129// the row as it was, so `routes.account` and `routes.app` there are still
1130// the old tenant's.
1131// `good_version`, `good_digest`, `good_demand` and `serving_version`
1132// (zero-downtime spec §8.5) are absent from the ON CONFLICT clause on
1133// purpose: an existing row keeps them whatever the incoming Route carries.
1134// They have one writer each (SetGoodBuild, SetServingVersion), and no
1135// full-row caller knows what an instance last served.
1136// opsAccount is the ops account's sqid: account #1, pinned by migrate. It is
1137// release.OpsAccount (release cannot be imported here; a test in release
1138// pins the two together through EffectiveAccount).
1139const opsAccount = "bab"
1140
1141// EffectiveAccount is the account a route's stored account names: itself
1142// when it is a valid account sqid (controlname.ValidAccount), else the ops
1143// account. It is release.RouteAccount on the account alone, which root and
1144// the agent use for a route's tenant; Upsert compares tenants by it.
1145func EffectiveAccount(account string) string {
1146 if controlname.ValidAccount(account) {
1147 return account
1148 }
1149 return opsAccount
1150}
1151
1152// effectiveAccountSQL is EffectiveAccount as a SQLite expression over col.
1153// controlname.ValidAccount is ^[a-z]{3,16}$: three to sixteen characters,
1154// none outside a-z. SQLite's length and GLOB both stop at an embedded NUL,
1155// so the NUL is rejected explicitly (instr compares the whole value);
1156// without that "abc\x00x" would read as the valid "abc" while Go rejects it.
1157// TestEffectiveAccountSQLIsEffectiveAccount pins the two together.
1158func effectiveAccountSQL(col string) string {
1159 return `(CASE WHEN length(` + col + `) BETWEEN 3 AND 16 AND instr(` + col + `, char(0)) = 0 AND ` + col +
1160 ` NOT GLOB '*[^a-z]*' THEN ` + col +
1161 ` ELSE '` + opsAccount + `' END)`
1162}
1163
1164// tenantChangedSQL is true in Upsert's ON CONFLICT clause when the incoming
1165// row names another tenant than the stored one: another effective account
1166// or another app.
1167var tenantChangedSQL = effectiveAccountSQL("routes.account") + ` != ` + effectiveAccountSQL("excluded.account") +
1168 ` OR routes.app != excluded.app`
1169
1170func (r *Registry) Upsert(t Route) error {
1171 if r.remote != nil {
1172 _, err := r.remote.call(nil, remoteRequest{Method: "Upsert", TValue: t}, nil)
1173 return err
1174 }
1175
1176 res, err := r.sql.Exec(`
1177INSERT INTO routes (`+routeCols+`)
1178VALUES (`+placeholders(routeColCount)+`)
1179ON CONFLICT(host) DO UPDATE SET
1180 slug=excluded.slug, kind=excluded.kind, socket=excluded.socket,
1181 addr=excluded.addr,
1182 status=CASE WHEN routes.hold_op != '' THEN routes.status ELSE excluded.status END,
1183 last_error=excluded.last_error,
1184 db_path=excluded.db_path, version=excluded.version,
1185 prev_version=excluded.prev_version, app=excluded.app, pool=excluded.pool,
1186 region=excluded.region, channel=excluded.channel,
1187 hibernate=excluded.hibernate, canary=excluded.canary,
1188 next_due=excluded.next_due,
1189 backing=excluded.backing, status_named=excluded.status_named,
1190 platform=excluded.platform, dns_status=excluded.dns_status,
1191 dns_checked_at=excluded.dns_checked_at,
1192 cert_expires_at=excluded.cert_expires_at, account=excluded.account,
1193 environment=CASE WHEN excluded.environment = '' THEN routes.environment ELSE excluded.environment END,
1194 replication=CASE WHEN excluded.hibernate = 0 THEN '' WHEN excluded.replication = '' THEN routes.replication ELSE excluded.replication END,
1195 caps=CASE WHEN excluded.caps = '' THEN routes.caps ELSE excluded.caps END,
1196 tunnel=CASE WHEN excluded.tunnel = '' THEN routes.tunnel ELSE excluded.tunnel END,
1197 provisioned=CASE WHEN excluded.provisioned = 0 THEN routes.provisioned ELSE excluded.provisioned END,
1198 seed=CASE WHEN excluded.seed = '' THEN routes.seed ELSE excluded.seed END,
1199 unit=CASE WHEN excluded.unit = '' THEN routes.unit ELSE excluded.unit END,
1200 fleet_box=CASE WHEN excluded.fleet_box = '' THEN routes.fleet_box ELSE excluded.fleet_box END,
1201 addrs=excluded.addrs, health=excluded.health, steering=excluded.steering,
1202 replicas=excluded.replicas,
1203 gate=CASE WHEN excluded.gate = '' THEN routes.gate ELSE excluded.gate END,
1204 args=CASE WHEN excluded.args = '' THEN routes.args ELSE excluded.args END,
1205 idle_seconds=CASE WHEN excluded.idle_seconds = 0 THEN routes.idle_seconds ELSE excluded.idle_seconds END,
1206 max_run_seconds=CASE WHEN excluded.max_run_seconds = 0 THEN routes.max_run_seconds ELSE excluded.max_run_seconds END,
1207 update_settings=CASE WHEN `+tenantChangedSQL+` THEN excluded.update_settings
1208 WHEN excluded.update_settings = '' THEN routes.update_settings ELSE excluded.update_settings END,
1209 build_state=CASE WHEN `+tenantChangedSQL+` THEN excluded.build_state
1210 WHEN excluded.build_state = '' THEN routes.build_state ELSE excluded.build_state END,
1211 seed_source=CASE WHEN excluded.seed_source = '' THEN routes.seed_source ELSE excluded.seed_source END
1212WHERE routes.claim_incarnation=excluded.claim_incarnation AND routes.claim_stamp=excluded.claim_stamp
1213AND routes.claim_owner=excluded.claim_owner AND routes.claim_row_id=excluded.claim_row_id`,
1214 routeArgs(normalizeRoute(t))...)
1215 if err != nil {
1216 return err
1217 }
1218 n, err := res.RowsAffected()
1219 if err == nil && n != 1 {
1220 return ErrClaimOwnership
1221 }
1222 return err
1223}
1224
1225// normalizeRoute applies Upsert's defaults and invariants to a row about to
1226// be inserted. The import swap's new row goes through it too; a restored
1227// original does not (it goes back exactly as it was stored).
1228func normalizeRoute(t Route) Route {
1229 if t.CreatedAt == 0 {
1230 t.CreatedAt = time.Now().Unix()
1231 }
1232 if t.Status == "" {
1233 t.Status = "ready"
1234 }
1235 if t.Kind == "" {
1236 t.Kind = "instance"
1237 }
1238 // The replication invariant, INSERT half (the ON CONFLICT half is the
1239 // `excluded.hibernate = 0` arm in Upsert): a non-empty class on a row that
1240 // does not hibernate is a column that lies. Nothing reads it there —
1241 // the activator's wake path is its only reader, and box-wide
1242 // /etc/litestream.yml covers every non-hibernating database at its own
1243 // interval — so a stored `daily` would be reported by `carlos routes`
1244 // while the database was streamed every second. See SetHibernate.
1245 if !t.Hibernate {
1246 t.Replication = ""
1247 }
1248 // #162's replica invariant, its clean form: a replica SET on a row that
1249 // IS itself a replica is a column that lies — a replica of a replica is
1250 // not a shape any supported writer produces, and a replica row's
1251 // placement is its own Region. Dropped silently, like the replication
1252 // rung above.
1253 if t.Replica {
1254 t.Replicas = ""
1255 }
1256 return t
1257}
1258
1259// routeArgs is t's values in routeCols order, exactly as given: every route
1260// insert binds through it, so a column added later is added here once.
1261func routeArgs(t Route) []any {
1262 return []any{
1263 t.Host, t.Slug, t.Kind, t.Socket, t.Addr, t.Status, t.LastError,
1264 t.DBPath, t.Version, t.PrevVersion, t.App, t.Pool, t.Region, t.Channel,
1265 b2iDB(t.Hibernate), b2iDB(t.Canary), t.Replication, t.NextDue, t.Backing,
1266 b2iDB(t.StatusNamed), b2iDB(t.Platform), t.DNSStatus, t.DNSCheckedAt,
1267 t.CertExpiresAt, t.Account, t.Environment, t.Caps, t.Tunnel,
1268 b2iDB(t.Provisioned), t.Seed, t.Unit, t.FleetBox, t.Addrs, t.Health,
1269 t.Steering, t.Gate, t.CreatedAt, t.Args, t.IdleSeconds, t.MaxRunSeconds,
1270 t.Replicas, b2iDB(t.Replica), t.ClaimIncarnation, t.ClaimStamp, t.ClaimOwner, t.ClaimRowID, t.HoldOp,
1271 t.GoodVersion, t.GoodDigest, t.GoodDemand, t.ServingVersion, t.UpdateSettings, t.BuildState,
1272 t.SeedSource, t.ImportGate,
1273 }
1274}
1275
1276// SetSeedSource converges a row's seed column from its record.
1277func (r *Registry) SetSeedSource(host, v string) error {
1278 if r.remote != nil {
1279 return errRemoteMethod
1280 }
1281 res, err := r.sql.Exec(`UPDATE routes SET seed_source = ? WHERE host = ?`, v, host)
1282 if err != nil {
1283 return err
1284 }
1285 n, err := res.RowsAffected()
1286 if err != nil {
1287 return err
1288 }
1289 if n == 0 {
1290 return fmt.Errorf("no route for host %q", host)
1291 }
1292 return nil
1293}
1294
1295// HoldStatuses are the row statuses the edge never serves, ticks refuse and
1296// no ordinary wake reaches (moves spec §5.2).
1297var HoldStatuses = map[string]bool{"moving": true, "landing": true, "restoring": true, "releasing": true, "relocating": true}
1298
1299// IsHold reports whether status is one of HoldStatuses.
1300func IsHold(status string) bool { return HoldStatuses[status] }
1301
1302// SetHold writes a hold status with the operation holding it. It refuses a
1303// status that is not in HoldStatuses and an empty op, and errors when no row
1304// matched.
1305//
1306// Every SetHold caller also touches the stage-requested marker (moves Task
1307// 13's rollback hold rule relies on it); SetHold itself does not, because the
1308// marker lives outside this table.
1309func (r *Registry) SetHold(host, status, op string) error {
1310 if r.remote != nil {
1311 _, err := r.remote.call(nil, remoteRequest{Method: "SetHold", Host: host, Status: status, Op: op}, nil)
1312 return err
1313 }
1314
1315 if !IsHold(status) || op == "" {
1316 return fmt.Errorf("registry: %q with op %q is not a hold", status, op)
1317 }
1318 res, err := r.sql.Exec(`UPDATE routes SET status = ?, hold_op = ? WHERE host = ?`, status, op, host)
1319 if err != nil {
1320 return err
1321 }
1322 n, err := res.RowsAffected()
1323 if err != nil {
1324 return err
1325 }
1326 if n == 0 {
1327 return fmt.Errorf("no route for host %q", host)
1328 }
1329 return nil
1330}
1331
1332// SetHoldIf is SetHold conditioned on the row's current hold_op, in one
1333// statement: the row is held status under op only while its hold_op is
1334// still expectOp, which is "" (the caller saw the row unheld) or op itself
1335// (an op changing the status of its own hold). Any other expectOp is
1336// refused: a row held by another op leaves that hold only through
1337// ReleaseHold, under the op that holds it (§5.2), never by takeover. false
1338// with no error means nothing matched (the row's hold changed since the
1339// caller read it, or there is no row), so a hold decided from an earlier
1340// read never lands over what is there now (moves Task 6 fix round 1).
1341//
1342// As with SetHold, every caller touches the stage-requested marker after a
1343// write that landed.
1344func (r *Registry) SetHoldIf(host, status, op, expectOp string) (bool, error) {
1345 if r.remote != nil {
1346 out, err := r.remote.call(nil, remoteRequest{Method: "SetHoldIf", Host: host, Status: status, Op: op, ExpectOp: expectOp}, nil)
1347 return out.Changed, err
1348 }
1349
1350 if !IsHold(status) || op == "" {
1351 return false, fmt.Errorf("registry: %q with op %q is not a hold", status, op)
1352 }
1353 if expectOp != "" && expectOp != op {
1354 return false, fmt.Errorf("registry: hold %s by %q expecting %q: another op's hold is released by that op, not taken over", host, op, expectOp)
1355 }
1356 res, err := r.sql.Exec(`UPDATE routes SET status = ?, hold_op = ? WHERE host = ? AND hold_op = ?`, status, op, host, expectOp)
1357 if err != nil {
1358 return false, err
1359 }
1360 n, err := res.RowsAffected()
1361 return n > 0, err
1362}
1363
1364// HandHold hands a held row from one op to another in one statement: the
1365// row is held status under toOp only while its hold_op is still fromOp
1366// (moves Plan 4, ruling J3). It is the one takeover there is, between the
1367// two ops of one move: the move id hands its landing or moving hold to the
1368// rollback id. Releasing first and holding after would leave the row
1369// unheld between the two writes. An unheld row is never taken (SetHoldIf
1370// with expectOp "" is that), and a row held by any other op is left alone.
1371// false with no error means nothing matched: the row's hold changed since
1372// the caller read it (a replay finds it already handed over), or there is
1373// no row. The box never calls this directly: internal/host's handHold
1374// wraps it under WithHostHoldLock and touches the stage-requested marker
1375// after a hand-over that landed, which this package cannot reach.
1376func (r *Registry) HandHold(host, status, toOp, fromOp string) (bool, error) {
1377 if r.remote != nil {
1378 out, err := r.remote.call(nil, remoteRequest{Method: "HandHold", Host: host, Status: status, ToOp: toOp, FromOp: fromOp}, nil)
1379 return out.Changed, err
1380 }
1381
1382 if !IsHold(status) || toOp == "" || fromOp == "" || toOp == fromOp {
1383 return false, fmt.Errorf("registry: hand %s from %q to %q as %q is not a hand-over between two ops' holds", host, fromOp, toOp, status)
1384 }
1385 res, err := r.sql.Exec(`UPDATE routes SET status = ?, hold_op = ? WHERE host = ? AND hold_op = ?`, status, toOp, host, fromOp)
1386 if err != nil {
1387 return false, err
1388 }
1389 n, err := res.RowsAffected()
1390 return n > 0, err
1391}
1392
1393// ReleaseHold is §5.2's one conditional statement: a row leaves a hold only
1394// under the op that holds it. false means nothing matched (a late write from a
1395// finished operation, an unheld row, or an empty op).
1396func (r *Registry) ReleaseHold(host, op string) (bool, error) {
1397 if r.remote != nil {
1398 out, err := r.remote.call(nil, remoteRequest{Method: "ReleaseHold", Host: host, Op: op}, nil)
1399 return out.Changed, err
1400 }
1401
1402 if op == "" {
1403 return false, nil
1404 }
1405 res, err := r.sql.Exec(`UPDATE routes SET status = 'ready', hold_op = '' WHERE host = ? AND hold_op = ?`, host, op)
1406 if err != nil {
1407 return false, err
1408 }
1409 n, err := res.RowsAffected()
1410 return n > 0, err
1411}
1412
1413// DeleteIfHold deletes host's row only while op holds it: the source's row
1414// deletion at finalization (moves spec §7.9 step 3). A row held by another
1415// op, or not held, is not deleted and the answer is false.
1416func (r *Registry) DeleteIfHold(host, op string) (bool, error) {
1417 if r.remote != nil {
1418 out, err := r.remote.call(nil, remoteRequest{Method: "DeleteIfHold", Host: host, Op: op}, nil)
1419 return out.Changed, err
1420 }
1421
1422 if op == "" {
1423 return false, nil
1424 }
1425 res, err := r.sql.Exec(`DELETE FROM routes WHERE host = ? AND hold_op = ?`, host, op)
1426 if err != nil {
1427 return false, err
1428 }
1429 n, err := res.RowsAffected()
1430 return n > 0, err
1431}
1432
1433// DeleteIfHoldWithTimers is the source's finalization (moves spec §7.9 step
1434// 3, ruling H5(d)): host's row and every timer row of host are deleted in one
1435// transaction, and only while op holds the row. false means nothing matched
1436// and nothing was deleted, timers included, so a timers clear never runs
1437// ahead of the row it belongs to or lands on a successor's rows.
1438func (r *Registry) DeleteIfHoldWithTimers(host, op string) (bool, error) {
1439 if r.remote != nil {
1440 out, err := r.remote.call(nil, remoteRequest{Method: "DeleteIfHoldWithTimers", Host: host, Op: op}, nil)
1441 return out.Changed, err
1442 }
1443
1444 if op == "" {
1445 return false, nil
1446 }
1447 tx, err := r.sql.Begin()
1448 if err != nil {
1449 return false, err
1450 }
1451 defer tx.Rollback()
1452 res, err := tx.Exec(`DELETE FROM routes WHERE host = ? AND hold_op = ?`, host, op)
1453 if err != nil {
1454 return false, err
1455 }
1456 if n, err := res.RowsAffected(); err != nil || n == 0 {
1457 return false, err
1458 }
1459 if _, err := tx.Exec(`DELETE FROM timers WHERE host = ?`, host); err != nil {
1460 return false, err
1461 }
1462 if err := tx.Commit(); err != nil {
1463 return false, err
1464 }
1465 return true, nil
1466}
1467
1468// HoldRows returns every row held by an operation (a non-empty hold_op), ordered by
1469// host. Over a fresh connection, like List, so a reader in another process
1470// sees the latest commit.
1471func (r *Registry) HoldRows() ([]Route, error) {
1472 if r.remote != nil {
1473 out, err := r.remote.call(nil, remoteRequest{Method: "HoldRows"}, nil)
1474 return out.Routes, err
1475 }
1476
1477 db, err := sql.Open("sqlite", r.path)
1478 if err != nil {
1479 return nil, err
1480 }
1481 defer db.Close()
1482 if _, err := db.Exec("PRAGMA busy_timeout=5000"); err != nil {
1483 return nil, err
1484 }
1485 rows, err := db.Query(`SELECT ` + routeCols + ` FROM routes WHERE hold_op != '' ORDER BY host`)
1486 if err != nil {
1487 return nil, err
1488 }
1489 defer rows.Close()
1490 var out []Route
1491 for rows.Next() {
1492 t, err := scanRoute(rows.Scan)
1493 if err != nil {
1494 return nil, err
1495 }
1496 out = append(out, t)
1497 }
1498 return out, rows.Err()
1499}
1500
1501// WithHoldsFrozen runs fn with the rows an operation holds, while no other
1502// connection can write the registry: it takes SQLite's write lock first
1503// (BEGIN IMMEDIATE), so a SetHold or ReleaseHold elsewhere waits on its
1504// busy_timeout until fn returns, and the rows fn was given are still the
1505// held rows when it does. It is root's activation guard (moves Task 13): the
1506// check that no row is held and the step that puts an older executable in
1507// place happen with the answer held still. fn must be quick (a rename);
1508// writers give up after busy_timeout. Nothing is written: the transaction is
1509// rolled back. fn's error is returned.
1510func (r *Registry) WithHoldsFrozen(fn func(held []Route) error) error {
1511 if r.remote != nil {
1512 _, err := r.remote.call(nil, remoteRequest{Method: "WithHoldsFrozen"}, func(out remoteResponse) error { return fn(out.Held) })
1513 return err
1514 }
1515
1516 ctx := context.Background()
1517 conn, err := r.sql.Conn(ctx)
1518 if err != nil {
1519 return err
1520 }
1521 defer conn.Close()
1522 if _, err := conn.ExecContext(ctx, "BEGIN IMMEDIATE"); err != nil {
1523 return err
1524 }
1525 defer conn.ExecContext(ctx, "ROLLBACK")
1526 rows, err := conn.QueryContext(ctx, `SELECT `+routeCols+` FROM routes WHERE hold_op != '' ORDER BY host`)
1527 if err != nil {
1528 return err
1529 }
1530 var held []Route
1531 for rows.Next() {
1532 t, err := scanRoute(rows.Scan)
1533 if err != nil {
1534 rows.Close()
1535 return err
1536 }
1537 held = append(held, t)
1538 }
1539 if err := rows.Err(); err != nil {
1540 rows.Close()
1541 return err
1542 }
1543 rows.Close()
1544 return fn(held)
1545}
1546
1547// SetAccount stamps a route's app→account linkage as a narrow UPDATE — the
1548// reconcile pass re-stamps this every tick from the bucket manifests, and a
1549// read-modify-write Upsert racing a deploy's SetVersions (or the activator's
1550// SetNextDue) would clobber those columns.
1551func (r *Registry) SetAccount(host, account string) error {
1552 if r.remote != nil {
1553 _, err := r.remote.call(nil, remoteRequest{Method: "SetAccount", Host: host, Account: account}, nil)
1554 return err
1555 }
1556
1557 _, err := r.sql.Exec(`UPDATE routes SET account = ? WHERE host = ?`, account, host)
1558 return err
1559}
1560
1561// SetChannel repoints a route's release-channel subscription as a narrow
1562// UPDATE (same rationale as SetAccount: a read-modify-write Upsert racing a
1563// deploy's SetVersions or the activator's SetNextDue would clobber those
1564// columns). Unlike SetAccount it errors when no row matched, so `carlos
1565// route` can report an unknown host rather than silently doing nothing — this
1566// is the one setter a human types a hostname into directly.
1567func (r *Registry) SetChannel(host, channel string) error {
1568 if r.remote != nil {
1569 _, err := r.remote.call(nil, remoteRequest{Method: "SetChannel", Host: host, Channel: channel}, nil)
1570 return err
1571 }
1572
1573 res, err := r.sql.Exec(`UPDATE routes SET channel = ? WHERE host = ?`, channel, host)
1574 if err != nil {
1575 return err
1576 }
1577 n, err := res.RowsAffected()
1578 if err != nil {
1579 return err
1580 }
1581 if n == 0 {
1582 return fmt.Errorf("no route for host %q", host)
1583 }
1584 return nil
1585}
1586
1587// SetStatus writes a route's lifecycle status word. Same contract as
1588// SetChannel: a narrow UPDATE that cannot clobber concurrent column writes,
1589// and an error when no row matched. The residence-move cut (internal/host's
1590// move worker) is its first caller — status "moving" takes a route out of
1591// the edge's served set and the activator's managed set in one write, which
1592// is what quiesces the child ahead of the final mirror round.
1593func (r *Registry) SetStatus(host, status string) error {
1594 if r.remote != nil {
1595 _, err := r.remote.call(nil, remoteRequest{Method: "SetStatus", Host: host, Status: status}, nil)
1596 return err
1597 }
1598
1599 res, err := r.sql.Exec(`UPDATE routes SET status = ? WHERE host = ?`, status, host)
1600 if err != nil {
1601 return err
1602 }
1603 n, err := res.RowsAffected()
1604 if err != nil {
1605 return err
1606 }
1607 if n == 0 {
1608 return fmt.Errorf("no route for host %q", host)
1609 }
1610 return nil
1611}
1612
1613// SetCaps writes a route's operator-granted capability tokens — the ONLY
1614// writer of that column, and deliberately the only one: `carlos route
1615// -grant/-revoke` is the operator's door to a transfer of box authority, and
1616// a second writer (a full-row Upsert, a reconcile pass) would be a way for a
1617// grant to appear or disappear without anyone typing it. Callers pass the
1618// canonical value caps.Grant/caps.Revoke returned; this setter validates
1619// nothing, exactly as SetChannel validates no channel name — the rails live
1620// at the door.
1621//
1622// Same contract and same rationale as SetChannel: a narrow UPDATE (so it
1623// cannot clobber a concurrent SetVersions or SetNextDue), and an error when
1624// no row matched, because a human types a hostname into this one directly
1625// and a silent no-op on a typo would be worse than a refusal.
1626//
1627// The write is a compare-and-swap on the column's prior value, because the
1628// caller computed `to` FROM `from`: two operators racing this door (or one
1629// racing a slow shell) would otherwise merge stale reads — B's grant built on
1630// a read taken before A's revoke would silently re-assert the very token A
1631// just removed, on the column that hands out :25 and the box's IAM role. A
1632// lost race is a refusal that names the current value; the operator re-runs
1633// against reality.
1634func (r *Registry) SetCaps(host, from, to string) error {
1635 if r.remote != nil {
1636 _, err := r.remote.call(nil, remoteRequest{Method: "SetCaps", Host: host, From: from, To: to}, nil)
1637 return err
1638 }
1639
1640 res, err := r.sql.Exec(`UPDATE routes SET caps = ? WHERE host = ? AND caps = ?`, to, host, from)
1641 if err != nil {
1642 return err
1643 }
1644 n, err := res.RowsAffected()
1645 if err != nil {
1646 return err
1647 }
1648 if n == 0 {
1649 var cur string
1650 serr := r.sql.QueryRow(`SELECT caps FROM routes WHERE host = ?`, host).Scan(&cur)
1651 if serr == sql.ErrNoRows {
1652 return fmt.Errorf("no route for host %q", host)
1653 }
1654 if serr != nil {
1655 return serr
1656 }
1657 return fmt.Errorf("grants for %q changed underneath this command (now %q) — re-run it", host, capsOrNone(cur))
1658 }
1659 return nil
1660}
1661
1662// capsOrNone renders a caps column value for an error message — "none"
1663// rather than "" so a lost race against a revoke reads as an answer.
1664func capsOrNone(caps string) string {
1665 if caps == "" {
1666 return "none"
1667 }
1668 return caps
1669}
1670
1671// SetDBPath stamps a route's database path narrowly. The converge pass uses
1672// it to teach ops.FleetDBs (the box-wide litestream unit's config generator)
1673// where a `data.replicate: platform` app's databases actually live, instead
1674// of depending on an operator having remembered `-db` at `carlos add` — the
1675// manifest names the database FILES, the platform knows the state directory,
1676// and this is where the two meet.
1677//
1678// Errors on an unmatched host, like SetChannel and SetCaps: the caller is a
1679// pass that has just read the row, so "the row is gone" is a race worth
1680// reporting rather than swallowing.
1681//
1682// Compare-and-swap on the column's prior value, the same shape and the same
1683// reason as SetCaps: the caller decided to write `to` BECAUSE it read `from`
1684// — the whole stamp rule is "only when it differs, and only over a value this
1685// pass recognises". An hourly root pass racing an operator's `carlos add -db`
1686// (or a second box's pass against a shared registry) would otherwise overwrite
1687// a path nobody has looked at, and the column decides which file the box-wide
1688// replicator streams. A lost race is a refusal naming the current value, and
1689// the next pass re-decides against reality.
1690func (r *Registry) SetDBPath(host, from, to string) error {
1691 if r.remote != nil {
1692 _, err := r.remote.call(nil, remoteRequest{Method: "SetDBPath", Host: host, From: from, To: to}, nil)
1693 return err
1694 }
1695
1696 res, err := r.sql.Exec(`UPDATE routes SET db_path = ? WHERE host = ? AND db_path = ?`, to, host, from)
1697 if err != nil {
1698 return err
1699 }
1700 n, err := res.RowsAffected()
1701 if err != nil {
1702 return err
1703 }
1704 if n == 0 {
1705 var cur string
1706 serr := r.sql.QueryRow(`SELECT db_path FROM routes WHERE host = ?`, host).Scan(&cur)
1707 if serr == sql.ErrNoRows {
1708 return fmt.Errorf("no route for host %q", host)
1709 }
1710 if serr != nil {
1711 return serr
1712 }
1713 return fmt.Errorf("db_path for %q changed underneath this pass (now %q) — leaving it alone; the next pass decides against the new value", host, cur)
1714 }
1715 return nil
1716}
1717
1718// ErrPathsChanged is SetInstancePaths's answer when the row's two path
1719// columns no longer hold the values the caller compared against.
1720var ErrPathsChanged = errors.New("registry: instance paths changed underneath this pass")
1721
1722// SetInstancePaths moves a row between the flat and the nested layout
1723// (isolated-exec design §8.2 step 8): one UPDATE guarded by BOTH columns'
1724// current values, so a row that was migrated, reverted or re-added between
1725// the pass's read and its write is left exactly as it is, and the caller
1726// learns what it holds now. It is SetDBPath's sibling; the two columns are
1727// swapped together because a row with one flat path and one nested path is
1728// neither layout, and every consumer would refuse it.
1729func (r *Registry) SetInstancePaths(host, fromDB, fromSock, toDB, toSock string) error {
1730 if r.remote != nil {
1731 _, err := r.remote.call(nil, remoteRequest{Method: "SetInstancePaths", Host: host, FromDB: fromDB, FromSock: fromSock, ToDB: toDB, ToSock: toSock}, nil)
1732 return err
1733 }
1734
1735 res, err := r.sql.Exec(`UPDATE routes SET db_path = ?, socket = ? WHERE host = ? AND db_path = ? AND socket = ?`, toDB, toSock, host, fromDB, fromSock)
1736 if err != nil {
1737 return err
1738 }
1739 n, err := res.RowsAffected()
1740 if err != nil {
1741 return err
1742 }
1743 if n == 0 {
1744 var curDB, curSock string
1745 serr := r.sql.QueryRow(`SELECT db_path, socket FROM routes WHERE host = ?`, host).Scan(&curDB, &curSock)
1746 if serr == sql.ErrNoRows {
1747 return fmt.Errorf("no route for host %q", host)
1748 }
1749 if serr != nil {
1750 return serr
1751 }
1752 return fmt.Errorf("%w: %s now holds db %q socket %q", ErrPathsChanged, host, curDB, curSock)
1753 }
1754 return nil
1755}
1756
1757// SetBacking stamps who owns a route's process: "" (an exec child of the
1758// agent) or BackingUnit (a carlos-app@<host> systemd unit). It is the door
1759// the app-capabilities migration (design §5b, §5c) needs and did not have:
1760// backing could be chosen at `carlos add` and never changed afterwards, so
1761// the reversible cutover — stop the hand-provisioned unit, copy the data,
1762// stamp the row, let the adopt pass converge it — had no way to stamp, and
1763// the reverse move no way to undo.
1764//
1765// Narrow UPDATE with an error on an unmatched host, like SetChannel and
1766// SetCaps: a human types the hostname into this one directly, and a silent
1767// no-op on a typo would be worse than a refusal. It validates nothing — the
1768// value rail and the row-ownership rails live at the door in cli.go, exactly
1769// as they do for the caps column.
1770//
1771// Deliberately NOT a compare-and-swap. Backing is not a set an operator
1772// merges into (SetCaps's case) and not a value an hourly pass rewrites
1773// (SetDBPath's): it is one operator, at one step of a documented migration,
1774// naming the value they want.
1775func (r *Registry) SetBacking(host, backing string) error {
1776 if r.remote != nil {
1777 _, err := r.remote.call(nil, remoteRequest{Method: "SetBacking", Host: host, Backing: backing}, nil)
1778 return err
1779 }
1780
1781 res, err := r.sql.Exec(`UPDATE routes SET backing = ? WHERE host = ?`, backing, host)
1782 if err != nil {
1783 return err
1784 }
1785 n, err := res.RowsAffected()
1786 if err != nil {
1787 return err
1788 }
1789 if n == 0 {
1790 return fmt.Errorf("no route for host %q", host)
1791 }
1792 return nil
1793}
1794
1795// SetUnit stamps the systemd unit that owns a route's process — the door
1796// issue #110 needed and did not have: a console is an addr-backed route under
1797// an operator-run `console.service`, which the adopt pass's restart dispatch
1798// could reach by neither of its two arms (Bounce owns no such process, and
1799// there is no carlos-app@<host> unit to try-restart), so promote → adopt
1800// stamped a new Version behind a process still running the old build.
1801//
1802// Narrow UPDATE with an error on an unmatched host, the SetBacking contract
1803// and for the same reason: a human types the hostname into this one directly
1804// (`carlos route -unit console.service` against a live registry is how the
1805// production consoles get theirs), and a silent no-op on a typo would be
1806// worse than a refusal.
1807//
1808// Deliberately NOT a compare-and-swap, again following SetBacking: unit is
1809// not a set an operator merges into (SetCaps's case) and not a value a pass
1810// rewrites hourly (SetDBPath's) — it is one operator naming the unit that
1811// owns one process.
1812//
1813// It DOES validate, which SetBacking does not, and the difference is the
1814// point: backing has two legal values and the doors compare against both, so
1815// a bad value cannot survive the flag. A unit name is free text an operator
1816// types once, on the row whose whole purpose is to be handed to systemctl
1817// months later — a typo would be discovered as a restart that silently did
1818// nothing, i.e. as the very bug this column closes. "" is accepted, because
1819// clearing the binding is a legitimate write (see ValidUnitName).
1820func (r *Registry) SetUnit(host, unit string) error {
1821 if r.remote != nil {
1822 _, err := r.remote.call(nil, remoteRequest{Method: "SetUnit", Host: host, Unit: unit}, nil)
1823 return err
1824 }
1825
1826 if !ValidUnitName(unit) {
1827 return fmt.Errorf("unit %q: want a systemd service unit name (e.g. console.service) or \"\" to clear", unit)
1828 }
1829 res, err := r.sql.Exec(`UPDATE routes SET unit = ? WHERE host = ?`, unit, host)
1830 if err != nil {
1831 return err
1832 }
1833 n, err := res.RowsAffected()
1834 if err != nil {
1835 return err
1836 }
1837 if n == 0 {
1838 return fmt.Errorf("no route for host %q", host)
1839 }
1840 return nil
1841}
1842
1843// SetReplication puts a route on a rung of the replication ladder, as a
1844// narrow UPDATE for the reason SetChannel and SetUnit are: a
1845// read-modify-write Upsert racing a deploy's SetVersions or the activator's
1846// SetNextDue would clobber those columns.
1847//
1848// It validates, like SetUnit and unlike SetBacking: the value reaches
1849// internal/activator's replicationSyncInterval, which answers an unknown
1850// class with litestream's default — so `--replication dayly` would be
1851// accepted, written, and discovered as a route that quietly never left the
1852// most expensive rung. That is the failure the column's whole existence is
1853// meant to end.
1854//
1855// "" is a legal value: it is the pre-ladder default a cleared route returns
1856// to. It reaches here only from `carlos route --replication -`, never from
1857// a full-row Upsert, which preserves the column instead.
1858func (r *Registry) SetReplication(host, class string) error {
1859 if r.remote != nil {
1860 _, err := r.remote.call(nil, remoteRequest{Method: "SetReplication", Host: host, Class: class}, nil)
1861 return err
1862 }
1863
1864 if !ValidReplication(class) {
1865 return fmt.Errorf("replication %q: want one of %s, or \"\" for the default", class, strings.Join(ReplicationClasses(), ", "))
1866 }
1867 res, err := r.sql.Exec(`UPDATE routes SET replication = ? WHERE host = ?`, class, host)
1868 if err != nil {
1869 return err
1870 }
1871 n, err := res.RowsAffected()
1872 if err != nil {
1873 return err
1874 }
1875 if n == 0 {
1876 return fmt.Errorf("no route for host %q", host)
1877 }
1878 return nil
1879}
1880
1881// SetUpstreams repoints a route's multi-upstream set as a narrow UPDATE —
1882// same rationale as SetChannel/SetUnit: a read-modify-write Upsert racing a
1883// deploy's SetVersions or the activator's SetNextDue would clobber those
1884// columns, and this is how the provision reconcile pass converges them
1885// independently of everything else a full-row Upsert would touch. Unlike
1886// environment/caps/tunnel/provisioned/seed/unit/fleet_box, addrs and health
1887// are PLAIN overwrites in Upsert (registry.go:684), not preserve-when-empty
1888// — but provision.routeFor now carries Record.Addrs/Health onto every route
1889// it mints, and the reconcile pass's owned-route branch never calls Upsert
1890// (it converges through narrow setters, this one included), so a reconcile
1891// tick cannot blank what a prior SetUpstreams call wrote. The one other
1892// plain-overwrite path, `carlos add` re-run over an existing host, is closed
1893// at the CLI's own door (cli.go's runAdd, Task 8) rather than here: that verb
1894// now copies an existing route's Addrs/Health onto the literal it builds
1895// before calling Upsert, so a re-add cannot blank what this setter wrote.
1896//
1897// All THREE upstream columns move together, and that is the point: addr is
1898// the legacy single-upstream spelling of the same fact addrs carries, and
1899// AddrList folds addr in whenever addrs is empty. A setter that wrote addrs
1900// and left addr standing would leave the row saying two different things —
1901// the drift log firing every pass forever on a row nothing can converge, and,
1902// the moment the addrs set is cleared again, AddrList quietly folding the
1903// STALE addr back in and sending live traffic to a decommissioned upstream
1904// (found in review, 2026-08-12). Callers hand over the whole upstream shape:
1905// addr is the single legacy upstream ("" = none), addrs the already
1906// comma-joined set ("" = none), health the probe-config JSON verbatim ("" =
1907// unprobed). None is validated here — the rails live at the provision pass's
1908// own door (ValidateShape), which runs before a record ever reaches these
1909// columns, exactly as SetCaps's callers validate before calling it.
1910//
1911// Errors on an unmatched host, the SetChannel/SetUnit contract: a silent
1912// no-op on a host that does not exist would hide a reconcile pass writing to
1913// the wrong row.
1914func (r *Registry) SetUpstreams(host, addr, addrs, health string) error {
1915 if r.remote != nil {
1916 _, err := r.remote.call(nil, remoteRequest{Method: "SetUpstreams", Host: host, Addr: addr, Addrs: addrs, Health: health}, nil)
1917 return err
1918 }
1919
1920 res, err := r.sql.Exec(`UPDATE routes SET addr = ?, addrs = ?, health = ? WHERE host = ?`,
1921 addr, addrs, health, host)
1922 if err != nil {
1923 return err
1924 }
1925 n, err := res.RowsAffected()
1926 if err != nil {
1927 return err
1928 }
1929 if n == 0 {
1930 return fmt.Errorf("no route for host %q", host)
1931 }
1932 return nil
1933}
1934
1935// SetSteering repoints a route's DNS steering opt-in ("" or "latency") as a
1936// narrow UPDATE — same rationale as SetUpstreams: a read-modify-write Upsert
1937// racing a deploy's SetVersions or the activator's SetNextDue would clobber
1938// other columns, and this is how the provision reconcile pass converges
1939// steering independently of everything else a full-row Upsert would touch.
1940// Unlike SetUpstreams this column is single — steering is one fact, not
1941// three columns describing one — so there is nothing here for a second
1942// argument to move together with.
1943//
1944// Value validation is NOT this door's job: the rail is
1945// provision.ValidSteering, run before a value ever reaches a record, the
1946// same discipline SetUpstreams follows for its own columns (the rails live
1947// at the provision pass's door, per SetCaps's rule).
1948//
1949// Errors on an unmatched host, the SetChannel/SetUpstreams contract: a
1950// reconcile pass that has just read the row must notice it vanishing
1951// underneath, not silently do nothing.
1952func (r *Registry) SetSteering(host, steering string) error {
1953 if r.remote != nil {
1954 _, err := r.remote.call(nil, remoteRequest{Method: "SetSteering", Host: host, Steering: steering}, nil)
1955 return err
1956 }
1957
1958 res, err := r.sql.Exec(`UPDATE routes SET steering = ? WHERE host = ?`, steering, host)
1959 if err != nil {
1960 return err
1961 }
1962 n, err := res.RowsAffected()
1963 if err != nil {
1964 return err
1965 }
1966 if n == 0 {
1967 return fmt.Errorf("no route for host %q", host)
1968 }
1969 return nil
1970}
1971
1972// SetUpdateSettings repoints a route's effective update settings as a narrow
1973// UPDATE: reconcile converges it from the record every pass, in both
1974// directions, including down to "". Errors on an unmatched host, like
1975// SetSteering.
1976func (r *Registry) SetUpdateSettings(host, col string) error {
1977 if r.remote != nil {
1978 _, err := r.remote.call(nil, remoteRequest{Method: "SetUpdateSettings", Host: host, UpdateSettings: col}, nil)
1979 return err
1980 }
1981
1982 res, err := r.sql.Exec(`UPDATE routes SET update_settings = ? WHERE host = ?`, col, host)
1983 if err != nil {
1984 return err
1985 }
1986 n, err := res.RowsAffected()
1987 if err != nil {
1988 return err
1989 }
1990 if n == 0 {
1991 return fmt.Errorf("no route for host %q", host)
1992 }
1993 return nil
1994}
1995
1996// SetBuildState stores the agent's copy of a route's build-state object as a
1997// narrow UPDATE: reconcile and the wake path refresh it, in both directions,
1998// including down to "". Errors on an unmatched host, like SetUpdateSettings.
1999func (r *Registry) SetBuildState(host, col string) error {
2000 if r.remote != nil {
2001 _, err := r.remote.call(nil, remoteRequest{Method: "SetBuildState", Host: host, BuildState: col}, nil)
2002 return err
2003 }
2004
2005 res, err := r.sql.Exec(`UPDATE routes SET build_state = ? WHERE host = ?`, col, host)
2006 if err != nil {
2007 return err
2008 }
2009 n, err := res.RowsAffected()
2010 if err != nil {
2011 return err
2012 }
2013 if n == 0 {
2014 return fmt.Errorf("no route for host %q", host)
2015 }
2016 return nil
2017}
2018
2019// SetReplicas repoints a route's replica placement set (#162) as a narrow
2020// UPDATE: the provision pass converges this column every tick from the
2021// record, and the record's value is authoritative in BOTH directions —
2022// including down to "". That asymmetry with the CASE-preserved columns
2023// above is the whole point: a set that shrank (a replica deleted in a
2024// region the account left) must actually shrink on the row, or the edges
2025// file goes on publishing a replica placement the account no longer has —
2026// a residence lie, not a cosmetic one. Errors on an unmatched host,
2027// SetChannel's contract: the reconciler must notice the row vanishing
2028// underneath rather than silently do nothing.
2029func (r *Registry) SetReplicas(host, replicas string) error {
2030 if r.remote != nil {
2031 _, err := r.remote.call(nil, remoteRequest{Method: "SetReplicas", Host: host, Replicas: replicas}, nil)
2032 return err
2033 }
2034
2035 res, err := r.sql.Exec(`UPDATE routes SET replicas = ? WHERE host = ?`, replicas, host)
2036 if err != nil {
2037 return err
2038 }
2039 n, err := res.RowsAffected()
2040 if err != nil {
2041 return err
2042 }
2043 if n == 0 {
2044 return fmt.Errorf("no route for host %q", host)
2045 }
2046 return nil
2047}
2048
2049// SetReplica stamps or clears a route's replica mark (#162) as a narrow
2050// UPDATE — the provision pass converges it from Record.Role. The mark is
2051// never an operator verb (a role flip is a data-plane event; delete and
2052// re-create is the supported transition, Record.Role's doc), so unlike
2053// SetChannel there is no human-typing-hostnames caller to keep the
2054// error-on-unmatched contract for — but it is kept anyway, SetChannel's
2055// contract verbatim, because a reconcile pass that has just read the row
2056// must notice it vanishing underneath, not silently do nothing.
2057func (r *Registry) SetReplica(host string, replica bool) error {
2058 if r.remote != nil {
2059 _, err := r.remote.call(nil, remoteRequest{Method: "SetReplica", Host: host, Replica: replica}, nil)
2060 return err
2061 }
2062
2063 res, err := r.sql.Exec(`UPDATE routes SET replica = ? WHERE host = ?`, b2iDB(replica), host)
2064 if err != nil {
2065 return err
2066 }
2067 n, err := res.RowsAffected()
2068 if err != nil {
2069 return err
2070 }
2071 if n == 0 {
2072 return fmt.Errorf("no route for host %q", host)
2073 }
2074 return nil
2075}
2076
2077// b2iDB is Upsert's b2i for the narrow setters, which sit outside that
2078// function's closure.
2079func b2iDB(b bool) int {
2080 if b {
2081 return 1
2082 }
2083 return 0
2084}
2085
2086// SetGate stamps or clears a route's password gate — the narrow UPDATE the
2087// provision converge uses (never a full-row Upsert, which preserves a set
2088// gate by design and so could not clear one). Errors on an unmatched host,
2089// SetBacking's contract.
2090func (r *Registry) SetGate(host, hash string) error {
2091 if r.remote != nil {
2092 _, err := r.remote.call(nil, remoteRequest{Method: "SetGate", Host: host, Hash: hash}, nil)
2093 return err
2094 }
2095
2096 res, err := r.sql.Exec(`UPDATE routes SET gate = ? WHERE host = ?`, hash, host)
2097 if err != nil {
2098 return err
2099 }
2100 n, err := res.RowsAffected()
2101 if err != nil {
2102 return err
2103 }
2104 if n == 0 {
2105 return fmt.Errorf("no route for host %q", host)
2106 }
2107 return nil
2108}
2109
2110// SetChannels repoints every host in hosts to channel as ONE transaction —
2111// every row moves or none do. `carlos route -app` uses this for its bulk
2112// form: the caller runs every route's safety-rail check to completion FIRST
2113// (see cli.go's runRoute), and only once every route has cleared it does it
2114// call this to write. Without that, a check-then-write-per-route loop can
2115// leave an app split across two channels the moment one route mid-batch
2116// fails the rail (or the process dies mid-loop) — an app that is meant to
2117// move as one unit (Tito's eighteen routes off canary/platform-adoption)
2118// must never be left half-moved. Same "error on an unmatched host" contract
2119// as SetChannel, but any single failure rolls the whole batch back.
2120func (r *Registry) SetChannels(hosts []string, channel string) error {
2121 if r.remote != nil {
2122 _, err := r.remote.call(nil, remoteRequest{Method: "SetChannels", Hosts: hosts, Channel: channel}, nil)
2123 return err
2124 }
2125
2126 if len(hosts) == 0 {
2127 return nil
2128 }
2129 tx, err := r.sql.Begin()
2130 if err != nil {
2131 return err
2132 }
2133 stmt, err := tx.Prepare(`UPDATE routes SET channel = ? WHERE host = ?`)
2134 if err != nil {
2135 tx.Rollback()
2136 return err
2137 }
2138 for _, h := range hosts {
2139 res, err := stmt.Exec(channel, h)
2140 if err != nil {
2141 stmt.Close()
2142 tx.Rollback()
2143 return err
2144 }
2145 n, err := res.RowsAffected()
2146 if err != nil {
2147 stmt.Close()
2148 tx.Rollback()
2149 return err
2150 }
2151 if n == 0 {
2152 stmt.Close()
2153 tx.Rollback()
2154 return fmt.Errorf("no route for host %q", h)
2155 }
2156 }
2157 stmt.Close()
2158 return tx.Commit()
2159}
2160
2161// SetEnvironment repoints a route's named-environment binding as a narrow
2162// UPDATE — same rationale and same "error on an unmatched host" contract as
2163// SetChannel (a human types a hostname into `carlos route -environment`
2164// directly, and a silent no-op on a typo would be worse than a refusal).
2165// env == "" clears the binding back to the app's default bundle alone; the
2166// CLI's "-environment -" sentinel resolves to that before calling here, so
2167// this function itself has no notion of "clear" beyond writing "".
2168func (r *Registry) SetEnvironment(host, env string) error {
2169 if r.remote != nil {
2170 _, err := r.remote.call(nil, remoteRequest{Method: "SetEnvironment", Host: host, Env: env}, nil)
2171 return err
2172 }
2173
2174 res, err := r.sql.Exec(`UPDATE routes SET environment = ? WHERE host = ?`, env, host)
2175 if err != nil {
2176 return err
2177 }
2178 n, err := res.RowsAffected()
2179 if err != nil {
2180 return err
2181 }
2182 if n == 0 {
2183 return fmt.Errorf("no route for host %q", host)
2184 }
2185 return nil
2186}
2187
2188// SetTunnel repoints a route's reverse-channel binding as a narrow UPDATE —
2189// same rationale and same "error on an unmatched host" contract as
2190// SetEnvironment (a human types a hostname into `carlos route -tunnel`
2191// directly, and a silent no-op on a typo would be worse than a refusal).
2192// tunnel == "" clears the binding back to a Socket/Addr-routed upstream; the
2193// CLI's "-tunnel -" sentinel resolves to that before calling here, so this
2194// function itself has no notion of "clear" beyond writing "".
2195func (r *Registry) SetTunnel(host, tunnel string) error {
2196 if r.remote != nil {
2197 _, err := r.remote.call(nil, remoteRequest{Method: "SetTunnel", Host: host, Tunnel: tunnel}, nil)
2198 return err
2199 }
2200
2201 res, err := r.sql.Exec(`UPDATE routes SET tunnel = ? WHERE host = ?`, tunnel, host)
2202 if err != nil {
2203 return err
2204 }
2205 n, err := res.RowsAffected()
2206 if err != nil {
2207 return err
2208 }
2209 if n == 0 {
2210 return fmt.Errorf("no route for host %q", host)
2211 }
2212 return nil
2213}
2214
2215// SetFleetBox stamps the label of the fleet box CURRENTLY serving this
2216// route as a narrow UPDATE — same rationale and same "error on an unmatched
2217// host" contract as SetTunnel. label == "" clears the binding; this is a
2218// placement pass's door, distinct from SetPlacement (which writes the
2219// placement TARGET, not the box currently fulfilling it).
2220func (r *Registry) SetFleetBox(host, label string) error {
2221 if r.remote != nil {
2222 _, err := r.remote.call(nil, remoteRequest{Method: "SetFleetBox", Host: host, Label: label}, nil)
2223 return err
2224 }
2225
2226 res, err := r.sql.Exec(`UPDATE routes SET fleet_box = ? WHERE host = ?`, label, host)
2227 if err != nil {
2228 return err
2229 }
2230 n, err := res.RowsAffected()
2231 if err != nil {
2232 return err
2233 }
2234 if n == 0 {
2235 return fmt.Errorf("no route for host %q", host)
2236 }
2237 return nil
2238}
2239
2240// SetPlacement repoints a route's placement TARGET — stored in the Region
2241// column, which already carries a route's residency constraint and, as of
2242// this task, doubles as where a fleet-bound route wants to run (see
2243// FleetPlacement/PlacementTarget, fleets.go, for the grammar that
2244// distinguishes the two). target == "" clears the binding. Any other value
2245// must parse as "fleet/<name>" via FleetPlacement — a bare region code like
2246// "eu" is a FUTURE placement form this v1 does not accept here (readers use
2247// FleetPlacement to tell the two apart; it simply answers ("", false) for a
2248// bare code, same as for anything else this setter refuses).
2249//
2250// Same "error on an unmatched host" contract as SetTunnel/SetFleetBox: a
2251// human or a placement pass typing a hostname here should get a refusal on a
2252// typo, not a silent no-op.
2253func (r *Registry) SetPlacement(host, target string) error {
2254 if r.remote != nil {
2255 _, err := r.remote.call(nil, remoteRequest{Method: "SetPlacement", Host: host, Target: target}, nil)
2256 return err
2257 }
2258
2259 if target != "" {
2260 if _, ok := FleetPlacement(target); !ok {
2261 return fmt.Errorf("invalid placement target %q (want \"\" or \"fleet/<name>\")", target)
2262 }
2263 }
2264 res, err := r.sql.Exec(`UPDATE routes SET region = ? WHERE host = ?`, target, host)
2265 if err != nil {
2266 return err
2267 }
2268 n, err := res.RowsAffected()
2269 if err != nil {
2270 return err
2271 }
2272 if n == 0 {
2273 return fmt.Errorf("no route for host %q", host)
2274 }
2275 return nil
2276}
2277
2278// SetEnvironments is SetEnvironment's bulk sibling, mirroring SetChannels:
2279// one transaction across every host in hosts, so `carlos route -app
2280// -environment` can never leave an app split across two environments the
2281// moment one route mid-batch fails — the caller (cli.go's runRoute) runs
2282// every route's existence-rail check to completion FIRST, and only once
2283// every route has cleared it does it call this to write.
2284func (r *Registry) SetEnvironments(hosts []string, env string) error {
2285 if r.remote != nil {
2286 _, err := r.remote.call(nil, remoteRequest{Method: "SetEnvironments", Hosts: hosts, Env: env}, nil)
2287 return err
2288 }
2289
2290 if len(hosts) == 0 {
2291 return nil
2292 }
2293 tx, err := r.sql.Begin()
2294 if err != nil {
2295 return err
2296 }
2297 stmt, err := tx.Prepare(`UPDATE routes SET environment = ? WHERE host = ?`)
2298 if err != nil {
2299 tx.Rollback()
2300 return err
2301 }
2302 for _, h := range hosts {
2303 res, err := stmt.Exec(env, h)
2304 if err != nil {
2305 stmt.Close()
2306 tx.Rollback()
2307 return err
2308 }
2309 n, err := res.RowsAffected()
2310 if err != nil {
2311 stmt.Close()
2312 tx.Rollback()
2313 return err
2314 }
2315 if n == 0 {
2316 stmt.Close()
2317 tx.Rollback()
2318 return fmt.Errorf("no route for host %q", h)
2319 }
2320 }
2321 stmt.Close()
2322 return tx.Commit()
2323}
2324
2325// SetChannelsAndEnvironments is SetChannels and SetEnvironments' combined
2326// sibling for `carlos route -channel X -environment Y`: BOTH columns, for
2327// every host in hosts, in ONE transaction — so a combined move can never
2328// commit the channel repoint and then fail the environment repoint (or vice
2329// versa), leaving a route split between the two writes the caller asked for
2330// as a single atomic change. Same all-or-nothing contract as its two
2331// single-column siblings; the caller (runRoute) has already run both rails'
2332// checks to completion before this is called.
2333func (r *Registry) SetChannelsAndEnvironments(hosts []string, channel, env string) error {
2334 if r.remote != nil {
2335 _, err := r.remote.call(nil, remoteRequest{Method: "SetChannelsAndEnvironments", Hosts: hosts, Channel: channel, Env: env}, nil)
2336 return err
2337 }
2338
2339 if len(hosts) == 0 {
2340 return nil
2341 }
2342 tx, err := r.sql.Begin()
2343 if err != nil {
2344 return err
2345 }
2346 stmt, err := tx.Prepare(`UPDATE routes SET channel = ?, environment = ? WHERE host = ?`)
2347 if err != nil {
2348 tx.Rollback()
2349 return err
2350 }
2351 for _, h := range hosts {
2352 res, err := stmt.Exec(channel, env, h)
2353 if err != nil {
2354 stmt.Close()
2355 tx.Rollback()
2356 return err
2357 }
2358 n, err := res.RowsAffected()
2359 if err != nil {
2360 stmt.Close()
2361 tx.Rollback()
2362 return err
2363 }
2364 if n == 0 {
2365 stmt.Close()
2366 tx.Rollback()
2367 return fmt.Errorf("no route for host %q", h)
2368 }
2369 }
2370 stmt.Close()
2371 return tx.Commit()
2372}
2373
2374// SetDNSStatus stamps a route's DNS health verdict + check time, narrowly
2375// (same rationale as SetAccount; this lands every few minutes on every host).
2376func (r *Registry) SetDNSStatus(host, status string, checkedAt int64) error {
2377 if r.remote != nil {
2378 _, err := r.remote.call(nil, remoteRequest{Method: "SetDNSStatus", Host: host, Status: status, CheckedAt: checkedAt}, nil)
2379 return err
2380 }
2381
2382 _, err := r.sql.Exec(`UPDATE routes SET dns_status = ?, dns_checked_at = ? WHERE host = ?`, status, checkedAt, host)
2383 return err
2384}
2385
2386// SetCertExpiresAt stamps a route's cached-cert NotAfter, narrowly. 0 =
2387// unknown (no cert in the cache, or one we couldn't parse).
2388func (r *Registry) SetCertExpiresAt(host string, at int64) error {
2389 if r.remote != nil {
2390 _, err := r.remote.call(nil, remoteRequest{Method: "SetCertExpiresAt", Host: host, At: at}, nil)
2391 return err
2392 }
2393
2394 _, err := r.sql.Exec(`UPDATE routes SET cert_expires_at = ? WHERE host = ?`, at, host)
2395 return err
2396}
2397
2398// SetVersions records a host's current + previous version as a narrow UPDATE,
2399// so a slow deploy's read-modify-write can never clobber concurrent writes to
2400// other columns.
2401func (r *Registry) SetVersions(host, ver, prev string) error {
2402 if r.remote != nil {
2403 _, err := r.remote.call(nil, remoteRequest{Method: "SetVersions", Host: host, Ver: ver, Prev: prev}, nil)
2404 return err
2405 }
2406
2407 _, err := r.sql.Exec(`UPDATE routes SET version = ?, prev_version = ? WHERE host = ?`, ver, prev, host)
2408 return err
2409}
2410
2411// GoodBuild is the good-build triple of zero-downtime spec §8.5.
2412type GoodBuild struct{ Version, Digest, Demand string }
2413
2414// Good returns the route's good-build triple.
2415func (t Route) Good() GoodBuild {
2416 return GoodBuild{Version: t.GoodVersion, Digest: t.GoodDigest, Demand: t.GoodDemand}
2417}
2418
2419// ErrGoodBuildChanged is SetGoodBuild's answer when the row no longer holds
2420// the triple the caller expected to replace.
2421var ErrGoodBuildChanged = errors.New("registry: good build changed underneath this write")
2422
2423// SetGoodBuild replaces host's good build with next, but only if the row
2424// still holds expect (spec §8.5: "written with an expected-current
2425// condition"). Two writers that each saw a different generation become
2426// current cannot both win; the loser learns what the row holds now.
2427func (r *Registry) SetGoodBuild(host string, expect, next GoodBuild) error {
2428 if r.remote != nil {
2429 _, err := r.remote.call(nil, remoteRequest{Method: "SetGoodBuild", Host: host, ExpectGood: expect, NextGood: next}, nil)
2430 return err
2431 }
2432
2433 res, err := r.sql.Exec(`UPDATE routes SET good_version = ?, good_digest = ?, good_demand = ?
2434WHERE host = ? AND good_version = ? AND good_digest = ? AND good_demand = ?`,
2435 next.Version, next.Digest, next.Demand, host, expect.Version, expect.Digest, expect.Demand)
2436 if err != nil {
2437 return err
2438 }
2439 n, err := res.RowsAffected()
2440 if err != nil {
2441 return err
2442 }
2443 if n == 1 {
2444 return nil
2445 }
2446 var cur GoodBuild
2447 serr := r.sql.QueryRow(`SELECT good_version, good_digest, good_demand FROM routes WHERE host = ?`, host).
2448 Scan(&cur.Version, &cur.Digest, &cur.Demand)
2449 if serr == sql.ErrNoRows {
2450 return fmt.Errorf("no route for host %q", host)
2451 }
2452 if serr != nil {
2453 return serr
2454 }
2455 return fmt.Errorf("%w: %s now holds %+v", ErrGoodBuildChanged, host, cur)
2456}
2457
2458// SetServingVersion records the build of the generation that just became
2459// current (spec §3), as a narrow UPDATE.
2460func (r *Registry) SetServingVersion(host, ver string) error {
2461 if r.remote != nil {
2462 _, err := r.remote.call(nil, remoteRequest{Method: "SetServingVersion", Host: host, Ver: ver}, nil)
2463 return err
2464 }
2465
2466 _, err := r.sql.Exec(`UPDATE routes SET serving_version = ? WHERE host = ?`, ver, host)
2467 return err
2468}
2469
2470// SetHibernate flips the per-host hibernation opt-in as a narrow UPDATE.
2471// Turning hibernation OFF also clears `replication`, and that is the point
2472// of doing it here rather than in each caller. The replication class is read
2473// in exactly one place — the activator's wake path — and box-wide
2474// /etc/litestream.yml deliberately covers every NON-hibernating database at
2475// its own interval (internal/ops/litestream.go, ops.FleetDBsWithSkipped).
2476// So the moment a route stops hibernating, a stored `daily` describes
2477// nothing that happens: `carlos routes` would keep printing it while
2478// litestream streamed the database every second, which is the lying column
2479// the `carlos route --replication` rail (cli.go's replicationRail) exists to
2480// prevent, merely relocated to a later moment.
2481//
2482// Both doors that turn hibernation off come through here — `carlos route
2483// --hibernate=false` (cli.go's runRouteHibernate) and the provisioning
2484// converge, which flips the column from the instance record on every pass —
2485// and any door added later will too. The third writer, a full-row Upsert
2486// carrying hibernate=0 (`carlos add` with no --hibernate), is covered by the
2487// matching arm of that statement's ON CONFLICT clause. Between them the
2488// invariant is total: a non-empty `replication` implies a hibernating row.
2489//
2490// Clearing rather than preserving-for-later is deliberate. A preserved class
2491// is invisible state that changes behaviour weeks after it was typed, and
2492// the operator turning hibernation back on is already at a prompt where
2493// naming the rung again costs one command; runRouteHibernate prints what was
2494// dropped so it is a fact they are told, not one they discover.
2495func (r *Registry) SetHibernate(host string, on bool) error {
2496 if r.remote != nil {
2497 _, err := r.remote.call(nil, remoteRequest{Method: "SetHibernate", Host: host, On: on}, nil)
2498 return err
2499 }
2500
2501 if !on {
2502 _, err := r.sql.Exec(`UPDATE routes SET hibernate = 0, replication = '' WHERE host = ?`, host)
2503 return err
2504 }
2505 _, err := r.sql.Exec(`UPDATE routes SET hibernate = 1 WHERE host = ?`, host)
2506 return err
2507}
2508
2509// SetNextDue records the earliest server-side scheduled work for a host, so
2510// the activator can wake a sleeping instance for its own schedule. 0 = none.
2511func (r *Registry) SetNextDue(host string, due int64) error {
2512 if r.remote != nil {
2513 _, err := r.remote.call(nil, remoteRequest{Method: "SetNextDue", Host: host, Due: due}, nil)
2514 return err
2515 }
2516
2517 _, err := r.sql.Exec(`UPDATE routes SET next_due = ? WHERE host = ?`, due, host)
2518 return err
2519}
2520
2521// ClearNextDueIf CONSUMES a fired due time: it clears routes.next_due for
2522// host, but only while the row still holds due — the exact value that fired.
2523//
2524// A fired timer is one-shot, and this is what makes it so. The instance path
2525// gets that for free: an instance re-records next_due from its own probe on
2526// every stop, so a passed due time is overwritten the moment the host sleeps
2527// again. A WORKER's stop deliberately writes nothing (it has no socket to
2528// probe, and a failed probe records 0 — erasing the schedule that is a
2529// sleeping sidecar's only way back up), so without this the row keeps its
2530// passed value and DueInstances returns it on EVERY sweep: wake, restore,
2531// idle, park, re-wake, forever, with nothing in the logs that reads as a
2532// failure.
2533//
2534// Conditional, not a bare clear, because the child that this wake just
2535// spawned may already have posted its NEXT due time over the agent socket
2536// (SetWorkerNextDue) before the wake resolved. Clearing unconditionally would
2537// erase a schedule that was set after the one being consumed; the WHERE
2538// next_due = ? is what keeps consumption from eating it. A row that moved
2539// under us is simply left alone.
2540func (r *Registry) ClearNextDueIf(host string, due int64) error {
2541 if r.remote != nil {
2542 _, err := r.remote.call(nil, remoteRequest{Method: "ClearNextDueIf", Host: host, Due: due}, nil)
2543 return err
2544 }
2545
2546 _, err := r.sql.Exec(`UPDATE routes SET next_due = 0 WHERE host = ? AND next_due = ?`, host, due)
2547 return err
2548}
2549
2550// AlwaysOnWorkers returns every non-hibernating sidecar route in a runnable
2551// status — design §7.2's "the record exists ⇒ the process runs", asked as a
2552// query so the activator's sweep can start one that appeared AFTER the
2553// agent's boot pass ran (the boot pass runs once, at arming, and never
2554// again).
2555//
2556// Status is filtered exactly as the due-time sweep and the boot pass filter
2557// it (host's workersToStart): only "ready", or the legacy "" some pre-status
2558// rows carry — a row still provisioning, or draining, must not be spawned.
2559func (r *Registry) AlwaysOnWorkers() ([]Route, error) {
2560 if r.remote != nil {
2561 out, err := r.remote.call(nil, remoteRequest{Method: "AlwaysOnWorkers"}, nil)
2562 return out.Routes, err
2563 }
2564
2565 rows, err := r.sql.Query(`SELECT ` + routeCols + ` FROM routes
2566WHERE kind = 'sidecar' AND hibernate = 0 AND (status = '' OR status = 'ready') ORDER BY host`)
2567 if err != nil {
2568 return nil, err
2569 }
2570 defer rows.Close()
2571 var out []Route
2572 for rows.Next() {
2573 t, err := scanRoute(rows.Scan)
2574 if err != nil {
2575 return nil, err
2576 }
2577 out = append(out, t)
2578 }
2579 return out, rows.Err()
2580}
2581
2582// SetSeed stamps a route's first-boot init argv as a narrow UPDATE — same
2583// rationale as SetAccount (a read-modify-write Upsert racing a deploy's
2584// SetVersions or the activator's SetNextDue would clobber it). Unlike
2585// SetAccount it errors when no row matched (the SetChannel contract): the
2586// instance-provisioning reconcile pass re-stamps this every tick and must
2587// notice a row that vanished under it rather than silently doing nothing.
2588func (r *Registry) SetSeed(host, seed string) error {
2589 if r.remote != nil {
2590 _, err := r.remote.call(nil, remoteRequest{Method: "SetSeed", Host: host, Seed: seed}, nil)
2591 return err
2592 }
2593
2594 res, err := r.sql.Exec(`UPDATE routes SET seed = ? WHERE host = ?`, seed, host)
2595 if err != nil {
2596 return err
2597 }
2598 n, err := res.RowsAffected()
2599 if err != nil {
2600 return err
2601 }
2602 if n == 0 {
2603 return fmt.Errorf("no route for host %q", host)
2604 }
2605 return nil
2606}
2607
2608// SetArgs repoints a sidecar's argv — the narrow UPDATE the sidecar
2609// reconcile pass uses when the app's manifest changed.
2610func (r *Registry) SetArgs(host, args string) error {
2611 if r.remote != nil {
2612 _, err := r.remote.call(nil, remoteRequest{Method: "SetArgs", Host: host, Args: args}, nil)
2613 return err
2614 }
2615
2616 _, err := r.sql.Exec(`UPDATE routes SET args = ? WHERE host = ?`, args, host)
2617 return err
2618}
2619
2620// SetWorkerLimits repoints a sidecar's idle window and run ceiling, both in
2621// seconds, 0 meaning "use the platform default". Same narrow-UPDATE shape
2622// and same reason as SetArgs: the manifest is the truth, this is the copy
2623// the sweeper reads on every tick.
2624func (r *Registry) SetWorkerLimits(host string, idle, maxRun int) error {
2625 if r.remote != nil {
2626 _, err := r.remote.call(nil, remoteRequest{Method: "SetWorkerLimits", Host: host, Idle: idle, MaxRun: maxRun}, nil)
2627 return err
2628 }
2629
2630 _, err := r.sql.Exec(
2631 `UPDATE routes SET idle_seconds = ?, max_run_seconds = ? WHERE host = ?`,
2632 idle, maxRun, host)
2633 return err
2634}
2635
2636// SetProvisioned stamps (or clears) the flag that marks a route as the
2637// instance-provisioning reconcile pass's own — a narrow UPDATE, same
2638// rationale as SetSeed. It errors when no row matched, the SetChannel
2639// contract: the pass must notice a row that vanished under it rather than
2640// silently doing nothing.
2641//
2642// This flag is an authority transfer, not a label: it is the condition that
2643// GRANTS the reconcile pass's removal phase authority over a row (that loop
2644// also gates on region/pool scope and on the record being gone). Only that pass's
2645// adoption step (provision.Reconcile, design §4.2) should ever set it, and
2646// it does so only after matching the row's shape against the record.
2647func (r *Registry) SetProvisioned(host string, on bool) error {
2648 if r.remote != nil {
2649 _, err := r.remote.call(nil, remoteRequest{Method: "SetProvisioned", Host: host, On: on}, nil)
2650 return err
2651 }
2652
2653 v := 0
2654 if on {
2655 v = 1
2656 }
2657 res, err := r.sql.Exec(`UPDATE routes SET provisioned = ? WHERE host = ?`, v, host)
2658 if err != nil {
2659 return err
2660 }
2661 n, err := res.RowsAffected()
2662 if err != nil {
2663 return err
2664 }
2665 if n == 0 {
2666 return fmt.Errorf("no route for host %q", host)
2667 }
2668 return nil
2669}
2670
2671// SetStatusNamed flips a route's public-status-page naming opt-in as a narrow
2672// UPDATE, same rationale as SetSeed (a read-modify-write Upsert racing a
2673// deploy's SetVersions or the activator's SetNextDue would clobber those). It
2674// errors when no row matched, the SetChannel contract: the provisioning
2675// reconcile pass converges this column from the instance record and must
2676// notice a row that vanished under it rather than silently doing nothing.
2677func (r *Registry) SetStatusNamed(host string, on bool) error {
2678 if r.remote != nil {
2679 _, err := r.remote.call(nil, remoteRequest{Method: "SetStatusNamed", Host: host, On: on}, nil)
2680 return err
2681 }
2682
2683 v := 0
2684 if on {
2685 v = 1
2686 }
2687 res, err := r.sql.Exec(`UPDATE routes SET status_named = ? WHERE host = ?`, v, host)
2688 if err != nil {
2689 return err
2690 }
2691 n, err := res.RowsAffected()
2692 if err != nil {
2693 return err
2694 }
2695 if n == 0 {
2696 return fmt.Errorf("no route for host %q", host)
2697 }
2698 return nil
2699}
2700
2701// DueInstances returns hibernatable instance AND sidecar routes whose
2702// next_due has arrived (0 means no schedule and never matches). Sidecars
2703// are included because the due-time sweep is their ONLY timer wake source:
2704// nothing can dial a sidecar, so no request will ever wake one.
2705func (r *Registry) DueInstances(now int64) ([]Route, error) {
2706 if r.remote != nil {
2707 out, err := r.remote.call(nil, remoteRequest{Method: "DueInstances", Now: now}, nil)
2708 return out.Routes, err
2709 }
2710
2711 rows, err := r.sql.Query(`SELECT `+routeCols+` FROM routes
2712WHERE kind IN ('instance','sidecar') AND next_due > 0 AND next_due <= ? ORDER BY host`, now)
2713 if err != nil {
2714 return nil, err
2715 }
2716 defer rows.Close()
2717 var out []Route
2718 for rows.Next() {
2719 t, err := scanRoute(rows.Scan)
2720 if err != nil {
2721 return nil, err
2722 }
2723 out = append(out, t)
2724 }
2725 return out, rows.Err()
2726}
2727
2728// RouteAlias is one derived row of the route_aliases table: an extra
2729// hostname that resolves to an existing route's host, materialized from a
2730// domains.Claim by a later reconcile pass (this package owns the storage
2731// shape only — nothing here decides which claims win). Alias is the extra
2732// hostname being attached; Host is the route it points at. DNSStatus,
2733// DNSCheckedAt and CertExpiresAt are box-stamped health state, same shape as
2734// the identically-named columns on routes, and are deliberately excluded
2735// from Fingerprint (see there) so a stamping pass alone never forces an edge
2736// reload.
2737type RouteAlias struct {
2738 Alias string
2739 Host string
2740 Account string
2741 App string
2742 DNSStatus string
2743 DNSCheckedAt int64
2744 CertExpiresAt int64
2745 CreatedAt int64
2746}
2747
2748// ListAliases returns every route alias, ordered by alias.
2749func (r *Registry) ListAliases() ([]RouteAlias, error) {
2750 if r.remote != nil {
2751 out, err := r.remote.call(nil, remoteRequest{Method: "ListAliases"}, nil)
2752 return out.Aliases, err
2753 }
2754
2755 rows, err := r.sql.Query(`SELECT alias, host, account, app, dns_status,
2756dns_checked_at, cert_expires_at, created_at FROM route_aliases ORDER BY alias`)
2757 if err != nil {
2758 return nil, err
2759 }
2760 defer rows.Close()
2761 var out []RouteAlias
2762 for rows.Next() {
2763 var a RouteAlias
2764 if err := rows.Scan(&a.Alias, &a.Host, &a.Account, &a.App, &a.DNSStatus,
2765 &a.DNSCheckedAt, &a.CertExpiresAt, &a.CreatedAt); err != nil {
2766 return nil, err
2767 }
2768 out = append(out, a)
2769 }
2770 return out, rows.Err()
2771}
2772
2773// UpsertAlias inserts or updates an alias row by its hostname. Only host,
2774// account and app are overwritten on conflict — the DNS/cert stamps and
2775// created_at are reconcile-pass- and box-stamp-owned state that a repoint
2776// must not reset out from under the passes that maintain them (the same
2777// preserved-column shape Upsert uses for routes, applied here by simply
2778// leaving those columns out of the SET list rather than a CASE guard, since
2779// every writer of this table already knows it must not carry stamps).
2780func (r *Registry) UpsertAlias(a RouteAlias) error {
2781 if r.remote != nil {
2782 _, err := r.remote.call(nil, remoteRequest{Method: "UpsertAlias", A: a}, nil)
2783 return err
2784 }
2785
2786 _, err := r.sql.Exec(`
2787INSERT INTO route_aliases (alias, host, account, app, dns_status, dns_checked_at, cert_expires_at, created_at)
2788VALUES (?, ?, ?, ?, ?, ?, ?, ?)
2789ON CONFLICT(alias) DO UPDATE SET host=excluded.host, account=excluded.account, app=excluded.app`,
2790 a.Alias, a.Host, a.Account, a.App, a.DNSStatus, a.DNSCheckedAt, a.CertExpiresAt, a.CreatedAt)
2791 return err
2792}
2793
2794// DeleteAlias removes an alias by its hostname. Idempotent: an alias already
2795// gone is a success, same contract as Delete on routes.
2796func (r *Registry) DeleteAlias(alias string) error {
2797 if r.remote != nil {
2798 _, err := r.remote.call(nil, remoteRequest{Method: "DeleteAlias", Alias: alias}, nil)
2799 return err
2800 }
2801
2802 _, err := r.sql.Exec(`DELETE FROM route_aliases WHERE alias = ?`, alias)
2803 return err
2804}
2805
2806// SetAliasDNSStatus stamps an alias's DNS health verdict + check time,
2807// narrowly (same rationale as SetDNSStatus on routes: it lands on its own
2808// cadence and must not race a concurrent UpsertAlias repoint).
2809func (r *Registry) SetAliasDNSStatus(alias, status string, at int64) error {
2810 if r.remote != nil {
2811 _, err := r.remote.call(nil, remoteRequest{Method: "SetAliasDNSStatus", Alias: alias, Status: status, At: at}, nil)
2812 return err
2813 }
2814
2815 _, err := r.sql.Exec(`UPDATE route_aliases SET dns_status = ?, dns_checked_at = ? WHERE alias = ?`, status, at, alias)
2816 return err
2817}
2818
2819// SetAliasCertExpiresAt stamps an alias's cached-cert NotAfter, narrowly.
2820// 0 = unknown (same rationale as SetCertExpiresAt on routes).
2821func (r *Registry) SetAliasCertExpiresAt(alias string, at int64) error {
2822 if r.remote != nil {
2823 _, err := r.remote.call(nil, remoteRequest{Method: "SetAliasCertExpiresAt", Alias: alias, At: at}, nil)
2824 return err
2825 }
2826
2827 _, err := r.sql.Exec(`UPDATE route_aliases SET cert_expires_at = ? WHERE alias = ?`, at, alias)
2828 return err
2829}
2830
2831// WildcardDomain is one derived row of the wildcard_domains table: a
2832// customer domain whose claim carries wildcard:true and whose primary
2833// route lives on this box — i.e. a delegated wildcard-cert parent the
2834// edge should obtain *.host for. Wholly pass-owned, like route_aliases:
2835// every row traces to a claim, so removal needs no ownership flag.
2836type WildcardDomain struct {
2837 Host string
2838 Account string
2839 App string
2840 CreatedAt int64
2841}
2842
2843// ListWildcardDomains returns every wildcard domain, ordered by host.
2844func (r *Registry) ListWildcardDomains() ([]WildcardDomain, error) {
2845 if r.remote != nil {
2846 out, err := r.remote.call(nil, remoteRequest{Method: "ListWildcardDomains"}, nil)
2847 return out.Wildcards, err
2848 }
2849
2850 rows, err := r.sql.Query(`SELECT host, account, app, created_at FROM wildcard_domains ORDER BY host`)
2851 if err != nil {
2852 return nil, err
2853 }
2854 defer rows.Close()
2855 var out []WildcardDomain
2856 for rows.Next() {
2857 var w WildcardDomain
2858 if err := rows.Scan(&w.Host, &w.Account, &w.App, &w.CreatedAt); err != nil {
2859 return nil, err
2860 }
2861 out = append(out, w)
2862 }
2863 return out, rows.Err()
2864}
2865
2866// UpsertWildcardDomain inserts or updates a row by host.
2867func (r *Registry) UpsertWildcardDomain(w WildcardDomain) error {
2868 if r.remote != nil {
2869 _, err := r.remote.call(nil, remoteRequest{Method: "UpsertWildcardDomain", W: w}, nil)
2870 return err
2871 }
2872
2873 _, err := r.sql.Exec(`
2874INSERT INTO wildcard_domains (host, account, app, created_at)
2875VALUES (?, ?, ?, ?)
2876ON CONFLICT(host) DO UPDATE SET account=excluded.account, app=excluded.app, created_at=excluded.created_at`,
2877 w.Host, w.Account, w.App, w.CreatedAt)
2878 return err
2879}
2880
2881// DeleteWildcardDomain removes host's row. Idempotent: a host already gone
2882// is a success, same contract as DeleteAlias.
2883func (r *Registry) DeleteWildcardDomain(host string) error {
2884 if r.remote != nil {
2885 _, err := r.remote.call(nil, remoteRequest{Method: "DeleteWildcardDomain", Host: host}, nil)
2886 return err
2887 }
2888
2889 _, err := r.sql.Exec(`DELETE FROM wildcard_domains WHERE host = ?`, host)
2890 return err
2891}
2892
2893// RoutePath is one derived row of the route_paths table: a leading URL path
2894// segment on Host that is served by the route at Target instead of by Host's
2895// own route. Wholly pass-owned, like route_aliases and wildcard_domains:
2896// every row traces to a claim's Paths entry, so removal needs no ownership
2897// flag.
2898//
2899// A path row deliberately carries NO upstream, channel or environment of its
2900// own. It is a pointer; everything about how the traffic is served comes from
2901// Target's ordinary route row. That is what makes pointing a subset of
2902// prefixes at a second route a working canary rather than a special case.
2903//
2904// Prefix is a bare segment — "paulca", never "/paulca".
2905type RoutePath struct {
2906 Host string
2907 Prefix string
2908 Target string
2909 Account string
2910 App string
2911}
2912
2913// ListPaths returns every path route, ordered by host then prefix.
2914func (r *Registry) ListPaths() ([]RoutePath, error) {
2915 if r.remote != nil {
2916 out, err := r.remote.call(nil, remoteRequest{Method: "ListPaths"}, nil)
2917 return out.Paths, err
2918 }
2919
2920 rows, err := r.sql.Query(`SELECT host, prefix, target, account, app
2921FROM route_paths ORDER BY host, prefix`)
2922 if err != nil {
2923 return nil, err
2924 }
2925 defer rows.Close()
2926 var out []RoutePath
2927 for rows.Next() {
2928 var p RoutePath
2929 if err := rows.Scan(&p.Host, &p.Prefix, &p.Target, &p.Account, &p.App); err != nil {
2930 return nil, err
2931 }
2932 out = append(out, p)
2933 }
2934 return out, rows.Err()
2935}
2936
2937// UpsertPath inserts or repoints one path route by its (host, prefix) key.
2938// Unlike UpsertAlias there are no stamp columns to preserve — nothing on this
2939// table is maintained on a separate cadence — so every column is overwritten.
2940func (r *Registry) UpsertPath(p RoutePath) error {
2941 if r.remote != nil {
2942 _, err := r.remote.call(nil, remoteRequest{Method: "UpsertPath", P: p}, nil)
2943 return err
2944 }
2945
2946 _, err := r.sql.Exec(`
2947INSERT INTO route_paths (host, prefix, target, account, app)
2948VALUES (?, ?, ?, ?, ?)
2949ON CONFLICT(host, prefix) DO UPDATE SET
2950 target=excluded.target, account=excluded.account, app=excluded.app`,
2951 p.Host, p.Prefix, p.Target, p.Account, p.App)
2952 return err
2953}
2954
2955// DeletePath removes one path route. Idempotent: a row already gone is a
2956// success, the same contract as DeleteAlias — the reconcile pass deletes
2957// unconditionally from its own view of what is no longer desired.
2958func (r *Registry) DeletePath(host, prefix string) error {
2959 if r.remote != nil {
2960 _, err := r.remote.call(nil, remoteRequest{Method: "DeletePath", Host: host, Prefix: prefix}, nil)
2961 return err
2962 }
2963
2964 _, err := r.sql.Exec(`DELETE FROM route_paths WHERE host = ? AND prefix = ?`, host, prefix)
2965 return err
2966}
2967
2968// Fingerprint summarizes the routes, fleets, fleet_boxes, route_aliases,
2969// wildcard_domains and route_paths tables over a fresh connection, for the
2970// edge's cheap
2971// change poll. It is content-accurate: it hashes the actual VALUES of the
2972// reload-relevant columns, not a length-weighted aggregate, so two
2973// DIFFERENT values of the SAME length
2974// (channel canary/1 -> canary/2, a git short-sha version bump, a rotated
2975// sha256 token hash, a failover restamp box-a -> box-b) change the result
2976// exactly like any other write. `grants` stays deliberately excluded
2977// (registry.go:93's rule: grants change unit shape, not routing), and so does
2978// `unit` (it changes who restarts the process, not what the edge serves). The
2979// route count is folded into the result so an empty table and a never-opened
2980// one still differ from a populated one.
2981// Still a fresh connection per poll — it must see other processes' commits
2982// (same WAL-snapshot quirk as List).
2983//
2984// Closes #15: the edge's X-Carlos-Version response header reads Version off
2985// the route the fingerprint gated in, and git short shas are same-length by
2986// construction — the old length-weighted aggregate could not see a version
2987// bump land, so the header would serve stale until an unrelated column
2988// happened to change too.
2989//
2990// db_path is hashed too (issue #69): a re-add that only re-points a route's
2991// database must reach a running edge, or the activator keeps waking the host
2992// on the stale cached path.
2993//
2994// `addrs` and `health` are hashed too (issue #152, multi-upstream routing): a
2995// SetUpstreams call that resizes or reshuffles a route's upstream set, or
2996// changes its probe config, is exactly as reload-relevant as an addr rewrite
2997// — an edge that never noticed would keep dispatching to a dropped upstream
2998// or never start probing a newly-added one.
2999//
3000// `region` and `fleet_box` are hashed for the customer-fleets placement path,
3001// and the two-box e2e (internal/edge/fleet_e2e_test.go) is what proved they
3002// have to be: a failover restamp (ReconcileFleetBindings -> SetFleetBox,
3003// box-a -> box-b) left the old aggregate byte-identical and the edge kept
3004// dispatching to the box that had died — rule 1 pointing at a corpse the
3005// moment it re-dialed, with no operator around to SIGHUP, because a restamp
3006// is UNATTENDED where an operator's `carlos route` edit is not. Note that a
3007// LENGTH-based fold would not have fixed it either: fleet box labels are
3008// overwhelmingly the same length as each other ("pi-1"/"pi-2",
3009// "box-a"/"box-b"), which is precisely the blind spot hashing the values
3010// closes.
3011//
3012// `steering` (multi-region ingress design Part 4) is deliberately NOT hashed
3013// here — see Route.Steering's own doc comment for the exclusion, stated in
3014// the same place `unit`'s exclusion is (registry.go:93's rule, restated):
3015// steering changes what DNS answers, not what the edge serves, and the
3016// converger that acts on it is a host housekeeper duty reading the registry
3017// directly, never gated on this poll (plan amendment b485ec5, re-ruling this
3018// task's own first pass, which hashed it here on the wrong premise that
3019// SnapshotFingerprint's routeCols-derived coverage extended to this function
3020// too — it does not; this function's column list is hand-curated).
3021//
3022// The fleets and fleet_boxes tables are hashed into the SAME digest, each
3023// under its own section marker so a value can never migrate across sections
3024// and cancel a change out. That is what lets the edge's Watch poll
3025// (edge.go:830) notice a fleet mutation — a new box, a detach, a token
3026// rotation — and reload, exactly as it notices a route write. Both tables'
3027// `rev` counters (bumped by UpsertFleet, AddFleetBox, SetFleetBoxToken,
3028// SetFleetBoxStatus) are hashed alongside their columns: with content
3029// hashing they are belt-and-braces rather than load-bearing, but they keep
3030// the guarantee true for any future writer that mutates a row into a
3031// byte-identical shape.
3032//
3033// wildcard_domains (Task 4 carry from Task 2's review) is hashed for the
3034// same reload-latency reason as fleets/fleet_boxes above: the claim-derived
3035// delegated wildcard parents (internal/domains' ReconcileAliases, Task 3)
3036// land here, and wildcard.go's refresh() runs off Edge.OnReload — which
3037// only fires when Watch's 2s Fingerprint poll actually moves. Without this
3038// section a new delegated parent (or one going away) would be invisible to
3039// that poll and wait for the wildcard manager's own 12h tick instead of the
3040// usual few-second bound every other route/alias mutation gets. All four
3041// columns are hashed — there is no cadence-stamped column on this table to
3042// exclude, unlike route_aliases' dns_status/cert_expires_at.
3043//
3044// route_paths is hashed for the same reload-latency reason, and it is the
3045// section most worth stating explicitly: a path flip is the ONLY routing
3046// change in the system whose whole observable effect is in this table. A
3047// missing section here would not degrade a flip, it would make flips do
3048// nothing at all — reconciled correctly, stored correctly, never served —
3049// until some unrelated route write happened to move the digest.
3050//
3051// The ORDER BY clauses are what make the digest deterministic across polls;
3052// a hypothetical reordering would only cost a redundant reload (idempotent),
3053// never a missed change — the failure mode worth engineering against is the
3054// false NEGATIVE.
3055func (r *Registry) Fingerprint() (string, error) {
3056 if r.remote != nil {
3057 out, err := r.remote.call(nil, remoteRequest{Method: "Fingerprint"}, nil)
3058 return out.Text, err
3059 }
3060
3061 db, err := sql.Open("sqlite", r.path)
3062 if err != nil {
3063 return "", err
3064 }
3065 defer db.Close()
3066 if _, err := db.Exec("PRAGMA busy_timeout=5000"); err != nil {
3067 return "", err
3068 }
3069 rows, err := db.Query(`
3070SELECT rowid, status, socket, addr, version, hibernate, channel, backing, tunnel,
3071 db_path, region, fleet_box, addrs, health, gate, hold_op, import_gate,
3072 claim_incarnation, claim_stamp, claim_owner, claim_row_id
3073FROM routes ORDER BY rowid`)
3074 if err != nil {
3075 return "", err
3076 }
3077 defer rows.Close()
3078 h := sha256.New()
3079 n := 0
3080 for rows.Next() {
3081 var rowid, hibernate int64
3082 var claim ClaimOwnership
3083 var status, socket, addr, version, channel, backing, tunnel, dbPath, region, fleetBox, addrs, health, gate, holdOp, importGate string
3084 if err := rows.Scan(&rowid, &status, &socket, &addr, &version, &hibernate,
3085 &channel, &backing, &tunnel, &dbPath, &region, &fleetBox, &addrs, &health, &gate, &holdOp, &importGate,
3086 &claim.ClaimIncarnation, &claim.ClaimStamp, &claim.ClaimOwner, &claim.ClaimRowID); err != nil {
3087 return "", err
3088 }
3089 fmt.Fprintf(h, "%d\x1f%s\x1f%s\x1f%s\x1f%s\x1f%d\x1f%s\x1f%s\x1f%s\x1f%s\x1f%s\x1f%s\x1f%s\x1f%s\x1f%s",
3090 rowid, status, socket, addr, version, hibernate, channel, backing, tunnel,
3091 dbPath, region, fleetBox, addrs, health, gate)
3092 // Only a held row carries the extra field: an unheld registry's digest
3093 // is byte-identical to the pre-hold_op encoding (switch-off contract),
3094 // while a hold handed to another op with the status unchanged still
3095 // moves it.
3096 if holdOp != "" {
3097 fmt.Fprintf(h, "\x1fhold_op=%s", holdOp)
3098 }
3099 // An import's gate follows the same rule: the edge must reload when a
3100 // gate is set, closed or cleared, and an ungated registry digests as
3101 // it did before the column existed.
3102 if importGate != "" {
3103 fmt.Fprintf(h, "\x1fimport_gate=%q", importGate)
3104 }
3105 // Ownership follows the same rule: the all-default value is omitted,
3106 // and each non-default field is tagged and quoted so a partial value
3107 // cannot alias another field's.
3108 if claim.ClaimIncarnation != "" {
3109 fmt.Fprintf(h, "\x1fclaim_incarnation=%q", claim.ClaimIncarnation)
3110 }
3111 if claim.ClaimStamp != 0 {
3112 fmt.Fprintf(h, "\x1fclaim_stamp=%d", claim.ClaimStamp)
3113 }
3114 if claim.ClaimOwner != "" {
3115 fmt.Fprintf(h, "\x1fclaim_owner=%q", claim.ClaimOwner)
3116 }
3117 if claim.ClaimRowID != "" {
3118 fmt.Fprintf(h, "\x1fclaim_row_id=%q", claim.ClaimRowID)
3119 }
3120 fmt.Fprint(h, "\x1e")
3121 n++
3122 }
3123 if err := rows.Err(); err != nil {
3124 return "", err
3125 }
3126
3127 // Section marker: everything after this belongs to fleets, never to
3128 // routes — without it a fleet name could in principle produce the same
3129 // byte stream a route column did and mask a change.
3130 fmt.Fprint(h, "\x1dfleets\x1e")
3131 frows, err := db.Query(`SELECT name, account, bucket, rev FROM fleets ORDER BY name`)
3132 if err != nil {
3133 return "", err
3134 }
3135 defer frows.Close()
3136 for frows.Next() {
3137 var name, account, bucket string
3138 var rev int64
3139 if err := frows.Scan(&name, &account, &bucket, &rev); err != nil {
3140 return "", err
3141 }
3142 fmt.Fprintf(h, "%s\x1f%s\x1f%s\x1f%d\x1e", name, account, bucket, rev)
3143 }
3144 if err := frows.Err(); err != nil {
3145 return "", err
3146 }
3147
3148 fmt.Fprint(h, "\x1dfleet_boxes\x1e")
3149 brows, err := db.Query(`SELECT fleet, label, token_hash, status, rev
3150FROM fleet_boxes ORDER BY fleet, label`)
3151 if err != nil {
3152 return "", err
3153 }
3154 defer brows.Close()
3155 for brows.Next() {
3156 var fleet, label, tokenHash, status string
3157 var rev int64
3158 if err := brows.Scan(&fleet, &label, &tokenHash, &status, &rev); err != nil {
3159 return "", err
3160 }
3161 fmt.Fprintf(h, "%s\x1f%s\x1f%s\x1f%s\x1f%d\x1e", fleet, label, tokenHash, status, rev)
3162 }
3163 if err := brows.Err(); err != nil {
3164 return "", err
3165 }
3166
3167 // route_aliases: only alias/host/account/app decide which hostnames the
3168 // edge routes and where. dns_status/dns_checked_at/cert_expires_at are
3169 // deliberately excluded here for the same reason the routes SELECT above
3170 // omits its own dns_status/cert_expires_at columns — a box-side pass
3171 // stamps them on a 5-minute cadence, and hashing a value that moves on
3172 // that cadence would force a pointless fleet-wide edge reload every pass.
3173 fmt.Fprint(h, "\x1droute_aliases\x1e")
3174 arows, err := db.Query(`SELECT alias, host, account, app FROM route_aliases ORDER BY alias`)
3175 if err != nil {
3176 return "", err
3177 }
3178 defer arows.Close()
3179 for arows.Next() {
3180 var alias, host, account, app string
3181 if err := arows.Scan(&alias, &host, &account, &app); err != nil {
3182 return "", err
3183 }
3184 fmt.Fprintf(h, "%s\x1f%s\x1f%s\x1f%s\x1e", alias, host, account, app)
3185 }
3186 if err := arows.Err(); err != nil {
3187 return "", err
3188 }
3189
3190 // wildcard_domains: see the doc comment above — this is what lets a
3191 // claim-derived delegated wildcard parent reach wildcard.go's refresh()
3192 // on the edge's normal 2s poll instead of waiting on its own 12h tick.
3193 fmt.Fprint(h, "\x1dwildcard_domains\x1e")
3194 wrows, err := db.Query(`SELECT host, account, app, created_at FROM wildcard_domains ORDER BY host`)
3195 if err != nil {
3196 return "", err
3197 }
3198 defer wrows.Close()
3199 for wrows.Next() {
3200 var host, account, app string
3201 var createdAt int64
3202 if err := wrows.Scan(&host, &account, &app, &createdAt); err != nil {
3203 return "", err
3204 }
3205 fmt.Fprintf(h, "%s\x1f%s\x1f%s\x1f%d\x1e", host, account, app, createdAt)
3206 }
3207 if err := wrows.Err(); err != nil {
3208 return "", err
3209 }
3210
3211 // route_paths: which prefix on which hostname reaches which route is a
3212 // routing decision in exactly the sense route_aliases' alias/host is, so
3213 // it hashes here or a flip never reaches the live map. All five columns
3214 // hash — unlike route_aliases there is no box-stamped, cadence-driven
3215 // column on this table to exclude, so there is nothing here that could
3216 // force a pointless fleet-wide reload.
3217 fmt.Fprint(h, "\x1droute_paths\x1e")
3218 prows, err := db.Query(`SELECT host, prefix, target, account, app
3219FROM route_paths ORDER BY host, prefix`)
3220 if err != nil {
3221 return "", err
3222 }
3223 defer prows.Close()
3224 for prows.Next() {
3225 var host, prefix, target, account, app string
3226 if err := prows.Scan(&host, &prefix, &target, &account, &app); err != nil {
3227 return "", err
3228 }
3229 fmt.Fprintf(h, "%s\x1f%s\x1f%s\x1f%s\x1f%s\x1e", host, prefix, target, account, app)
3230 }
3231 if err := prows.Err(); err != nil {
3232 return "", err
3233 }
3234
3235 return fmt.Sprintf("%d:%x", n, h.Sum(nil)), nil
3236}
3237
3238// SnapshotFingerprint summarizes the registry for the SNAPSHOT pass, where
3239// Fingerprint summarizes it for the edge's reload poll. The two answer
3240// different questions and must not be conflated:
3241//
3242// Fingerprint hashes only the columns a reload cares about, and deliberately
3243// EXCLUDES caps, unit, replication, environment, account, canary and
3244// provisioned — they change who restarts a process or what a tenant may do,
3245// not what the edge serves. That exclusion is correct for reloads and wrong
3246// for snapshots: those box-scoped columns are exactly the rows nothing else
3247// in the bucket can rebuild, so a cold-standby restore that misses them
3248// silently drops caps grants and console unit stamps. Gating the snapshot on
3249// Fingerprint meant a caps grant or a unit stamp never triggered a write at
3250// all. So SnapshotFingerprint hashes EVERY column of routes, plus accounts,
3251// fleets and fleet_boxes — anything the restore has to reproduce.
3252//
3253// The routes query is `SELECT rowid, ` + routeCols, so a column added to
3254// routeCols is covered here automatically (no fourth place to update); the
3255// values are scanned generically and written in column order, which is why
3256// this needs no per-column typing the way Fingerprint does.
3257//
3258// "Every column" has exactly four exceptions, snapshotChurnCols: the
3259// scheduling stamps (next_due, dns_checked_at), the serving_version rollout
3260// stamp, and build_state, the agent's copy of each instance's build state
3261// (its ReadAt moves on every refresh). The box rewrites them on its own
3262// clock with no operator change behind them. Hashing those made the gate
3263// never hold (#207: both production boxes wrote a generation on
3264// essentially every 5-minute tick, so the 8-generation history was ~40
3265// minutes deep, not 8 changes deep). They are still IN the snapshot; only
3266// the decision to take one ignores them. A restore therefore loses up to
3267// one tick of those stamps, which the activator and DNS sweeps and the
3268// build-state refresh re-derive on their first pass anyway.
3269//
3270// route_aliases and wildcard_domains are not hashed either: both are
3271// pass-owned materializations of domain claims (domains.Reconcile rebuilds
3272// every row from the claim records), so a restore recovers them by
3273// running.
3274//
3275// Hashing the VACUUM INTO output instead was rejected: SQLite's page image
3276// is not byte-deterministic across runs of identical content (freelist and
3277// page-ordering effects), so it would report a change on every pass and
3278// snapshot forever.
3279//
3280// Fresh connection, same reason as Fingerprint and List: a long-lived
3281// modernc connection can serve a stale WAL snapshot of another process's
3282// commits.
3283// snapshotChurnCols are the routes columns SnapshotFingerprint skips: stamps
3284// the box's own sweeps rewrite with nothing behind them an operator did.
3285// Keep this to columns that are re-derived within one sweep of a restore.
3286var snapshotChurnCols = map[string]bool{
3287 "next_due": true, // activator scheduling (SetNextDue, every pass)
3288 "dns_checked_at": true, // DNS sweep stamp (SetDNSStatus, every sweep)
3289 "serving_version": true, // rollout stamp (SetServingVersion, every wake and swap)
3290 "build_state": true, // the agent's build-state copy (SetBuildState, ReadAt moves every refresh)
3291}
3292
3293// snapshotClaimCols are the ownership columns, hashed only when non-default.
3294var snapshotClaimCols = map[string]bool{
3295 "claim_incarnation": true,
3296 "claim_stamp": true,
3297 "claim_owner": true,
3298 "claim_row_id": true,
3299}
3300
3301// snapshotWhenSetCols are the good-build columns (zero-downtime spec §8.5)
3302// and the update-settings column. A restore has to reproduce them, so they
3303// are hashed, but only when set and tagged, on the hold_op rule: a registry
3304// that has never recorded a good build or set update settings digests exactly
3305// as it did before the columns existed, so upgrading does not force a snapshot
3306// on every box.
3307var snapshotWhenSetCols = map[string]bool{
3308 "good_version": true,
3309 "good_digest": true,
3310 "good_demand": true,
3311 "update_settings": true,
3312}
3313
3314func (r *Registry) SnapshotFingerprint() (string, error) {
3315 if r.remote != nil {
3316 out, err := r.remote.call(nil, remoteRequest{Method: "SnapshotFingerprint"}, nil)
3317 return out.Text, err
3318 }
3319
3320 db, err := sql.Open("sqlite", r.path)
3321 if err != nil {
3322 return "", err
3323 }
3324 defer db.Close()
3325 if _, err := db.Exec("PRAGMA busy_timeout=5000"); err != nil {
3326 return "", err
3327 }
3328
3329 rows, err := db.Query(`SELECT rowid, ` + routeCols + ` FROM routes ORDER BY rowid`)
3330 if err != nil {
3331 return "", err
3332 }
3333 defer rows.Close()
3334 cols, err := rows.Columns()
3335 if err != nil {
3336 return "", err
3337 }
3338 h := sha256.New()
3339 n := 0
3340 vals := make([]sql.NullString, len(cols))
3341 dest := make([]any, len(cols))
3342 for i := range vals {
3343 dest[i] = &vals[i]
3344 }
3345 for rows.Next() {
3346 if err := rows.Scan(dest...); err != nil {
3347 return "", err
3348 }
3349 for i, v := range vals {
3350 if snapshotChurnCols[cols[i]] {
3351 continue
3352 }
3353 // hold_op is hashed only when set, tagged, so a registry with no
3354 // held rows digests exactly as it did before the column existed.
3355 if cols[i] == "hold_op" {
3356 if v.Valid && v.String != "" {
3357 fmt.Fprintf(h, "hold_op=%s\x1f", v.String)
3358 }
3359 continue
3360 }
3361 // seed_source follows the same rule: a registry with no seeded
3362 // rows digests exactly as it did before the column existed.
3363 if cols[i] == "seed_source" {
3364 if v.Valid && v.String != "" {
3365 fmt.Fprintf(h, "seed_source=%s\x1f", v.String)
3366 }
3367 continue
3368 }
3369 // So does import_gate: a registry with no gated rows digests
3370 // exactly as it did before the column existed.
3371 if cols[i] == "import_gate" {
3372 if v.Valid && v.String != "" {
3373 fmt.Fprintf(h, "import_gate=%s\x1f", v.String)
3374 }
3375 continue
3376 }
3377 // Claim ownership columns follow the hold_op rule: default
3378 // values are omitted, anything else is tagged and quoted.
3379 if snapshotClaimCols[cols[i]] {
3380 if v.Valid && v.String != "" && (cols[i] != "claim_stamp" || v.String != "0") {
3381 fmt.Fprintf(h, "%s=%q\x1f", cols[i], v.String)
3382 }
3383 continue
3384 }
3385 if snapshotWhenSetCols[cols[i]] {
3386 if v.Valid && v.String != "" {
3387 fmt.Fprintf(h, "%s=%q\x1f", cols[i], v.String)
3388 }
3389 continue
3390 }
3391 // NULL and "" must not hash alike: a column that has never been
3392 // written is a different restore than one explicitly cleared.
3393 if v.Valid {
3394 fmt.Fprintf(h, "%s\x1f", v.String)
3395 } else {
3396 fmt.Fprint(h, "\x00\x1f")
3397 }
3398 }
3399 fmt.Fprint(h, "\x1e")
3400 n++
3401 }
3402 if err := rows.Err(); err != nil {
3403 return "", err
3404 }
3405
3406 // Section markers, same discipline as Fingerprint: a value can never
3407 // migrate across sections and cancel another change out.
3408 fmt.Fprint(h, "\x1daccounts\x1e")
3409 arows, err := db.Query(`SELECT id, sqid, name FROM accounts ORDER BY id`)
3410 if err != nil {
3411 return "", err
3412 }
3413 defer arows.Close()
3414 for arows.Next() {
3415 var id int64
3416 var sqid, name string
3417 if err := arows.Scan(&id, &sqid, &name); err != nil {
3418 return "", err
3419 }
3420 fmt.Fprintf(h, "%d\x1f%s\x1f%s\x1e", id, sqid, name)
3421 }
3422 if err := arows.Err(); err != nil {
3423 return "", err
3424 }
3425
3426 fmt.Fprint(h, "\x1dfleets\x1e")
3427 frows, err := db.Query(`SELECT name, account, bucket, rev FROM fleets ORDER BY name`)
3428 if err != nil {
3429 return "", err
3430 }
3431 defer frows.Close()
3432 for frows.Next() {
3433 var name, account, bucket string
3434 var rev int64
3435 if err := frows.Scan(&name, &account, &bucket, &rev); err != nil {
3436 return "", err
3437 }
3438 fmt.Fprintf(h, "%s\x1f%s\x1f%s\x1f%d\x1e", name, account, bucket, rev)
3439 }
3440 if err := frows.Err(); err != nil {
3441 return "", err
3442 }
3443
3444 fmt.Fprint(h, "\x1dfleet_boxes\x1e")
3445 brows, err := db.Query(`SELECT fleet, label, token_hash, status, rev
3446FROM fleet_boxes ORDER BY fleet, label`)
3447 if err != nil {
3448 return "", err
3449 }
3450 defer brows.Close()
3451 for brows.Next() {
3452 var fleet, label, tokenHash, status string
3453 var rev int64
3454 if err := brows.Scan(&fleet, &label, &tokenHash, &status, &rev); err != nil {
3455 return "", err
3456 }
3457 fmt.Fprintf(h, "%s\x1f%s\x1f%s\x1f%s\x1f%d\x1e", fleet, label, tokenHash, status, rev)
3458 }
3459 if err := brows.Err(); err != nil {
3460 return "", err
3461 }
3462 if err := hashImportJournal(db, h); err != nil {
3463 return "", err
3464 }
3465
3466 return fmt.Sprintf("%d:%x", n, h.Sum(nil)), nil
3467}
3468
3469// SnapshotTo writes a consistent single-file copy of the registry to path
3470// via VACUUM INTO on a fresh connection — same fresh-connection recipe as
3471// Fingerprint (modernc WAL visibility). path must not already exist:
3472// modernc's VACUUM INTO silently overwrites an existing file rather than
3473// refusing it (unlike upstream SQLite's CLI), so we check first — callers
3474// stage into a temp dir. The bound-parameter form (not fmt.Sprintf/%q) is
3475// required: %q is Go string escaping, not SQL escaping, so a path
3476// containing a backslash gets VACUUM INTO writing to a different on-disk
3477// path than the one os.Stat just checked, silently defeating the guard.
3478func (r *Registry) SnapshotTo(path string) error {
3479 if r.remote != nil {
3480 _, err := r.remote.call(nil, remoteRequest{Method: "SnapshotTo", Path: path}, nil)
3481 return err
3482 }
3483
3484 if _, err := os.Stat(path); err == nil {
3485 return fmt.Errorf("registry: snapshot target already exists: %s", path)
3486 } else if !os.IsNotExist(err) {
3487 return err
3488 }
3489 db, err := sql.Open("sqlite", r.path)
3490 if err != nil {
3491 return err
3492 }
3493 defer db.Close()
3494 _, err = db.Exec("VACUUM INTO ?", path)
3495 return err
3496}
3497