// SyncExec is the exec-backend half of config delivery: for every // activator-spawned route (socket-upstream, non-unit), materialize the // route's merged manifest into /.config.env — the file // ExecBackend.Start layers UNDER the instance's own .env at spawn. // Sync owns this namespace outright (nothing else writes *.config.env), // which is why an absent manifest REMOVES the file here, where the // unit-path Sync leaves /etc/carlos/apps files alone: there is no // hand-staged-file migration hazard by construction. See spec // 2026-08-02-exec-config-delivery-design.md. // // A third eligibility case (issue #18): a TCP-addr exec route (Backing == // "", Socket == "", Addr or Addrs set) whose BOUND BUNDLE has opted in via // additive "exec_delivery": "file" manifest field (Manifest.ExecDelivery) // gets the identical file, materialized with the same layering guarantees, // environments-awareness, reap discipline, and mass-removal/path-traversal // guards as a socket-exec route — but is NEVER bounced (see syncExecHost // and execAddrCandidate): carlos routes to these processes without // spawning or owning them, so the restart half belongs to the app's own // supervisor. An un-opted TCP-addr route is skipped exactly as before this // case existed — see execAddrCandidate and SyncExec's checkOptedIn closure. // // "Bound bundle" is issue #195: the flag is read from the route's named // environment when that bundle STATES a value, and from the app's default // bundle otherwise. Before that it was app-level only, so opting one // relay route in wrote a file for every other TCP-addr route of the same // app — a fleet-wide switch thrown to deliver one service's credential. // // Resolution mirrors sync.go's exactly — the group is (Account, App, // Environment) and a route's bound environment is an overlay on the app's // default bundle (environments.go, pipelines design §4a). Only the file // semantics differ, and only where sync's ownership of the namespace // makes them differ: absence removes rather than skips, and there is no // key-loss guard (see syncExecHost). package appconfig import ( "bytes" "context" "crypto/ecdh" "crypto/sha256" "encoding/hex" "errors" "fmt" "os" "path/filepath" "sort" "strings" "time" "github.com/carlosframework/platform/internal/hostwrite" "github.com/carlosframework/platform/internal/instancefs" "github.com/carlosframework/platform/internal/provision" "github.com/carlosframework/platform/internal/registry" "github.com/carlosframework/platform/internal/release" ) // configEnvSuffix names sync's file namespace under dataDir. Everything // with this suffix belongs to sync; nothing else in dataDir does. const configEnvSuffix = ".config.env" // nestedRoute reports whether rt's DB and socket are exactly the nested // layout's defaults (isolated-exec design §8.1). It is the same two-column // test activator.Classify makes, spelled out here rather than imported so // this package need not take a dependency on internal/activator for one // comparison. func nestedRoute(dataDir string, rt registry.Route) bool { return rt.DBPath == provision.NestedDBPath(dataDir, rt.Host) && rt.Socket == provision.NestedSocket(rt.Host) } // ConfigEnvPath is where a route's rendered bundle lands: inside the // instance directory for a nested row (isolated-exec design §7), flat for // everything else, including opted-in addr routes whose supervisor reads // the flat path. func ConfigEnvPath(dataDir string, rt registry.Route) string { if nestedRoute(dataDir, rt) { return filepath.Join(provision.InstanceDir(dataDir, rt.Host), rt.Host+configEnvSuffix) } return filepath.Join(dataDir, rt.Host+configEnvSuffix) } // SyncExec resolves config once per distinct (Account, App, Environment) // among the box's exec-syncable routes and converges every such route's // /.config.env, calling bounce(host) whenever a file's // content changed (including removal) so a running instance respawns // with fresh env on its next wake. Die-free per group, like Sync. // // The group carries Environment for the same reason Sync's does // (pipelines design §4a): two routes of ONE app bound to different // environments resolve to different manifests, and keying on (acct, app) // alone would render whichever route was seen first into both files. // // ack, if non-nil, is called once per route this pass actually WRITES a // changed file for, with that route's config-adoption acknowledgement // (spec §5, configack.go) — nil means no acks, same contract as Sync's. A // route whose file is REMOVED (manifest absent) gets no ack: there is // nothing for a route to have adopted once its config is gone, and a // removal is not one of spec §5's three delivery shapes. // // opts adjust one pass; see SkipFenced. func 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 { return SyncExecWithCallbacks(ctx, reg, s, priv, dataDir, appsDomain, Callbacks{Restart: bounce, Ack: ack}, opts...) } func SyncExecWithCallbacks(ctx context.Context, reg *registry.Registry, s release.Store, priv *ecdh.PrivateKey, dataDir, appsDomain string, callbacks Callbacks, opts ...SyncOption) error { var o syncOptions for _, opt := range opts { opt(&o) } if o.hostWriteCount > 1 { return errHostWriteOptions } hostWrites := o.hostWrites bounce, ack := callbacks.Restart, callbacks.Ack if hostWrites.Enabled && bounce == nil && callbacks.SelectedRestart != nil { bounce = func(string) error { return hostwrite.ErrUnavailable } } routes, err := reg.List() if err != nil { return err } callbacks.enabled = hostWrites.Enabled selected := map[string]hostwrite.Work{} for _, rt := range routes { if !execSyncable(rt) && !execSidecarCandidate(rt) && !execAddrCandidate(rt) { continue } if !o.kept(rt) { continue } if o.fenced != nil && o.fenced(rt.Host) { continue } work, selectErr := hostWrites.Select(ctx, rt.Host, "config") if selectErr != nil { return fmt.Errorf("%s: select config work: %w", rt.Host, selectErr) } selected[rt.Host] = work } // hostSet names every route on this box, in or out of scope, so the // namespace-collision guard below sees a route x.config even though // x.config itself may never reach syncExecHost. Built once: the guard // runs once per in-scope route in the materialization loop, and every // route's Host is fixed for the pass. hostSet := make(map[string]bool, len(routes)) for _, rt := range routes { hostSet[rt.Host] = true } type groupKey struct{ acct, app, env string } // The same three states Sync resolves to, with this path's own file // semantics for the middle one: a manifest to converge to; "never // configured at all" (m == nil, err == nil), which REMOVES the file // here because sync owns the *.config.env namespace outright (nothing // hand-staged can live in it); and "resolution failed" (err != nil), // which leaves the file exactly as it is and says so, loudly, per // route. An error is never absence — folding the two together would // delete a live route's config on a bucket blip or a deleted bundle. type resolution struct { m *Manifest err error } resolved := map[groupKey]resolution{} // The default bundle is read once per (acct, app) and reused across // that app's environments — same cache, same reason, as Sync's. type appKey struct{ acct, app string } type defaultRes struct { m *Manifest found bool err error } defaults := map[appKey]defaultRes{} // getIfExists, not Get: an app with no manifest object in the bucket // at all must be distinguishable from one whose every var was unset. // Get folds those together into an empty manifest; here the first // removes the route's config file and the second converges it to an // empty one. loadDefault := func(acct, app string) defaultRes { ak := appKey{acct, app} if d, done := defaults[ak]; done { return d } m, found, err := getIfExists(ctx, s, acct, app) if err != nil { err = fmt.Errorf("%s/%s: config fetch failed: %w", acct, app, err) } d := defaultRes{m: m, found: found, err: err} defaults[ak] = d return d } // optedInRes is checkOptedIn's cached verdict. known is false ONLY for // a fetch error: v is a placeholder in that case, never a // determination — see checkOptedIn's def.err branch for why that // distinction matters to the stale-file notice below. type optedInRes struct { v bool known bool } // optKey keys the opt-in decision on the BUNDLE a route's config // actually resolves through, not on the app alone (issue #195). Two // routes of one app bound to different environments can now differ, so // the cache — and the once-per-pass logs below — must too. type optKey struct{ acct, app, env string } // optedIn caches the exec_delivery decision so a box with many TCP-addr // routes on one bundle doesn't cost more than one manifest read for it // (loadDefault/loadEnvBundle already dedupe the fetches; this dedupes // the value-interpretation, including the logs below, which must fire // at most once per (acct, app, env) per pass, not once per route). optedIn := map[optKey]optedInRes{} // envBundles caches the named-bundle read the same way loadDefault // caches the default's, and for the same reason. type envRes struct { m *Manifest found bool err error } envBundles := map[optKey]envRes{} loadEnvBundle := func(acct, app, env string) envRes { k := optKey{acct, app, env} if e, done := envBundles[k]; done { return e } m, found, err := getIfExistsAt(ctx, s, EnvironmentKey(acct, app, env)) if err != nil { err = fmt.Errorf("%s/%s (environment %q): config fetch failed: %w", acct, app, env, err) } e := envRes{m: m, found: found, err: err} envBundles[k] = e return e } checkOptedIn := func(acct, app, env string) optedInRes { k := optKey{acct, app, env} if c, done := optedIn[k]; done { return c } // The route's OWN bundle first, and only when it STATES something: // a named bundle silent on the field inherits the app default, // exactly as a key it does not set inherits the default bundle's // value. That PRECEDENCE now lives in resolveExecDelivery, which // AwaitingRestart resolves through too, so the notice an operator // reads and the file this sweep writes can never disagree about // who is opted in. What stays here is what THIS caller owes the // two non-verdict outcomes: a loud line in the sweep's log, once // per bundle per pass, and every file left exactly as it was. var eb envRes if env != "" { eb = loadEnvBundle(acct, app, env) if eb.err != nil { // NOT a determination — the same contract the default // bundle's fetch-failure branch below gives: say so loudly, // leave every file alone, retry next pass. 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) c := optedInRes{v: false, known: false} optedIn[k] = c return c } // found-but-silent, and absent, both fall through to the app // default inside resolveExecDelivery. A bundle bound but // MISSING is a defect the route's own resolution reports // (LoadMerged refuses it) and its file is left as-is there — // the verdict reached here never gets to a write in that case. } v, err := resolveExecDelivery(eb.m, eb.found, func() (*Manifest, bool, error) { d := loadDefault(acct, app) return d.m, d.found, d.err }) if err != nil { // NOT a determination: this app may well be opted in and this // box just failed to see it this pass — indistinguishable here // from any other route's ordinary fetch-failure blip. Silence // would mean an opted-in app's delivery can stall with zero // trace in the log, on the exact path this feature exists to // light up, so say so loudly — the same "unresolved, left // as-is" contract the resolution loop below already gives a // bound-group fetch failure. // // It is safe to leave any existing file exactly as it is // regardless of how long this persists: NOT because a fetch // failure and "never opted in" are indistinguishable (they // aren't, that's the whole point of this branch existing // separately from the ones below) but because reapConfigEnv // keys on ROUTE EXISTENCE, never on scope (its live map is // built from every route's Host, full stop — see its own doc // comment). A route that drops out of scope for one pass, for // ANY reason, is simply not visited by the resolution loop, // the materialization loop, or reap; its file is untouched by // construction, not because this function decided to spare // it. Next pass retries from scratch. 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) c := optedInRes{v: false, known: false} optedIn[k] = c return c } if v.Unknown != "" { // A manifest a NEWER carlos wrote with a value THIS binary // doesn't understand must not brick this box's sync (additive // discipline, AGENTS.md) — ignore it, loudly, once, and treat // the bundle as not opted in rather than refusing the whole // pass. Which bundle said it is named, because "upgrade // carlos" is useless advice without knowing where to look. where := "the default bundle" if v.FromEnvironment { where = fmt.Sprintf("environment %q", env) } 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) } c := optedInRes{v: v.On, known: true} optedIn[k] = c return c } // inScope is the single door both loops below use to decide whether a // route is in scope this pass, and — for the addr and sidecar shapes — // that it must never be bounced. One function so the two loops // structurally cannot diverge on either question (see the "if they // ever diverge" comment on the second loop's map lookup, which depends // on the same key having been computed the same way here). // // sidecarShape never bounces for a DIFFERENT reason than addrShape // does: carlos owns a sidecar's process (unlike the addr shape, whose // app-supervised process carlos never touches), but Bounce alone has // no way back for a worker — nothing dials one the way a request // dials an instance, so an automatic bounce here would either strand // an always-on sidecar until the agent restarts or leave a // hibernating one silent until its schedule next comes due, on every // ordinary config-sync pass. Restarting a sidecar on purpose is // `carlos restart -app X` (host/restart.go), which pairs the stop // with an explicit wake; this pass only ever writes the file. inScope := func(rt registry.Route) (ok, addrShape, sidecarShape bool) { // A route WithRouteFilter leaves out is out of scope before // anything is read for it: no bundle, no opt-in, no overlay. if !o.kept(rt) { return false, false, false } if execSyncable(rt) { return true, false, false } if execSidecarCandidate(rt) { return true, false, true } if execAddrCandidate(rt) && checkOptedIn(release.RouteAccount(rt), rt.App, rt.Environment).v { return true, true, false } return false, false, false } for _, rt := range routes { if in, _, _ := inScope(rt); !in { continue } k := groupKey{release.RouteAccount(rt), rt.App, rt.Environment} if _, done := resolved[k]; done { continue } def := loadDefault(k.acct, k.app) if def.err != nil { // A fetch failure is NOT "absent": leave this app's files // alone this pass rather than removing config on a blip. resolved[k] = resolution{err: def.err} continue } if k.env == "" { if !def.found { resolved[k] = resolution{} // absent -> remove continue } resolved[k] = resolution{m: def.m} continue } // A bound environment asserts the bundle exists, so LoadMerged // treats its absence as an error rather than an empty overlay — // and that error stays contained to the routes bound to it. There // is no never-configured case here: an app can lack a default // bundle and still resolve (environment-only), and a missing // environment is the error above, not silence. Critically for // THIS path, that error must not read as absence: removing the // file would strip a live instance's config because someone // deleted a bundle. m, err := LoadMerged(ctx, s, k.acct, k.app, k.env) if err != nil || m == nil { if err == nil { err = fmt.Errorf("config: resolved a nil manifest for environment %q", k.env) } resolved[k] = resolution{err: err} continue } resolved[k] = resolution{m: m} } // Mass-removal misconfiguration guard. A registry rebuild that mangles // the account column makes release.RouteAccount fall back to "ops" for // every app at once, so every group's bucket key 404s and every group // in this pass looks "absent" — indistinguishable, from here, from // every manifest object having genuinely been deleted. An unguarded // pass would then remove every config file on the box in one sweep. // Require MORE THAN ONE group before refusing: a single-app box (a // self-hoster who really did delete their one manifest) must still // converge normally, and a lone group can't produce a false "mass" // signal — only a registry-wide fault spanning multiple apps can look // like this. // // Groups in the ERROR state are counted on neither side: they are // neither absent nor present, they never remove anything anyway, and // letting one sit in the denominator would silently disarm the guard // on a box where one app's bundle happens to be broken. That choice // has a direction: a PARTIALLY errored mangled box can still remove // the remaining absent groups' files, which counting errors as absent // would have prevented — but counting them as absent would also freeze // legitimate removals for as long as any one app's bundle stays // broken, indefinitely and silently. Bounded, recoverable over-removal // beats an unbounded silent freeze; routes bound to an environment are // protected by the error path regardless, since an unresolved group // never reaches a removal at all. // // The instance layer does not enter this count, and does not need to. // The fault this guard exists for is registry-wide — a mangled account // column makes EVERY key of every app 404 at once, the overlay listing // included, since it hangs off the same mangled prefix — so an overlay // could not survive the fault it is counting. A route that does have an // overlay never reaches a removal branch at all (see below), so it is // neither protected nor endangered by this number. eligible, absent := 0, 0 for _, r := range resolved { if r.err != nil { continue } eligible++ if r.m == nil { absent++ } } skipRemovals := eligible > 1 && absent == eligible if skipRemovals { // Name what was actually checked — "every app" would overclaim, // since errored groups were never in this count. 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) } // The instance layer (senders design §6.2), resolved per route after the // group base. Same loader, same cost, same failure contract as the unit // pass's: one listing per app, a Get only for a host it named, and an // unresolved overlay leaves the file exactly as it is. overlayFor := instanceOverlayLoader(ctx, s) // One line per app when the LISTING fails, not one per route: every // route of that app fails identically and for the same reason, since // instanceOverlayLoader caches that failure per (account, app) — // independent of Environment, which is why this is keyed on appKey and // not groupKey. A per-host Get or parse failure is a DIFFERENT claim — // two routes can have two separately corrupt overlay objects, each // wrong for its own reason — so that case is never folded in here; see // the errors.As branch below. said := map[appKey]bool{} // done is WithHostOutcome's answer per in-scope host: false until the // host's file is as this pass resolved it. done := map[string]bool{} for _, rt := range routes { in, addrShape, sidecarShape := inScope(rt) if !in { continue } done[rt.Host] = false // A host the layout pass holds has its files between two // directories, and either path this pass could pick would be the // wrong one by the time the pass finishes. Nothing is written or // removed for it; the next pass after the move renders it where the // row then says. if _, ok := selected[rt.Host]; !ok { fmt.Printf(" %s: layout migration in flight; bundle not written this pass\n", rt.Host) continue } k := groupKey{release.RouteAccount(rt), rt.App, rt.Environment} // Insist the group was really resolved rather than taking the map's // zero value: a zero resolution reads as "absent", which on this // path REMOVES the file. The two loops filter through the same // inScope today, so this cannot fire — but if they ever diverge, // this turns a silent config deletion into a skip. r, ok := resolved[k] if !ok { continue } if r.err != nil { // Loud, per route, every pass: this route is running on a // stale config file until an operator fixes it. Its file is // left exactly as it is — nothing below this line runs for it. env := k.env if env == "" { env = "default" } fmt.Printf(" %s: config unresolved (environment %q), config file left as-is: %v\n", rt.Host, env, r.err) continue } // Fetched here, used at the bottom, because a failure must be able to // stop a REMOVAL too: deciding a file is orphaned while unable to see // whether this instance has an overlay would delete a live tenant's // delivered secrets on a listing blip. inst, instETag, instErr := overlayFor(k.acct, k.app, rt.Host) if instErr != nil { var listErr *instanceOverlayListErr if errors.As(instErr, &listErr) { // Said ONCE per (account, app), not once per route: the // listing this failed on is cached per app inside the // loader, so every route of the app, across every // environment it is bound to, fails identically and for // the same reason; a line each would bury the cause under // its own repetitions on a box with many routes. ak := appKey{k.acct, k.app} if !said[ak] { said[ak] = true // rt.Host anchors the line to a real route (the first // one this pass happened to reach), matching sync.go's // own wording, so an operator scanning the log has a // hostname to grep for — every other route of this app // is silently covered by the same line. fmt.Printf(" %s/%s: instance config unresolved (seen at %s), config files left as-is: %v\n", k.acct, k.app, rt.Host, instErr) } } else { // A per-host Get or parse failure, unlike the listing // above, is specific to THIS route: the listing already // succeeded (it named this host), so another route of // the same app can have a perfectly good overlay, or a // failure of its own for a different reason. Folding this // into the app-level line would leave that route's stale // config file invisible, so it is reported every time. fmt.Printf(" %s: instance config unresolved, config file left as-is: %v\n", rt.Host, instErr) } continue } // An overlay is configuration, so a route that has one is never // "absent" for the MASS-REMOVAL guard below, however its group base // resolved: writing a file a bundle asked for is not a removal. // // This narrowing (the `inst == nil` clause, added by this branch) // makes the guard itself strictly narrower than it was before: a // route with an overlay now sails through where it used to be held // with everything else. That is backstopped, everywhere this // narrowing actually matters (r.m == nil, i.e. a mangled account // column made the group base read absent), by the #344 guard just // below — EXCEPT for a custom-domain route. release.HostAccountLabel // only derives a to check from a PLATFORM-domain hostname; a // custom-domain host's label is "", the #344 check's `sq != ""` test // is false, and the guard simply does not fire. So a custom-domain // route with an overlay, on a box whose account column is mangled in // the exact way #344 exists to catch, has no backstop here at all — // it sails through this guard AND past #344, and the wholesale // overlay-alone replacement both guards were meant to prevent for it // can still happen. Known and accepted (final review, plan 4): the // fix would need a host-independent way to validate the account // column, which #344 does not have either. if r.m == nil && inst == nil && skipRemovals { continue } // The #344 guard: an absent manifest removes the config file ONLY // when the account it was looked up under agrees with the account // the host itself names. A platform host's label is not a // moving part; the row's account column is (the domains reconcile // stamps it from bucket listings, and a mis-stamp deleted a live // tenant's delivered secrets on 2026-08-28). Held, loudly, like an // unresolved group — the fix is the row's account, not this file. // // UNLIKE the mass-removal guard above, this hold is NOT narrowed by // an overlay (review finding, plan 4 task 7 fix round 1: the brief's // own reasoning — "a route with an overlay never reaches a removal // branch anyway" — does not transfer here, because this is not a // removal branch. It is "this account column cannot be trusted, // touch nothing." A mis-stamp that makes the group's default-bundle // READ land under the wrong prefix makes the instance layer's own // WRITE (ApplyInstance) land under that same wrong prefix too, so an // overlay resolving here under the mis-stamped account is exactly // what a mis-stamped fleet produces — not evidence the account // column is safe to act on for this route. Wholesale-replacing the // file with that overlay's contents would be the 2026-08-28 failure // again, just reached through the overlay instead of the base. if r.m == nil { if sq := release.HostAccountLabel(rt.Host, appsDomain); sq != "" && sq != k.acct { 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", rt.Host, k.acct, sq) continue } } // Namespace-collision guard, checked before EITHER shape can reach // syncExecHost below (the single call site both shapes share): if a // route ".config" also lives on this box, its hand-staged // instance env (.env for THAT route) is named // ".config.env" — byte-identical to what converging rt // would write here. Refuse loudly rather than clobber another // owner's file; see reapConfigEnv's twin guard for the // deletion-side half of the same hazard. if hostSet[rt.Host+".config"] { fmt.Printf(" %s: not converging — route %q exists on this box and %s%s is its instance-env file (.env); refusing to write into another owner's namespace\n", rt.Host, rt.Host+".config", rt.Host, configEnvSuffix) continue } // The bucket key whose absence would drive a removal, named in the // removal log so cause and effect land on one line. Always the // DEFAULT bundle's key: removal is reachable only from the absent // state, and only an unbound group can resolve absent (a bound one // resolves through LoadMerged, which errors on a missing bundle // rather than returning nothing). A bound group's key would be // dead in every branch that reads this. // // addrShape routes get nil in bounce's place, not the real // closure with a runtime check skipping the call: carlos does not // own an app-supervised process, so there is structurally nothing // to hand syncExecHost that COULD invoke it for this route (issue // #18 design comment) — bounceLogged's existing nil-is-a-no-op // contract does the rest. Same shape for the header: addrShape // routes get execAddrConfigHeader, socket routes get "" (no // header at all) — see execAddrConfigHeader's doc comment for why // this must never apply to the socket shape. // // sidecarShape ALSO gets nil in bounce's place, but for the // opposite reason addrShape does: carlos owns this process, but // Bounce cannot safely stand in for a wake here — see inScope's // doc comment. No header either; a sidecar's file is read by // ExecBackend.instanceEnv, the same reader an instance's is. hostBounce := bounce header := "" switch { case addrShape: hostBounce = nil header = execAddrConfigHeader case sidecarShape: hostBounce = nil } // The group's merge, overlaid by this instance's own layer. A nil // base with an overlay is the overlay ALONE, which is a real // configuration and not the absence that drives a removal; nil and // nil together stay nil, which still removes the file. m := r.m if m != nil || inst != nil { m = applyInstanceOverlay(k.app, m, inst) } hostCallbacks := callbacks // With Managed set (the release scheduler is armed or refusing, // final review F7), the scheduler's own routes and the rest part // here. A socket route it does not manage is restarted as it is // with the flag off, by Unmanaged, and its delivery is none of the // scheduler's business. A native sidecar it manages keeps Delivered // below though config sync has no restart for it: the scheduler's // config attempt is how its new file reaches a running worker. managed := callbacks.Managed != nil && callbacks.Managed(rt) if callbacks.Managed != nil && hostBounce != nil && !managed { hostBounce = callbacks.Unmanaged hostCallbacks.Restart = callbacks.Unmanaged hostCallbacks.Delivered = nil } if hostBounce == nil { hostCallbacks.Restart = nil hostCallbacks.SelectedRestart = nil if !sidecarShape || !managed { hostCallbacks.Delivered = nil } } err := syncExecHostClaimed(ctx, selected[rt.Host], rt, m, Key(k.acct, k.app), priv, dataDir, hostBounce, header, instETag, ack, hostCallbacks) // A nested row's bundle lives in its instance directory, which the // activator makes at the first wake. Before then the write cannot // land, so the row is named and the pass goes on to the other // routes, claims on or off; the agent runs the pass again once the // directory is there (WhenInstanceDirAbsent). A render that failed // fails as before, directory or not. if errors.Is(err, errInstanceDirAbsent) { fmt.Printf(" %s: instance directory not made yet; config is written once it is\n", rt.Host) if o.dirAbsent != nil { o.dirAbsent(rt.Host, filepath.Dir(ConfigEnvPath(dataDir, rt))) } continue } if err != nil && hostWrites.Enabled { return err } if err == nil { done[rt.Host] = true } } if o.outcome != nil { for host, ok := range done { o.outcome(host, ok) } } // Stale-secrets notice (issue #18 design ruling, ratified 2026-08-03 // review): an addr-shaped route whose app is DEFINITIVELY not opted in // this pass (off, never configured, or an unrecognized exec_delivery // value — checkOptedIn's known == true, as opposed to a transient // fetch error) but still has a route row keeps any earlier // .config.env EXACTLY as it is: it was never added to // inScope/resolved above, so neither the resolution loop nor the // materialization loop touched it, and reap (below) never removes a // file whose route still has a row. // // RULING: do not auto-delete it. Removing the file would change what // the app's OWN supervisor layers in on its next respawn — a live // environment change caused by a flag flip, taking effect at a moment // carlos does not control and cannot announce, with no bounce (there // never is one for this shape) to make it observable. That is worse // than a stale file. It is also consistent with every other // ineligibility transition in this file: a route losing // Status: "ready" or gaining Platform: true doesn't get its file // deleted either, only stops being written to. // // BUT staying silent about it is wrong, more than for those other // transitions, because this file holds DECRYPTED secrets, and once an // app opts out nothing will EVER update it again: rotate a secret in // the bucket afterward and this file keeps serving yesterday's // plaintext, indefinitely, with carlos reporting nothing. "Off" reads // to an operator as "stop delivering config"; what it actually does // is "freeze whatever was last delivered, forever". Name the file and // the manual remedy, once per pass, so an operator doesn't mistake // the one for the other — the remedy has to be manual (delete it once // the app's own supervisor has stopped reading it) because carlos has // no way to know when that is. for _, rt := range routes { if !execAddrCandidate(rt) || !o.kept(rt) { continue } c := checkOptedIn(release.RouteAccount(rt), rt.App, rt.Environment) if c.v || !c.known { continue } path := ConfigEnvPath(dataDir, rt) if _, err := os.Stat(path); err != nil { continue // nothing was ever staged for this route: nothing to warn about } 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) } // An empty route list is never authority to delete anything — // registry.Open silently creates an empty database on a wrong path or // an unmounted /data (same guard, same reason, as the unit-path Sync). if len(routes) == 0 { return nil } if !hostWrites.Enabled { reapConfigEnv(routes, dataDir) } else { reapSelectedConfigTemps(ctx, dataDir, selected) } return nil } func reapSelectedConfigTemps(ctx context.Context, dataDir string, selected map[string]hostwrite.Work) { ents, err := os.ReadDir(dataDir) if err != nil { return } for _, entry := range ents { if entry.IsDir() || !strings.HasSuffix(entry.Name(), configEnvSuffix+".tmp") { continue } host := strings.TrimSuffix(entry.Name(), configEnvSuffix+".tmp") work := selected[host] if work == nil { continue } if err := guarded(ctx, work, renderedIdentity(nil, false, false, false), func() error { return os.Remove(filepath.Join(dataDir, entry.Name())) }); err != nil { fmt.Printf(" %s: remove stale config temp file: %v\n", entry.Name(), err) } } } // SyncOption adjusts one SyncExec pass. The options are variadic so the // ordinary call keeps its shape. type SyncOption func(*syncOptions) type syncOptions struct { fenced func(host string) bool keep func(rt registry.Route) bool dirAbsent func(host, dir string) outcome func(host string, done bool) hostWrites hostwrite.Config hostWriteCount int } // WithRouteFilter narrows one SyncExec pass to the routes keep accepts. A // route it leaves out is out of scope for the pass: nothing is read for it, // and its file is neither written nor removed (reap keys on route existence, // not scope). The agent's scoped pass uses it to converge only the apps the // change index says moved (S3 change-index design §4.5). Nil keeps every // route. func WithRouteFilter(keep func(registry.Route) bool) SyncOption { return func(o *syncOptions) { o.keep = keep } } func (o *syncOptions) kept(rt registry.Route) bool { return o.keep == nil || o.keep(rt) } // WhenInstanceDirAbsent hands SyncExec a callback for every nested row whose // bundle it could not write because the row's instance directory does not // exist yet: the activator makes that directory at the instance's first // wake (prepare), so a nested row created with settings has nowhere to put // its .config.env until then. The pass skips such a row cleanly, says // so, and calls dirAbsent with the host and the directory it waits for, so // the agent can run the pass again as soon as the directory appears rather // than leaving the instance without its settings until the hourly floor // (harness Run 9, finding 6). func WhenInstanceDirAbsent(dirAbsent func(host, dir string)) SyncOption { return func(o *syncOptions) { o.dirAbsent = dirAbsent } } func WithHostWrite(cfg hostwrite.Config) SyncOption { return func(o *syncOptions) { o.hostWrites = cfg o.hostWriteCount++ } } // SkipFenced makes SyncExec leave alone every in-scope route for which // fenced reports true: no bundle is written, removed or bounced for it, // and the pass says so. The agent passes the layout pass's journal // predicate. The pass also holds the agent's config-sync lock while it // moves a host, so this is the second net: it covers a journal that // outlives one pass, such as a completed revert held until the intent can // be read. func SkipFenced(fenced func(host string) bool) SyncOption { return func(o *syncOptions) { o.fenced = fenced } } // WithHostOutcome hands SyncExec a callback it calls, once a pass has gone // through every route without stopping, for each in-scope route: done is // true when the route's file is as the pass resolved it (written, already up // to date, removed, or rightly left absent), and false when the pass left it // as it was for any reason (an unresolved bundle or overlay, a guard, a // layout migration in flight, a write that failed, an instance directory not // made yet). A route out of scope is not named. The agent keeps a host whose // instance directory it waits for until the host is done (fix round 1, // Astra's finding 3: a pass that skipped a host on a transient read failure // returned nil, and the agent took that as delivered). func WithHostOutcome(outcome func(host string, done bool)) SyncOption { return func(o *syncOptions) { o.outcome = outcome } } // execRouteBase is the lifecycle-managed-instance shape every exec route // kind shares: the unit path's syncable() exclusions, narrowed to // non-unit backing (unit routes belong to the root adopt path). Neither // SyncExec eligibility case on its own — see execSyncable, execAddrCandidate // and execSidecarCandidate, which each narrow it by upstream shape. // // Kind "sidecar" is included alongside "instance" (Task 11, design §9.1): // this is the direct replacement for the hand-staged // -sidecar.env — `carlos env set` must reach a sidecar, secrets are // sealed and delivered the same way an instance's are, and the file this // predicate gates is exactly what ExecBackend.instanceEnv reads at // StartWorker's spawn. Every other clause is untouched: a sidecar row is // still excluded by Platform, a non-ready Status, or Backing == // BackingUnit, same as an instance would be. func execRouteBase(rt registry.Route) bool { return (rt.Kind == "instance" || rt.IsSidecar()) && rt.App != "" && !rt.Platform && (rt.Status == "" || rt.Status == "ready") && rt.Backing != registry.BackingUnit } // execSyncable: lifecycle-managed instances the ACTIVATOR spawns — and // therefore whose environment this sweep can actually deliver and whose // process it can bounce. Narrows execRouteBase to a socket upstream; // unconditionally in scope, opt-in or not (this is the original, PR #16 // shape). func execSyncable(rt registry.Route) bool { return execRouteBase(rt) && rt.Socket != "" } // execAddrCandidate: the TCP-addr exec shape (issue #18) — an // app-supervised instance family carlos routes to but neither spawns nor // owns the process of (titogo's router fleet is the motivating case: // titogo-router + controld own the instance processes, carlos just // proxies to 127.0.0.1:). Narrows execRouteBase to an addr upstream // with no socket. Both registry spellings are accepted: Addr is the legacy // single-upstream field, while Addrs is the HA field used even when a route // currently has only one upstream. Treating only Addr as eligible silently // skipped config delivery when an existing route migrated to Addrs. // // This predicate is the ROUTE-SHAPE half only — it says nothing about // whether the route is actually in scope this pass. Unlike execSyncable, // that also requires the app's default bundle to carry "exec_delivery": // "file" (Manifest.ExecDelivery): the reader of the file this shape // produces is the app's OWN supervisor, not carlos, so writing it for an // app that never reads it would be dead weight at best and, if the app's // supervisor treats the file's mere presence or mtime as a restart // signal, an unwanted bounce at worst. See SyncExec's checkOptedIn/ // inScope closures, which gate this predicate on that opt-in. func execAddrCandidate(rt registry.Route) bool { return execRouteBase(rt) && rt.Socket == "" && (rt.Addr != "" || rt.Addrs != "") } // execSidecarCandidate: the standalone-worker exec shape (Task 11, design // §9.1) — a sidecar row carries neither Socket nor Addr by construction // (sidecarRouteFor's contract: a sidecar binds nothing and nothing dials // it), so it can never satisfy execSyncable or execAddrCandidate; this is // its own narrowing of execRouteBase, keyed on Kind alone. // // Unconditionally in scope, like execSyncable and UNLIKE execAddrCandidate: // a sidecar's process is spawned and owned by THIS box's activator // (ExecBackend.StartWorker), the same as a socket-exec instance, so there // is no "app-supervised, may not read the file" case here to opt into — // carlos always owns delivery for a route it owns the process of. func execSidecarCandidate(rt registry.Route) bool { return execRouteBase(rt) && rt.IsSidecar() } // ExecConfigApps is the distinct (account, app) set the agent's fast // generation loop (host package) polls: every app with at least one route // SyncExec could deliver config to. Accounts are resolved through // release.RouteAccount, the same way every other read of a route's account // resolves, so the key the loop watches is the key a write actually touches. // // The SHAPE half only — the TCP-addr case's opt-in (Manifest.ExecDelivery) // is NOT consulted, because consulting it costs a bucket read per app, // which is exactly what the fast loop exists to avoid. The cost of that // choice is bounded: polling an un-opted app's generation is one wasted // small read per tick, and the SyncExec pass a change triggers applies the // opt-in properly before writing anything. // // execSidecarCandidate is included alongside execSyncable and // execAddrCandidate (Task 11 fix round 1): left out, a sidecar-only app's // config — delivered by a direct-bucket write that never bumps // control/serial, exactly what the pinfra scripts do — would fall back to // the hourly floor while an instance next to it converges on this loop's // fast cadence. Not an error, just work happening late; the same // half-migrated-consumer shape this branch has repeatedly found. Costs // nothing extra to include unconditionally: a sidecar's process is spawned // and owned by carlos exactly like a socket-exec instance's (see // execSidecarCandidate's own doc comment), so there is no opt-in question // to defer here the way there is for the addr shape. // // Exported from THIS file, beside the predicates it composes, so the loop's // notion of eligibility can never drift from SyncExec's own — an app the // loop watched but SyncExec skipped would burn reads forever, and an app it // didn't watch would silently fall back to the hourly pass. func ExecConfigApps(routes []registry.Route) [][2]string { seen := map[[2]string]bool{} var out [][2]string for _, rt := range routes { if !execSyncable(rt) && !execAddrCandidate(rt) && !execSidecarCandidate(rt) { continue } k := [2]string{release.RouteAccount(rt), rt.App} if seen[k] { continue } seen[k] = true out = append(out, k) } // Sorted so the loop's per-tick read order — and any log line built // from it — is stable across passes, where registry order is not. sort.Slice(out, func(i, j int) bool { if out[i][0] != out[j][0] { return out[i][0] < out[j][0] } return out[i][1] < out[j][1] }) return out } // execDeliveryVerdict is the DECIDED half of exec_delivery resolution // (issue #195): given a route's bound bundle and its app's default one, // what does the flag say? Pure and log-free, so that the two callers who // need the answer — SyncExec's checkOptedIn, which writes the files, and // AwaitingRestart, which tells an operator a file was written that nothing // has read — reach it through one function rather than through two // readings of one rule. A notice that disagreed with the sweep about who // gets a file would be worse than no notice: it would name the wrong // route, in the exact place an operator went looking for certainty. // // Fetch FAILURES are deliberately not modelled here. They are not // determinations (see checkOptedIn's def.err branch), and the two callers // owe them different things — a loud log line and a left-alone file on one // side, a named "could not tell" host on the other. A caller that could // not read a bundle must not call this. type execDeliveryVerdict struct { // On is the only question SyncExec asks: does this bundle get files? On bool // Unknown is non-empty when the DECIDING bundle stated a value this // binary does not understand — decided "off", but for a reason worth // saying out loud (additive discipline, AGENTS.md: a manifest a newer // carlos wrote must not brick an older box, and must not pass silently // either). Carried out rather than logged in place, because the two // callers word it differently and print it to different places. Unknown string // FromEnvironment records WHICH bundle decided, so a caller's message // can name it. False means the app default decided — including the // common case where a named bundle was silent and inherited. FromEnvironment bool } // resolveExecDelivery applies the #195 precedence: the route's OWN bundle // first, and only when it STATES something; the app default otherwise. // Absence is inheritance, never "off" — an operator who has never // mentioned exec_delivery in a bundle has not asked for delivery to stop. // // loadDefault is a thunk rather than a value because the default bundle // must not be FETCHED when the named one already decided: eager fetching // would let an unrelated blip on the default's read turn a decided verdict // into an unresolved one, and would cost a bucket read per call on the // path where the answer was already in hand. func resolveExecDelivery(envM *Manifest, envFound bool, loadDefault func() (*Manifest, bool, error)) (execDeliveryVerdict, error) { if envFound && envM.ExecDelivery == ExecDeliveryFile { return execDeliveryVerdict{On: true, FromEnvironment: true}, nil } if envFound && envM.ExecDelivery != "" { return execDeliveryVerdict{Unknown: envM.ExecDelivery, FromEnvironment: true}, nil } defM, defFound, err := loadDefault() if err != nil { return execDeliveryVerdict{}, err } switch { case !defFound: return execDeliveryVerdict{}, nil case defM.ExecDelivery == ExecDeliveryFile: return execDeliveryVerdict{On: true}, nil case defM.ExecDelivery == "": return execDeliveryVerdict{}, nil default: return execDeliveryVerdict{Unknown: defM.ExecDelivery}, nil } } // RestartHost names one route a config write delivered a file to and that // nothing bounced. Unit is carried because it decides the REMEDY, and the // two remedies are not interchangeable: an addr route naming its own unit // (issue #110's consoles) is the one addr shape `carlos restart` can // actually cycle, where a plain one reaches nothing at all — the agent's // restart leg skips it (internal/host/restart.go, the rt.Unit != "" test) // and the activator it would otherwise ask has no child for a process // carlos never spawned. Telling an operator to run a command that is a // documented no-op for their route would be a second silent failure // answering the first. type RestartHost struct { Host string Unit string } // RestartPending is what a config write left behind on ONE box: files that // landed for processes carlos does not own, and therefore does not restart. // // Unresolved is the honest half and the reason this is not a plain // []string. A route whose exec_delivery could not be resolved this call — // its bundle unreadable, or the bundle it is bound to missing outright — // is a route nothing can say "delivered" or "not delivered" about. Dropping // it silently would commit the very conflation this notice exists to end: // "nothing needs a restart" and "I could not tell" rendered identically is // how discussion #16 got filed in the first place. type RestartPending struct { Hosts []RestartHost Unresolved []string } // Empty reports whether there is nothing to say — no host delivered to and // none unresolved. The renderers' one test, so "say nothing" is one // judgement rather than four. func (r RestartPending) Empty() bool { return len(r.Hosts) == 0 && len(r.Unresolved) == 0 } // AwaitingRestart answers, for a config write to app's envName bundle // ("" = the default), which of this box's routes were handed a file that // nothing will make their process read. It is the reporting half of the // ownership line SyncExec draws: carlos writes these files and // deliberately never bounces the processes behind them, so the only thing // that can close the loop is telling the person who just ran the command. // See discussion #16 — `secrets set` reported success, `secrets list` // confirmed the key, and the value reached nothing for want of one line. // // keys are the var names the write touched (set and unset together), used // ONLY to skip routes whose merged config the write cannot have changed — // see the shadowing test below. An empty slice disables that narrowing, // which is what a write with no vars in it (turning exec_delivery on) // needs: there is nothing to shadow, and the file appears regardless. // // Eligibility is execAddrCandidate plus the opt-in, which is exactly // SyncExec's inScope for the addr shape — reached through the same // predicate and the same resolveExecDelivery, from the same file, for the // same reason ExecConfigApps is exported from here. // // SCOPE, and it is a real limit: routes is whatever the caller could see, // which is one box's registry (the #145 blind spot internal/console/ // apiroutes.go documents). This function cannot know what it was not // shown, so its callers say what they looked at rather than letting a // short list read as a complete one. func AwaitingRestart(ctx context.Context, s release.Store, routes []registry.Route, acct, app, envName string, keys []string) RestartPending { var out RestartPending // The instance layer, through the same loader the sweeps use, so this // notice and the pass that writes the files agree about what sits above // a bundle. One listing for the app, then a Get per route that has an // overlay; both live for one call, like the caches below. overlayFor := instanceOverlayLoader(ctx, s) // Both caches exist for the same reason SyncExec's do: a box with many // TCP-addr routes on one bundle must not cost more than one read for // it. Unlike SyncExec's, they live for one call. var ( defM *Manifest defFound bool defErr error defDone bool ) loadDefault := func() (*Manifest, bool, error) { if !defDone { defM, defFound, defErr = getIfExists(ctx, s, acct, app) defDone = true } return defM, defFound, defErr } type envRes struct { m *Manifest found bool err error } envBundles := map[string]envRes{} loadEnv := func(env string) envRes { if e, done := envBundles[env]; done { return e } m, found, err := getIfExistsAt(ctx, s, EnvironmentKey(acct, app, env)) e := envRes{m: m, found: found, err: err} envBundles[env] = e return e } for _, rt := range routes { if !execAddrCandidate(rt) || rt.App != app || release.RouteAccount(rt) != acct { continue } // Which routes a write REACHES. A named bundle is read by the // routes bound to it and by nothing else. The default bundle is // read by every route of the app, bound or not, because a named // bundle is an overlay ON it (environments.go, LoadMerged) rather // than a replacement for it. if envName != "" && rt.Environment != envName { continue } eb := envRes{} if rt.Environment != "" { eb = loadEnv(rt.Environment) if eb.err != nil { out.Unresolved = append(out.Unresolved, rt.Host) continue } // A route bound to a bundle that does not exist resolves to an // ERROR, not to the app default: LoadMerged refuses it, and // SyncExec leaves the route's file exactly as it was. Nothing // was delivered, so "delivered" would be a lie — but neither // is this route fine, so it is not a skip either. if !eb.found { out.Unresolved = append(out.Unresolved, rt.Host) continue } } v, err := resolveExecDelivery(eb.m, eb.found, loadDefault) if err != nil { out.Unresolved = append(out.Unresolved, rt.Host) continue } // Not opted in — including the Unknown case, which decides "off" // — means no file was ever written for this route, by this write // or any other. Nothing to say. if !v.On { continue } // The shadowing test, and the reason keys is a parameter: where every // touched key is shadowed, the merged manifest is byte-identical, // syncExecHost's bytes.Equal writes nothing, and naming the route // would send someone to restart a process for a value it was never // going to see. An empty keys disables this whole test (nothing to // shadow — see keys' own doc comment), so the instance layer is // consulted only when it could actually change the verdict: a route // whose overlay is unreadable must not be downgraded to Unresolved // over a call that was never going to ask it anything. if len(keys) > 0 { // Which layers sit ABOVE the bundle that was written, for this // route. A write cannot change what a route resolves to for a // key some higher layer states, and the third layer sits above // both of the other two. inst, _, instErr := overlayFor(acct, app, rt.Host) if instErr != nil { out.Unresolved = append(out.Unresolved, rt.Host) continue } var above []*Manifest if envName == "" && rt.Environment != "" { above = append(above, eb.m) } above = append(above, inst) if allShadowed(keys, above...) { continue } } out.Hosts = append(out.Hosts, RestartHost{Host: rt.Host, Unit: rt.Unit}) } // Sorted so a caller's output — and any test pinning it — is stable // across passes, where registry order is not. Same discipline as // ExecConfigApps'. sort.Slice(out.Hosts, func(i, j int) bool { return out.Hosts[i].Host < out.Hosts[j].Host }) sort.Strings(out.Unresolved) return out } // allShadowed reports whether every one of keys is STATED by at least one // of the layers above the bundle a write touched — the test that decides // whether that write can have changed what a route actually resolves to. An // unset key counts the same as a set one: removing a key a higher layer // shadows changes the merge by nothing. // // above is variadic because how many layers sit over a write depends on // which bundle was written: a default-bundle write to a bound route is // under both the named bundle and the instance overlay, a named-bundle // write is under the overlay alone, and a write to the overlay itself has // nothing over it. A nil layer (no bundle of that kind for this route) is // skipped rather than treated as stating nothing, so an empty list answers // false and the route is named. func allShadowed(keys []string, above ...*Manifest) bool { for _, k := range keys { stated := false for _, m := range above { if m == nil { continue } if _, ok := m.Vars[k]; ok { stated = true break } } if !stated { return false } } return true } // execAddrConfigHeader is prepended to a TCP-addr (issue #18) route's // .config.env — and ONLY that shape. RenderEnv itself stays // header-free, deliberately: it is shared by sync.go's unit path AND this // file's socket-exec path, and either one growing a header would change // the byte content of every EXISTING file of that shape on upgrade — // sync.go's own comment on lostKeys already weighs a "# managed by // carlos" marker for the unit path and rejects it for exactly this // churn. The socket-exec shape has the same installed-base problem: every // currently-running instance's file would look "changed" on the pass // after this ships, and — unlike the addr shape — a socket route DOES // bounce on a content change, so that churn would restart every // socket-exec instance on the fleet once, for a comment. The addr shape // has no installed base at all (this is its first pass ever) and never // bounces, so the header costs it nothing. // // Format: "#"-prefixed and blank lines are the universal EnvironmentFile // comment convention — systemd's own EnvironmentFile= parser skips them, // and this repo's own guard for the unit path, lostKeys (sync.go), // already relies on exactly that tolerance when parsing a hand-staged // file. The issue #18 design comment specifies titogo's controld layers // this file the same way systemd's EnvironmentFile= does (default bundle // under the app-supervisor's own env), so a plain KEY=value-plus-#-comments // reader is the assumed parse contract on that side too; this repo has no // titogo checkout to verify it against directly, so treat this as an // assumption to confirm against titogo's actual parser before or during // the app-half PR, not a verified fact. const execAddrConfigHeader = `# Written by carlos (SyncExec, issue #18). Carlos may overwrite or remove # this file on any future sync pass, but NEVER restarts this process because # of it — carlos does not own it. This app's own supervisor owns restarts # and must notice and act on changes here on its own schedule. ` // syncExecHost converges one route's config file. m == nil means the // manifest object is absent from the bucket: remove the file (sync owns // the namespace; a file with no manifest is a leftover, and leaving it // would keep dead config flowing into spawns). key is the default // bundle's key, Key(acct, app) — the only key whose absence can reach // this branch — included in the removal log line so cause (which bucket // key came back absent) and effect (which file got removed) land // together on one line, instead of requiring a reader to cross-reference // the fetch-side log. // // bounce may be nil: bounceLogged treats that as "nothing to call", which // is how a TCP-addr (issue #18) route's file gets converged or removed // exactly like a socket-exec route's — same write/rename/remove path, // same logging — while never restarting anything, because carlos does // not own that process. The caller (SyncExec) passes nil for those routes // structurally, not a flag this function branches on. // // header is prepended to the rendered content when non-empty // (execAddrConfigHeader for the addr shape, "" for socket — see that // constant's doc comment for why the two must never be swapped or // unified) — folded into the SAME diff/write/rename path as the vars // themselves, so a header-only change (e.g. its text edited in a future // release) converges exactly like a var change would, once. // // instETag is the etag of the instance overlay this route's merge already // folded in (empty when the route has no overlay), carried through to the // ack unchanged — see ConfigAck.InstanceConfigETag. // // ack, if non-nil, is called once this route's file has actually changed // (written, never on removal — see SyncExec's own doc comment) with its // config-adoption acknowledgement (spec §5). Restarted/RestartError come // from bounceLogged's own outcome when bounce is non-nil (the socket-exec // shape: spec §5 step 5 counts that bounce as the cutover, "since the // platform restarted it") and are always false/empty when bounce is nil // (the tcp-exec-file shape: its process belongs to an external supervisor // this platform never restarts at all, so that shape's cutover is a // member acknowledgement, `carlos relay adopted`, instead). func syncExecHost(rt registry.Route, m *Manifest, key string, priv *ecdh.PrivateKey, dataDir string, bounce func(host string) error, header, instETag string, ack func(ConfigAck)) { _ = syncExecHostClaimed(context.Background(), legacyHostWork{}, rt, m, key, priv, dataDir, bounce, header, instETag, ack) } func 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 { callbacks := configCallbacks(work, bounce, ack, options) if rt.Host == "" || rt.Host == "." || rt.Host == ".." || rt.Host != filepath.Base(rt.Host) { fmt.Printf(" %q: not a bare hostname, refusing to derive a config file path from it\n", rt.Host) return nil } path := ConfigEnvPath(dataDir, rt) if m == nil { id := renderedIdentity(nil, false, rt.Environment != "", instETag != "") if err := guarded(ctx, work, id, func() error { return os.Remove(path) }); err != nil { if !os.IsNotExist(err) { fmt.Printf(" %s: remove config for %s (manifest absent): %v\n", rt.Host, key, err) } if os.IsNotExist(err) { return nil } return err } fmt.Printf(" %s: manifest %s absent, removed its config file\n", rt.Host, key) if err := guarded(ctx, work, id, func() error { return nil }); err != nil { return err } if callbacks.Delivered != nil { callbacks.Delivered(rt.Host, id.Config) } if bounce != nil || callbacks.SelectedRestart != nil { if callbacks.enabled { if err := callbacks.restart(ctx, hostwrite.Selection{Host: rt.Host, Work: work, Identity: id}); !errors.Is(err, ErrDeliveredOnWake) { return err } return nil } bounceLogged(rt.Host, bounce) } return nil } // keepAbsent: no instance overlay. A bundle that renders nothing for a // route whose file is absent leaves it absent (renderExecConfig). r, err := renderExecConfig(ctx, work, rt, m, priv, dataDir, header, instETag, instETag == "") if err != nil { return err } if !r.changed { return nil // up to date — stay quiet on the periodic tick } id := r.id if err := guarded(ctx, work, id, func() error { return nil }); err != nil { return err } if callbacks.Delivered != nil { callbacks.Delivered(rt.Host, id.Config) } var bounceErr error if bounce != nil || callbacks.SelectedRestart != nil { if callbacks.enabled { bounceErr = callbacks.restart(ctx, hostwrite.Selection{Host: rt.Host, Work: work, Identity: id}) if bounceErr != nil && !errors.Is(bounceErr, ErrDeliveredOnWake) { return bounceErr } } else { bounceErr = bounceLogged(rt.Host, bounce) } } if ack != nil || callbacks.SelectedAck != nil { a := ConfigAck{Schema: ConfigAckSchema, Host: rt.Host, Shape: shapeOf(rt), At: time.Now(), InstanceConfigETag: instETag} if suffixes, err := relaySuffixesFromRelayVar(m, priv, routeBinding(rt)); err != nil { fmt.Printf(" %s: config updated, but CARLOS_RELAY could not be read for the config-ack: %v\n", rt.Host, err) } else { a.RelaySuffixes = suffixes } // bounce == nil (the tcp-exec-file/addr shape) leaves Restarted // false and RestartError empty — see this function's own doc // comment for why. bounce != nil is the socket-exec shape, and its // bounce IS this pass's restart (spec §5 step 5: a socket-exec // route's cutover is the ack "since the platform restarted it"), // so the ack has to carry what bounceLogged actually returned // rather than assume success the way the removal branch above may. // A sleeper's restart answers ErrDeliveredOnWake: nothing ran and // nothing failed, so the ack carries the unit pass's marker. if bounce != nil { switch { case errors.Is(bounceErr, ErrDeliveredOnWake): a.Hibernating = true case bounceErr != nil: a.RestartError = bounceErr.Error() default: a.Restarted = true } } if err := guarded(ctx, work, id, func() error { return nil }); err != nil { return err } return callbacks.acknowledge(ctx, hostwrite.Selection{Host: rt.Host, Work: work, Identity: id}, a) } return nil } // errInstanceDirAbsent is a nested row's write that found no instance // directory: the activator has not woken the instance here yet. var errInstanceDirAbsent = errors.New("the instance directory is not made yet") // execRender is what renderExecConfig did: the file's path, the bytes it // holds now, whether this call wrote them, and the identity the writes were // guarded under (zero when nothing was written). type execRender struct { path string rendered []byte changed bool id hostwrite.Identity } // renderExecConfig is the file-writing half of syncExecHostClaimed for a // present manifest: render m for rt, compare with what is on disk, and // write through work only when the bytes differ. Extracted so the held-row // render (RenderHeld, moves design §9.1) writes through the very same // reader, renderer and writer SyncExec does, rather than a second copy of // them. The bare-hostname check stays with each caller, before any path is // derived. // // keepAbsent (config sync's pass, for a route with no instance overlay): // when the file is absent and the render is empty, nothing is written and // the render counts as up to date. An absent file and an empty one give a // spawn the same environment (the activator reads a missing config env as // none), and an absent file is what every never-configured app's instances // already have. Writing the empty file would be a config change for every // running instance: a restart, a delivered revision and an ack, for a // bundle that only gained settings boxes read from the bucket, such as the // swap policy (Astra plan 6 R1 #1, plan Q3). The held render (RenderHeld) // passes false: a move's ack hashes the file it wrote. func renderExecConfig(ctx context.Context, work hostwrite.Work, rt registry.Route, m *Manifest, priv *ecdh.PrivateKey, dataDir, header, instETag string, keepAbsent bool) (execRender, error) { return renderExecConfigBefore(ctx, work, rt, m, priv, dataDir, header, instETag, nil, keepAbsent) } // renderExecConfigBefore is renderExecConfig with before, when not nil, // called with the hex sha256 of the rendered bytes once they are known and // before the file is written or found up to date (RenderHeldBefore). An // error from it ends the render with nothing written. // // before and keepAbsent are independent. before runs as soon as the bytes // are rendered, ahead of both the up-to-date check and the keepAbsent skip, // so it sees every render whatever becomes of the write: it is a witness of // what the render holds, not of what was written. Its only callers are held // renders (RenderHeldBefore), which pass keepAbsent=false and so never skip; // config sync passes before=nil and keepAbsent per its overlay. A caller // that someday passes both gets before called with the hash of an empty // render that was then deliberately not written. func 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) { path := ConfigEnvPath(dataDir, rt) // A nested row's current file lives on tenant-owned ground, so its read // goes through instancefs the same way its write below does: a plain // os.ReadFile would block forever on a FIFO a tenant planted at this // name (isolated-exec design §3.3). The flat directory is agent-owned // throughout and keeps the plain read it always used. var cur []byte var existed bool var err error if nestedRoute(dataDir, rt) { cur, err = instancefs.ReadBeneathLimit(filepath.Dir(path), filepath.Base(path), 1<<20) existed = err == nil if err != nil && !os.IsNotExist(err) { fmt.Printf(" %s: read %s: %v\n", rt.Host, path, err) return execRender{}, err } } else { cur, err = os.ReadFile(path) existed = err == nil if err != nil && !os.IsNotExist(err) { fmt.Printf(" %s: read %s: %v\n", rt.Host, path, err) return execRender{}, err } } // Where sync.go has its defaultMissing/lostKeys guard, this path has // nothing — deliberately. That guard exists because /etc/carlos/apps // files may be HAND-STAGED, so a bound-environment-only merged view // dropping keys could delete config no one put in the bucket. This // file is sync-owned by construction: every byte in it was rendered by // a previous pass of this function from a manifest. A merged view that // drops keys is therefore correct convergence, not truncation — and // the instance's own .env, the one file here that IS // hand-staged, is a separate file layered on top at spawn and never // read or written by this sweep. // Deliberately NOT run through applyAgentVars (sync.go, spec §7.3): // this is .config.env, one layer under the socket-exec // instance's own .env, and the activator applies withVar/ // withoutVars to CARLOS_PURGE at spawn time (backend_exec.go's // Start) — cache-CONDITIONAL, set only when b.Cache != nil. Baking // CARLOS_PURGE=header into this file unconditionally would make the // var dishonest on disk on a cacheless box, the exact failure mode // agent-ownership exists to prevent. The asymmetry with sync.go's // unit path (which does apply it, unconditionally, because cache // construction is unconditional there) is correct; do not "fix" it. rendered, err := RenderEnv(m, priv, routeBinding(rt)) if err != nil { fmt.Printf(" %s: render config: %v\n", rt.Host, err) return execRender{}, err } if header != "" { rendered = append([]byte(header), rendered...) } out := execRender{path: path, rendered: rendered} if before != nil { sum := sha256.Sum256(rendered) if err := before(hex.EncodeToString(sum[:])); err != nil { return execRender{}, err } } if existed && bytes.Equal(cur, rendered) { return out, nil // up to date — stay quiet on the periodic tick } if keepAbsent && !existed && len(rendered) == 0 { return out, nil // absent, and nothing to put in it: as an unconfigured app's } id := renderedIdentity(rendered, true, rt.Environment != "", instETag != "") out.id = id // A nested row's config file lives inside its own instance directory, // tenant-owned ground (isolated-exec design §3.2). So it is written // through instancefs.WriteAtomic's O_EXCL temp name and openat2 // resolution rather than a predictable path.tmp: the same reasoning // backend_exec.go's own writes into that directory follow. The flat // directory is agent-owned throughout, so it keeps the plain // temp-then-rename it always used. if nestedRoute(dataDir, rt) { if err := guarded(ctx, work, id, func() error { return instancefs.WriteAtomic(filepath.Dir(path), filepath.Base(path), rendered, 0o600) }); err != nil { if _, lerr := os.Lstat(filepath.Dir(path)); errors.Is(lerr, os.ErrNotExist) { return execRender{}, fmt.Errorf("%w: %s", errInstanceDirAbsent, filepath.Dir(path)) } fmt.Printf(" %s: write %s: %v\n", rt.Host, path, err) return execRender{}, err } } else { tmp := path + ".tmp" if err := guarded(ctx, work, id, func() error { return os.WriteFile(tmp, rendered, 0o600) }); err != nil { fmt.Printf(" %s: write %s: %v\n", rt.Host, path, err) return execRender{}, err } if err := guarded(ctx, work, id, func() error { return os.Rename(tmp, path) }); err != nil { _ = guarded(ctx, work, id, func() error { return os.Remove(tmp) }) fmt.Printf(" %s: rename %s: %v\n", rt.Host, path, err) return execRender{}, err } } fmt.Printf(" %s: config updated\n", rt.Host) out.changed = true return out, nil } // bounceLogged calls bounce — best-effort in the sense that a failure is // logged rather than aborting the pass, the file on disk is already // correct and the next natural wake picks it up — and returns what bounce // itself returned, so a caller that also writes a config-ack (syncExecHost // above) can report whether THIS pass's restart actually succeeded rather // than silently discarding that fact. bounce == nil (the tcp-exec-file/ // addr shape, whose process this platform does not own) is a no-op // returning nil. func bounceLogged(host string, bounce func(host string) error) error { if bounce == nil { return nil } if err := bounce(host); err != nil { if errors.Is(err, ErrDeliveredOnWake) { fmt.Printf(" %s: config updated, asleep: delivered on wake\n", host) return err } fmt.Printf(" %s: bounce after config change: %v\n", host, err) return err } return nil } // reapConfigEnv deletes *.config.env (and stale *.config.env.tmp) for // hosts with no route row at all. It matches ONLY sync's own suffix: // .env and -sidecar.env are other owners' files and are // never candidates, whatever their host's route state. func reapConfigEnv(routes []registry.Route, dataDir string) { ents, err := os.ReadDir(dataDir) if err != nil { return } live := make(map[string]bool, len(routes)) for _, rt := range routes { live[rt.Host] = true } for _, e := range ents { if e.IsDir() { continue } if strings.HasSuffix(e.Name(), configEnvSuffix+".tmp") { // A crashed pass's leftover holds decrypted secrets and is // stale by construction once reap runs. if err := os.Remove(filepath.Join(dataDir, e.Name())); err != nil { fmt.Printf(" %s: remove stale config temp file: %v\n", e.Name(), err) } continue } if !strings.HasSuffix(e.Name(), configEnvSuffix) { continue } host := strings.TrimSuffix(e.Name(), configEnvSuffix) if live[host] { continue } if asInstanceEnvHost := strings.TrimSuffix(e.Name(), ".env"); live[asInstanceEnvHost] { // The name is ambiguous: .config.env is host x's sync-owned // config file AND host x.config's hand-staged instance env // (.env). With no route x but a live route x.config, the // bytes on disk may be the latter — another owner's file, never // deleted here. fmt.Printf(" %s: not reaped — live route %q claims this name as its instance env\n", e.Name(), asInstanceEnvHost) continue } if err := os.Remove(filepath.Join(dataDir, e.Name())); err != nil { fmt.Printf(" %s: remove orphaned config file: %v\n", host, err) continue } fmt.Printf(" %s: no route on this box, removed its config file\n", host) } reapNestedConfigEnv(live, dataDir) } // reapNestedConfigEnv is reapConfigEnv's nested half (isolated-exec design // §7): a nested row's rendered bundle lives inside its own instance // directory, one level under /instances, so a stale file there is // found by walking that tree rather than dataDir's own listing. The flat // directory's .config / .config.env name collision (reapConfigEnv's // own ambiguity guard, above) cannot happen inside an instance directory. // Nothing in there is named after some OTHER host, so no such guard is // needed here. func reapNestedConfigEnv(live map[string]bool, dataDir string) { root := filepath.Join(dataDir, "instances") dirs, err := os.ReadDir(root) if err != nil { return // no nested layout on this box (or nothing has migrated yet) } for _, d := range dirs { if !d.IsDir() { continue } instDir := filepath.Join(root, d.Name()) ents, err := os.ReadDir(instDir) if err != nil { continue } for _, e := range ents { if e.IsDir() { continue } // A nested config file is written through instancefs.WriteAtomic, // whose temp name is "..tmp-" beside the real name, not // ".tmp" the way the flat directory's plain rename leaves // one: the leading dot and the "-" suffix are both load // bearing here, or a crashed pass's leftover (it holds decrypted // secrets) is never reaped. if strings.HasPrefix(e.Name(), ".") && strings.Contains(e.Name(), configEnvSuffix+".tmp-") { if err := os.Remove(filepath.Join(instDir, e.Name())); err != nil { fmt.Printf(" %s: remove stale config temp file: %v\n", e.Name(), err) } continue } if !strings.HasSuffix(e.Name(), configEnvSuffix) { continue } host := strings.TrimSuffix(e.Name(), configEnvSuffix) if live[host] { continue } if err := os.Remove(filepath.Join(instDir, e.Name())); err != nil { fmt.Printf(" %s: remove orphaned config file: %v\n", host, err) continue } fmt.Printf(" %s: no route on this box, removed its config file\n", host) } } }