carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package provision
2
3// Reconcile turns bucket instance records into registry routes, for THIS
4// agent's own region and pool set — plus, since the multi-region ingress
5// design's Part 3, the serving classes every platform box materializes
6// wherever they are homed (see "serving classes" below). Spec:
7// docs/superpowers/specs/2026-08-04-instance-provisioning-design.md §4.
8//
9// DNS seam (spec §10): multi-region deployments write the per-slug CNAME at
10// provision time in the console's create handler, not here — the reconcile
11// pass stays DNS-free so a box never needs Route53 credentials.
12
13import (
14 "context"
15 "crypto/ed25519"
16 "encoding/json"
17 "errors"
18 "fmt"
19 "log"
20 "net"
21 "os"
22 "slices"
23 "strings"
24 "sync"
25 "time"
26
27 "github.com/carlosframework/platform/internal/domains"
28 "github.com/carlosframework/platform/internal/registry"
29 "github.com/carlosframework/platform/internal/release"
30 "github.com/carlosframework/platform/internal/seeds"
31)
32
33// ReconcileConfig is everything one pass reads its world through — the same
34// injection-seam shape domains.Config uses (tests supply fixtures, the host
35// agent supplies production values).
36type ReconcileConfig struct {
37 ClaimEnabled bool
38 ClaimInstall func(context.Context, ClaimRecord, func(registry.ClaimOwnership) error) error
39 ClaimRetire func(context.Context, registry.Route) error
40
41 // Region is this agent's region code. "" is the unconstrained region —
42 // a box that hasn't been told its place serves only region-less
43 // records, never someone else's residency promise.
44 Region string
45 // Pools are the pools this box serves. Empty means the shared "" pool
46 // only — NOT "every pool" (poolServed below).
47 Pools []string
48 // DataDir feeds DefaultDBPath ("/data" in production).
49 DataDir string
50 // Now is the clock seam; nil = time.Now.
51 Now func() time.Time
52 // Inbound is this box's inbound-work declaration (CARLOS_INBOUND, design
53 // §8). "" and InboundAny are today's behaviour exactly — an inert
54 // default, the apps-domain-style gate this repo already uses — and
55 // InboundNone means the box serves sidecar work only: no certs, no
56 // inbound ports, possibly a private subnet with egress-only networking.
57 // Read by ReconcileSidecars' placement gate (matches a record's own
58 // "no-inbound" placement request against this box) AND by Reconcile's
59 // mint gate below (refuses ALL routed instance minting outright — an
60 // ordinary instance record carries no placement vocabulary to match).
61 //
62 // Callers building this from a raw env reading MUST pass it through
63 // NormalizeInbound first (host/agent.go's provisionTickConfig does) —
64 // both gates below compare this field with exact string equality
65 // against InboundNone, so an un-folded value would defeat them silently.
66 Inbound string
67 // Layout answers which layout this box's intent names, "" or
68 // release.LayoutModeFlat for flat and release.LayoutModeNested for
69 // nested, read from the deploy store the pass already has in hand
70 // (isolated-exec design §8.6 step 5, brought forward in plan 5): a row
71 // minted on a nested box is minted at the nested paths, never flat
72 // and then migrated, so a new tenant never runs flat beside nested
73 // siblings, not even for one pass. An error defers every mint on this
74 // pass rather than guessing: a box that cannot tell its layout must
75 // not mint a flat row the fleet may want nested. Nil is flat (tests,
76 // and a caller with no intent to read).
77 Layout func(ctx context.Context, s release.Store) (string, error)
78 // DryRun reports what the pass would do and writes nothing — not to the
79 // registry, not anywhere. The backfill mitigation (design §4.3): a
80 // production route is adopted only after someone has read what adopting
81 // it would mean, because adoption is what grants the removal phase
82 // authority over that row.
83 DryRun bool
84 // ReleasePub verifies the production flag before a trash marker is
85 // allowed to retire an app's routes (#340), the same guard and the same
86 // key domains.Config carries for the same decision. nil leaves the
87 // retirement UNGUARDED rather than disabling it — domains §1c's posture,
88 // carried over deliberately so the two passes cannot reach opposite
89 // conclusions about the same app: a keyless deployment has no way to
90 // make the guard pass, and holding every trash forever there would break
91 // hiding entirely. Nothing destructive to the bucket happens on this
92 // path; the auto-purge that does still lives in domains, still refuses
93 // without a key.
94 ReleasePub ed25519.PublicKey
95 // ConfigGate answers whether this box can provide the config of the
96 // route a record would mint (amadan #213): nil error means yes, an error
97 // means do not mint it this pass. It is handed the route the mint would
98 // create (routeFor / sidecarRouteFor), so it reads App, Account,
99 // Environment and the shape off the one renderer, and it decides for
100 // itself which shapes need config at all. A refused record is retried
101 // every pass, so a box that gains the key mints on its next tick. The
102 // gate owns its own logging and operator-visible record; this pass only
103 // honours the answer. Existing routes are not unminted by a refusal: the
104 // wake-time gate (activator.ExecBackend.ConfigGate) is what stops one
105 // starting. Nil: no gate, every record mints as before.
106 ConfigGate func(ctx context.Context, rt registry.Route) error
107 // Hold answers whether a record's row on this box belongs to a running
108 // move (moves spec §5.2, §9.6, §19 K9) and, if so, the hold status and
109 // the op holding it. When ok, a row minted for rec is minted held
110 // (status, HoldOp op) at flat paths, never ready and never nested
111 // (Phase 1 lands flat, G11); an unheld row of this pass's own is held
112 // with SetHoldIf (Upsert never changes a hold on conflict, G10) once a
113 // fresh read of the record, under the host's hold lock
114 // (WithHostHoldLock), still asks for it, except under a recovery hold
115 // (restoring, releasing), which only mints (holdExisting); a row
116 // already held, by this op or another, is left as it is. Nil: no move
117 // holds anything, main's pass exactly.
118 Hold func(rec Record) (status, op string, ok bool)
119 // KeepParticipant answers whether a host's claim names this box as a
120 // participant of a running operation (claim spec §8, moves §9.6): the
121 // removal phase never removes such a row, whatever the record's region
122 // says. The operation removes it at its own finalization. Nil: none is.
123 KeepParticipant func(host string) bool
124 // HoldWritten is called after every hold this pass writes (a held mint
125 // or a SetHoldIf that landed), so the caller can touch the
126 // stage-requested marker: root re-judges what it staged under the new
127 // hold (registry.SetHold's contract, G10). Nil: nobody is told.
128 HoldWritten func(host string)
129 // RunSeen is called with every in-scope record of an untrashed app that
130 // carries a run, once per pass, before the pass acts on the record (run
131 // spec §5.2: pickup rides reconcile's own record reads, so an idle box
132 // pays nothing extra). It must not block: the box's runs worker only
133 // notes the record and acts after the pass, by which time a row this
134 // pass minted is in the registry. Nil: no one is told.
135 RunSeen func(rec Record)
136 // Seeds is the seeded-instances wiring (seedreconcile.go). Nil: every
137 // seeded record is left alone.
138 Seeds *SeedHooks
139
140 // beforeHoldWrite is the tests' pause under the host's hold lock, just
141 // before holdExisting's conditional write (TestReconcileHoldWrite...).
142 // Nil in the agent.
143 beforeHoldWrite func(host string)
144}
145
146// InboundAny and InboundNone are the closed vocabulary ReconcileConfig.Inbound
147// is read against — the BOX's half of design §8's two independent halves (the
148// record's half is PlacementAny/PlacementNoInbound). "" reads as InboundAny:
149// a box that has not been told is a box that serves, which is every box that
150// exists today.
151const (
152 InboundAny = "any"
153 InboundNone = "none"
154)
155
156// NormalizeInbound folds a raw CARLOS_INBOUND reading (whitespace and case)
157// and maps it onto the closed vocabulary above. Review fix (Task 10
158// finding): the box-side comparisons this feeds are exact string equality
159// against InboundNone, so an un-folded "None"/"NONE"/"off" fell through as
160// if the variable were unset — a box meant to be no-inbound would silently
161// get handed traffic it structurally cannot serve, the same silent outage
162// this task exists to prevent, just triggered from the other direction.
163//
164// An UNRECOGNISED value (anything other than "", "any" or "none" once
165// folded) is treated as InboundNone — restrictive, not inert — and logged
166// loudly, naming the offending value. The only way to produce an
167// unrecognised value is to have typed one: CARLOS_INBOUND is never set by
168// anything but an operator's own hand, and an operator only sets this
169// variable at all when they mean to restrict. So the restrictive reading
170// matches intent in the overwhelmingly likely case, and it is this repo's
171// house style for an unparseable declaration already (a provision manifest
172// with schema > 1 is refused outright, never guessed at). The failure mode
173// of guessing wrong here is loud and bounded — new mints are refused with a
174// log line naming host and reason, nothing already running is evacuated —
175// where the opposite default's failure mode is a box quietly accepting
176// traffic it cannot serve.
177func NormalizeInbound(raw string) string {
178 v := strings.ToLower(strings.TrimSpace(raw))
179 switch v {
180 case "", InboundAny, InboundNone:
181 return v
182 default:
183 log.Printf("provision: CARLOS_INBOUND=%q is not a recognised value (want \"\", %q or %q); "+
184 "treating this box as CARLOS_INBOUND=%s (no-inbound, restrictive) rather than guessing it means unrestricted",
185 raw, InboundAny, InboundNone, InboundNone)
186 return InboundNone
187 }
188}
189
190// acctApp identifies one app within one account — the unit both the record
191// scan and the manifest/seed cache below key on, since app names are unique
192// only within an account, never globally.
193type acctApp struct{ acct, app string }
194
195// trashState is what one app's trash marker means to this pass. Two fields
196// and not one bool, because the two questions have different safe answers:
197// see the trash cache in Reconcile for which is which.
198type trashState struct {
199 declineMint bool
200 removable bool
201}
202
203// productionClear reports whether a trashed app's routes may be retired —
204// i.e. whether we can positively see that it is NOT flagged production.
205// Dropping every route of a production app is an outage, which is precisely
206// what the flag exists to prevent, so an unreadable flag refuses in the same
207// direction as an ON one: the state an attacker with delete-but-not-sign can
208// manufacture is exactly the unreadable one.
209//
210// A nil key is the keyless box, which acts on the marker — domains §1c's
211// posture, and the comment on ReconcileConfig.ReleasePub says why.
212//
213// Both refusals log EVERY pass, deliberately: an operator who wrote that
214// marker on purpose has to be told why nothing happened, or they will assume
215// deletion is broken.
216func productionClear(ctx context.Context, s release.Store, pub ed25519.PublicKey, acct, app string) bool {
217 if pub == nil {
218 return true
219 }
220 flag, err := release.GetProduction(ctx, s, pub, acct, app)
221 switch {
222 case err != nil:
223 log.Printf("provision: cannot read the production flag for %s/%s — refusing to act on the trash marker: %v", acct, app, err)
224 return false
225 case flag != nil && flag.Production:
226 log.Printf("provision: trash marker on production app %s/%s — refusing to act on it; turn production off first", acct, app)
227 return false
228 }
229 return true
230}
231
232// ---- serving classes -------------------------------------------------------
233//
234// Which boxes materialize a record. The multi-region ingress design's Part 3
235// table, verbatim, and the ONE place this pass states it:
236//
237// Everywhere static (bake-served): the edge resolves the app's
238// channel pointer and serves the shipped tar out of the
239// deployment store every box already reads, so no box
240// needs the home box to serve it.
241// Everywhere, probed addr routes whose upstreams are ALL publicly dialable:
242// each edge runs its own prober and dials the backends
243// directly, so per-edge live sets are per-edge by design.
244// Home-boxed exec, unit-owned, socket-activated, tunnel- and
245// fleet-backed: the process exists on exactly ONE box,
246// and a request that lands elsewhere backhauls to it
247// (internal/edge's hop). Unchanged invariant.
248//
249// Everything here decides SCOPE only. Nothing about the route a record mints
250// changes with the box that mints it — routeFor stays the one renderer, so a
251// far box's copy and the home box's copy are the same route, column for
252// column, including the record's own Region.
253//
254// Single-box inertness: with one box there is no far box, every record is in
255// scope for the same reason it was before, and the widened half of the gate
256// never fires.
257
258// everywhereClass reports whether rec's serving CLASS is one every platform
259// box materializes. It answers the class question only — the tenancy
260// questions (pool, fleet placement) are inScope's, below.
261//
262// Conservative by construction: anything this function cannot positively
263// classify as servable from a box that holds nothing but the bucket is
264// home-boxed. That is the direction a wrong answer must fail in — a route that
265// stays home-boxed is served exactly as it is today (and backhauled from
266// elsewhere), while one wrongly promoted to everywhere is a box proxying to an
267// upstream it cannot reach.
268func everywhereClass(rec Record) bool {
269 // A replica is the residence rule made structural (#162): it belongs to
270 // the box its placement names and nowhere else, and this widening is the
271 // mint gate for every non-fleet box. ValidateShape refuses a replica
272 // with an upstream, so the addr arm below could never fire on one — but
273 // "could never through THIS door" is not "cannot", and a replica of a
274 // static kind would pass the kind arm above if the kind rail ever
275 // moved. The role check is FIRST and absolute.
276 if rec.Role == "replica" {
277 return false
278 }
279 // Unit names the systemd unit that owns this route's process (issue
280 // #110); adoption CYCLES it. That unit exists on the home box, so a far
281 // box adopting the row would be stamping authority over a unit name that
282 // means something else there, or nothing at all.
283 if rec.Unit != "" {
284 return false
285 }
286 // Belt and braces: ValidateShape already refuses hibernation on both
287 // classes below (a static route has no process to wake, an addr-backed one
288 // has no socket), and this pass validates every record before the gate. A
289 // hibernating route needs the activator and the socket only its home box
290 // has, so if that door ever moved, this must not be the hole it opens.
291 if rec.HibernateOrDefault() {
292 return false
293 }
294 if rec.KindOrDefault() == "static" {
295 return true
296 }
297 ups := rec.AddrsOrDefault()
298 if len(ups) == 0 {
299 return false // socket-backed: the exec child lives on one box
300 }
301 for _, a := range ups {
302 if !publiclyDialable(a) {
303 return false
304 }
305 }
306 return true
307}
308
309// everywhereRoute is everywhereClass read off a registry ROW instead of a
310// record — the removal phase's half of the same question, asked when the
311// record is gone and the row is all that is left to ask.
312//
313// The two must agree, or the widened mint gate leaves immortal routes: a far
314// box that mints an everywhere-class route and then cannot see it as one is a
315// box serving a retired site forever. They agree by construction today because
316// routeFor is the one renderer between them, and
317// TestEverywhereClassAgreesAcrossRecordAndRow locks that: a class rule added to
318// one of these functions alone recreates exactly the immortality bug this pair
319// was written to close, with every other test still green.
320//
321// They are separate functions rather than one over a shared shape because the
322// row carries columns no record can state (Backing, Tunnel, Caps), and a row
323// that has GROWN one of those since it was minted is deliberately read as
324// home-boxed here. State that cost honestly, because it is bigger than a stale
325// row: an operator who runs `carlos route -grant` (or -backing/-tunnel) against
326// the FAR box's copy takes that copy out of removal scope permanently. Deleting
327// the record then retires the route on its home box and leaves the far box
328// still SERVING the retired site, grants intact, with no record left anywhere
329// to explain it — so `carlos remove` stops being fleet-wide the moment anyone
330// touches a far copy. The alternative (removing rows carrying operator columns)
331// hands an unattended bucket-record deletion the grant purge registry.Delete
332// documents as human-gated, which is worse. The conservative gate stands; the
333// gap is tracked in the bookkeeping issue, not papered over here.
334func everywhereRoute(rt registry.Route) bool {
335 // The replica mark is everywhereClass's role check, read off the row
336 // (the record whose class it was may be exactly what is gone) — the
337 // same FIRST-and-absolute position, for the same reason.
338 if rt.Replica {
339 return false
340 }
341 if rt.Unit != "" || rt.Hibernate || rt.Backing != "" || rt.Tunnel != "" || rt.Caps != "" {
342 return false
343 }
344 if rt.Kind == "static" {
345 return true
346 }
347 ups := rt.AddrList()
348 if len(ups) == 0 {
349 return false
350 }
351 for _, a := range ups {
352 if !publiclyDialable(a) {
353 return false
354 }
355 }
356 return true
357}
358
359// publiclyDialable reports whether one upstream is an address every edge in
360// the deployment can reach — the "publicly-reachable upstreams" half of the
361// spec's table.
362//
363// A NAME is not dialable for this purpose even if it resolves to a public
364// address today: validAddrsElement's doc comment already records why a name
365// cannot be judged at all here (it is free to move after the check), and the
366// legacy Addr column is full of names pointing at box-local backends. It stays
367// home-boxed, which is where those routes are serving from today.
368//
369// Loopback and link-local are the spec's own examples (another box's
370// 127.0.0.1 is not this box's). RFC1918 and ULA are refused for exactly the
371// same reason one layer out: a private address is private to ONE network, and
372// two boxes in two regions are two networks. IsGlobalUnicast covers the rest
373// (unspecified, multicast, loopback, link-local).
374func publiclyDialable(addr string) bool {
375 h, _, err := net.SplitHostPort(addr)
376 if err != nil {
377 return false
378 }
379 ip := net.ParseIP(h)
380 if ip == nil {
381 return false
382 }
383 return ip.IsGlobalUnicast() && !ip.IsPrivate()
384}
385
386// inScope answers "is this region/pool pair this pass's business" — the one
387// gate the record scan and the removal phase both ask, so a record this box
388// mints is a record this box can retire.
389//
390// Pool is compared FIRST and never widened. It is a TENANCY boundary, not a
391// geography: a pool names the boxes a tenant's routes are allowed to land on
392// (customer fleets, dedicated boxes), and "everywhere" in the spec's table
393// means every box that already serves this tenant, not every box in the world.
394//
395// Region equality is the unchanged home-box test. It is widened for the
396// everywhere classes, and only between PLATFORM regions: a fleet placement
397// ("fleet/<name>", registry.FleetPlacement) is the other tenancy boundary and
398// is refused from both sides — the platform's boxes do not pick up a
399// customer fleet's routes, and a customer's box does not pick up the
400// platform's.
401func (cfg ReconcileConfig) inScope(region, pool string, everywhere bool) bool {
402 if !poolServed(cfg.Pools, pool) {
403 return false
404 }
405 if region == cfg.Region {
406 return true
407 }
408 if !everywhere {
409 return false
410 }
411 _, recordOnFleet := registry.FleetPlacement(region)
412 _, boxOnFleet := registry.FleetPlacement(cfg.Region)
413 return !recordOnFleet && !boxOnFleet
414}
415
416// RouteFor is the one answer to "what route does this record describe". The
417// mint path builds from it and the adoption match test (design §4.2)
418// compares against it — one function, so a shape the reconciler would mint
419// and a shape it will adopt can never drift apart.
420//
421// Socket and DBPath are DERIVED, never declared: DefaultSocket and
422// DefaultDBPath exist so a hand-added route and a provisioned one land on
423// identical path shapes, and their doc comment records what two names for
424// one database cost. Deriving DBPath EXPLICITLY here — rather than leaving it
425// empty on a route that has one — is also what keeps ops.FleetDBs'
426// hibernating-exclusion correct: internal/ops/litestream.go builds that
427// exclusion set from `r.Hibernate && r.DBPath != ""`, so a hibernating route
428// whose row carried no path would be swept up by the dataDir glob and
429// replicated box-wide as if it never slept.
430//
431// nested mints the derived paths at the nested layout's helpers instead
432// (InstanceDir, NestedSocket): the shape the layout pass would otherwise
433// move a flat row to. Only the mint branch passes true, from
434// ReconcileConfig.Layout; the drift and adoption comparisons derive flat
435// and judge a nested row by NestedPaths.
436func RouteFor(rec Record, dataDir, seed string, nested bool, policy *UpdatePolicy) registry.Route {
437 rt := registry.Route{
438 Host: rec.Host, Slug: rec.Slug, Kind: rec.KindOrDefault(), Unit: rec.Unit,
439 App: rec.App, Account: rec.Account, Pool: rec.Pool, Region: rec.Region,
440 Channel: rec.Channel, Environment: rec.Environment,
441 Hibernate: rec.HibernateOrDefault(), StatusNamed: rec.StatusNamedOrDefault(),
442 Steering: rec.Steering,
443 Replicas: replicaSet(rec.Replicas),
444 Replica: rec.Role == "replica",
445 Gate: rec.Gate,
446 Provisioned: true, Seed: seed, Status: "ready",
447 // The record's own settings over the app's policy (settings spec
448 // §3.2); "" for default settings, so an untouched fleet mints the
449 // row it always did.
450 UpdateSettings: EffectiveUpdates(rec, policy).Column(),
451 SeedSource: SeedColumn(rec),
452 }
453 if rec.DerivesPaths() {
454 rt.Socket = DefaultSocket(rec.Host)
455 rt.DBPath = DefaultDBPath(dataDir, rec.Host)
456 // Only an activator-managed row has a nested shape: an unflagged
457 // socket route is another supervisor's process, whose paths stay
458 // flat in every release (design §8.2 step 1).
459 if nested && rt.Hibernate {
460 rt.Socket = NestedSocket(rec.Host)
461 rt.DBPath = NestedDBPath(dataDir, rec.Host)
462 }
463 }
464 rt.Addr, rt.Addrs, rt.Health = upstreamCols(rec)
465 return rt
466}
467
468// mintLayoutReader answers "mint nested?" for one pass, asking cfg.Layout
469// at most once and only when a mint needs the answer. A nil Layout is
470// flat. The answer is cached error and all: a store that failed once this
471// pass is not asked again per record.
472func mintLayoutReader(ctx context.Context, s release.Store, cfg ReconcileConfig) func() (bool, error) {
473 var (
474 asked bool
475 nested bool
476 err error
477 )
478 return func() (bool, error) {
479 if cfg.Layout == nil {
480 return false, nil
481 }
482 if !asked {
483 asked = true
484 var mode string
485 mode, err = cfg.Layout(ctx, s)
486 nested = err == nil && mode == release.LayoutModeNested
487 }
488 return nested, err
489 }
490}
491
492// upstreamCols renders a record's THREE upstream columns exactly as a minted
493// route carries them: the legacy single addr, the comma-joined addr set, and
494// the probe-config JSON.
495//
496// It exists as its own function because TWO paths now write these columns —
497// the mint (routeFor, above) and the owned-row converge (convergeOwned, which
498// calls registry.SetUpstreams) — and a second, independent derivation is how
499// a route that was minted correctly comes to be "converged" onto a different
500// value every pass, forever. One derivation, two callers.
501//
502// All three move together because they are one fact seen three ways: addr and
503// addrs are two spellings of "where this route proxies" (registry's AddrList
504// folds addr in whenever addrs is empty), and health decides which of those
505// upstreams the edge will use. A record that migrates from the legacy addr to
506// an addrs set — which is exactly what the console's PATCH door does to a
507// pre-existing single-upstream tenant — must clear the row's addr in the same
508// write, or the row keeps a decommissioned upstream that resurfaces through
509// the fold the moment the set is cleared again.
510//
511// Addrs is read from the record's EXPLICIT field only, never
512// AddrsOrDefault's Addr-folding — a legacy Addr-only record must mint (and
513// converge to) a byte-identical route to before these fields existed
514// (registry.Route.Addrs documents the fold-back that keeps single-upstream
515// callers working off Addr alone). Health, by contrast, goes through
516// HealthOrDefault: a multi-upstream route with no stated probe MUST be
517// probed, or the edge has no way to tell which of its upstreams is dark.
518func upstreamCols(rec Record) (addr, addrs, health string) {
519 addr = rec.Addr
520 if len(rec.Addrs) > 0 {
521 addrs = strings.Join(rec.Addrs, ",")
522 }
523 // WithDefaults, not the raw pointer: a record with explicit-but-partial
524 // probe config (e.g. only Path set) must still mint a route whose
525 // Health JSON carries the platform's fully-materialized defaults for
526 // every field it left at zero. An un-defaulted interval_ms of 0
527 // reaching the edge would mean a tight-loop probe against a customer
528 // upstream.
529 if h := rec.HealthOrDefault(); h != nil {
530 withDefaults := h.WithDefaults()
531 // Health is a struct of strings and ints — json.Marshal cannot fail
532 // on it (no channels, funcs, cyclic pointers, unsupported types).
533 // The err == nil guard exists to fail CLOSED on the impossible case
534 // rather than panic: leaving health == "" would mean the route
535 // mints unprobed, not that a bad value reaches the edge.
536 if b, err := json.Marshal(withDefaults); err == nil {
537 health = string(b)
538 }
539 }
540 return addr, addrs, health
541}
542
543// shapeMatches reports whether an existing route is the same ROUTE the
544// record describes — the gate on adoption (design §4.2), and so the gate on
545// this pass acquiring removal authority over a row it did not mint.
546//
547// Channel, Environment, Hibernate and Steering are deliberately absent: those
548// are the columns the record is taking ownership OF, and a hand-added route
549// on the wrong channel is the whole reason to adopt one. Requiring them to
550// agree would make adoption impossible in exactly the case it exists for —
551// Steering named explicitly here because a record carrying
552// steering:"latency" over an unprovisioned row is a real deployment shape
553// (an operator hand-added the route before the console's steering PATCH
554// door existed to touch it), and refusing that adoption forever would buy
555// nothing: convergeOwned stamps Steering onto the row unconditionally the
556// moment adoption succeeds (ruled 2026-08-23, plan amendment b485ec5,
557// superseding this task's own first pass, which compared it here).
558//
559// Backing, Tunnel and Caps are deliberately required to be empty (unset) on
560// the existing row — registry.Route's own doc comments name these three as
561// the box-scoped operator columns, and a record has a field for none of
562// them, so a record can never fully describe a row that carries one:
563// - Backing "unit" names a carlos-app@<host> systemd tenant; adopting
564// across it would claim a process shape the record says nothing about.
565// - Tunnel is routing-decisive and OUTRANKS the Socket/Addr a record can
566// state: internal/edge picks the tunnel proxy before it ever looks at
567// Socket, so a tunnel-backed route can agree with a record on every
568// other compared column while every byte it actually serves goes down
569// a channel the record cannot see or reclaim.
570// - Caps is the operator-granted, box-local capability grant column
571// (port:25, host-role, ...). registry.Delete's purge of a route's
572// grants is already treated as a human-gated, last-resort action
573// elsewhere in this file (the shape-drift log below); adoption must not
574// hand that purge to an unattended bucket-record deletion.
575//
576// A row this pass cannot state the shape of is a row this pass must not
577// claim to describe, no matter how well everything else lines up.
578//
579// These three refusals guard the adoption INSTANT, not the row forever after:
580// `carlos route -backing/-unit/-tunnel/-grant` still work on a Provisioned
581// row, so an already-owned route can acquire one of them later. See the
582// removal phase's comment in Reconcile for what that costs.
583//
584// Platform and Canary are likewise required false: those flags mark a row
585// domains.Reconcile minted and re-mints/removes on its own authority
586// (mintedRoute leaves Slug/Pool/Region/DBPath at their zero values, so a
587// platform apex on an unconstrained-region box can otherwise agree with a
588// record on every other compared column). Adoption must not hand this
589// pass's removal authority to a row another pass already owns and
590// periodically rebuilds — nor let it write Channel/Environment onto a row
591// the CLI itself refuses (platformCanaryOwnedRow, cli.go) for the same
592// reason.
593//
594// Everything else that identifies the route is compared, including the
595// derived Socket and DBPath — two rows agreeing on kind and app but not on
596// where the database lives are not the same route, and adopting across that
597// difference would point a running process at a second copy of its own data.
598func shapeMatches(existing, want registry.Route) bool {
599 return shapeMismatch(existing, want) == ""
600}
601
602// shapeMismatch names the first column on which existing disagrees with
603// want — including the box-scoped and pass-ownership guards shapeMatches
604// documents — or "" when every compared column agrees. It backs both
605// shapeMatches and the adoption refusal log: printing only a couple of
606// columns there (the original message did app/kind/addr) leaves every
607// refusal over any OTHER column looking identical and undiagnosable to the
608// operator the refusal message exists for.
609//
610// `replication` is box-scoped and inexpressible in a record exactly like
611// Backing/Tunnel/Caps, and is DELIBERATELY not listed here — see the
612// asymmetry note above the removal loop below for why refusing adoption over
613// it would cost the fleet the backfill and buy nothing.
614func shapeMismatch(existing, want registry.Route) string {
615 switch {
616 case existing.Backing != "":
617 return fmt.Sprintf("backing %q (box-scoped, not expressible in a record)", existing.Backing)
618 case existing.Tunnel != "":
619 return fmt.Sprintf("tunnel %q (box-scoped, not expressible in a record)", existing.Tunnel)
620 case existing.Caps != "":
621 return fmt.Sprintf("caps %q (box-scoped, not expressible in a record)", existing.Caps)
622 case existing.Platform:
623 return "platform (owned by the domains reconcile pass)"
624 case existing.Canary:
625 return "canary (owned by the domains reconcile pass)"
626 // Replicas is deliberately absent, replication's twin: the column is
627 // CONVERGED in the owned arm (SetReplicas — the record is authoritative
628 // in both directions), so a divergence here is repaired, never a reason
629 // to refuse adoption. Listing it would opt every route with a settled
630 // replica set out of the backfill permanently and invisibly, the exact
631 // trade the replication paragraph below refuses — and a replica set
632 // grants no authority over the route's process; it names where the
633 // account's mirrors live.
634 case existing.App != want.App:
635 return fmt.Sprintf("app %q/%q", existing.App, want.App)
636 case existing.Account != want.Account:
637 return fmt.Sprintf("account %q/%q", existing.Account, want.Account)
638 case existing.Kind != want.Kind:
639 return fmt.Sprintf("kind %q/%q", existing.Kind, want.Kind)
640 case existing.Addr != want.Addr:
641 return fmt.Sprintf("addr %q/%q", existing.Addr, want.Addr)
642 case existing.Addrs != want.Addrs:
643 return fmt.Sprintf("addrs %q/%q", existing.Addrs, want.Addrs)
644 case existing.Health != want.Health:
645 return fmt.Sprintf("health %q/%q", existing.Health, want.Health)
646 case existing.Unit != want.Unit:
647 return fmt.Sprintf("unit %q/%q", existing.Unit, want.Unit)
648 case existing.Slug != want.Slug:
649 return fmt.Sprintf("slug %q/%q", existing.Slug, want.Slug)
650 case existing.Pool != want.Pool:
651 return fmt.Sprintf("pool %q/%q", existing.Pool, want.Pool)
652 case existing.Region != want.Region:
653 return fmt.Sprintf("region %q/%q", existing.Region, want.Region)
654 case existing.Socket != want.Socket:
655 return fmt.Sprintf("socket %q/%q", existing.Socket, want.Socket)
656 case existing.DBPath != want.DBPath:
657 return fmt.Sprintf("db %q/%q", existing.DBPath, want.DBPath)
658 default:
659 return ""
660 }
661}
662
663// convergeOwned applies the record's Channel, Environment, StatusNamed,
664// UPSTREAM SET and Steering onto a route it owns — the columns the record is
665// authoritative for once it owns the row, whether ownership was just granted
666// by adoption or was already held by an earlier pass. Both the owned-route
667// converge branch and the adoption branch call this, so there is exactly one
668// place these columns get written from a record, never two copies drifting
669// apart.
670//
671// Addrs/Health converge UNCONDITIONALLY on divergence — no "only when the
672// record states one" guard of the kind Unit carries — and that difference is
673// deliberate. Unit has a supported out-of-band writer (`carlos route -unit`,
674// issue #110) whose stamp a record that never mentioned the field must not
675// treat as drift. The upstream set has no such writer: the record IS the
676// desired state, the console's PATCH door is how it changes, and clearing the
677// set (PATCH with an empty addrs) has to reach the row or that door would move
678// the bucket and nothing else. Both values come from upstreamCols, the same
679// derivation the mint uses, so a route minted from a record cannot be
680// "converged" onto anything but what it already carries.
681//
682// Steering converges the same way, for the same reason: it has no supported
683// out-of-band writer either (Task 3's console door and CLI both write the
684// RECORD, never the row directly), so "" is as much an answer as "latency" —
685// a record that clears its steering opt-in must reach the row, or the
686// console's PATCH door would move the bucket and nothing else, the exact
687// silent-stomp shape the upstream-set paragraph above already documents.
688//
689// StatusNamed converges only when the record STATES it (design §3, the same
690// tri-state Hibernate carries): absent means "this record does not speak to
691// the status page", and stamping a derived false over an operator's `carlos
692// add -status-named` would be the silent stomp this design exists to end.
693// Unlike Hibernate it is converged at the adoption instant too — it decides
694// what a public page prints, not whether a live process is allowed to sleep.
695//
696// Every write logs on success as well as on failure. These are the writes
697// that REVERT an operator's own `carlos route -environment` or `-hibernate`;
698// a row that moved with nothing saying so is the exact failure class of the
699// production incident behind #130, seen from the outside.
700//
701// dryRun is a parameter here, not left to callers, deliberately: this
702// function is reached from two branches, and a dry-run guard living in only
703// one of the two callers is exactly the single-unguarded-write failure mode
704// the pass exists to prevent (design §4.3). Guarding here means both call
705// sites are safe by construction, not by each one remembering to check.
706func convergeOwned(reg *registry.Registry, existing registry.Route, rec Record, policy *UpdatePolicy, policyKnown bool, dryRun bool) {
707 if rec.Channel != "" && existing.Channel != rec.Channel {
708 if dryRun {
709 log.Printf("provision: dry-run: would converge %s channel %s -> %s", rec.Host, existing.Channel, rec.Channel)
710 } else if err := reg.SetChannel(rec.Host, rec.Channel); err != nil {
711 log.Printf("provision: converge channel on %s: %v", rec.Host, err)
712 } else {
713 log.Printf("provision: converged %s channel %s -> %s", rec.Host, existing.Channel, rec.Channel)
714 }
715 }
716 if existing.Environment != rec.Environment {
717 if dryRun {
718 log.Printf("provision: dry-run: would converge %s environment %q -> %q", rec.Host, existing.Environment, rec.Environment)
719 } else if err := reg.SetEnvironment(rec.Host, rec.Environment); err != nil {
720 log.Printf("provision: converge environment on %s: %v", rec.Host, err)
721 } else {
722 log.Printf("provision: converged %s environment %q -> %q", rec.Host, existing.Environment, rec.Environment)
723 }
724 }
725 if rec.StatusNamed != nil && existing.StatusNamed != *rec.StatusNamed {
726 if dryRun {
727 log.Printf("provision: dry-run: would converge %s status-named %v -> %v", rec.Host, existing.StatusNamed, *rec.StatusNamed)
728 } else if err := reg.SetStatusNamed(rec.Host, *rec.StatusNamed); err != nil {
729 log.Printf("provision: converge status-named on %s: %v", rec.Host, err)
730 } else {
731 log.Printf("provision: converged %s status-named %v -> %v", rec.Host, existing.StatusNamed, *rec.StatusNamed)
732 }
733 }
734 // One UPDATE for all three columns, because they are one fact: where the
735 // route proxies (addr, addrs — two spellings of the same thing, which
736 // registry.AddrList folds together) and the probe that decides which of
737 // those upstreams the edge will send traffic to. Writing them separately
738 // would leave a window in which a route's new upstreams are live under the
739 // OLD probe's path/expected-status — a probe that can mark every new
740 // upstream dark — and leaving addr out entirely would leave the row saying
741 // two contradictory things forever (see upstreamCols).
742 if addr, addrs, health := upstreamCols(rec); existing.Addr != addr ||
743 existing.Addrs != addrs || existing.Health != health {
744 if dryRun {
745 log.Printf("provision: dry-run: would converge %s upstreams addr %q -> %q, addrs %q -> %q (probe %q -> %q)",
746 rec.Host, existing.Addr, addr, existing.Addrs, addrs, existing.Health, health)
747 } else if err := reg.SetUpstreams(rec.Host, addr, addrs, health); err != nil {
748 log.Printf("provision: converge upstreams on %s: %v", rec.Host, err)
749 } else {
750 log.Printf("provision: converged %s upstreams addr %q -> %q, addrs %q -> %q (probe %q -> %q)",
751 rec.Host, existing.Addr, addr, existing.Addrs, addrs, existing.Health, health)
752 }
753 }
754 if existing.Steering != rec.Steering {
755 if dryRun {
756 log.Printf("provision: dry-run: would converge %s steering %q -> %q", rec.Host, existing.Steering, rec.Steering)
757 } else if err := reg.SetSteering(rec.Host, rec.Steering); err != nil {
758 log.Printf("provision: converge steering on %s: %v", rec.Host, err)
759 } else {
760 log.Printf("provision: converged %s steering %q -> %q", rec.Host, existing.Steering, rec.Steering)
761 }
762 }
763 // Replicas CONVERGES, where steering merely repoints: the bucket's value
764 // is authoritative in BOTH directions, including down to none — a set
765 // that shrank must actually shrink on the row, or the edges file goes on
766 // publishing a replica placement the account no longer has. This is the
767 // one deliberate widening the owned arm permits, and the reason is in
768 // Record.Replicas' doc comment: replica placement is the bucket's
769 // decision to own, not an operator column to preserve.
770 if want := replicaSet(rec.Replicas); existing.Replicas != want {
771 if dryRun {
772 log.Printf("provision: dry-run: would converge %s replicas %q -> %q", rec.Host, existing.Replicas, want)
773 } else if err := reg.SetReplicas(rec.Host, want); err != nil {
774 log.Printf("provision: converge replicas on %s: %v", rec.Host, err)
775 } else {
776 log.Printf("provision: converged %s replicas %q -> %q", rec.Host, existing.Replicas, want)
777 }
778 }
779 // The replica MARK converges the same both-ways way, from the same
780 // record: it decides how the edges file publishes the row (replica
781 // mark vs plain home), which is exactly the fact steering's fold sorts
782 // hosts by. Upsert would preserve it wrong (a re-mint carrying no role
783 // must DEMOTE a row that carried one), so the narrow setter owns it.
784 if want := rec.Role == "replica"; existing.Replica != want {
785 if dryRun {
786 log.Printf("provision: dry-run: would converge %s replica mark %v -> %v", rec.Host, existing.Replica, want)
787 } else if err := reg.SetReplica(rec.Host, want); err != nil {
788 log.Printf("provision: converge replica mark on %s: %v", rec.Host, err)
789 } else {
790 log.Printf("provision: converged %s replica mark %v -> %v", rec.Host, existing.Replica, want)
791 }
792 }
793 // Update settings (settings spec §4): the record over the app policy,
794 // both directions, including back to "". Left alone when the policy
795 // could not be read this pass.
796 if policyKnown {
797 if want := EffectiveUpdates(rec, policy).Column(); existing.UpdateSettings != want {
798 if dryRun {
799 log.Printf("provision: dry-run: would converge %s update settings %q -> %q", rec.Host, existing.UpdateSettings, want)
800 } else if err := reg.SetUpdateSettings(rec.Host, want); err != nil {
801 log.Printf("provision: converge update settings on %s: %v", rec.Host, err)
802 } else {
803 log.Printf("provision: converged %s update settings %q -> %q", rec.Host, existing.UpdateSettings, want)
804 }
805 }
806 }
807}
808
809// Reconcile materializes bucket instance records into registry routes for
810// THIS agent's region and pools: mint on appearance, converge seed, remove
811// on record deletion — removal only for routes this pass (or an earlier one
812// running the same scope) minted, marked by Provisioned, the same authority
813// discipline as domains.Reconcile's canary removal. Per-item failures log
814// and skip; a failed account-manifest or record read additionally disables
815// the removal phase for the whole pass — a transient read failure must
816// never mass-retire a fleet. Minting is additive and always safe, so it
817// proceeds regardless.
818func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg ReconcileConfig) error {
819 if cfg.Now == nil {
820 cfg.Now = time.Now
821 }
822
823 // scan is the record-collection phase over st: the in-scope records by
824 // host, the hosts whose record is there but cannot be used (protect),
825 // and whether anything failed to read (readFailed, which disables
826 // removal for the whole pass). The pass scans s; a pass over a record
827 // view snapshot that has a removal to decide scans the bucket too
828 // (freshStore), so both judge every record by exactly these rules (S3
829 // change-index design §4.4).
830 type recordScan struct {
831 recs map[string]Record
832 protect map[string]bool
833 readFailed bool
834 }
835 scan := func(st release.Store) (recordScan, error) {
836 known, readFailed, err := knownAccounts(ctx, st)
837 if err != nil {
838 return recordScan{}, err
839 }
840
841 // --- collect in-scope records --------------------------------------------
842 keys, partial, err := listAppSubtree(ctx, st, known, "instances/")
843 if err != nil {
844 return recordScan{}, fmt.Errorf("provision: list apps: %w", err)
845 }
846 if partial {
847 readFailed = true // unseen records must not read as retired
848 }
849 recs := map[string]Record{} // host -> record, in-scope only
850 // protect holds hosts whose record KEY parsed (ParseRecordKey succeeded)
851 // but whose body could not be trusted — unparseable JSON, or a body that
852 // disagrees with its own path. Such a host must NEVER be treated as "the
853 // bucket retired it": a non-additive Record encoding change reaching an
854 // older binary would make EVERY existing record fail exactly this way,
855 // and without this guard one tick would delete every provisioned route
856 // in scope. This is narrower than readFailed (which vetoes removal for
857 // the WHOLE pass): only the specific host with the unusable body is held
858 // back — every other record still converges/removes normally the same
859 // pass.
860 protect := map[string]bool{}
861 var recordKeys []string
862 for _, key := range keys {
863 if _, _, _, ok := ParseRecordKey(key, known); ok {
864 recordKeys = append(recordKeys, key)
865 }
866 }
867 for _, part := range chunks(recordKeys) {
868 bodies := getEach(ctx, st, part)
869 for _, key := range part {
870 acct, app, host, _ := ParseRecordKey(key, known)
871 raw, err := bodies[key].body, bodies[key].err
872 if err != nil {
873 log.Printf("provision: skipping record %s: read failed: %v", key, err)
874 // The record's absence from recs below must not read as "the
875 // bucket retired it" — a transient Get failure disables removal
876 // for the whole pass, same as an unreadable account manifest.
877 readFailed = true
878 continue
879 }
880 rec, err := DecodeRecord(raw)
881 if err != nil {
882 log.Printf("provision: skipping record %s: malformed: %v", key, err)
883 protect[host] = true
884 continue
885 }
886 if rec.Host != host || rec.Account != acct || rec.App != app {
887 log.Printf("provision: skipping record %s: disagrees with its own path (host=%q account=%q app=%q)",
888 key, rec.Host, rec.Account, rec.App)
889 protect[host] = true
890 continue
891 }
892 // The record IS the authorization, and this pass turns its host into
893 // a socket path and a database path (DefaultSocket/DefaultDBPath)
894 // and a registry primary key. The console's create door enforces
895 // this same shape — but a door is not a guarantee about what is
896 // already in the bucket: anything with bucket write access, and any
897 // record written by a different (older, or future) writer, arrives
898 // here unexamined. Both doors hold the invariant, neither trusts
899 // the other. Protected like a corrupted body, and for the same
900 // reason: a record that cannot be used is not a record that was
901 // retired.
902 if !ValidHostShape(rec.Host) {
903 log.Printf("provision: skipping record %s: host %q is not a hostname", key, rec.Host)
904 protect[host] = true
905 continue
906 }
907 // ValidateShape is the console create door's policy check — but a
908 // door is not a guarantee about what is already in the bucket (the
909 // same reasoning as the host-shape check just above). This pass
910 // materializes whatever it finds, so a record whose Kind/Addr/Unit/
911 // Environment combination is malformed must be caught here too, or
912 // a bad value becomes a live route. Protected like a corrupted
913 // body: the record is there, it just cannot be used.
914 if err := ValidateShape(rec); err != nil {
915 log.Printf("provision: skipping record %s: invalid shape: %v", key, err)
916 protect[host] = true
917 continue
918 }
919 // ValidateShape deliberately does not inspect Channel (it validates
920 // shape fields, and Channel is not one), but a channel-less record
921 // must not become — or converge — a route pinned to no channel:
922 // that is issue #130's failure mode arriving through a different
923 // door than the one Task 3 closed. Protected like a corrupted body:
924 // the record is there, it just cannot be used.
925 if rec.Channel == "" {
926 log.Printf("provision: skipping record %s: empty channel", key)
927 protect[host] = true
928 continue
929 }
930 // Scope. For a home-boxed record this is the region/pool test it
931 // always was; for a static or public-addr one the region half widens
932 // to every platform box in the deployment (everywhereClass, above).
933 // A replica record narrows it: it belongs ONLY to the box whose
934 // placement is the record's own Region — the placement the create
935 // door validated as armed — so a replica record minted on the wrong
936 // region would mint a route with no mirror behind it and no lease
937 // speaking to it, a process holding live order data nobody
938 // authorized into that region (the residence rule, #162). Every
939 // other box skips it exactly like any other out-of-scope record;
940 // the replica's own box sees Region == cfg.Region through the
941 // ordinary test above.
942 if !cfg.inScope(rec.Region, rec.Pool, everywhereClass(rec)) {
943 continue // another agent's jurisdiction, not this pass's business
944 }
945 // Two accounts writing the same host is a collision this pass
946 // cannot arbitrate silently: whichever record this listing reaches
947 // FIRST wins the host, and the loser is skipped loudly rather than
948 // overwriting the winner in the map with no trace of the conflict.
949 if prev, dup := recs[rec.Host]; dup {
950 log.Printf("provision: duplicate host %s: keeping %s, skipping %s", rec.Host,
951 RecordKey(prev.Account, prev.App, prev.Host), RecordKey(rec.Account, rec.App, rec.Host))
952 continue
953 }
954 recs[rec.Host] = rec
955 }
956 }
957 return recordScan{recs: recs, protect: protect, readFailed: readFailed}, nil
958 }
959 sc, err := scan(s)
960 if err != nil {
961 return err
962 }
963 recs, protect, readFailed := sc.recs, sc.protect, sc.readFailed
964
965 // --- manifest seed cache, per (acct, app), lazily -------------------------
966 // known=false (a genuine read failure, not "no manifest") means the seed
967 // value returned is a mint-only fallback: safe to hand a BRAND NEW route
968 // (nothing existing to protect; the next readable pass converges it),
969 // never safe to feed the converge branch below — SetSeed-ing an existing
970 // route to "" on a transient blip would erase a working seed for a
971 // reason that has nothing to do with the manifest actually changing.
972 // Each app with records costs two reads below (its manifest and its trash
973 // marker), made lazily, one at a time, inside the record loop. Both are
974 // read here first, eight at a time, for every app the records name; the
975 // lookups below then judge exactly what they judged before, from these
976 // results, and read for themselves only for an app not prefetched (a
977 // route's app with no record, at removal time).
978 // The manifest is kept only as the seed it renders to, never whole: the
979 // lookup below has always reduced it to that at once.
980 type manifestRead struct {
981 seed string
982 policy *UpdatePolicy
983 err error
984 }
985 type tombRead struct {
986 tomb *release.AppTombstone
987 err error
988 }
989 var recApps []string
990 appOf := map[string]acctApp{}
991 for _, rec := range recs {
992 k := acctApp{rec.Account, rec.App}
993 id := rec.Account + "/" + rec.App
994 if _, seen := appOf[id]; !seen {
995 appOf[id] = k
996 recApps = append(recApps, id)
997 }
998 }
999 slices.Sort(recApps)
1000 manifests := map[acctApp]manifestRead{}
1001 tombs := map[acctApp]tombRead{}
1002 var prefetchMu sync.Mutex
1003 forEach(recApps, func(id string) {
1004 k := appOf[id]
1005 // Trash first, as the record loop asks: an app in the trash, or one
1006 // whose marker will not read, is never minted for, so its manifest
1007 // was never read and is not read here either.
1008 tomb, terr := release.ReadAppTombstone(ctx, s, k.acct, k.app)
1009 prefetchMu.Lock()
1010 tombs[k] = tombRead{tomb, terr}
1011 prefetchMu.Unlock()
1012 if tomb != nil || terr != nil {
1013 return
1014 }
1015 m, merr := GetManifest(ctx, s, k.acct, k.app)
1016 r := manifestRead{err: merr}
1017 if merr == nil {
1018 r.seed = seedJSON(m)
1019 r.policy = m.Updates
1020 }
1021 prefetchMu.Lock()
1022 manifests[k] = r
1023 prefetchMu.Unlock()
1024 })
1025
1026 seedCache := map[acctApp]string{}
1027 seedKnown := map[acctApp]bool{}
1028 seenManifest := map[acctApp]bool{}
1029 seedFor := func(acct, app string) (seed string, known bool) {
1030 k := acctApp{acct, app}
1031 if seenManifest[k] {
1032 return seedCache[k], seedKnown[k]
1033 }
1034 seenManifest[k] = true
1035 var rendered string
1036 var err error
1037 if r, ok := manifests[k]; ok {
1038 rendered, err = r.seed, r.err
1039 } else {
1040 var m *Manifest
1041 if m, err = GetManifest(ctx, s, acct, app); err == nil {
1042 rendered = seedJSON(m)
1043 }
1044 }
1045 if err != nil {
1046 if errors.Is(err, os.ErrNotExist) {
1047 seedCache[k], seedKnown[k] = "", true // absent manifest: confirmed no seed
1048 return seedCache[k], seedKnown[k]
1049 }
1050 // A record already written is truth regardless — the manifest
1051 // only governs the API door — but a genuine read failure must
1052 // still hold back removal: this pass cannot tell a disabled
1053 // feature from a transient blip, so it treats it as the latter.
1054 log.Printf("provision: manifest %s/%s: read failed: %v", acct, app, err)
1055 readFailed = true
1056 return "", false
1057 }
1058 seedCache[k], seedKnown[k] = rendered, true
1059 return seedCache[k], seedKnown[k]
1060 }
1061
1062 // policyFor is the app's update policy for this pass (settings spec
1063 // §3.2). known is false when the manifest could not be read: the caller
1064 // then leaves every row's update_settings as it is, because a read
1065 // failure is not evidence the policy went away. An absent manifest is
1066 // known: no policy.
1067 policyFor := func(acct, app string) (*UpdatePolicy, bool) {
1068 k := acctApp{acct, app}
1069 if r, ok := manifests[k]; ok {
1070 if r.err != nil {
1071 return nil, errors.Is(r.err, os.ErrNotExist)
1072 }
1073 return r.policy, true
1074 }
1075 m, err := GetManifest(ctx, s, acct, app)
1076 switch {
1077 case errors.Is(err, os.ErrNotExist):
1078 return nil, true
1079 case err != nil:
1080 return nil, false
1081 }
1082 return m.Updates, true
1083 }
1084 // policyUnread says a row was not minted because policyFor could not
1085 // read the app's policy. A read failure is not "no policy": minting now
1086 // would mint an instance that inherits manual as auto, and root would
1087 // adopt the head over its data's build. Absent is known.
1088 policyUnread := func(host string) {
1089 log.Printf("provision: %s: not minted this pass: the app's update policy could not be read", host)
1090 }
1091
1092 // --- trash cache, per (acct, app), lazily ---------------------------------
1093 // An app in the trash provisions nothing, and its existing routes are
1094 // RETIRED — both halves are this pass's job (#340).
1095 //
1096 // Retirement used to be domains.Reconcile's alone, on the grounds that it
1097 // held the production-flag guard. That deferral was a live bug: the
1098 // domains pass runs only on the box holding the housekeeper lease
1099 // (agent.go's domainsLoop wraps it in runHousekeeperDuties), and its
1100 // registry write is local, so on a multi-box deployment every OTHER box
1101 // kept a trashed app's route forever and went on serving it from its own
1102 // edge. Single-box deployments were correct by accident. This pass runs
1103 // on every box and already reads the marker, so the trigger belongs here
1104 // — and the guard comes with it (cfg.ReleasePub). Both passes now remove
1105 // and neither mints, so they agree rather than fight.
1106 //
1107 // One marker, two DIFFERENT answers, and conflating them is the trap:
1108 //
1109 // declineMint — true also when the marker is UNREADABLE. Not minting
1110 // is the reversible direction, and a bucket writer who could plant
1111 // garbage here to stall minting can already retire the instance
1112 // outright by deleting its record.
1113 // removable — true ONLY for a marker positively read and present,
1114 // whose app is not flagged production. Removal is not reversible, so
1115 // an unreadable marker (or an unreadable flag) must never reach it.
1116 //
1117 // trashOver is that lookup over st, answering from pre where pre holds
1118 // the app: the pass's own over s and its prefetch, and the removal
1119 // phase's over the bucket when this pass reads a snapshot (below).
1120 trashOver := func(st release.Store, pre map[acctApp]tombRead) func(acct, app string) trashState {
1121 trashCache := map[acctApp]trashState{}
1122 trashSeen := map[acctApp]bool{}
1123 return func(acct, app string) trashState {
1124 k := acctApp{acct, app}
1125 if trashSeen[k] {
1126 return trashCache[k]
1127 }
1128 trashSeen[k] = true
1129 var tomb *release.AppTombstone
1130 var err error
1131 if r, ok := pre[k]; ok {
1132 tomb, err = r.tomb, r.err
1133 } else {
1134 tomb, err = release.ReadAppTombstone(ctx, st, acct, app)
1135 }
1136 switch {
1137 case err != nil:
1138 log.Printf("provision: trash marker for %s/%s unreadable: %v — not minting for this app", acct, app, err)
1139 trashCache[k] = trashState{declineMint: true}
1140 case tomb != nil:
1141 log.Printf("provision: %s/%s is in the trash — not minting instance routes for it", acct, app)
1142 trashCache[k] = trashState{declineMint: true, removable: productionClear(ctx, st, cfg.ReleasePub, acct, app)}
1143 }
1144 return trashCache[k]
1145 }
1146 }
1147 trashFor := trashOver(s, tombs)
1148 trashedFor := func(acct, app string) bool { return trashFor(acct, app).declineMint }
1149
1150 // --- mint / converge / conflict -------------------------------------------
1151 routes, err := reg.List()
1152 if err != nil {
1153 return fmt.Errorf("provision: list routes: %w", err)
1154 }
1155 byHost := map[string]registry.Route{}
1156 for _, rt := range routes {
1157 byHost[rt.Host] = rt
1158 }
1159 mintLayout := mintLayoutReader(ctx, s, cfg)
1160 // The import journal is read before the records are acted on, every
1161 // pass (seeded-instances spec §5.7, seedreconcile.go). A host it handled
1162 // is skipped by the loop below, and every journaled host by the removal
1163 // phase: the journal owns the row until its row is gone. An unreadable
1164 // journal could hide any host, so the removal phase does not run.
1165 journalHandled, journaled, journalOK := journalPass(ctx, s, reg, cfg, recs, readFailed, protect)
1166 if !journalOK {
1167 readFailed = true
1168 }
1169
1170 for _, rec := range recs {
1171 if journalHandled[rec.Host] {
1172 continue
1173 }
1174 if trashedFor(rec.Account, rec.App) {
1175 continue
1176 }
1177 if cfg.RunSeen != nil && rec.ActiveRun != nil {
1178 cfg.RunSeen(rec)
1179 }
1180 // A mint-only fallback ("", the record-already-written-is-truth
1181 // case) is fine here even when the manifest is unreadable this
1182 // pass — there is no existing seed value a brand new route could
1183 // clobber, and a later pass converges it once the manifest reads.
1184 seed, seedIsKnown := seedFor(rec.Account, rec.App)
1185 existing, ok := byHost[rec.Host]
1186 // A seed is dispatched before anything else (seeded-instances spec
1187 // §3.2, codex 13): an aborted one is finished, an unready one is
1188 // skipped, and a seeded row never carries the manifest's seed
1189 // command.
1190 if rec.Seed != nil {
1191 var row *registry.Route
1192 if ok {
1193 row = &existing
1194 }
1195 switch seedAction(rec, row, cfg, mintLayout, journalOK) {
1196 case seedSkip:
1197 continue
1198 case seedSwap:
1199 // The swap mints the import's row, so it waits for the
1200 // app's update policy as a mint does (settings spec §3.2).
1201 p, pKnown := policyFor(rec.Account, rec.App)
1202 if !pKnown {
1203 policyUnread(rec.Host)
1204 continue
1205 }
1206 swapImport(ctx, s, reg, cfg, rec, row, mintLayout, p)
1207 continue
1208 case seedFinishAbort:
1209 // A --from seed this box minted is still running: its row
1210 // goes first, and a later pass that finds no row rolls the
1211 // seed back (seedreconcile.go).
1212 if ok && rec.Seed.Kind == seeds.KindReplica {
1213 retireAbortedSeedRow(ctx, reg, cfg, rec, existing)
1214 continue
1215 }
1216 finishAbortedSeed(ctx, s, reg, cfg, rec)
1217 continue
1218 }
1219 seed, seedIsKnown = "", true
1220 }
1221 // A move's hold is judged first (moves spec §9.6). A missing row is
1222 // minted held by mintHeld, which has a ready mint's refusals and
1223 // the same claim hook (below) around its write, so the hold and the
1224 // claim both have to allow the mint. An existing row is held in
1225 // place with a narrow status write, outside the claim hook as the
1226 // other status writers are, and then converged as usual: the owned
1227 // branch's narrow setters never touch status or hold_op.
1228 if cfg.Hold != nil {
1229 if status, op, held := cfg.Hold(rec); held {
1230 if !ok {
1231 p, known := policyFor(rec.Account, rec.App)
1232 mintHeld(ctx, reg, cfg, rec, seed, status, op, p, known)
1233 continue
1234 }
1235 holdExisting(ctx, s, reg, cfg, rec, existing, status, op)
1236 }
1237 }
1238
1239 // Refusals that need no claim are decided BEFORE cfg.claimInstall: the
1240 // hook activates the host guard and writes inventory, effects that a
1241 // record this pass will not act on must never cause. Only work that
1242 // is going to touch the registry reaches it.
1243 var prior *registry.Route
1244 nested := false
1245 if ok {
1246 prior = &existing
1247 nested = NestedPaths(cfg.DataDir, existing)
1248 } else {
1249 // A box that declared CARLOS_INBOUND=none (design §8) has told
1250 // this pass what work it will ACCEPT, not made a claim about its
1251 // network — fleet boxes are already outbound-only dial in this
1252 // estate. Ordinary instance records carry no placement
1253 // vocabulary of their own (that is ReconcileSidecars' gate,
1254 // Task 9, over SidecarRecord.Placement); every routed instance
1255 // this pass mints needs inbound service, so a no-inbound box
1256 // must refuse ALL of them outright, loudly, rather than mint
1257 // something it can never serve — a misfiled record becomes a
1258 // log line here, never a silent outage.
1259 if cfg.Inbound == InboundNone {
1260 log.Printf("provision: refusing to mint route %s (app %q acct %q): this box declares "+
1261 "CARLOS_INBOUND=none (no-inbound) and cannot serve routed instance traffic", rec.Host, rec.App, rec.Account)
1262 continue
1263 }
1264 // The box's layout decides the paths a fresh row is minted at.
1265 // Read once per pass, lazily, so a pass with nothing to mint
1266 // never reads it; a read that fails defers every mint on this
1267 // pass (ReconcileConfig.Layout).
1268 var lerr error
1269 if nested, lerr = mintLayout(); lerr != nil {
1270 log.Printf("provision: %s: not minting this pass: the box's layout intent could not be read: %v", rec.Host, lerr)
1271 continue
1272 }
1273 }
1274 // The route's whole shape now comes from the record (routeFor):
1275 // Kind, Addr, Unit, Environment, StatusNamed, Hibernate and the
1276 // path derivation. A record carrying none of them still mints the
1277 // exact route this branch hardcoded before they existed — that is
1278 // the additivity rule, pinned by TestRecordWireShapeIsAdditive and
1279 // TestReconcileMints.
1280 p, pKnown := policyFor(rec.Account, rec.App)
1281 if !ok && !pKnown {
1282 policyUnread(rec.Host)
1283 continue
1284 }
1285 nr := RouteFor(rec, cfg.DataDir, seed, nested, p)
1286 // A box that cannot provide this instance's config must not
1287 // admit it: it would start without its secrets, and a keyed
1288 // sibling would be left with a route that lost the race to one
1289 // that was never going to work. Refused before the claim hook, the
1290 // write and the dry-run line, so a dry run reports what a real pass
1291 // would do.
1292 if !ok && cfg.ConfigGate != nil {
1293 if err := cfg.ConfigGate(ctx, nr); err != nil {
1294 continue
1295 }
1296 }
1297 // An existing row this record neither owns nor may adopt is known to be
1298 // left alone before any claim work: with claims on, the hook would
1299 // activate the guard and write inventory only to be refused after.
1300 // (Flag off, the same decision is made inside the write below.)
1301 if ok && cfg.ClaimEnabled && !(existing.Provisioned && existing.App == rec.App && existing.Account == rec.Account) {
1302 if reason := shapeMismatch(existing, nr); reason != "" {
1303 log.Printf("provision: %s exists but is not the route this record describes (%s) — leaving it alone",
1304 rec.Host, reason)
1305 continue
1306 }
1307 }
1308 selected := ClaimRecord{Host: rec.Host, Key: RecordKey(rec.Account, rec.App, rec.Host), Family: "instance", Account: rec.Account, App: rec.App, CreatedAt: rec.CreatedAt, Value: rec, Route: nr, DryRun: cfg.DryRun}
1309 if err := cfg.claimInstall(ctx, selected, prior, func(owner registry.ClaimOwnership) error {
1310 switch {
1311 case !ok:
1312 nr.ClaimOwnership = owner
1313 if cfg.DryRun {
1314 log.Printf("provision: dry-run: would mint %s route %s (app %q acct %q channel %q)",
1315 nr.Kind, rec.Host, rec.App, rec.Account, rec.Channel)
1316 return nil
1317 }
1318 if err := reg.Upsert(nr); err != nil {
1319 log.Printf("provision: mint %s: %v", rec.Host, err)
1320 return nil
1321 }
1322 log.Printf("provision: minted %s route %s (app %q acct %q)", nr.Kind, rec.Host, rec.App, rec.Account)
1323 case existing.Provisioned && existing.App == rec.App && existing.Account == rec.Account:
1324 // The columns this record owns (design §4.1, issue #130). Narrow
1325 // UPDATEs, never a re-Upsert: a read-modify-write racing a
1326 // deploy's SetVersions or the activator's SetNextDue would
1327 // clobber those, and Upsert cannot express "clear Environment"
1328 // at all (it preserves a non-empty value by design).
1329 convergeOwned(reg, existing, rec, p, pKnown, cfg.DryRun)
1330 // Absent means "this record does not speak to hibernation" —
1331 // never a derivation stamped over an operator's own setting.
1332 //
1333 // Converging hibernation OFF also drops the route's replication
1334 // rung, inside registry.SetHibernate — a rung is read only on
1335 // the activator's wake path, so on a route the record has just
1336 // stopped hibernating it would describe nothing that happens
1337 // while `carlos routes` went on printing it. A record cannot
1338 // express the rung (it is box-scoped, like caps and tunnel), so
1339 // this pass cannot restore one either; the drop is LOGGED with
1340 // the flip rather than left for an operator to notice, because
1341 // it is the one operator-typed value a converge tick can
1342 // silently spend.
1343 //
1344 // A nested row keeps its flag whatever the record says: only a
1345 // row the activator manages is one the layout revert can stop
1346 // and move back, so clearing the flag would strand its files
1347 // under the instance tree (design §8.2, downgrade rules). A row
1348 // with a layout journal is mid-move whatever its paths say, and
1349 // recovery needs it managed just the same.
1350 if rec.Hibernate != nil && existing.Hibernate != *rec.Hibernate && !*rec.Hibernate &&
1351 (NestedPaths(cfg.DataDir, existing) || LayoutJournalExists(cfg.DataDir, rec.Host)) {
1352 log.Printf("provision: %s: record asks to clear hibernate but the row's paths are nested; revert the layout first", rec.Host)
1353 } else if rec.Hibernate != nil && existing.Hibernate != *rec.Hibernate {
1354 dropped := ""
1355 if !*rec.Hibernate && existing.Replication != "" {
1356 dropped = fmt.Sprintf(" (replication %q cleared: only a hibernating route's rung is read)", existing.Replication)
1357 }
1358 if cfg.DryRun {
1359 log.Printf("provision: dry-run: would converge %s hibernate %v -> %v%s", rec.Host, existing.Hibernate, *rec.Hibernate, dropped)
1360 } else if err := reg.SetHibernate(rec.Host, *rec.Hibernate); err != nil {
1361 log.Printf("provision: converge hibernate on %s: %v", rec.Host, err)
1362 } else {
1363 log.Printf("provision: converged %s hibernate %v -> %v%s", rec.Host, existing.Hibernate, *rec.Hibernate, dropped)
1364 }
1365 }
1366 if existing.Gate != rec.Gate {
1367 if cfg.DryRun {
1368 log.Printf("provision: dry-run: would converge %s gate %s -> %s", rec.Host, gateWord(existing.Gate), gateWord(rec.Gate))
1369 } else if err := reg.SetGate(rec.Host, rec.Gate); err != nil {
1370 log.Printf("provision: converge gate on %s: %v", rec.Host, err)
1371 } else {
1372 log.Printf("provision: converged %s gate %s -> %s", rec.Host, gateWord(existing.Gate), gateWord(rec.Gate))
1373 }
1374 }
1375 // The seed source goes first, and the seed command follows only
1376 // once it has landed (seeded-instances preflight M10). A row
1377 // whose record has a seed gets its seed column before its seed
1378 // command is cleared, so between the two writes it is placeable
1379 // (seededRestore, never fresh). In the other order, or with the
1380 // source write failed and the command cleared anyway, the row
1381 // would hold neither, and a wake then could boot it empty.
1382 sourceConverged := true
1383 if col := SeedColumn(rec); existing.SeedSource != col {
1384 if cfg.DryRun {
1385 log.Printf("provision: dry-run: would converge %s seed source", rec.Host)
1386 } else if err := reg.SetSeedSource(rec.Host, col); err != nil {
1387 log.Printf("provision: converge seed source on %s: %v", rec.Host, err)
1388 sourceConverged = false
1389 }
1390 }
1391 // seedIsKnown guards against stamping a transient manifest-read
1392 // failure's "" fallback over a working seed — see seedFor.
1393 if sourceConverged && seedIsKnown && existing.Seed != seed {
1394 if cfg.DryRun {
1395 log.Printf("provision: dry-run: would converge %s seed", rec.Host)
1396 } else if err := reg.SetSeed(rec.Host, seed); err != nil {
1397 log.Printf("provision: converge seed on %s: %v", rec.Host, err)
1398 }
1399 }
1400 // Unit is compared only when the RECORD states one: `carlos
1401 // route -unit` (issue #110) is the supported way to stamp the
1402 // unit owning a route's process out of band, and a record that
1403 // never spoke to Unit must not treat that stamp as drift — or
1404 // this would fire on a healthy, correctly-stamped route every
1405 // pass, forever.
1406 // Addr is deliberately NOT compared here any more: it is a
1407 // CONVERGED column now (convergeOwned writes it beside Addrs and
1408 // Health through SetUpstreams), and a drift log that kept naming a
1409 // column the same pass had just fixed would fire forever on a
1410 // healthy row — the exact "undiagnosable refusal" this message was
1411 // widened to prevent. What remains are the columns nothing
1412 // converges in place: the route CLASS and the two filesystem paths
1413 // derived from it.
1414 //
1415 // A nested row's paths differ from the record's flat derivation
1416 // by design (the layout pass moved them), so when that is the
1417 // only difference the line says so, rather than pointing an
1418 // operator at `carlos remove`.
1419 want := RouteFor(rec, cfg.DataDir, seed, false, p)
1420 unitDrift := rec.Unit != "" && want.Unit != existing.Unit
1421 if want.Kind != existing.Kind || unitDrift ||
1422 want.Socket != existing.Socket || want.DBPath != existing.DBPath {
1423 if want.Kind == existing.Kind && !unitDrift && NestedPaths(cfg.DataDir, existing) {
1424 log.Printf("provision: %s: row is on the nested layout (db %q socket %q); the record's shared paths are not drift",
1425 rec.Host, existing.DBPath, existing.Socket)
1426 } else {
1427 log.Printf("provision: %s: record describes a different route SHAPE than the row "+
1428 "(kind %q/%q unit %q/%q socket %q/%q db %q/%q) — not converged in place; "+
1429 "`carlos remove %s` purges this route's capability grants (registry.Delete's contract, "+
1430 "not a bug) before the next pass re-mints it, so treat that as a last resort, not a routine fix",
1431 rec.Host, want.Kind, existing.Kind, want.Unit, existing.Unit,
1432 want.Socket, existing.Socket, want.DBPath, existing.DBPath, rec.Host)
1433 }
1434 }
1435 default:
1436 // A route exists that this pass did not mint. If it is the same
1437 // route the record describes, adopt it — that is what makes
1438 // backfilling the fleet a bucket-side write rather than a box
1439 // session (design §4.2). If it is not, leave it exactly as alone
1440 // as before: adoption grants this pass's removal phase authority
1441 // over the row, so the match test is the only thing standing
1442 // between a backfill and a retired production route.
1443 // A hand-added row on a nested box (`carlos add` there mints
1444 // nested too) matches the record at the nested paths; the row's
1445 // own shape says which derivation to compare against.
1446 want := RouteFor(rec, cfg.DataDir, seed, NestedPaths(cfg.DataDir, existing), p)
1447 if reason := shapeMismatch(existing, want); reason != "" {
1448 log.Printf("provision: %s exists but is not the route this record describes (%s) — leaving it alone",
1449 rec.Host, reason)
1450 return nil
1451 }
1452 if cfg.DryRun {
1453 log.Printf("provision: dry-run: would adopt existing route %s (app %q acct %q) — this record would own its channel and environment",
1454 rec.Host, rec.App, rec.Account)
1455 // convergeOwned reports channel and environment together,
1456 // and only the columns that actually differ (design §4.1) —
1457 // a fixed "converging channel X -> Y" line here would both
1458 // omit environment (the defect this fixes) and claim a
1459 // convergence that would not happen when the route is
1460 // already on the record's channel. Every write inside
1461 // convergeOwned is dry-run guarded there, so this call
1462 // writes nothing.
1463 convergeOwned(reg, existing, rec, p, pKnown, true)
1464 return nil
1465 }
1466 if err := reg.SetProvisioned(rec.Host, true); err != nil {
1467 log.Printf("provision: adopt %s: %v", rec.Host, err)
1468 return nil
1469 }
1470 log.Printf("provision: adopted existing route %s (app %q acct %q) — this record now owns its channel and environment",
1471 rec.Host, rec.App, rec.Account)
1472 // Hibernate is deliberately NOT converged here even when
1473 // explicit: this first pass over a hand-added route should
1474 // change what it serves and who owns the column, not whether a
1475 // live process is allowed to sleep. It converges on the next
1476 // pass through the owned branch above, by which point the
1477 // adoption is already visible in the log.
1478 convergeOwned(reg, existing, rec, p, pKnown, cfg.DryRun)
1479 }
1480 return nil
1481 }); err != nil {
1482 log.Printf("provision: claim install %s: %v", rec.Host, err)
1483 }
1484 }
1485
1486 // --- removal, only when nothing failed to read this pass ------------------
1487 // Three conditions, each load-bearing: Provisioned (the flag — not the
1488 // app, not the channel — grants removal authority; a hand-added route
1489 // never has it), scope (this box's own region/pool jurisdiction — a
1490 // foreign box's provisioned route is not this pass's business), and a
1491 // retirement the BUCKET decided rather than one this pass guessed —
1492 // either the record is gone, or the app carries a trash marker (#340,
1493 // and see the trash cache above for why the marker's two answers are
1494 // not the same answer).
1495 //
1496 // Scope is asked through the SAME inScope as the record scan above, with
1497 // the class read off the row (everywhereRoute) because the record whose
1498 // class it was may be exactly what is gone. Both halves must widen
1499 // together:
1500 // a far box that mints a static route under the widened mint gate and then
1501 // judges it out of scope here would serve a retired site forever.
1502 //
1503 // What reaches reg.Delete here may carry Backing, Tunnel or Caps even
1504 // though shapeMismatch refuses to ADOPT a row carrying any of them: those
1505 // refusals cover the adoption instant only. `carlos route -backing/-unit/
1506 // -tunnel/-grant` all still work on a Provisioned row, so a route can
1507 // acquire an operator column after it was minted or adopted and remain
1508 // one unattended record-deletion away from registry.Delete's grant purge.
1509 // That asymmetry predates this pass's adoption step; it is stated here so
1510 // the adoption guard is not mistaken for a standing guarantee.
1511 //
1512 // `replication` (discussion #10) is the same KIND of column — box-scoped,
1513 // operator-typed, inexpressible in a record — and is deliberately exempt
1514 // from shapeMismatch rather than added to it. The three that are listed
1515 // there are listed for a reason that does not hold for this one: each
1516 // grants or relocates AUTHORITY over a route's process (a capability, a
1517 // process owner, an ingress path), and adoption is itself an authority
1518 // transfer, so handing bucket control a row that already carries one is
1519 // the thing worth stopping to look at. A replication rung grants nothing.
1520 // It names how often a database is copied off the box, and it survives
1521 // adoption unchanged: adoption's own write is SetProvisioned, the owned
1522 // branch converges through narrow setters that never touch this column,
1523 // and the one path that DOES clear it — registry.SetHibernate, when a
1524 // record turns hibernation off — is the honest answer rather than a loss,
1525 // since a non-hibernating route's rung is read by nobody.
1526 //
1527 // Listing it would mean an operator who tuned a large sleeping tenant's
1528 // rung had, by that act, opted the route out of the backfill this pass
1529 // exists to perform — permanently and invisibly, until someone thought to
1530 // clear a column to let a record adopt a row. That is the exact opposite
1531 // trade from the other three: it would strand precisely the hibernating
1532 // tenants the ladder was built for, in exchange for guarding an authority
1533 // this column does not carry.
1534 //
1535 // retire runs that phase over one set of facts: the records, the
1536 // protected hosts and the trash lookup. With stopAtFirst it removes
1537 // nothing and reports whether any row would go. A record view
1538 // snapshot's absence is not the bucket's (a write that missed its bump
1539 // is missing from a Complete snapshot until the backstop rebuild), so a
1540 // pass over a snapshot that would remove anything decides every removal
1541 // again from the bucket's own walk, below (S3 change-index design §4.4).
1542 retire := func(recs map[string]Record, protect map[string]bool, trashFor func(acct, app string) trashState, stopAtFirst bool) (wouldRemove bool) {
1543 for _, rt := range routes {
1544 // Removal authority is scoped to this pass's OWN kind. A sidecar
1545 // row is Provisioned too — that flag is what grants a pass removal
1546 // authority — and it can never appear in the INSTANCE record set,
1547 // so without this guard the two passes would delete each other's
1548 // rows on every tick, forever, with no error anywhere and nothing
1549 // in the logs that reads as a failure. ReconcileSidecars carries
1550 // the mirror of this guard; both directions are asserted
1551 // (TestInstanceReconcileNeverRemovesSidecarRows).
1552 if rt.Kind == SidecarKind {
1553 continue
1554 }
1555 if !rt.Provisioned || !cfg.inScope(rt.Region, rt.Pool, everywhereRoute(rt)) {
1556 continue
1557 }
1558 // Two triggers, asked in this order because the first is the
1559 // cheap one: the bucket retired the instance (its record is
1560 // gone), or the bucket retired the whole APP (a trash marker,
1561 // while the record stays put). Both name the bucket's own
1562 // decision; neither is a guess this pass made.
1563 //
1564 // The trash lookup needs both halves of the app's identity off
1565 // the row. A Provisioned row always carries them — routeFor
1566 // copies them from the record — so a row missing either is a
1567 // shape this pass did not mint, and not one it will retire.
1568 why := "record gone"
1569 if _, ok := recs[rt.Host]; ok {
1570 if rt.Account == "" || rt.App == "" || !trashFor(rt.Account, rt.App).removable {
1571 continue
1572 }
1573 why = "app in the trash"
1574 }
1575 if journaled[rt.Host] {
1576 continue // the import journal owns the row (seedreconcile.go)
1577 }
1578 if protect[rt.Host] {
1579 log.Printf("provision: not removing %s: its record exists but could not be read cleanly this pass", rt.Host)
1580 continue
1581 }
1582 // A participant of a running operation keeps its row whatever
1583 // the record's region says (moves spec §9.6, claim spec §8): a
1584 // move's source after the flip, its target during a rollback.
1585 // The operation deletes the row at its own finalization.
1586 if cfg.KeepParticipant != nil && cfg.KeepParticipant(rt.Host) {
1587 log.Printf("provision: not removing %s (%s): participant of a running operation", rt.Host, why)
1588 continue
1589 }
1590 if stopAtFirst {
1591 return true
1592 }
1593 if cfg.DryRun {
1594 log.Printf("provision: dry-run: would remove retired %s route %s (%s)", rt.Kind, rt.Host, why)
1595 continue
1596 }
1597 if err := cfg.claimRetire(ctx, reg, rt); err != nil {
1598 log.Printf("provision: remove retired %s route %s: %v", rt.Kind, rt.Host, err)
1599 continue
1600 }
1601 log.Printf("provision: removed retired %s route %s (%s)", rt.Kind, rt.Host, why)
1602 }
1603 return false
1604 }
1605 if readFailed || !retire(recs, protect, trashFor, onSnapshot(s)) {
1606 return nil
1607 }
1608 // A snapshot pass with a removal to make: the record walk again, over
1609 // the bucket, and the removal phase over what it read, by the walk's
1610 // own rules (invalid shapes and empty channels protected before scope,
1611 // hosts matched across every key, the bucket's trash marker). Anything
1612 // it cannot read keeps every row this pass, as in the walk.
1613 live := freshStore(s)
1614 lsc, err := scan(live)
1615 if err != nil || lsc.readFailed {
1616 log.Printf("provision: not removing any route this pass: the record view lacks a record or holds a trash marker, and the bucket's own walk could not be read cleanly%s", walkErrSuffix(err))
1617 return nil
1618 }
1619 retire(lsc.recs, lsc.protect, trashOver(live, nil), false)
1620 return nil
1621}
1622
1623// mintHeld mints a move participant's row held (moves spec §9.6 "mint"): the
1624// record's own route at flat paths whatever the box's layout intent (Phase 1
1625// lands flat, G11), with status and hold_op set by the INSERT, so it is never
1626// ready for even one pass. The refusals a ready mint has (no-inbound box,
1627// the config gate, a dry run) apply unchanged, and so does its claim hook
1628// (claim Plan 6b, cfg.claimInstall): the write runs inside it, after every
1629// refusal that needs no claim, so with claims on the row carries the owner
1630// the hook grants and the hook's guard and inventory effects happen only for
1631// a mint that is going to land. With claims off the hook is a pass-through.
1632// A status that is not a hold, or an empty op, mints nothing: a
1633// participant's row is never minted ready.
1634func mintHeld(ctx context.Context, reg *registry.Registry, cfg ReconcileConfig, rec Record, seed, status, op string, policy *UpdatePolicy, policyKnown bool) {
1635 if !registry.IsHold(status) || op == "" {
1636 log.Printf("provision: %s: not minting: the move's hold %q by %q is not a hold", rec.Host, status, op)
1637 return
1638 }
1639 if cfg.Inbound == InboundNone {
1640 log.Printf("provision: refusing to mint held route %s (app %q acct %q): this box declares "+
1641 "CARLOS_INBOUND=none (no-inbound) and cannot serve routed instance traffic", rec.Host, rec.App, rec.Account)
1642 return
1643 }
1644 if !policyKnown {
1645 // The same wait as the ordinary mint: an instance that inherits
1646 // manual from its app must not land held as auto.
1647 log.Printf("provision: %s: not minted this pass: the app's update policy could not be read", rec.Host)
1648 return
1649 }
1650 nr := RouteFor(rec, cfg.DataDir, seed, false, policy)
1651 nr.Status, nr.HoldOp = status, op
1652 if cfg.ConfigGate != nil {
1653 if err := cfg.ConfigGate(ctx, nr); err != nil {
1654 return
1655 }
1656 }
1657 written := false
1658 selected := ClaimRecord{Host: rec.Host, Key: RecordKey(rec.Account, rec.App, rec.Host), Family: "instance", Account: rec.Account, App: rec.App, CreatedAt: rec.CreatedAt, Value: rec, Route: nr, DryRun: cfg.DryRun}
1659 if err := cfg.claimInstall(ctx, selected, nil, func(owner registry.ClaimOwnership) error {
1660 nr.ClaimOwnership = owner
1661 if cfg.DryRun {
1662 log.Printf("provision: dry-run: would mint %s route %s held %s by %s (app %q acct %q channel %q)",
1663 nr.Kind, rec.Host, status, op, rec.App, rec.Account, rec.Channel)
1664 return nil
1665 }
1666 if err := reg.Upsert(nr); err != nil {
1667 log.Printf("provision: mint held %s: %v", rec.Host, err)
1668 return nil
1669 }
1670 log.Printf("provision: minted %s route %s held %s by %s (app %q acct %q)", nr.Kind, rec.Host, status, op, rec.App, rec.Account)
1671 written = true
1672 return nil
1673 }); err != nil {
1674 log.Printf("provision: claim install held %s: %v", rec.Host, err)
1675 }
1676 if written && cfg.HoldWritten != nil {
1677 cfg.HoldWritten(rec.Host)
1678 }
1679}
1680
1681// hostHoldLocks is one in-process mutex per host for the hold decision on
1682// an existing row: see WithHostHoldLock.
1683var hostHoldLocks struct {
1684 mu sync.Mutex
1685 hosts map[string]*sync.Mutex
1686}
1687
1688// WithHostHoldLock runs fn holding host's hold lock, exclusively within this
1689// process. Reconcile's hold decision for an existing row (holdExisting: the
1690// record re-read, the hold asked again of it, the conditional write) runs
1691// under it, so nothing that ends the hold can run between that read and
1692// that write. The landing's open and its finalisation (moves Task 10,
1693// movesland.go) MUST take this same lock around their record read and their
1694// ReleaseHold or DeleteIfHold, and so must any later writer that ends a hold
1695// reconcile could re-assert from the record: without it, an open that lands
1696// in the window is re-held on the next line. Writers in other processes
1697// (root) are met by SetHoldIf's own condition, not by this lock.
1698//
1699// A leaf: its holder reads one record, writes one row and takes no other
1700// lock. Nothing takes it while holding the held-stage lock (G30), and
1701// nothing holds it across a wait on another host.
1702func WithHostHoldLock(host string, fn func()) {
1703 hostHoldLocks.mu.Lock()
1704 if hostHoldLocks.hosts == nil {
1705 hostHoldLocks.hosts = map[string]*sync.Mutex{}
1706 }
1707 l := hostHoldLocks.hosts[host]
1708 if l == nil {
1709 l = &sync.Mutex{}
1710 hostHoldLocks.hosts[host] = l
1711 }
1712 hostHoldLocks.mu.Unlock()
1713 l.Lock()
1714 defer l.Unlock()
1715 fn()
1716}
1717
1718// holdExisting holds an existing row for a move (G10): Upsert never changes
1719// a hold on conflict, so an unheld row of this pass's own is held with
1720// SetHoldIf. A row already held by op is left as is; a row held by another
1721// op is that op's, and is left alone; a row this pass did not mint (not
1722// Provisioned, or another app's) is not taken into a move.
1723//
1724// Three rules keep a hold write from closing a row an operation has opened,
1725// or from landing over a hold written since the pass's listing (fix round 1):
1726//
1727// - a recovery hold (restoring, releasing) is never written over an
1728// unheld row. The recovering operation holds its own row as it enters
1729// the recovery, and opens it while the record still names the
1730// recovery (moves spec §7.4 reverting `probed`, §7.8 `opened`), so an
1731// unheld row there is one it opened. Only a missing row is minted.
1732// - the record is read again just before the write, and the hold is
1733// asked again of the fresh record, all under the host's hold lock
1734// (WithHostHoldLock), which the open and the finalisation take too:
1735// this pass listed the records before the routes, and a target opens
1736// its row only after its commit, so a fresh record still at landing
1737// means the row has not been opened, and under the lock it cannot be
1738// opened before the write lands.
1739// - the write is SetHoldIf expecting the row unheld: a hold another op
1740// wrote since the listing (or since this pass's own route read) is
1741// never overwritten; the row is left to it and the next pass judges
1742// it afresh.
1743//
1744// SetHoldIf can wait out root's activation guard and fail (G30): logged,
1745// and the next pass tries again.
1746func holdExisting(ctx context.Context, s release.Store, reg *registry.Registry, cfg ReconcileConfig, rec Record, existing registry.Route, status, op string) {
1747 switch {
1748 case !registry.IsHold(status) || op == "":
1749 log.Printf("provision: %s: not holding: the move's hold %q by %q is not a hold", rec.Host, status, op)
1750 return
1751 case existing.HoldOp == op:
1752 return
1753 case existing.HoldOp != "":
1754 log.Printf("provision: %s: held %s by %s, not by %s; leaving it", rec.Host, existing.Status, existing.HoldOp, op)
1755 return
1756 case status == "restoring" || status == "releasing":
1757 return
1758 case !existing.Provisioned || existing.App != rec.App || existing.Account != rec.Account:
1759 log.Printf("provision: %s: the row is not this pass's (app %q acct %q); not holding it for %s", rec.Host, existing.App, existing.Account, op)
1760 return
1761 case cfg.DryRun:
1762 log.Printf("provision: dry-run: would hold %s %s by %s", rec.Host, status, op)
1763 return
1764 }
1765 WithHostHoldLock(rec.Host, func() {
1766 // From the bucket, not a record view snapshot: the listing may be one.
1767 fresh, err := GetRecord(ctx, freshStore(s), rec.Account, rec.App, rec.Host)
1768 if err != nil {
1769 log.Printf("provision: %s: not holding this pass: re-read the record: %v", rec.Host, err)
1770 return
1771 }
1772 if st, o, ok := cfg.Hold(*fresh); !ok || st != status || o != op {
1773 return // the move moved on since the listing; the next pass judges it afresh
1774 }
1775 if cfg.beforeHoldWrite != nil {
1776 cfg.beforeHoldWrite(rec.Host)
1777 }
1778 held, err := reg.SetHoldIf(rec.Host, status, op, "")
1779 if err != nil {
1780 log.Printf("provision: hold %s %s by %s: %v", rec.Host, status, op, err)
1781 return
1782 }
1783 if !held {
1784 log.Printf("provision: %s: not held %s by %s: the row's hold changed under this pass; the next pass judges it afresh", rec.Host, status, op)
1785 return
1786 }
1787 log.Printf("provision: held %s %s by %s (was %s)", rec.Host, status, op, existing.Status)
1788 if cfg.HoldWritten != nil {
1789 cfg.HoldWritten(rec.Host)
1790 }
1791 })
1792}
1793
1794// gateWord renders a gate column for a log line: "open" or "set", never the
1795// hash — it has no business in a journal.
1796func gateWord(hash string) string {
1797 if hash == "" {
1798 return "open"
1799 }
1800 return "set"
1801}
1802
1803// knownAccounts lists accounts/ and materializes the sqid set this pass
1804// trusts for ParseRecordKey's "known account" guard — the same three-way
1805// sqid agreement domains.Reconcile enforces (reconcile.go:106): the key
1806// path's sqid, the manifest body's own sqid, and the frozen encoding of the
1807// body's id must all agree, or the manifest is a lie about identity and is
1808// skipped, loudly. A tombstoned manifest (m.Deleted()) is skipped too —
1809// retired accounts own no in-scope records.
1810//
1811// Listing failure returns an error (nothing here can proceed without it); a
1812// per-manifest read or parse failure instead sets readFailed and is
1813// otherwise skipped — never fatal to the pass, but disabling enough that the
1814// removal phase in Reconcile refuses to run on it.
1815func knownAccounts(ctx context.Context, s release.Store) (known map[string]bool, readFailed bool, err error) {
1816 known = map[string]bool{domains.OpsSqid: true}
1817 keys, err := s.List(ctx, "accounts/")
1818 if err != nil {
1819 return nil, false, fmt.Errorf("provision: list accounts: %w", err)
1820 }
1821 var manifestKeys []string
1822 for _, key := range keys {
1823 if _, ok := domains.AccountManifestSqid(key); ok {
1824 manifestKeys = append(manifestKeys, key)
1825 }
1826 }
1827 for _, part := range chunks(manifestKeys) {
1828 bodies := getEach(ctx, s, part)
1829 for _, key := range part {
1830 sqid, _ := domains.AccountManifestSqid(key)
1831 body, err := bodies[key].body, bodies[key].err
1832 if err != nil {
1833 log.Printf("provision: skipping %s: read failed: %v", key, err)
1834 readFailed = true
1835 continue
1836 }
1837 var m domains.AccountManifest
1838 if err := json.Unmarshal(body, &m); err != nil || m.ID <= 0 || m.Sqid == "" {
1839 log.Printf("provision: skipping %s: malformed account manifest: %v", key, err)
1840 readFailed = true
1841 continue
1842 }
1843 if m.Sqid != sqid || domains.Sqid(m.ID) != sqid {
1844 log.Printf("provision: skipping %s: sqid %q/%q is not Sqid(%d)=%q", key, sqid, m.Sqid, m.ID, domains.Sqid(m.ID))
1845 readFailed = true
1846 continue
1847 }
1848 if m.Deleted() {
1849 continue
1850 }
1851 known[sqid] = true
1852 }
1853 }
1854 return known, readFailed, nil
1855}
1856
1857// trashGate builds the per-(acct, app) "is this app in the trash" predicate
1858// the SIDECAR pass gates MINTING on, caching each answer for the pass. what
1859// names the route family in the log line and is all that ever varied between
1860// callers.
1861//
1862// The instance pass had the same shape until #340 gave it a SECOND answer —
1863// whether a trashed app's routes may also be RETIRED, which needs the
1864// production-flag guard (trashFor/trashState above). Retirement is not a
1865// question this predicate can answer, so the sidecar pass keeps the
1866// mint-only one rather than inheriting an authority its own removal phase
1867// was never designed around. A trashed app's sidecar rows therefore linger
1868// until their records go, which is the same behaviour every pass had before
1869// #340 and the safe direction — but it is a gap, recorded here and in the
1870// design's follow-ups, not a decision this branch made.
1871//
1872// The predicate answers TRUE for an unreadable or malformed marker as well as
1873// a valid one: not minting is the reversible direction, and a bucket writer
1874// who could plant a marker to stall minting can already retire the record
1875// outright.
1876func trashGate(ctx context.Context, s release.Store, what string) func(acct, app string) bool {
1877 cache := map[acctApp]bool{}
1878 seen := map[acctApp]bool{}
1879 return func(acct, app string) bool {
1880 k := acctApp{acct, app}
1881 if seen[k] {
1882 return cache[k]
1883 }
1884 seen[k] = true
1885 tomb, err := release.ReadAppTombstone(ctx, s, acct, app)
1886 switch {
1887 case err != nil:
1888 log.Printf("provision: trash marker for %s/%s unreadable: %v — not minting for this app", acct, app, err)
1889 cache[k] = true
1890 case tomb != nil:
1891 log.Printf("provision: %s/%s is in the trash — not minting %s routes for it", acct, app, what)
1892 cache[k] = true
1893 }
1894 return cache[k]
1895 }
1896}
1897
1898// ListAllRecords enumerates EVERY instance record in the deployment — the
1899// same account-qualified walk Reconcile does (list accounts/ for the trusted
1900// sqid set, list apps/, ParseRecordKey, Get, decode), exported for callers
1901// that need deployment-GLOBAL route facts rather than one app's.
1902//
1903// It exists because the registry is not deployment-global truth. Routes are
1904// provisioned per box: an everywhereClass record (exec/unit/socket kinds) is
1905// homed to ONE box, so that host is simply absent from every other box's
1906// registry. Any deployment-wide decision derived from one box's registry
1907// therefore changes meaning when the work moves to a different box — which,
1908// for DNS steering (internal/steering), meant a housekeeper lease migration
1909// could delete a live steered host's records and NXDOMAIN it until the lease
1910// came back. The records ARE the deployment's truth; this is how to read all
1911// of them.
1912//
1913// FAIL-CLOSED, and that is the deliberate difference from Reconcile's own
1914// walk. Reconcile tolerates a partial read (it logs, sets readFailed, and
1915// vetoes only its removal phase) because it must still MINT on a degraded
1916// pass. A caller deriving global state has no such split: a short list is
1917// indistinguishable from records having been deleted, so every read failure
1918// is an error and no records are returned —
1919//
1920// - accounts/ listing fails, or ANY account manifest is unreadable,
1921// malformed, or disagrees with its own sqid: that account's records all
1922// fail ParseRecordKey's known-account guard and would vanish silently
1923// from the result. Fatal.
1924// - apps/ listing fails, or ANY record object will not Get. Fatal.
1925//
1926// A record that READS but does not decode — or whose body disagrees with its
1927// own path — is the other class: a content problem in one object, which is
1928// skipped and logged (Reconcile's `protect` treatment, minus the registry
1929// bookkeeping). One malformed record must not blind a caller to every other.
1930func ListAllRecords(ctx context.Context, s release.Store) ([]Record, error) {
1931 recs, _, _, err := readAllRecords(ctx, s, true)
1932 return recs, err
1933}
1934
1935// ListAllRecordsStrict is ListAllRecords with one more answer: the hosts it
1936// skipped. ListAllRecords is already fail-closed on every READ failure (a
1937// listing that fails, a manifest or a record that will not Get is an error
1938// and no records come back), but it logs and drops a record that reads and
1939// then does not decode, or whose body disagrees with its own path, so the
1940// set it returns is silently short of those records. A caller that draws a
1941// conclusion from a record's absence (the seeds sweep deciding no record
1942// names a snapshot) cannot tell that gap from a record that is not there.
1943// ListAllRecordsStrict names the gap: unreadable holds the host of each
1944// record key whose body did not decode or disagreed with its path (keyed by
1945// the host in the path, never the body's). unreadable is non-nil on
1946// success. The records returned are exactly ListAllRecords' set, and every
1947// error is ListAllRecords' error; a caller deleting on absence must keep
1948// everything that belongs to a host in unreadable.
1949//
1950// One more blind spot, shared with ListAllRecords and Reconcile: records
1951// under an account with no manifest, or whose manifest is soft-deleted, are
1952// not in recs and not in unreadable. They are invisible, not unreadable.
1953func ListAllRecordsStrict(ctx context.Context, s release.Store) (recs []Record, unreadable map[string]bool, err error) {
1954 recs, _, unreadable, err = readAllRecords(ctx, s, true)
1955 if err != nil {
1956 return nil, nil, err
1957 }
1958 return recs, unreadable, nil
1959}
1960
1961// ListAllRecordsTolerant is ListAllRecords for a caller that acts on each
1962// record it reads, one at a time, and draws nothing from a record's absence
1963// (the run sweep's expiry and lost-run phases). An account manifest that
1964// will not read cleanly, an app whose records will not list, or a record
1965// that will not Get is logged and skipped, and complete comes back false;
1966// every other record is returned. err is only the top-level listing failing,
1967// when nothing could be read. A caller that also draws conclusions from
1968// absence must act on that only when complete is true.
1969func ListAllRecordsTolerant(ctx context.Context, s release.Store) (recs []Record, complete bool, err error) {
1970 recs, complete, _, err = readAllRecords(ctx, s, false)
1971 return recs, complete, err
1972}
1973
1974// readAllRecords is both: strict fails on any read failure, as
1975// ListAllRecords documents; tolerant logs it, skips what it could not read
1976// and reports complete false. In both modes unreadable holds the path host
1977// of every record that read but did not decode or disagreed with its path
1978// (skipped, logged); it is non-nil whenever err is nil.
1979func readAllRecords(ctx context.Context, s release.Store, strict bool) (recs []Record, complete bool, unreadable map[string]bool, err error) {
1980 complete = true
1981 unreadable = map[string]bool{}
1982 known, readFailed, err := knownAccounts(ctx, s)
1983 if err != nil {
1984 return nil, false, nil, err
1985 }
1986 if readFailed {
1987 if strict {
1988 return nil, false, nil, errors.New("provision: an account manifest could not be read cleanly — refusing to report a partial record set")
1989 }
1990 log.Printf("provision: an account manifest could not be read cleanly: that account's records are skipped")
1991 complete = false
1992 }
1993 // Sorted (listAppSubtree sorts) so the result — and anything folded
1994 // from it — is stable call to call, whatever order the store lists in.
1995 keys, partial, err := listAppSubtree(ctx, s, known, "instances/")
1996 if err != nil {
1997 return nil, false, nil, fmt.Errorf("provision: list apps: %w", err)
1998 }
1999 if partial {
2000 if strict {
2001 return nil, false, nil, errors.New("provision: an app's records could not be listed — refusing to report a partial record set")
2002 }
2003 log.Printf("provision: an app's records could not be listed: they are skipped")
2004 complete = false
2005 }
2006 var recordKeys []string
2007 for _, key := range keys {
2008 if _, _, _, ok := ParseRecordKey(key, known); ok {
2009 recordKeys = append(recordKeys, key)
2010 }
2011 }
2012 for _, part := range chunks(recordKeys) {
2013 bodies := getEach(ctx, s, part)
2014 for _, key := range part {
2015 acct, app, host, _ := ParseRecordKey(key, known)
2016 raw, err := bodies[key].body, bodies[key].err
2017 if err != nil {
2018 if strict {
2019 return nil, false, nil, fmt.Errorf("provision: read record %s: %w", key, err)
2020 }
2021 log.Printf("provision: skipping record %s: unreadable: %v", key, err)
2022 complete = false
2023 continue
2024 }
2025 rec, err := DecodeRecord(raw)
2026 if err != nil {
2027 log.Printf("provision: skipping record %s: malformed: %v", key, err)
2028 unreadable[host] = true
2029 continue
2030 }
2031 if rec.Host != host || rec.Account != acct || rec.App != app {
2032 log.Printf("provision: skipping record %s: disagrees with its own path (host=%q account=%q app=%q)",
2033 key, rec.Host, rec.Account, rec.App)
2034 unreadable[host] = true
2035 continue
2036 }
2037 recs = append(recs, rec)
2038 }
2039 }
2040 return recs, complete, unreadable, nil
2041}
2042
2043// listAppSubtree returns the keys a record reader needs to see: every key
2044// under apps/<acct>/<app>/<sub> for each known account and each of its apps,
2045// sorted — or, on a store that cannot be walked that way, every key under
2046// apps/, as the readers always listed. The callers parse every key exactly as
2047// before (ParseRecordKey, ParseSidecarKey); this changes only which keys they
2048// are shown.
2049//
2050// It exists because List(ctx, "apps/") returns every object under every app —
2051// every release artifact and every log chunk — to find the records among
2052// them. On the flagship on 2026-10-05 apps/ held 59,288 objects, 50,695 of
2053// them logs, for 152 instance records: sixty list pages per reader, three
2054// readers on every serial bump, and most of why a box took 16 to 43 seconds
2055// to notice a promote (#231). The walk is one ListDirs of apps/, one per
2056// known account, and one small List per app.
2057//
2058// It finds the same records the full listing did. A record's account is
2059// always one of known (the parsers refuse any other), and its app is a
2060// directory directly under the account, so no record lives anywhere the walk
2061// does not look — PROVIDED ListDirs answers in the bucket's shape. Only a
2062// store that says so (release.HasBucketListDirs: the S3 store, and a prefix
2063// store over one) is walked; the fleet door, whose ListDirs("apps/") is its
2064// own account's app directories, and DirStore, which lists by walking its
2065// whole root, are listed the old way. Assuming the shape on the fleet door
2066// found no records at all, and the reconcile pass would have read that as
2067// every route retired.
2068//
2069// Failure comes in two sizes. The top-level listing failing is an error, as
2070// the single List failing was. One account's or one app's listing failing is
2071// PARTIAL: the keys that were listed come back, and partial reports that some
2072// were not. The callers treat that as they treat a record they could not
2073// read — removal is vetoed for the whole pass, everything that was seen still
2074// converges — because a partial record set must never read as "the bucket
2075// retired the rest", and one app's bad minute must not stop every other app's
2076// routes from being minted.
2077func listAppSubtree(ctx context.Context, s release.Store, known map[string]bool, sub string) (keys []string, partial bool, err error) {
2078 if !release.HasBucketListDirs(s) {
2079 keys, err := s.List(ctx, "apps/")
2080 slices.Sort(keys) // Store.List promises no order; the walk's is sorted
2081 return keys, false, err
2082 }
2083 accts, err := s.ListDirs(ctx, "apps/")
2084 if err != nil {
2085 return nil, false, err
2086 }
2087 var acctPrefixes []string
2088 for _, ap := range accts {
2089 if known[strings.TrimSuffix(strings.TrimPrefix(ap, "apps/"), "/")] {
2090 acctPrefixes = append(acctPrefixes, ap)
2091 }
2092 }
2093
2094 var (
2095 mu sync.Mutex
2096 prefixes []string
2097 out []string
2098 )
2099 fail := func(what string, err error) {
2100 log.Printf("provision: listing %s: %v — its records are unseen this pass", what, err)
2101 partial = true
2102 }
2103 // A handful at a time, accounts and apps alike: each is one small round
2104 // trip, and a box walks a few dozen accounts and a few hundred apps.
2105 forEach(acctPrefixes, func(ap string) {
2106 apps, err := s.ListDirs(ctx, ap)
2107 mu.Lock()
2108 defer mu.Unlock()
2109 if err != nil {
2110 fail(ap, err)
2111 return
2112 }
2113 for _, p := range apps {
2114 prefixes = append(prefixes, p+sub)
2115 }
2116 })
2117 forEach(prefixes, func(p string) {
2118 keys, err := s.List(ctx, p)
2119 mu.Lock()
2120 defer mu.Unlock()
2121 if err != nil {
2122 fail(p, err)
2123 return
2124 }
2125 out = append(out, keys...)
2126 })
2127 slices.Sort(out)
2128 return out, partial, nil
2129}
2130
2131// readChunk is how many keys a judging loop prefetches at once: enough to keep
2132// listWalkers busy, few enough that a large record set is never held in
2133// memory whole, and that a loop stopping at its first bad read has read only
2134// a chunk ahead of it.
2135const readChunk = 64
2136
2137// chunks splits keys into runs of at most readChunk, in order.
2138func chunks(keys []string) [][]string {
2139 var out [][]string
2140 for len(keys) > readChunk {
2141 out = append(out, keys[:readChunk])
2142 keys = keys[readChunk:]
2143 }
2144 if len(keys) > 0 {
2145 out = append(out, keys)
2146 }
2147 return out
2148}
2149
2150// fetched is one prefetched object: its body, or why it could not be read.
2151type fetched struct {
2152 body []byte
2153 err error
2154}
2155
2156// getEach reads every key in keys, listWalkers at a time, and returns each
2157// one's body or error by key.
2158//
2159// The record readers used to Get each record in turn inside the loop that
2160// judged it. With 152 records on the flagship that was the bulk of a 12.5s
2161// reconcile and a 6.3s moves pass on every serial bump (#231, measured on the
2162// box with the fast loop's stage timing). The loops are unchanged: they still
2163// visit keys in the same order and judge each one exactly as before, only
2164// reading the body from here rather than from the store. A key's read error
2165// is that key's, as it was.
2166//
2167// Callers prefetch one chunk (chunks) at a time and judge it before the next.
2168// A context already cancelled reads nothing: every key reports ctx.Err(), so
2169// a judging loop sees a read failure for it, never a missing body.
2170func getEach(ctx context.Context, s release.Store, keys []string) map[string]fetched {
2171 out := make(map[string]fetched, len(keys))
2172 if err := ctx.Err(); err != nil {
2173 for _, k := range keys {
2174 out[k] = fetched{err: err}
2175 }
2176 return out
2177 }
2178 var mu sync.Mutex
2179 forEach(keys, func(k string) {
2180 body, _, err := s.Get(ctx, k)
2181 mu.Lock()
2182 out[k] = fetched{body: body, err: err}
2183 mu.Unlock()
2184 })
2185 return out
2186}
2187
2188// forEach runs f over items, listWalkers at a time, and returns when all
2189// have finished.
2190func forEach(items []string, f func(string)) {
2191 const listWalkers = 8
2192 var wg sync.WaitGroup
2193 sem := make(chan struct{}, listWalkers)
2194 for _, it := range items {
2195 wg.Add(1)
2196 sem <- struct{}{}
2197 go func(it string) {
2198 defer wg.Done()
2199 defer func() { <-sem }()
2200 f(it)
2201 }(it)
2202 }
2203 wg.Wait()
2204}
2205
2206// poolServed reports whether pool is one this box serves. An empty
2207// cfg.Pools means the shared "" pool ONLY, never "every pool" — the same
2208// convention domains.Config's AppsDomain=="" carries: unconfigured means the
2209// default, not "all".
2210func poolServed(pools []string, pool string) bool {
2211 if len(pools) == 0 {
2212 return pool == ""
2213 }
2214 return slices.Contains(pools, pool)
2215}
2216
2217// SharedInstanceEligibility answers, from this box's own reconcile config,
2218// whether it mints an ORDINARY instance record: one in the shared ""
2219// pool, needing inbound service. The edges file publishes both answers
2220// (serves_shared_pool, accepts_inbound) so the console never offers an
2221// automatic choice of a region whose boxes would refuse the record
2222// (nearest-region spec §6.1). It is the reconciler's own predicates —
2223// poolServed and the CARLOS_INBOUND=none refusal in the reconcile pass — so
2224// publication and minting cannot disagree.
2225func (cfg ReconcileConfig) SharedInstanceEligibility() (sharedPool, inbound bool) {
2226 return poolServed(cfg.Pools, ""), cfg.Inbound != InboundNone
2227}
2228
2229// seedJSON renders a manifest's seed argv as the wire text SetSeed and a
2230// minted route's Seed field carry: "" for nil/empty (no seed), else the
2231// JSON array of the argv strings.
2232func seedJSON(m *Manifest) string {
2233 if m == nil || len(m.Seed) == 0 {
2234 return ""
2235 }
2236 b, err := json.Marshal(m.Seed) // []string always marshals; err is unreachable
2237 if err != nil {
2238 return ""
2239 }
2240 return string(b)
2241}
2242