carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// SyncExec is the exec-backend half of config delivery: for every
2// activator-spawned route (socket-upstream, non-unit), materialize the
3// route's merged manifest into <dataDir>/<host>.config.env — the file
4// ExecBackend.Start layers UNDER the instance's own <host>.env at spawn.
5// Sync owns this namespace outright (nothing else writes *.config.env),
6// which is why an absent manifest REMOVES the file here, where the
7// unit-path Sync leaves /etc/carlos/apps files alone: there is no
8// hand-staged-file migration hazard by construction. See spec
9// 2026-08-02-exec-config-delivery-design.md.
10//
11// A third eligibility case (issue #18): a TCP-addr exec route (Backing ==
12// "", Socket == "", Addr or Addrs set) whose BOUND BUNDLE has opted in via
13// additive "exec_delivery": "file" manifest field (Manifest.ExecDelivery)
14// gets the identical file, materialized with the same layering guarantees,
15// environments-awareness, reap discipline, and mass-removal/path-traversal
16// guards as a socket-exec route — but is NEVER bounced (see syncExecHost
17// and execAddrCandidate): carlos routes to these processes without
18// spawning or owning them, so the restart half belongs to the app's own
19// supervisor. An un-opted TCP-addr route is skipped exactly as before this
20// case existed — see execAddrCandidate and SyncExec's checkOptedIn closure.
21//
22// "Bound bundle" is issue #195: the flag is read from the route's named
23// environment when that bundle STATES a value, and from the app's default
24// bundle otherwise. Before that it was app-level only, so opting one
25// relay route in wrote a file for every other TCP-addr route of the same
26// app — a fleet-wide switch thrown to deliver one service's credential.
27//
28// Resolution mirrors sync.go's exactly — the group is (Account, App,
29// Environment) and a route's bound environment is an overlay on the app's
30// default bundle (environments.go, pipelines design §4a). Only the file
31// semantics differ, and only where sync's ownership of the namespace
32// makes them differ: absence removes rather than skips, and there is no
33// key-loss guard (see syncExecHost).
34package appconfig
35
36import (
37 "bytes"
38 "context"
39 "crypto/ecdh"
40 "crypto/sha256"
41 "encoding/hex"
42 "errors"
43 "fmt"
44 "os"
45 "path/filepath"
46 "sort"
47 "strings"
48 "time"
49
50 "github.com/carlosframework/platform/internal/hostwrite"
51 "github.com/carlosframework/platform/internal/instancefs"
52 "github.com/carlosframework/platform/internal/provision"
53 "github.com/carlosframework/platform/internal/registry"
54 "github.com/carlosframework/platform/internal/release"
55)
56
57// configEnvSuffix names sync's file namespace under dataDir. Everything
58// with this suffix belongs to sync; nothing else in dataDir does.
59const configEnvSuffix = ".config.env"
60
61// nestedRoute reports whether rt's DB and socket are exactly the nested
62// layout's defaults (isolated-exec design §8.1). It is the same two-column
63// test activator.Classify makes, spelled out here rather than imported so
64// this package need not take a dependency on internal/activator for one
65// comparison.
66func nestedRoute(dataDir string, rt registry.Route) bool {
67 return rt.DBPath == provision.NestedDBPath(dataDir, rt.Host) && rt.Socket == provision.NestedSocket(rt.Host)
68}
69
70// ConfigEnvPath is where a route's rendered bundle lands: inside the
71// instance directory for a nested row (isolated-exec design §7), flat for
72// everything else, including opted-in addr routes whose supervisor reads
73// the flat path.
74func ConfigEnvPath(dataDir string, rt registry.Route) string {
75 if nestedRoute(dataDir, rt) {
76 return filepath.Join(provision.InstanceDir(dataDir, rt.Host), rt.Host+configEnvSuffix)
77 }
78 return filepath.Join(dataDir, rt.Host+configEnvSuffix)
79}
80
81// SyncExec resolves config once per distinct (Account, App, Environment)
82// among the box's exec-syncable routes and converges every such route's
83// <dataDir>/<host>.config.env, calling bounce(host) whenever a file's
84// content changed (including removal) so a running instance respawns
85// with fresh env on its next wake. Die-free per group, like Sync.
86//
87// The group carries Environment for the same reason Sync's does
88// (pipelines design §4a): two routes of ONE app bound to different
89// environments resolve to different manifests, and keying on (acct, app)
90// alone would render whichever route was seen first into both files.
91//
92// ack, if non-nil, is called once per route this pass actually WRITES a
93// changed file for, with that route's config-adoption acknowledgement
94// (spec §5, configack.go) — nil means no acks, same contract as Sync's. A
95// route whose file is REMOVED (manifest absent) gets no ack: there is
96// nothing for a route to have adopted once its config is gone, and a
97// removal is not one of spec §5's three delivery shapes.
98//
99// opts adjust one pass; see SkipFenced.
100func SyncExec(ctx context.Context, reg *registry.Registry, s release.Store, priv *ecdh.PrivateKey, dataDir string, bounce func(host string) error, appsDomain string, ack func(ConfigAck), opts ...SyncOption) error {
101 return SyncExecWithCallbacks(ctx, reg, s, priv, dataDir, appsDomain, Callbacks{Restart: bounce, Ack: ack}, opts...)
102}
103
104func SyncExecWithCallbacks(ctx context.Context, reg *registry.Registry, s release.Store, priv *ecdh.PrivateKey, dataDir, appsDomain string, callbacks Callbacks, opts ...SyncOption) error {
105 var o syncOptions
106 for _, opt := range opts {
107 opt(&o)
108 }
109 if o.hostWriteCount > 1 {
110 return errHostWriteOptions
111 }
112 hostWrites := o.hostWrites
113 bounce, ack := callbacks.Restart, callbacks.Ack
114 if hostWrites.Enabled && bounce == nil && callbacks.SelectedRestart != nil {
115 bounce = func(string) error { return hostwrite.ErrUnavailable }
116 }
117 routes, err := reg.List()
118 if err != nil {
119 return err
120 }
121 callbacks.enabled = hostWrites.Enabled
122 selected := map[string]hostwrite.Work{}
123 for _, rt := range routes {
124 if !execSyncable(rt) && !execSidecarCandidate(rt) && !execAddrCandidate(rt) {
125 continue
126 }
127 if !o.kept(rt) {
128 continue
129 }
130 if o.fenced != nil && o.fenced(rt.Host) {
131 continue
132 }
133 work, selectErr := hostWrites.Select(ctx, rt.Host, "config")
134 if selectErr != nil {
135 return fmt.Errorf("%s: select config work: %w", rt.Host, selectErr)
136 }
137 selected[rt.Host] = work
138 }
139 // hostSet names every route on this box, in or out of scope, so the
140 // namespace-collision guard below sees a route x.config even though
141 // x.config itself may never reach syncExecHost. Built once: the guard
142 // runs once per in-scope route in the materialization loop, and every
143 // route's Host is fixed for the pass.
144 hostSet := make(map[string]bool, len(routes))
145 for _, rt := range routes {
146 hostSet[rt.Host] = true
147 }
148 type groupKey struct{ acct, app, env string }
149 // The same three states Sync resolves to, with this path's own file
150 // semantics for the middle one: a manifest to converge to; "never
151 // configured at all" (m == nil, err == nil), which REMOVES the file
152 // here because sync owns the *.config.env namespace outright (nothing
153 // hand-staged can live in it); and "resolution failed" (err != nil),
154 // which leaves the file exactly as it is and says so, loudly, per
155 // route. An error is never absence — folding the two together would
156 // delete a live route's config on a bucket blip or a deleted bundle.
157 type resolution struct {
158 m *Manifest
159 err error
160 }
161 resolved := map[groupKey]resolution{}
162 // The default bundle is read once per (acct, app) and reused across
163 // that app's environments — same cache, same reason, as Sync's.
164 type appKey struct{ acct, app string }
165 type defaultRes struct {
166 m *Manifest
167 found bool
168 err error
169 }
170 defaults := map[appKey]defaultRes{}
171 // getIfExists, not Get: an app with no manifest object in the bucket
172 // at all must be distinguishable from one whose every var was unset.
173 // Get folds those together into an empty manifest; here the first
174 // removes the route's config file and the second converges it to an
175 // empty one.
176 loadDefault := func(acct, app string) defaultRes {
177 ak := appKey{acct, app}
178 if d, done := defaults[ak]; done {
179 return d
180 }
181 m, found, err := getIfExists(ctx, s, acct, app)
182 if err != nil {
183 err = fmt.Errorf("%s/%s: config fetch failed: %w", acct, app, err)
184 }
185 d := defaultRes{m: m, found: found, err: err}
186 defaults[ak] = d
187 return d
188 }
189 // optedInRes is checkOptedIn's cached verdict. known is false ONLY for
190 // a fetch error: v is a placeholder in that case, never a
191 // determination — see checkOptedIn's def.err branch for why that
192 // distinction matters to the stale-file notice below.
193 type optedInRes struct {
194 v bool
195 known bool
196 }
197 // optKey keys the opt-in decision on the BUNDLE a route's config
198 // actually resolves through, not on the app alone (issue #195). Two
199 // routes of one app bound to different environments can now differ, so
200 // the cache — and the once-per-pass logs below — must too.
201 type optKey struct{ acct, app, env string }
202 // optedIn caches the exec_delivery decision so a box with many TCP-addr
203 // routes on one bundle doesn't cost more than one manifest read for it
204 // (loadDefault/loadEnvBundle already dedupe the fetches; this dedupes
205 // the value-interpretation, including the logs below, which must fire
206 // at most once per (acct, app, env) per pass, not once per route).
207 optedIn := map[optKey]optedInRes{}
208 // envBundles caches the named-bundle read the same way loadDefault
209 // caches the default's, and for the same reason.
210 type envRes struct {
211 m *Manifest
212 found bool
213 err error
214 }
215 envBundles := map[optKey]envRes{}
216 loadEnvBundle := func(acct, app, env string) envRes {
217 k := optKey{acct, app, env}
218 if e, done := envBundles[k]; done {
219 return e
220 }
221 m, found, err := getIfExistsAt(ctx, s, EnvironmentKey(acct, app, env))
222 if err != nil {
223 err = fmt.Errorf("%s/%s (environment %q): config fetch failed: %w", acct, app, env, err)
224 }
225 e := envRes{m: m, found: found, err: err}
226 envBundles[k] = e
227 return e
228 }
229 checkOptedIn := func(acct, app, env string) optedInRes {
230 k := optKey{acct, app, env}
231 if c, done := optedIn[k]; done {
232 return c
233 }
234 // The route's OWN bundle first, and only when it STATES something:
235 // a named bundle silent on the field inherits the app default,
236 // exactly as a key it does not set inherits the default bundle's
237 // value. That PRECEDENCE now lives in resolveExecDelivery, which
238 // AwaitingRestart resolves through too, so the notice an operator
239 // reads and the file this sweep writes can never disagree about
240 // who is opted in. What stays here is what THIS caller owes the
241 // two non-verdict outcomes: a loud line in the sweep's log, once
242 // per bundle per pass, and every file left exactly as it was.
243 var eb envRes
244 if env != "" {
245 eb = loadEnvBundle(acct, app, env)
246 if eb.err != nil {
247 // NOT a determination — the same contract the default
248 // bundle's fetch-failure branch below gives: say so loudly,
249 // leave every file alone, retry next pass.
250 fmt.Printf(" %s/%s: exec_delivery unresolved for environment %q (config fetch failed), leaving this bundle's TCP-addr route file(s) as-is: %v\n", acct, app, env, eb.err)
251 c := optedInRes{v: false, known: false}
252 optedIn[k] = c
253 return c
254 }
255 // found-but-silent, and absent, both fall through to the app
256 // default inside resolveExecDelivery. A bundle bound but
257 // MISSING is a defect the route's own resolution reports
258 // (LoadMerged refuses it) and its file is left as-is there —
259 // the verdict reached here never gets to a write in that case.
260 }
261 v, err := resolveExecDelivery(eb.m, eb.found, func() (*Manifest, bool, error) {
262 d := loadDefault(acct, app)
263 return d.m, d.found, d.err
264 })
265 if err != nil {
266 // NOT a determination: this app may well be opted in and this
267 // box just failed to see it this pass — indistinguishable here
268 // from any other route's ordinary fetch-failure blip. Silence
269 // would mean an opted-in app's delivery can stall with zero
270 // trace in the log, on the exact path this feature exists to
271 // light up, so say so loudly — the same "unresolved, left
272 // as-is" contract the resolution loop below already gives a
273 // bound-group fetch failure.
274 //
275 // It is safe to leave any existing file exactly as it is
276 // regardless of how long this persists: NOT because a fetch
277 // failure and "never opted in" are indistinguishable (they
278 // aren't, that's the whole point of this branch existing
279 // separately from the ones below) but because reapConfigEnv
280 // keys on ROUTE EXISTENCE, never on scope (its live map is
281 // built from every route's Host, full stop — see its own doc
282 // comment). A route that drops out of scope for one pass, for
283 // ANY reason, is simply not visited by the resolution loop,
284 // the materialization loop, or reap; its file is untouched by
285 // construction, not because this function decided to spare
286 // it. Next pass retries from scratch.
287 fmt.Printf(" %s/%s: exec_delivery unresolved (config fetch failed), leaving this app's TCP-addr route file(s) as-is: %v\n", acct, app, err)
288 c := optedInRes{v: false, known: false}
289 optedIn[k] = c
290 return c
291 }
292 if v.Unknown != "" {
293 // A manifest a NEWER carlos wrote with a value THIS binary
294 // doesn't understand must not brick this box's sync (additive
295 // discipline, AGENTS.md) — ignore it, loudly, once, and treat
296 // the bundle as not opted in rather than refusing the whole
297 // pass. Which bundle said it is named, because "upgrade
298 // carlos" is useless advice without knowing where to look.
299 where := "the default bundle"
300 if v.FromEnvironment {
301 where = fmt.Sprintf("environment %q", env)
302 }
303 fmt.Printf(" %s/%s: config: exec_delivery %q in %s is not a value this binary understands — ignoring it (not opted in); upgrade carlos if this is expected\n", acct, app, v.Unknown, where)
304 }
305 c := optedInRes{v: v.On, known: true}
306 optedIn[k] = c
307 return c
308 }
309 // inScope is the single door both loops below use to decide whether a
310 // route is in scope this pass, and — for the addr and sidecar shapes —
311 // that it must never be bounced. One function so the two loops
312 // structurally cannot diverge on either question (see the "if they
313 // ever diverge" comment on the second loop's map lookup, which depends
314 // on the same key having been computed the same way here).
315 //
316 // sidecarShape never bounces for a DIFFERENT reason than addrShape
317 // does: carlos owns a sidecar's process (unlike the addr shape, whose
318 // app-supervised process carlos never touches), but Bounce alone has
319 // no way back for a worker — nothing dials one the way a request
320 // dials an instance, so an automatic bounce here would either strand
321 // an always-on sidecar until the agent restarts or leave a
322 // hibernating one silent until its schedule next comes due, on every
323 // ordinary config-sync pass. Restarting a sidecar on purpose is
324 // `carlos restart -app X` (host/restart.go), which pairs the stop
325 // with an explicit wake; this pass only ever writes the file.
326 inScope := func(rt registry.Route) (ok, addrShape, sidecarShape bool) {
327 // A route WithRouteFilter leaves out is out of scope before
328 // anything is read for it: no bundle, no opt-in, no overlay.
329 if !o.kept(rt) {
330 return false, false, false
331 }
332 if execSyncable(rt) {
333 return true, false, false
334 }
335 if execSidecarCandidate(rt) {
336 return true, false, true
337 }
338 if execAddrCandidate(rt) && checkOptedIn(release.RouteAccount(rt), rt.App, rt.Environment).v {
339 return true, true, false
340 }
341 return false, false, false
342 }
343 for _, rt := range routes {
344 if in, _, _ := inScope(rt); !in {
345 continue
346 }
347 k := groupKey{release.RouteAccount(rt), rt.App, rt.Environment}
348 if _, done := resolved[k]; done {
349 continue
350 }
351 def := loadDefault(k.acct, k.app)
352 if def.err != nil {
353 // A fetch failure is NOT "absent": leave this app's files
354 // alone this pass rather than removing config on a blip.
355 resolved[k] = resolution{err: def.err}
356 continue
357 }
358 if k.env == "" {
359 if !def.found {
360 resolved[k] = resolution{} // absent -> remove
361 continue
362 }
363 resolved[k] = resolution{m: def.m}
364 continue
365 }
366 // A bound environment asserts the bundle exists, so LoadMerged
367 // treats its absence as an error rather than an empty overlay —
368 // and that error stays contained to the routes bound to it. There
369 // is no never-configured case here: an app can lack a default
370 // bundle and still resolve (environment-only), and a missing
371 // environment is the error above, not silence. Critically for
372 // THIS path, that error must not read as absence: removing the
373 // file would strip a live instance's config because someone
374 // deleted a bundle.
375 m, err := LoadMerged(ctx, s, k.acct, k.app, k.env)
376 if err != nil || m == nil {
377 if err == nil {
378 err = fmt.Errorf("config: resolved a nil manifest for environment %q", k.env)
379 }
380 resolved[k] = resolution{err: err}
381 continue
382 }
383 resolved[k] = resolution{m: m}
384 }
385
386 // Mass-removal misconfiguration guard. A registry rebuild that mangles
387 // the account column makes release.RouteAccount fall back to "ops" for
388 // every app at once, so every group's bucket key 404s and every group
389 // in this pass looks "absent" — indistinguishable, from here, from
390 // every manifest object having genuinely been deleted. An unguarded
391 // pass would then remove every config file on the box in one sweep.
392 // Require MORE THAN ONE group before refusing: a single-app box (a
393 // self-hoster who really did delete their one manifest) must still
394 // converge normally, and a lone group can't produce a false "mass"
395 // signal — only a registry-wide fault spanning multiple apps can look
396 // like this.
397 //
398 // Groups in the ERROR state are counted on neither side: they are
399 // neither absent nor present, they never remove anything anyway, and
400 // letting one sit in the denominator would silently disarm the guard
401 // on a box where one app's bundle happens to be broken. That choice
402 // has a direction: a PARTIALLY errored mangled box can still remove
403 // the remaining absent groups' files, which counting errors as absent
404 // would have prevented — but counting them as absent would also freeze
405 // legitimate removals for as long as any one app's bundle stays
406 // broken, indefinitely and silently. Bounded, recoverable over-removal
407 // beats an unbounded silent freeze; routes bound to an environment are
408 // protected by the error path regardless, since an unresolved group
409 // never reaches a removal at all.
410 //
411 // The instance layer does not enter this count, and does not need to.
412 // The fault this guard exists for is registry-wide — a mangled account
413 // column makes EVERY key of every app 404 at once, the overlay listing
414 // included, since it hangs off the same mangled prefix — so an overlay
415 // could not survive the fault it is counting. A route that does have an
416 // overlay never reaches a removal branch at all (see below), so it is
417 // neither protected nor endangered by this number.
418 eligible, absent := 0, 0
419 for _, r := range resolved {
420 if r.err != nil {
421 continue
422 }
423 eligible++
424 if r.m == nil {
425 absent++
426 }
427 }
428 skipRemovals := eligible > 1 && absent == eligible
429 if skipRemovals {
430 // Name what was actually checked — "every app" would overclaim,
431 // since errored groups were never in this count.
432 fmt.Printf(" config sync: all %d resolvable (non-errored) config groups on this box resolved absent — refusing to remove any config files this pass; check the registry's account column and the deployment bucket\n", eligible)
433 }
434
435 // The instance layer (senders design §6.2), resolved per route after the
436 // group base. Same loader, same cost, same failure contract as the unit
437 // pass's: one listing per app, a Get only for a host it named, and an
438 // unresolved overlay leaves the file exactly as it is.
439 overlayFor := instanceOverlayLoader(ctx, s)
440 // One line per app when the LISTING fails, not one per route: every
441 // route of that app fails identically and for the same reason, since
442 // instanceOverlayLoader caches that failure per (account, app) —
443 // independent of Environment, which is why this is keyed on appKey and
444 // not groupKey. A per-host Get or parse failure is a DIFFERENT claim —
445 // two routes can have two separately corrupt overlay objects, each
446 // wrong for its own reason — so that case is never folded in here; see
447 // the errors.As branch below.
448 said := map[appKey]bool{}
449
450 // done is WithHostOutcome's answer per in-scope host: false until the
451 // host's file is as this pass resolved it.
452 done := map[string]bool{}
453 for _, rt := range routes {
454 in, addrShape, sidecarShape := inScope(rt)
455 if !in {
456 continue
457 }
458 done[rt.Host] = false
459 // A host the layout pass holds has its files between two
460 // directories, and either path this pass could pick would be the
461 // wrong one by the time the pass finishes. Nothing is written or
462 // removed for it; the next pass after the move renders it where the
463 // row then says.
464 if _, ok := selected[rt.Host]; !ok {
465 fmt.Printf(" %s: layout migration in flight; bundle not written this pass\n", rt.Host)
466 continue
467 }
468 k := groupKey{release.RouteAccount(rt), rt.App, rt.Environment}
469 // Insist the group was really resolved rather than taking the map's
470 // zero value: a zero resolution reads as "absent", which on this
471 // path REMOVES the file. The two loops filter through the same
472 // inScope today, so this cannot fire — but if they ever diverge,
473 // this turns a silent config deletion into a skip.
474 r, ok := resolved[k]
475 if !ok {
476 continue
477 }
478 if r.err != nil {
479 // Loud, per route, every pass: this route is running on a
480 // stale config file until an operator fixes it. Its file is
481 // left exactly as it is — nothing below this line runs for it.
482 env := k.env
483 if env == "" {
484 env = "default"
485 }
486 fmt.Printf(" %s: config unresolved (environment %q), config file left as-is: %v\n", rt.Host, env, r.err)
487 continue
488 }
489 // Fetched here, used at the bottom, because a failure must be able to
490 // stop a REMOVAL too: deciding a file is orphaned while unable to see
491 // whether this instance has an overlay would delete a live tenant's
492 // delivered secrets on a listing blip.
493 inst, instETag, instErr := overlayFor(k.acct, k.app, rt.Host)
494 if instErr != nil {
495 var listErr *instanceOverlayListErr
496 if errors.As(instErr, &listErr) {
497 // Said ONCE per (account, app), not once per route: the
498 // listing this failed on is cached per app inside the
499 // loader, so every route of the app, across every
500 // environment it is bound to, fails identically and for
501 // the same reason; a line each would bury the cause under
502 // its own repetitions on a box with many routes.
503 ak := appKey{k.acct, k.app}
504 if !said[ak] {
505 said[ak] = true
506 // rt.Host anchors the line to a real route (the first
507 // one this pass happened to reach), matching sync.go's
508 // own wording, so an operator scanning the log has a
509 // hostname to grep for — every other route of this app
510 // is silently covered by the same line.
511 fmt.Printf(" %s/%s: instance config unresolved (seen at %s), config files left as-is: %v\n", k.acct, k.app, rt.Host, instErr)
512 }
513 } else {
514 // A per-host Get or parse failure, unlike the listing
515 // above, is specific to THIS route: the listing already
516 // succeeded (it named this host), so another route of
517 // the same app can have a perfectly good overlay, or a
518 // failure of its own for a different reason. Folding this
519 // into the app-level line would leave that route's stale
520 // config file invisible, so it is reported every time.
521 fmt.Printf(" %s: instance config unresolved, config file left as-is: %v\n", rt.Host, instErr)
522 }
523 continue
524 }
525 // An overlay is configuration, so a route that has one is never
526 // "absent" for the MASS-REMOVAL guard below, however its group base
527 // resolved: writing a file a bundle asked for is not a removal.
528 //
529 // This narrowing (the `inst == nil` clause, added by this branch)
530 // makes the guard itself strictly narrower than it was before: a
531 // route with an overlay now sails through where it used to be held
532 // with everything else. That is backstopped, everywhere this
533 // narrowing actually matters (r.m == nil, i.e. a mangled account
534 // column made the group base read absent), by the #344 guard just
535 // below — EXCEPT for a custom-domain route. release.HostAccountLabel
536 // only derives a <sqid> to check from a PLATFORM-domain hostname; a
537 // custom-domain host's label is "", the #344 check's `sq != ""` test
538 // is false, and the guard simply does not fire. So a custom-domain
539 // route with an overlay, on a box whose account column is mangled in
540 // the exact way #344 exists to catch, has no backstop here at all —
541 // it sails through this guard AND past #344, and the wholesale
542 // overlay-alone replacement both guards were meant to prevent for it
543 // can still happen. Known and accepted (final review, plan 4): the
544 // fix would need a host-independent way to validate the account
545 // column, which #344 does not have either.
546 if r.m == nil && inst == nil && skipRemovals {
547 continue
548 }
549 // The #344 guard: an absent manifest removes the config file ONLY
550 // when the account it was looked up under agrees with the account
551 // the host itself names. A platform host's <sqid> label is not a
552 // moving part; the row's account column is (the domains reconcile
553 // stamps it from bucket listings, and a mis-stamp deleted a live
554 // tenant's delivered secrets on 2026-08-28). Held, loudly, like an
555 // unresolved group — the fix is the row's account, not this file.
556 //
557 // UNLIKE the mass-removal guard above, this hold is NOT narrowed by
558 // an overlay (review finding, plan 4 task 7 fix round 1: the brief's
559 // own reasoning — "a route with an overlay never reaches a removal
560 // branch anyway" — does not transfer here, because this is not a
561 // removal branch. It is "this account column cannot be trusted,
562 // touch nothing." A mis-stamp that makes the group's default-bundle
563 // READ land under the wrong prefix makes the instance layer's own
564 // WRITE (ApplyInstance) land under that same wrong prefix too, so an
565 // overlay resolving here under the mis-stamped account is exactly
566 // what a mis-stamped fleet produces — not evidence the account
567 // column is safe to act on for this route. Wholesale-replacing the
568 // file with that overlay's contents would be the 2026-08-28 failure
569 // again, just reached through the overlay instead of the base.
570 if r.m == nil {
571 if sq := release.HostAccountLabel(rt.Host, appsDomain); sq != "" && sq != k.acct {
572 fmt.Printf(" %s: no config manifest under account %q, but the host names account %q — config file left as-is; fix the route row's account column (#344)\n",
573 rt.Host, k.acct, sq)
574 continue
575 }
576 }
577 // Namespace-collision guard, checked before EITHER shape can reach
578 // syncExecHost below (the single call site both shapes share): if a
579 // route "<rt.Host>.config" also lives on this box, its hand-staged
580 // instance env (<host>.env for THAT route) is named
581 // "<rt.Host>.config.env" — byte-identical to what converging rt
582 // would write here. Refuse loudly rather than clobber another
583 // owner's file; see reapConfigEnv's twin guard for the
584 // deletion-side half of the same hazard.
585 if hostSet[rt.Host+".config"] {
586 fmt.Printf(" %s: not converging — route %q exists on this box and %s%s is its instance-env file (<host>.env); refusing to write into another owner's namespace\n", rt.Host, rt.Host+".config", rt.Host, configEnvSuffix)
587 continue
588 }
589 // The bucket key whose absence would drive a removal, named in the
590 // removal log so cause and effect land on one line. Always the
591 // DEFAULT bundle's key: removal is reachable only from the absent
592 // state, and only an unbound group can resolve absent (a bound one
593 // resolves through LoadMerged, which errors on a missing bundle
594 // rather than returning nothing). A bound group's key would be
595 // dead in every branch that reads this.
596 //
597 // addrShape routes get nil in bounce's place, not the real
598 // closure with a runtime check skipping the call: carlos does not
599 // own an app-supervised process, so there is structurally nothing
600 // to hand syncExecHost that COULD invoke it for this route (issue
601 // #18 design comment) — bounceLogged's existing nil-is-a-no-op
602 // contract does the rest. Same shape for the header: addrShape
603 // routes get execAddrConfigHeader, socket routes get "" (no
604 // header at all) — see execAddrConfigHeader's doc comment for why
605 // this must never apply to the socket shape.
606 //
607 // sidecarShape ALSO gets nil in bounce's place, but for the
608 // opposite reason addrShape does: carlos owns this process, but
609 // Bounce cannot safely stand in for a wake here — see inScope's
610 // doc comment. No header either; a sidecar's file is read by
611 // ExecBackend.instanceEnv, the same reader an instance's is.
612 hostBounce := bounce
613 header := ""
614 switch {
615 case addrShape:
616 hostBounce = nil
617 header = execAddrConfigHeader
618 case sidecarShape:
619 hostBounce = nil
620 }
621 // The group's merge, overlaid by this instance's own layer. A nil
622 // base with an overlay is the overlay ALONE, which is a real
623 // configuration and not the absence that drives a removal; nil and
624 // nil together stay nil, which still removes the file.
625 m := r.m
626 if m != nil || inst != nil {
627 m = applyInstanceOverlay(k.app, m, inst)
628 }
629 hostCallbacks := callbacks
630 // With Managed set (the release scheduler is armed or refusing,
631 // final review F7), the scheduler's own routes and the rest part
632 // here. A socket route it does not manage is restarted as it is
633 // with the flag off, by Unmanaged, and its delivery is none of the
634 // scheduler's business. A native sidecar it manages keeps Delivered
635 // below though config sync has no restart for it: the scheduler's
636 // config attempt is how its new file reaches a running worker.
637 managed := callbacks.Managed != nil && callbacks.Managed(rt)
638 if callbacks.Managed != nil && hostBounce != nil && !managed {
639 hostBounce = callbacks.Unmanaged
640 hostCallbacks.Restart = callbacks.Unmanaged
641 hostCallbacks.Delivered = nil
642 }
643 if hostBounce == nil {
644 hostCallbacks.Restart = nil
645 hostCallbacks.SelectedRestart = nil
646 if !sidecarShape || !managed {
647 hostCallbacks.Delivered = nil
648 }
649 }
650 err := syncExecHostClaimed(ctx, selected[rt.Host], rt, m, Key(k.acct, k.app), priv, dataDir, hostBounce, header, instETag, ack, hostCallbacks)
651 // A nested row's bundle lives in its instance directory, which the
652 // activator makes at the first wake. Before then the write cannot
653 // land, so the row is named and the pass goes on to the other
654 // routes, claims on or off; the agent runs the pass again once the
655 // directory is there (WhenInstanceDirAbsent). A render that failed
656 // fails as before, directory or not.
657 if errors.Is(err, errInstanceDirAbsent) {
658 fmt.Printf(" %s: instance directory not made yet; config is written once it is\n", rt.Host)
659 if o.dirAbsent != nil {
660 o.dirAbsent(rt.Host, filepath.Dir(ConfigEnvPath(dataDir, rt)))
661 }
662 continue
663 }
664 if err != nil && hostWrites.Enabled {
665 return err
666 }
667 if err == nil {
668 done[rt.Host] = true
669 }
670 }
671 if o.outcome != nil {
672 for host, ok := range done {
673 o.outcome(host, ok)
674 }
675 }
676 // Stale-secrets notice (issue #18 design ruling, ratified 2026-08-03
677 // review): an addr-shaped route whose app is DEFINITIVELY not opted in
678 // this pass (off, never configured, or an unrecognized exec_delivery
679 // value — checkOptedIn's known == true, as opposed to a transient
680 // fetch error) but still has a route row keeps any earlier
681 // <host>.config.env EXACTLY as it is: it was never added to
682 // inScope/resolved above, so neither the resolution loop nor the
683 // materialization loop touched it, and reap (below) never removes a
684 // file whose route still has a row.
685 //
686 // RULING: do not auto-delete it. Removing the file would change what
687 // the app's OWN supervisor layers in on its next respawn — a live
688 // environment change caused by a flag flip, taking effect at a moment
689 // carlos does not control and cannot announce, with no bounce (there
690 // never is one for this shape) to make it observable. That is worse
691 // than a stale file. It is also consistent with every other
692 // ineligibility transition in this file: a route losing
693 // Status: "ready" or gaining Platform: true doesn't get its file
694 // deleted either, only stops being written to.
695 //
696 // BUT staying silent about it is wrong, more than for those other
697 // transitions, because this file holds DECRYPTED secrets, and once an
698 // app opts out nothing will EVER update it again: rotate a secret in
699 // the bucket afterward and this file keeps serving yesterday's
700 // plaintext, indefinitely, with carlos reporting nothing. "Off" reads
701 // to an operator as "stop delivering config"; what it actually does
702 // is "freeze whatever was last delivered, forever". Name the file and
703 // the manual remedy, once per pass, so an operator doesn't mistake
704 // the one for the other — the remedy has to be manual (delete it once
705 // the app's own supervisor has stopped reading it) because carlos has
706 // no way to know when that is.
707 for _, rt := range routes {
708 if !execAddrCandidate(rt) || !o.kept(rt) {
709 continue
710 }
711 c := checkOptedIn(release.RouteAccount(rt), rt.App, rt.Environment)
712 if c.v || !c.known {
713 continue
714 }
715 path := ConfigEnvPath(dataDir, rt)
716 if _, err := os.Stat(path); err != nil {
717 continue // nothing was ever staged for this route: nothing to warn about
718 }
719 fmt.Printf(" %s: exec_delivery is off (or was never set) but %s still exists from an earlier opt-in — it holds DECRYPTED secrets and carlos will never update or remove it while this app stays un-opted-in; delete it by hand once the app's own supervisor has stopped reading it\n", rt.Host, path)
720 }
721 // An empty route list is never authority to delete anything —
722 // registry.Open silently creates an empty database on a wrong path or
723 // an unmounted /data (same guard, same reason, as the unit-path Sync).
724 if len(routes) == 0 {
725 return nil
726 }
727 if !hostWrites.Enabled {
728 reapConfigEnv(routes, dataDir)
729 } else {
730 reapSelectedConfigTemps(ctx, dataDir, selected)
731 }
732 return nil
733}
734
735func reapSelectedConfigTemps(ctx context.Context, dataDir string, selected map[string]hostwrite.Work) {
736 ents, err := os.ReadDir(dataDir)
737 if err != nil {
738 return
739 }
740 for _, entry := range ents {
741 if entry.IsDir() || !strings.HasSuffix(entry.Name(), configEnvSuffix+".tmp") {
742 continue
743 }
744 host := strings.TrimSuffix(entry.Name(), configEnvSuffix+".tmp")
745 work := selected[host]
746 if work == nil {
747 continue
748 }
749 if err := guarded(ctx, work, renderedIdentity(nil, false, false, false), func() error {
750 return os.Remove(filepath.Join(dataDir, entry.Name()))
751 }); err != nil {
752 fmt.Printf(" %s: remove stale config temp file: %v\n", entry.Name(), err)
753 }
754 }
755}
756
757// SyncOption adjusts one SyncExec pass. The options are variadic so the
758// ordinary call keeps its shape.
759type SyncOption func(*syncOptions)
760
761type syncOptions struct {
762 fenced func(host string) bool
763 keep func(rt registry.Route) bool
764 dirAbsent func(host, dir string)
765 outcome func(host string, done bool)
766 hostWrites hostwrite.Config
767 hostWriteCount int
768}
769
770// WithRouteFilter narrows one SyncExec pass to the routes keep accepts. A
771// route it leaves out is out of scope for the pass: nothing is read for it,
772// and its file is neither written nor removed (reap keys on route existence,
773// not scope). The agent's scoped pass uses it to converge only the apps the
774// change index says moved (S3 change-index design §4.5). Nil keeps every
775// route.
776func WithRouteFilter(keep func(registry.Route) bool) SyncOption {
777 return func(o *syncOptions) { o.keep = keep }
778}
779
780func (o *syncOptions) kept(rt registry.Route) bool { return o.keep == nil || o.keep(rt) }
781
782// WhenInstanceDirAbsent hands SyncExec a callback for every nested row whose
783// bundle it could not write because the row's instance directory does not
784// exist yet: the activator makes that directory at the instance's first
785// wake (prepare), so a nested row created with settings has nowhere to put
786// its <host>.config.env until then. The pass skips such a row cleanly, says
787// so, and calls dirAbsent with the host and the directory it waits for, so
788// the agent can run the pass again as soon as the directory appears rather
789// than leaving the instance without its settings until the hourly floor
790// (harness Run 9, finding 6).
791func WhenInstanceDirAbsent(dirAbsent func(host, dir string)) SyncOption {
792 return func(o *syncOptions) { o.dirAbsent = dirAbsent }
793}
794
795func WithHostWrite(cfg hostwrite.Config) SyncOption {
796 return func(o *syncOptions) {
797 o.hostWrites = cfg
798 o.hostWriteCount++
799 }
800}
801
802// SkipFenced makes SyncExec leave alone every in-scope route for which
803// fenced reports true: no bundle is written, removed or bounced for it,
804// and the pass says so. The agent passes the layout pass's journal
805// predicate. The pass also holds the agent's config-sync lock while it
806// moves a host, so this is the second net: it covers a journal that
807// outlives one pass, such as a completed revert held until the intent can
808// be read.
809func SkipFenced(fenced func(host string) bool) SyncOption {
810 return func(o *syncOptions) { o.fenced = fenced }
811}
812
813// WithHostOutcome hands SyncExec a callback it calls, once a pass has gone
814// through every route without stopping, for each in-scope route: done is
815// true when the route's file is as the pass resolved it (written, already up
816// to date, removed, or rightly left absent), and false when the pass left it
817// as it was for any reason (an unresolved bundle or overlay, a guard, a
818// layout migration in flight, a write that failed, an instance directory not
819// made yet). A route out of scope is not named. The agent keeps a host whose
820// instance directory it waits for until the host is done (fix round 1,
821// Astra's finding 3: a pass that skipped a host on a transient read failure
822// returned nil, and the agent took that as delivered).
823func WithHostOutcome(outcome func(host string, done bool)) SyncOption {
824 return func(o *syncOptions) { o.outcome = outcome }
825}
826
827// execRouteBase is the lifecycle-managed-instance shape every exec route
828// kind shares: the unit path's syncable() exclusions, narrowed to
829// non-unit backing (unit routes belong to the root adopt path). Neither
830// SyncExec eligibility case on its own — see execSyncable, execAddrCandidate
831// and execSidecarCandidate, which each narrow it by upstream shape.
832//
833// Kind "sidecar" is included alongside "instance" (Task 11, design §9.1):
834// this is the direct replacement for the hand-staged
835// <host>-sidecar.env — `carlos env set` must reach a sidecar, secrets are
836// sealed and delivered the same way an instance's are, and the file this
837// predicate gates is exactly what ExecBackend.instanceEnv reads at
838// StartWorker's spawn. Every other clause is untouched: a sidecar row is
839// still excluded by Platform, a non-ready Status, or Backing ==
840// BackingUnit, same as an instance would be.
841func execRouteBase(rt registry.Route) bool {
842 return (rt.Kind == "instance" || rt.IsSidecar()) && rt.App != "" && !rt.Platform &&
843 (rt.Status == "" || rt.Status == "ready") &&
844 rt.Backing != registry.BackingUnit
845}
846
847// execSyncable: lifecycle-managed instances the ACTIVATOR spawns — and
848// therefore whose environment this sweep can actually deliver and whose
849// process it can bounce. Narrows execRouteBase to a socket upstream;
850// unconditionally in scope, opt-in or not (this is the original, PR #16
851// shape).
852func execSyncable(rt registry.Route) bool {
853 return execRouteBase(rt) && rt.Socket != ""
854}
855
856// execAddrCandidate: the TCP-addr exec shape (issue #18) — an
857// app-supervised instance family carlos routes to but neither spawns nor
858// owns the process of (titogo's router fleet is the motivating case:
859// titogo-router + controld own the instance processes, carlos just
860// proxies to 127.0.0.1:<port>). Narrows execRouteBase to an addr upstream
861// with no socket. Both registry spellings are accepted: Addr is the legacy
862// single-upstream field, while Addrs is the HA field used even when a route
863// currently has only one upstream. Treating only Addr as eligible silently
864// skipped config delivery when an existing route migrated to Addrs.
865//
866// This predicate is the ROUTE-SHAPE half only — it says nothing about
867// whether the route is actually in scope this pass. Unlike execSyncable,
868// that also requires the app's default bundle to carry "exec_delivery":
869// "file" (Manifest.ExecDelivery): the reader of the file this shape
870// produces is the app's OWN supervisor, not carlos, so writing it for an
871// app that never reads it would be dead weight at best and, if the app's
872// supervisor treats the file's mere presence or mtime as a restart
873// signal, an unwanted bounce at worst. See SyncExec's checkOptedIn/
874// inScope closures, which gate this predicate on that opt-in.
875func execAddrCandidate(rt registry.Route) bool {
876 return execRouteBase(rt) && rt.Socket == "" && (rt.Addr != "" || rt.Addrs != "")
877}
878
879// execSidecarCandidate: the standalone-worker exec shape (Task 11, design
880// §9.1) — a sidecar row carries neither Socket nor Addr by construction
881// (sidecarRouteFor's contract: a sidecar binds nothing and nothing dials
882// it), so it can never satisfy execSyncable or execAddrCandidate; this is
883// its own narrowing of execRouteBase, keyed on Kind alone.
884//
885// Unconditionally in scope, like execSyncable and UNLIKE execAddrCandidate:
886// a sidecar's process is spawned and owned by THIS box's activator
887// (ExecBackend.StartWorker), the same as a socket-exec instance, so there
888// is no "app-supervised, may not read the file" case here to opt into —
889// carlos always owns delivery for a route it owns the process of.
890func execSidecarCandidate(rt registry.Route) bool {
891 return execRouteBase(rt) && rt.IsSidecar()
892}
893
894// ExecConfigApps is the distinct (account, app) set the agent's fast
895// generation loop (host package) polls: every app with at least one route
896// SyncExec could deliver config to. Accounts are resolved through
897// release.RouteAccount, the same way every other read of a route's account
898// resolves, so the key the loop watches is the key a write actually touches.
899//
900// The SHAPE half only — the TCP-addr case's opt-in (Manifest.ExecDelivery)
901// is NOT consulted, because consulting it costs a bucket read per app,
902// which is exactly what the fast loop exists to avoid. The cost of that
903// choice is bounded: polling an un-opted app's generation is one wasted
904// small read per tick, and the SyncExec pass a change triggers applies the
905// opt-in properly before writing anything.
906//
907// execSidecarCandidate is included alongside execSyncable and
908// execAddrCandidate (Task 11 fix round 1): left out, a sidecar-only app's
909// config — delivered by a direct-bucket write that never bumps
910// control/serial, exactly what the pinfra scripts do — would fall back to
911// the hourly floor while an instance next to it converges on this loop's
912// fast cadence. Not an error, just work happening late; the same
913// half-migrated-consumer shape this branch has repeatedly found. Costs
914// nothing extra to include unconditionally: a sidecar's process is spawned
915// and owned by carlos exactly like a socket-exec instance's (see
916// execSidecarCandidate's own doc comment), so there is no opt-in question
917// to defer here the way there is for the addr shape.
918//
919// Exported from THIS file, beside the predicates it composes, so the loop's
920// notion of eligibility can never drift from SyncExec's own — an app the
921// loop watched but SyncExec skipped would burn reads forever, and an app it
922// didn't watch would silently fall back to the hourly pass.
923func ExecConfigApps(routes []registry.Route) [][2]string {
924 seen := map[[2]string]bool{}
925 var out [][2]string
926 for _, rt := range routes {
927 if !execSyncable(rt) && !execAddrCandidate(rt) && !execSidecarCandidate(rt) {
928 continue
929 }
930 k := [2]string{release.RouteAccount(rt), rt.App}
931 if seen[k] {
932 continue
933 }
934 seen[k] = true
935 out = append(out, k)
936 }
937 // Sorted so the loop's per-tick read order — and any log line built
938 // from it — is stable across passes, where registry order is not.
939 sort.Slice(out, func(i, j int) bool {
940 if out[i][0] != out[j][0] {
941 return out[i][0] < out[j][0]
942 }
943 return out[i][1] < out[j][1]
944 })
945 return out
946}
947
948// execDeliveryVerdict is the DECIDED half of exec_delivery resolution
949// (issue #195): given a route's bound bundle and its app's default one,
950// what does the flag say? Pure and log-free, so that the two callers who
951// need the answer — SyncExec's checkOptedIn, which writes the files, and
952// AwaitingRestart, which tells an operator a file was written that nothing
953// has read — reach it through one function rather than through two
954// readings of one rule. A notice that disagreed with the sweep about who
955// gets a file would be worse than no notice: it would name the wrong
956// route, in the exact place an operator went looking for certainty.
957//
958// Fetch FAILURES are deliberately not modelled here. They are not
959// determinations (see checkOptedIn's def.err branch), and the two callers
960// owe them different things — a loud log line and a left-alone file on one
961// side, a named "could not tell" host on the other. A caller that could
962// not read a bundle must not call this.
963type execDeliveryVerdict struct {
964 // On is the only question SyncExec asks: does this bundle get files?
965 On bool
966 // Unknown is non-empty when the DECIDING bundle stated a value this
967 // binary does not understand — decided "off", but for a reason worth
968 // saying out loud (additive discipline, AGENTS.md: a manifest a newer
969 // carlos wrote must not brick an older box, and must not pass silently
970 // either). Carried out rather than logged in place, because the two
971 // callers word it differently and print it to different places.
972 Unknown string
973 // FromEnvironment records WHICH bundle decided, so a caller's message
974 // can name it. False means the app default decided — including the
975 // common case where a named bundle was silent and inherited.
976 FromEnvironment bool
977}
978
979// resolveExecDelivery applies the #195 precedence: the route's OWN bundle
980// first, and only when it STATES something; the app default otherwise.
981// Absence is inheritance, never "off" — an operator who has never
982// mentioned exec_delivery in a bundle has not asked for delivery to stop.
983//
984// loadDefault is a thunk rather than a value because the default bundle
985// must not be FETCHED when the named one already decided: eager fetching
986// would let an unrelated blip on the default's read turn a decided verdict
987// into an unresolved one, and would cost a bucket read per call on the
988// path where the answer was already in hand.
989func resolveExecDelivery(envM *Manifest, envFound bool, loadDefault func() (*Manifest, bool, error)) (execDeliveryVerdict, error) {
990 if envFound && envM.ExecDelivery == ExecDeliveryFile {
991 return execDeliveryVerdict{On: true, FromEnvironment: true}, nil
992 }
993 if envFound && envM.ExecDelivery != "" {
994 return execDeliveryVerdict{Unknown: envM.ExecDelivery, FromEnvironment: true}, nil
995 }
996 defM, defFound, err := loadDefault()
997 if err != nil {
998 return execDeliveryVerdict{}, err
999 }
1000 switch {
1001 case !defFound:
1002 return execDeliveryVerdict{}, nil
1003 case defM.ExecDelivery == ExecDeliveryFile:
1004 return execDeliveryVerdict{On: true}, nil
1005 case defM.ExecDelivery == "":
1006 return execDeliveryVerdict{}, nil
1007 default:
1008 return execDeliveryVerdict{Unknown: defM.ExecDelivery}, nil
1009 }
1010}
1011
1012// RestartHost names one route a config write delivered a file to and that
1013// nothing bounced. Unit is carried because it decides the REMEDY, and the
1014// two remedies are not interchangeable: an addr route naming its own unit
1015// (issue #110's consoles) is the one addr shape `carlos restart` can
1016// actually cycle, where a plain one reaches nothing at all — the agent's
1017// restart leg skips it (internal/host/restart.go, the rt.Unit != "" test)
1018// and the activator it would otherwise ask has no child for a process
1019// carlos never spawned. Telling an operator to run a command that is a
1020// documented no-op for their route would be a second silent failure
1021// answering the first.
1022type RestartHost struct {
1023 Host string
1024 Unit string
1025}
1026
1027// RestartPending is what a config write left behind on ONE box: files that
1028// landed for processes carlos does not own, and therefore does not restart.
1029//
1030// Unresolved is the honest half and the reason this is not a plain
1031// []string. A route whose exec_delivery could not be resolved this call —
1032// its bundle unreadable, or the bundle it is bound to missing outright —
1033// is a route nothing can say "delivered" or "not delivered" about. Dropping
1034// it silently would commit the very conflation this notice exists to end:
1035// "nothing needs a restart" and "I could not tell" rendered identically is
1036// how discussion #16 got filed in the first place.
1037type RestartPending struct {
1038 Hosts []RestartHost
1039 Unresolved []string
1040}
1041
1042// Empty reports whether there is nothing to say — no host delivered to and
1043// none unresolved. The renderers' one test, so "say nothing" is one
1044// judgement rather than four.
1045func (r RestartPending) Empty() bool { return len(r.Hosts) == 0 && len(r.Unresolved) == 0 }
1046
1047// AwaitingRestart answers, for a config write to app's envName bundle
1048// ("" = the default), which of this box's routes were handed a file that
1049// nothing will make their process read. It is the reporting half of the
1050// ownership line SyncExec draws: carlos writes these files and
1051// deliberately never bounces the processes behind them, so the only thing
1052// that can close the loop is telling the person who just ran the command.
1053// See discussion #16 — `secrets set` reported success, `secrets list`
1054// confirmed the key, and the value reached nothing for want of one line.
1055//
1056// keys are the var names the write touched (set and unset together), used
1057// ONLY to skip routes whose merged config the write cannot have changed —
1058// see the shadowing test below. An empty slice disables that narrowing,
1059// which is what a write with no vars in it (turning exec_delivery on)
1060// needs: there is nothing to shadow, and the file appears regardless.
1061//
1062// Eligibility is execAddrCandidate plus the opt-in, which is exactly
1063// SyncExec's inScope for the addr shape — reached through the same
1064// predicate and the same resolveExecDelivery, from the same file, for the
1065// same reason ExecConfigApps is exported from here.
1066//
1067// SCOPE, and it is a real limit: routes is whatever the caller could see,
1068// which is one box's registry (the #145 blind spot internal/console/
1069// apiroutes.go documents). This function cannot know what it was not
1070// shown, so its callers say what they looked at rather than letting a
1071// short list read as a complete one.
1072func AwaitingRestart(ctx context.Context, s release.Store, routes []registry.Route, acct, app, envName string, keys []string) RestartPending {
1073 var out RestartPending
1074 // The instance layer, through the same loader the sweeps use, so this
1075 // notice and the pass that writes the files agree about what sits above
1076 // a bundle. One listing for the app, then a Get per route that has an
1077 // overlay; both live for one call, like the caches below.
1078 overlayFor := instanceOverlayLoader(ctx, s)
1079 // Both caches exist for the same reason SyncExec's do: a box with many
1080 // TCP-addr routes on one bundle must not cost more than one read for
1081 // it. Unlike SyncExec's, they live for one call.
1082 var (
1083 defM *Manifest
1084 defFound bool
1085 defErr error
1086 defDone bool
1087 )
1088 loadDefault := func() (*Manifest, bool, error) {
1089 if !defDone {
1090 defM, defFound, defErr = getIfExists(ctx, s, acct, app)
1091 defDone = true
1092 }
1093 return defM, defFound, defErr
1094 }
1095 type envRes struct {
1096 m *Manifest
1097 found bool
1098 err error
1099 }
1100 envBundles := map[string]envRes{}
1101 loadEnv := func(env string) envRes {
1102 if e, done := envBundles[env]; done {
1103 return e
1104 }
1105 m, found, err := getIfExistsAt(ctx, s, EnvironmentKey(acct, app, env))
1106 e := envRes{m: m, found: found, err: err}
1107 envBundles[env] = e
1108 return e
1109 }
1110 for _, rt := range routes {
1111 if !execAddrCandidate(rt) || rt.App != app || release.RouteAccount(rt) != acct {
1112 continue
1113 }
1114 // Which routes a write REACHES. A named bundle is read by the
1115 // routes bound to it and by nothing else. The default bundle is
1116 // read by every route of the app, bound or not, because a named
1117 // bundle is an overlay ON it (environments.go, LoadMerged) rather
1118 // than a replacement for it.
1119 if envName != "" && rt.Environment != envName {
1120 continue
1121 }
1122 eb := envRes{}
1123 if rt.Environment != "" {
1124 eb = loadEnv(rt.Environment)
1125 if eb.err != nil {
1126 out.Unresolved = append(out.Unresolved, rt.Host)
1127 continue
1128 }
1129 // A route bound to a bundle that does not exist resolves to an
1130 // ERROR, not to the app default: LoadMerged refuses it, and
1131 // SyncExec leaves the route's file exactly as it was. Nothing
1132 // was delivered, so "delivered" would be a lie — but neither
1133 // is this route fine, so it is not a skip either.
1134 if !eb.found {
1135 out.Unresolved = append(out.Unresolved, rt.Host)
1136 continue
1137 }
1138 }
1139 v, err := resolveExecDelivery(eb.m, eb.found, loadDefault)
1140 if err != nil {
1141 out.Unresolved = append(out.Unresolved, rt.Host)
1142 continue
1143 }
1144 // Not opted in — including the Unknown case, which decides "off"
1145 // — means no file was ever written for this route, by this write
1146 // or any other. Nothing to say.
1147 if !v.On {
1148 continue
1149 }
1150 // The shadowing test, and the reason keys is a parameter: where every
1151 // touched key is shadowed, the merged manifest is byte-identical,
1152 // syncExecHost's bytes.Equal writes nothing, and naming the route
1153 // would send someone to restart a process for a value it was never
1154 // going to see. An empty keys disables this whole test (nothing to
1155 // shadow — see keys' own doc comment), so the instance layer is
1156 // consulted only when it could actually change the verdict: a route
1157 // whose overlay is unreadable must not be downgraded to Unresolved
1158 // over a call that was never going to ask it anything.
1159 if len(keys) > 0 {
1160 // Which layers sit ABOVE the bundle that was written, for this
1161 // route. A write cannot change what a route resolves to for a
1162 // key some higher layer states, and the third layer sits above
1163 // both of the other two.
1164 inst, _, instErr := overlayFor(acct, app, rt.Host)
1165 if instErr != nil {
1166 out.Unresolved = append(out.Unresolved, rt.Host)
1167 continue
1168 }
1169 var above []*Manifest
1170 if envName == "" && rt.Environment != "" {
1171 above = append(above, eb.m)
1172 }
1173 above = append(above, inst)
1174 if allShadowed(keys, above...) {
1175 continue
1176 }
1177 }
1178 out.Hosts = append(out.Hosts, RestartHost{Host: rt.Host, Unit: rt.Unit})
1179 }
1180 // Sorted so a caller's output — and any test pinning it — is stable
1181 // across passes, where registry order is not. Same discipline as
1182 // ExecConfigApps'.
1183 sort.Slice(out.Hosts, func(i, j int) bool { return out.Hosts[i].Host < out.Hosts[j].Host })
1184 sort.Strings(out.Unresolved)
1185 return out
1186}
1187
1188// allShadowed reports whether every one of keys is STATED by at least one
1189// of the layers above the bundle a write touched — the test that decides
1190// whether that write can have changed what a route actually resolves to. An
1191// unset key counts the same as a set one: removing a key a higher layer
1192// shadows changes the merge by nothing.
1193//
1194// above is variadic because how many layers sit over a write depends on
1195// which bundle was written: a default-bundle write to a bound route is
1196// under both the named bundle and the instance overlay, a named-bundle
1197// write is under the overlay alone, and a write to the overlay itself has
1198// nothing over it. A nil layer (no bundle of that kind for this route) is
1199// skipped rather than treated as stating nothing, so an empty list answers
1200// false and the route is named.
1201func allShadowed(keys []string, above ...*Manifest) bool {
1202 for _, k := range keys {
1203 stated := false
1204 for _, m := range above {
1205 if m == nil {
1206 continue
1207 }
1208 if _, ok := m.Vars[k]; ok {
1209 stated = true
1210 break
1211 }
1212 }
1213 if !stated {
1214 return false
1215 }
1216 }
1217 return true
1218}
1219
1220// execAddrConfigHeader is prepended to a TCP-addr (issue #18) route's
1221// <host>.config.env — and ONLY that shape. RenderEnv itself stays
1222// header-free, deliberately: it is shared by sync.go's unit path AND this
1223// file's socket-exec path, and either one growing a header would change
1224// the byte content of every EXISTING file of that shape on upgrade —
1225// sync.go's own comment on lostKeys already weighs a "# managed by
1226// carlos" marker for the unit path and rejects it for exactly this
1227// churn. The socket-exec shape has the same installed-base problem: every
1228// currently-running instance's file would look "changed" on the pass
1229// after this ships, and — unlike the addr shape — a socket route DOES
1230// bounce on a content change, so that churn would restart every
1231// socket-exec instance on the fleet once, for a comment. The addr shape
1232// has no installed base at all (this is its first pass ever) and never
1233// bounces, so the header costs it nothing.
1234//
1235// Format: "#"-prefixed and blank lines are the universal EnvironmentFile
1236// comment convention — systemd's own EnvironmentFile= parser skips them,
1237// and this repo's own guard for the unit path, lostKeys (sync.go),
1238// already relies on exactly that tolerance when parsing a hand-staged
1239// file. The issue #18 design comment specifies titogo's controld layers
1240// this file the same way systemd's EnvironmentFile= does (default bundle
1241// under the app-supervisor's own env), so a plain KEY=value-plus-#-comments
1242// reader is the assumed parse contract on that side too; this repo has no
1243// titogo checkout to verify it against directly, so treat this as an
1244// assumption to confirm against titogo's actual parser before or during
1245// the app-half PR, not a verified fact.
1246const execAddrConfigHeader = `# Written by carlos (SyncExec, issue #18). Carlos may overwrite or remove
1247# this file on any future sync pass, but NEVER restarts this process because
1248# of it — carlos does not own it. This app's own supervisor owns restarts
1249# and must notice and act on changes here on its own schedule.
1250`
1251
1252// syncExecHost converges one route's config file. m == nil means the
1253// manifest object is absent from the bucket: remove the file (sync owns
1254// the namespace; a file with no manifest is a leftover, and leaving it
1255// would keep dead config flowing into spawns). key is the default
1256// bundle's key, Key(acct, app) — the only key whose absence can reach
1257// this branch — included in the removal log line so cause (which bucket
1258// key came back absent) and effect (which file got removed) land
1259// together on one line, instead of requiring a reader to cross-reference
1260// the fetch-side log.
1261//
1262// bounce may be nil: bounceLogged treats that as "nothing to call", which
1263// is how a TCP-addr (issue #18) route's file gets converged or removed
1264// exactly like a socket-exec route's — same write/rename/remove path,
1265// same logging — while never restarting anything, because carlos does
1266// not own that process. The caller (SyncExec) passes nil for those routes
1267// structurally, not a flag this function branches on.
1268//
1269// header is prepended to the rendered content when non-empty
1270// (execAddrConfigHeader for the addr shape, "" for socket — see that
1271// constant's doc comment for why the two must never be swapped or
1272// unified) — folded into the SAME diff/write/rename path as the vars
1273// themselves, so a header-only change (e.g. its text edited in a future
1274// release) converges exactly like a var change would, once.
1275//
1276// instETag is the etag of the instance overlay this route's merge already
1277// folded in (empty when the route has no overlay), carried through to the
1278// ack unchanged — see ConfigAck.InstanceConfigETag.
1279//
1280// ack, if non-nil, is called once this route's file has actually changed
1281// (written, never on removal — see SyncExec's own doc comment) with its
1282// config-adoption acknowledgement (spec §5). Restarted/RestartError come
1283// from bounceLogged's own outcome when bounce is non-nil (the socket-exec
1284// shape: spec §5 step 5 counts that bounce as the cutover, "since the
1285// platform restarted it") and are always false/empty when bounce is nil
1286// (the tcp-exec-file shape: its process belongs to an external supervisor
1287// this platform never restarts at all, so that shape's cutover is a
1288// member acknowledgement, `carlos relay adopted`, instead).
1289func syncExecHost(rt registry.Route, m *Manifest, key string, priv *ecdh.PrivateKey, dataDir string, bounce func(host string) error, header, instETag string, ack func(ConfigAck)) {
1290 _ = syncExecHostClaimed(context.Background(), legacyHostWork{}, rt, m, key, priv, dataDir, bounce, header, instETag, ack)
1291}
1292
1293func syncExecHostClaimed(ctx context.Context, work hostwrite.Work, rt registry.Route, m *Manifest, key string, priv *ecdh.PrivateKey, dataDir string, bounce func(host string) error, header, instETag string, ack func(ConfigAck), options ...Callbacks) error {
1294 callbacks := configCallbacks(work, bounce, ack, options)
1295 if rt.Host == "" || rt.Host == "." || rt.Host == ".." || rt.Host != filepath.Base(rt.Host) {
1296 fmt.Printf(" %q: not a bare hostname, refusing to derive a config file path from it\n", rt.Host)
1297 return nil
1298 }
1299 path := ConfigEnvPath(dataDir, rt)
1300 if m == nil {
1301 id := renderedIdentity(nil, false, rt.Environment != "", instETag != "")
1302 if err := guarded(ctx, work, id, func() error { return os.Remove(path) }); err != nil {
1303 if !os.IsNotExist(err) {
1304 fmt.Printf(" %s: remove config for %s (manifest absent): %v\n", rt.Host, key, err)
1305 }
1306 if os.IsNotExist(err) {
1307 return nil
1308 }
1309 return err
1310 }
1311 fmt.Printf(" %s: manifest %s absent, removed its config file\n", rt.Host, key)
1312 if err := guarded(ctx, work, id, func() error { return nil }); err != nil {
1313 return err
1314 }
1315 if callbacks.Delivered != nil {
1316 callbacks.Delivered(rt.Host, id.Config)
1317 }
1318 if bounce != nil || callbacks.SelectedRestart != nil {
1319 if callbacks.enabled {
1320 if err := callbacks.restart(ctx, hostwrite.Selection{Host: rt.Host, Work: work, Identity: id}); !errors.Is(err, ErrDeliveredOnWake) {
1321 return err
1322 }
1323 return nil
1324 }
1325 bounceLogged(rt.Host, bounce)
1326 }
1327 return nil
1328 }
1329 // keepAbsent: no instance overlay. A bundle that renders nothing for a
1330 // route whose file is absent leaves it absent (renderExecConfig).
1331 r, err := renderExecConfig(ctx, work, rt, m, priv, dataDir, header, instETag, instETag == "")
1332 if err != nil {
1333 return err
1334 }
1335 if !r.changed {
1336 return nil // up to date — stay quiet on the periodic tick
1337 }
1338 id := r.id
1339 if err := guarded(ctx, work, id, func() error { return nil }); err != nil {
1340 return err
1341 }
1342 if callbacks.Delivered != nil {
1343 callbacks.Delivered(rt.Host, id.Config)
1344 }
1345 var bounceErr error
1346 if bounce != nil || callbacks.SelectedRestart != nil {
1347 if callbacks.enabled {
1348 bounceErr = callbacks.restart(ctx, hostwrite.Selection{Host: rt.Host, Work: work, Identity: id})
1349 if bounceErr != nil && !errors.Is(bounceErr, ErrDeliveredOnWake) {
1350 return bounceErr
1351 }
1352 } else {
1353 bounceErr = bounceLogged(rt.Host, bounce)
1354 }
1355 }
1356 if ack != nil || callbacks.SelectedAck != nil {
1357 a := ConfigAck{Schema: ConfigAckSchema, Host: rt.Host, Shape: shapeOf(rt), At: time.Now(), InstanceConfigETag: instETag}
1358 if suffixes, err := relaySuffixesFromRelayVar(m, priv, routeBinding(rt)); err != nil {
1359 fmt.Printf(" %s: config updated, but CARLOS_RELAY could not be read for the config-ack: %v\n", rt.Host, err)
1360 } else {
1361 a.RelaySuffixes = suffixes
1362 }
1363 // bounce == nil (the tcp-exec-file/addr shape) leaves Restarted
1364 // false and RestartError empty — see this function's own doc
1365 // comment for why. bounce != nil is the socket-exec shape, and its
1366 // bounce IS this pass's restart (spec §5 step 5: a socket-exec
1367 // route's cutover is the ack "since the platform restarted it"),
1368 // so the ack has to carry what bounceLogged actually returned
1369 // rather than assume success the way the removal branch above may.
1370 // A sleeper's restart answers ErrDeliveredOnWake: nothing ran and
1371 // nothing failed, so the ack carries the unit pass's marker.
1372 if bounce != nil {
1373 switch {
1374 case errors.Is(bounceErr, ErrDeliveredOnWake):
1375 a.Hibernating = true
1376 case bounceErr != nil:
1377 a.RestartError = bounceErr.Error()
1378 default:
1379 a.Restarted = true
1380 }
1381 }
1382 if err := guarded(ctx, work, id, func() error { return nil }); err != nil {
1383 return err
1384 }
1385 return callbacks.acknowledge(ctx, hostwrite.Selection{Host: rt.Host, Work: work, Identity: id}, a)
1386 }
1387 return nil
1388}
1389
1390// errInstanceDirAbsent is a nested row's write that found no instance
1391// directory: the activator has not woken the instance here yet.
1392var errInstanceDirAbsent = errors.New("the instance directory is not made yet")
1393
1394// execRender is what renderExecConfig did: the file's path, the bytes it
1395// holds now, whether this call wrote them, and the identity the writes were
1396// guarded under (zero when nothing was written).
1397type execRender struct {
1398 path string
1399 rendered []byte
1400 changed bool
1401 id hostwrite.Identity
1402}
1403
1404// renderExecConfig is the file-writing half of syncExecHostClaimed for a
1405// present manifest: render m for rt, compare with what is on disk, and
1406// write through work only when the bytes differ. Extracted so the held-row
1407// render (RenderHeld, moves design §9.1) writes through the very same
1408// reader, renderer and writer SyncExec does, rather than a second copy of
1409// them. The bare-hostname check stays with each caller, before any path is
1410// derived.
1411//
1412// keepAbsent (config sync's pass, for a route with no instance overlay):
1413// when the file is absent and the render is empty, nothing is written and
1414// the render counts as up to date. An absent file and an empty one give a
1415// spawn the same environment (the activator reads a missing config env as
1416// none), and an absent file is what every never-configured app's instances
1417// already have. Writing the empty file would be a config change for every
1418// running instance: a restart, a delivered revision and an ack, for a
1419// bundle that only gained settings boxes read from the bucket, such as the
1420// swap policy (Astra plan 6 R1 #1, plan Q3). The held render (RenderHeld)
1421// passes false: a move's ack hashes the file it wrote.
1422func renderExecConfig(ctx context.Context, work hostwrite.Work, rt registry.Route, m *Manifest, priv *ecdh.PrivateKey, dataDir, header, instETag string, keepAbsent bool) (execRender, error) {
1423 return renderExecConfigBefore(ctx, work, rt, m, priv, dataDir, header, instETag, nil, keepAbsent)
1424}
1425
1426// renderExecConfigBefore is renderExecConfig with before, when not nil,
1427// called with the hex sha256 of the rendered bytes once they are known and
1428// before the file is written or found up to date (RenderHeldBefore). An
1429// error from it ends the render with nothing written.
1430//
1431// before and keepAbsent are independent. before runs as soon as the bytes
1432// are rendered, ahead of both the up-to-date check and the keepAbsent skip,
1433// so it sees every render whatever becomes of the write: it is a witness of
1434// what the render holds, not of what was written. Its only callers are held
1435// renders (RenderHeldBefore), which pass keepAbsent=false and so never skip;
1436// config sync passes before=nil and keepAbsent per its overlay. A caller
1437// that someday passes both gets before called with the hash of an empty
1438// render that was then deliberately not written.
1439func renderExecConfigBefore(ctx context.Context, work hostwrite.Work, rt registry.Route, m *Manifest, priv *ecdh.PrivateKey, dataDir, header, instETag string, before func(string) error, keepAbsent bool) (execRender, error) {
1440 path := ConfigEnvPath(dataDir, rt)
1441 // A nested row's current file lives on tenant-owned ground, so its read
1442 // goes through instancefs the same way its write below does: a plain
1443 // os.ReadFile would block forever on a FIFO a tenant planted at this
1444 // name (isolated-exec design §3.3). The flat directory is agent-owned
1445 // throughout and keeps the plain read it always used.
1446 var cur []byte
1447 var existed bool
1448 var err error
1449 if nestedRoute(dataDir, rt) {
1450 cur, err = instancefs.ReadBeneathLimit(filepath.Dir(path), filepath.Base(path), 1<<20)
1451 existed = err == nil
1452 if err != nil && !os.IsNotExist(err) {
1453 fmt.Printf(" %s: read %s: %v\n", rt.Host, path, err)
1454 return execRender{}, err
1455 }
1456 } else {
1457 cur, err = os.ReadFile(path)
1458 existed = err == nil
1459 if err != nil && !os.IsNotExist(err) {
1460 fmt.Printf(" %s: read %s: %v\n", rt.Host, path, err)
1461 return execRender{}, err
1462 }
1463 }
1464 // Where sync.go has its defaultMissing/lostKeys guard, this path has
1465 // nothing — deliberately. That guard exists because /etc/carlos/apps
1466 // files may be HAND-STAGED, so a bound-environment-only merged view
1467 // dropping keys could delete config no one put in the bucket. This
1468 // file is sync-owned by construction: every byte in it was rendered by
1469 // a previous pass of this function from a manifest. A merged view that
1470 // drops keys is therefore correct convergence, not truncation — and
1471 // the instance's own <host>.env, the one file here that IS
1472 // hand-staged, is a separate file layered on top at spawn and never
1473 // read or written by this sweep.
1474 // Deliberately NOT run through applyAgentVars (sync.go, spec §7.3):
1475 // this is <host>.config.env, one layer under the socket-exec
1476 // instance's own <host>.env, and the activator applies withVar/
1477 // withoutVars to CARLOS_PURGE at spawn time (backend_exec.go's
1478 // Start) — cache-CONDITIONAL, set only when b.Cache != nil. Baking
1479 // CARLOS_PURGE=header into this file unconditionally would make the
1480 // var dishonest on disk on a cacheless box, the exact failure mode
1481 // agent-ownership exists to prevent. The asymmetry with sync.go's
1482 // unit path (which does apply it, unconditionally, because cache
1483 // construction is unconditional there) is correct; do not "fix" it.
1484 rendered, err := RenderEnv(m, priv, routeBinding(rt))
1485 if err != nil {
1486 fmt.Printf(" %s: render config: %v\n", rt.Host, err)
1487 return execRender{}, err
1488 }
1489 if header != "" {
1490 rendered = append([]byte(header), rendered...)
1491 }
1492 out := execRender{path: path, rendered: rendered}
1493 if before != nil {
1494 sum := sha256.Sum256(rendered)
1495 if err := before(hex.EncodeToString(sum[:])); err != nil {
1496 return execRender{}, err
1497 }
1498 }
1499 if existed && bytes.Equal(cur, rendered) {
1500 return out, nil // up to date — stay quiet on the periodic tick
1501 }
1502 if keepAbsent && !existed && len(rendered) == 0 {
1503 return out, nil // absent, and nothing to put in it: as an unconfigured app's
1504 }
1505 id := renderedIdentity(rendered, true, rt.Environment != "", instETag != "")
1506 out.id = id
1507 // A nested row's config file lives inside its own instance directory,
1508 // tenant-owned ground (isolated-exec design §3.2). So it is written
1509 // through instancefs.WriteAtomic's O_EXCL temp name and openat2
1510 // resolution rather than a predictable path.tmp: the same reasoning
1511 // backend_exec.go's own writes into that directory follow. The flat
1512 // directory is agent-owned throughout, so it keeps the plain
1513 // temp-then-rename it always used.
1514 if nestedRoute(dataDir, rt) {
1515 if err := guarded(ctx, work, id, func() error { return instancefs.WriteAtomic(filepath.Dir(path), filepath.Base(path), rendered, 0o600) }); err != nil {
1516 if _, lerr := os.Lstat(filepath.Dir(path)); errors.Is(lerr, os.ErrNotExist) {
1517 return execRender{}, fmt.Errorf("%w: %s", errInstanceDirAbsent, filepath.Dir(path))
1518 }
1519 fmt.Printf(" %s: write %s: %v\n", rt.Host, path, err)
1520 return execRender{}, err
1521 }
1522 } else {
1523 tmp := path + ".tmp"
1524 if err := guarded(ctx, work, id, func() error { return os.WriteFile(tmp, rendered, 0o600) }); err != nil {
1525 fmt.Printf(" %s: write %s: %v\n", rt.Host, path, err)
1526 return execRender{}, err
1527 }
1528 if err := guarded(ctx, work, id, func() error { return os.Rename(tmp, path) }); err != nil {
1529 _ = guarded(ctx, work, id, func() error { return os.Remove(tmp) })
1530 fmt.Printf(" %s: rename %s: %v\n", rt.Host, path, err)
1531 return execRender{}, err
1532 }
1533 }
1534 fmt.Printf(" %s: config updated\n", rt.Host)
1535 out.changed = true
1536 return out, nil
1537}
1538
1539// bounceLogged calls bounce — best-effort in the sense that a failure is
1540// logged rather than aborting the pass, the file on disk is already
1541// correct and the next natural wake picks it up — and returns what bounce
1542// itself returned, so a caller that also writes a config-ack (syncExecHost
1543// above) can report whether THIS pass's restart actually succeeded rather
1544// than silently discarding that fact. bounce == nil (the tcp-exec-file/
1545// addr shape, whose process this platform does not own) is a no-op
1546// returning nil.
1547func bounceLogged(host string, bounce func(host string) error) error {
1548 if bounce == nil {
1549 return nil
1550 }
1551 if err := bounce(host); err != nil {
1552 if errors.Is(err, ErrDeliveredOnWake) {
1553 fmt.Printf(" %s: config updated, asleep: delivered on wake\n", host)
1554 return err
1555 }
1556 fmt.Printf(" %s: bounce after config change: %v\n", host, err)
1557 return err
1558 }
1559 return nil
1560}
1561
1562// reapConfigEnv deletes *.config.env (and stale *.config.env.tmp) for
1563// hosts with no route row at all. It matches ONLY sync's own suffix:
1564// <host>.env and <host>-sidecar.env are other owners' files and are
1565// never candidates, whatever their host's route state.
1566func reapConfigEnv(routes []registry.Route, dataDir string) {
1567 ents, err := os.ReadDir(dataDir)
1568 if err != nil {
1569 return
1570 }
1571 live := make(map[string]bool, len(routes))
1572 for _, rt := range routes {
1573 live[rt.Host] = true
1574 }
1575 for _, e := range ents {
1576 if e.IsDir() {
1577 continue
1578 }
1579 if strings.HasSuffix(e.Name(), configEnvSuffix+".tmp") {
1580 // A crashed pass's leftover holds decrypted secrets and is
1581 // stale by construction once reap runs.
1582 if err := os.Remove(filepath.Join(dataDir, e.Name())); err != nil {
1583 fmt.Printf(" %s: remove stale config temp file: %v\n", e.Name(), err)
1584 }
1585 continue
1586 }
1587 if !strings.HasSuffix(e.Name(), configEnvSuffix) {
1588 continue
1589 }
1590 host := strings.TrimSuffix(e.Name(), configEnvSuffix)
1591 if live[host] {
1592 continue
1593 }
1594 if asInstanceEnvHost := strings.TrimSuffix(e.Name(), ".env"); live[asInstanceEnvHost] {
1595 // The name is ambiguous: <x>.config.env is host x's sync-owned
1596 // config file AND host x.config's hand-staged instance env
1597 // (<host>.env). With no route x but a live route x.config, the
1598 // bytes on disk may be the latter — another owner's file, never
1599 // deleted here.
1600 fmt.Printf(" %s: not reaped — live route %q claims this name as its instance env\n", e.Name(), asInstanceEnvHost)
1601 continue
1602 }
1603 if err := os.Remove(filepath.Join(dataDir, e.Name())); err != nil {
1604 fmt.Printf(" %s: remove orphaned config file: %v\n", host, err)
1605 continue
1606 }
1607 fmt.Printf(" %s: no route on this box, removed its config file\n", host)
1608 }
1609 reapNestedConfigEnv(live, dataDir)
1610}
1611
1612// reapNestedConfigEnv is reapConfigEnv's nested half (isolated-exec design
1613// §7): a nested row's rendered bundle lives inside its own instance
1614// directory, one level under <dataDir>/instances, so a stale file there is
1615// found by walking that tree rather than dataDir's own listing. The flat
1616// directory's <host>.config / <host>.config.env name collision (reapConfigEnv's
1617// own ambiguity guard, above) cannot happen inside an instance directory.
1618// Nothing in there is named after some OTHER host, so no such guard is
1619// needed here.
1620func reapNestedConfigEnv(live map[string]bool, dataDir string) {
1621 root := filepath.Join(dataDir, "instances")
1622 dirs, err := os.ReadDir(root)
1623 if err != nil {
1624 return // no nested layout on this box (or nothing has migrated yet)
1625 }
1626 for _, d := range dirs {
1627 if !d.IsDir() {
1628 continue
1629 }
1630 instDir := filepath.Join(root, d.Name())
1631 ents, err := os.ReadDir(instDir)
1632 if err != nil {
1633 continue
1634 }
1635 for _, e := range ents {
1636 if e.IsDir() {
1637 continue
1638 }
1639 // A nested config file is written through instancefs.WriteAtomic,
1640 // whose temp name is ".<name>.tmp-<hex>" beside the real name, not
1641 // "<name>.tmp" the way the flat directory's plain rename leaves
1642 // one: the leading dot and the "-<hex>" suffix are both load
1643 // bearing here, or a crashed pass's leftover (it holds decrypted
1644 // secrets) is never reaped.
1645 if strings.HasPrefix(e.Name(), ".") && strings.Contains(e.Name(), configEnvSuffix+".tmp-") {
1646 if err := os.Remove(filepath.Join(instDir, e.Name())); err != nil {
1647 fmt.Printf(" %s: remove stale config temp file: %v\n", e.Name(), err)
1648 }
1649 continue
1650 }
1651 if !strings.HasSuffix(e.Name(), configEnvSuffix) {
1652 continue
1653 }
1654 host := strings.TrimSuffix(e.Name(), configEnvSuffix)
1655 if live[host] {
1656 continue
1657 }
1658 if err := os.Remove(filepath.Join(instDir, e.Name())); err != nil {
1659 fmt.Printf(" %s: remove orphaned config file: %v\n", host, err)
1660 continue
1661 }
1662 fmt.Printf(" %s: no route on this box, removed its config file\n", host)
1663 }
1664 }
1665}
1666