carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Sync is the host-side convergence sweep: for every (app, environment) the
2// box's registry runs, resolve the merged config manifest once and
3// materialize the result into every route's EnvironmentFile. Shaped after
4// release.Pass (resolve once per group, converge once per route) but with
5// none of that function's staging/symlink/bake machinery — config isn't
6// versioned. The group is (Account, App, Environment): a route's bound
7// environment (environments.go, pipelines design §4a) is an overlay on the
8// app's default bundle, so two routes of one app can resolve differently.
9package appconfig
10
11import (
12 "bytes"
13 "context"
14 "crypto/ecdh"
15 "errors"
16 "fmt"
17 "os"
18 "path/filepath"
19 "sort"
20 "strings"
21 "time"
22
23 "github.com/carlosframework/platform/internal/hostwrite"
24 "github.com/carlosframework/platform/internal/registry"
25 "github.com/carlosframework/platform/internal/release"
26)
27
28// DefaultEnvDir is where carlos-app@.service's EnvironmentFile directive
29// looks for a route's env file in production
30// (EnvironmentFile=-/etc/carlos/apps/%i.env, %i = route Host).
31const DefaultEnvDir = "/etc/carlos/apps"
32
33// Sync resolves each route's merged config once per distinct
34// (Account, App, Environment) among reg's routes, then for every route
35// renders <envDir>/<route.Host>.env and, if it changed, writes it and
36// restarts that route's unit via restart. Die-free by design, like
37// release.Pass: a fetch or resolve failure for one app+environment logs and
38// continues rather than aborting the whole sweep.
39//
40// The cache key carries Environment as well as (Account, App) because two
41// routes of ONE app bound to different environments resolve to different
42// manifests (pipelines design §4a): keying on (acct, app) alone would render
43// whichever route was seen first into both files. Distinct environments cost
44// one extra resolve each, still one pass over the routes.
45//
46// ack, if non-nil, is called once per unit-backed route this pass actually
47// converges (wrote a changed file and attempted a restart) with that
48// route's config-adoption acknowledgement (spec §5, configack.go) — nil
49// means no acks, the shape every test and the exec-only loops that never
50// touch /etc/carlos/apps use.
51func Sync(ctx context.Context, reg *registry.Registry, s release.Store, priv *ecdh.PrivateKey, envDir string, restart func(host string) error, ack func(ConfigAck), options ...hostwrite.Config) error {
52 return SyncWithCallbacks(ctx, reg, s, priv, envDir, Callbacks{Restart: restart, Ack: ack}, options...)
53}
54
55func SyncWithCallbacks(ctx context.Context, reg *registry.Registry, s release.Store, priv *ecdh.PrivateKey, envDir string, callbacks Callbacks, options ...hostwrite.Config) error {
56 restart, ack := callbacks.Restart, callbacks.Ack
57 routes, err := reg.List()
58 if err != nil {
59 return err
60 }
61 hostWrites, err := hostWriteOption(options)
62 if err != nil {
63 return err
64 }
65 callbacks.enabled = hostWrites.Enabled
66 selected := map[string]hostwrite.Work{}
67 for _, rt := range routes {
68 if !syncable(rt) || rt.Backing != registry.BackingUnit {
69 continue
70 }
71 work, selectErr := hostWrites.Select(ctx, rt.Host, "config")
72 if selectErr != nil {
73 return fmt.Errorf("%s: select config work: %w", rt.Host, selectErr)
74 }
75 selected[rt.Host] = work
76 }
77 type groupKey struct{ acct, app, env string }
78 // A resolution is one of three states, and they are NOT interchangeable:
79 // a manifest to converge to; "this app was never configured" (nil, nil),
80 // which leaves the file alone; and "resolution failed" (nil, err), which
81 // also leaves the file alone but says so, loudly, per route. Never fold a
82 // failure into an empty manifest: an empty render differs from whatever
83 // is on disk, so it would truncate a live route's env file as root and
84 // restart the unit on it.
85 type resolution struct {
86 m *Manifest
87 err error
88 // defaultMissing marks a bound route's merged view as the named
89 // bundle ALONE, because the app has no config.json at all. Such a
90 // render is not an overlay, it is a wholesale replacement, so
91 // syncHost refuses to let it drop keys the file on disk already holds
92 // (see its doc comment; 2026-08-02 review, CRITICAL 1 and round 2).
93 // The materialization loop sets the same flag for an UNBOUND route
94 // whose only config is its instance overlay (senders design §6.2),
95 // which is the same replacement reached by a different door.
96 defaultMissing bool
97 }
98 resolved := map[groupKey]resolution{}
99 // The default bundle is read once per (acct, app) and reused across that
100 // app's environments: every bound route needs it twice over — LoadMerged
101 // merges it, and the CRITICAL 1 guard turns on whether it exists at all.
102 type appKey struct{ acct, app string }
103 type defaultRes struct {
104 m *Manifest
105 found bool
106 err error
107 }
108 defaults := map[appKey]defaultRes{}
109 // getIfExists, not Get: an app with no manifest object in the bucket at
110 // all must be distinguishable from one whose every var was unset. Get
111 // folds those together into an empty manifest; the first has to leave a
112 // host's hand-staged env file untouched and the second has to converge it
113 // to empty. LoadMerged makes the same fold, which is why this read exists
114 // alongside it rather than being replaced by it.
115 loadDefault := func(acct, app string) defaultRes {
116 ak := appKey{acct, app}
117 if d, done := defaults[ak]; done {
118 return d
119 }
120 m, found, err := getIfExists(ctx, s, acct, app)
121 if err != nil {
122 // Reported per route below rather than here (where PR #9 printed
123 // one line per app): the operator's question on a fetch failure
124 // is which routes are now running on a stale file, and the answer
125 // is per route either way.
126 err = fmt.Errorf("%s/%s: config fetch failed: %w", acct, app, err)
127 }
128 d := defaultRes{m: m, found: found, err: err}
129 defaults[ak] = d
130 return d
131 }
132 for _, rt := range routes {
133 if !syncable(rt) {
134 continue
135 }
136 k := groupKey{release.RouteAccount(rt), rt.App, rt.Environment}
137 if _, done := resolved[k]; done {
138 continue
139 }
140 def := loadDefault(k.acct, k.app)
141 if def.err != nil {
142 resolved[k] = resolution{err: def.err}
143 continue
144 }
145 if k.env == "" {
146 if !def.found {
147 resolved[k] = resolution{}
148 continue
149 }
150 resolved[k] = resolution{m: def.m}
151 continue
152 }
153 // A bound environment asserts the bundle exists, so LoadMerged treats
154 // its absence as an error rather than an empty overlay — and that
155 // error must stay contained to the routes bound to it. No
156 // never-configured case here: an app can lack a default bundle and
157 // still resolve (environment-only), and if the environment itself is
158 // missing that is the error above, not silence.
159 m, err := LoadMerged(ctx, s, k.acct, k.app, k.env)
160 if err != nil || m == nil {
161 if err == nil {
162 err = fmt.Errorf("config: resolved a nil manifest for environment %q", k.env)
163 }
164 resolved[k] = resolution{err: err}
165 continue
166 }
167 resolved[k] = resolution{m: m, defaultMissing: !def.found}
168 }
169 // The instance layer (senders design §6.2), resolved per route AFTER the
170 // group base above. The group is still (Account, App, Environment) and
171 // the default bundle is still read once per app: only the third layer is
172 // per host, and it costs one listing per app plus a Get for each
173 // instance that actually has an overlay.
174 overlayFor := instanceOverlayLoader(ctx, s)
175 // One line per app when the LISTING fails, not one per route: every
176 // route of that app fails identically and for the same reason, since
177 // instanceOverlayLoader caches that failure per (account, app) —
178 // independent of Environment, which is why this is keyed on appKey and
179 // not groupKey. A per-host Get or parse failure is a DIFFERENT claim —
180 // two routes can have two separately corrupt overlay objects, each
181 // wrong for its own reason — so that case is never folded in here; see
182 // the errors.As branch below.
183 said := map[appKey]bool{}
184 for _, rt := range routes {
185 if !syncable(rt) {
186 continue
187 }
188 k := groupKey{release.RouteAccount(rt), rt.App, rt.Environment}
189 r := resolved[k]
190 if r.err != nil {
191 // Loud, per route, every pass: this route is running on a stale
192 // env file until an operator fixes it. Its file is left exactly
193 // as it is — nothing below this line runs for it.
194 env := rt.Environment
195 if env == "" {
196 env = "default"
197 }
198 fmt.Printf(" %s: config unresolved (environment %q), env file left as-is: %v\n", rt.Host, env, r.err)
199 continue
200 }
201 // The instance overlay wins per key over the group's merge, and is
202 // fetched BEFORE the m == nil skip below: an unbound route of an app
203 // with no default bundle used to resolve to "never configured" and
204 // stop here, but with an overlay it now resolves to something (senders
205 // design §6.2), so the skip has to see the overlay first.
206 //
207 // Fetched ONLY for unit-backed routes (final review, plan 4): an
208 // exec-backed route never reaches syncHost below — SyncExec renders
209 // its overlay and reports its own listing/Get failures on it — so a
210 // Get paid here would be charged and discarded for every exec-backed
211 // route that has one, which is exactly the shape step 5 targets. A
212 // Get or parse failure paid here for such a route would also print
213 // "env file left as-is" about a file this pass never writes, a claim
214 // SyncExec already makes correctly on its own. Deliberately NOT
215 // hoisting the rt.Backing check up to gate this whole block, though:
216 // that would make the "exec-backed" print below fire for an
217 // exec-backed route with no config at all, which it does not do
218 // today — the check stays exactly where it always was, and only the
219 // overlay fetch itself is narrowed.
220 var inst *Manifest
221 var instETag string
222 if rt.Backing == registry.BackingUnit {
223 var err error
224 inst, instETag, err = overlayFor(k.acct, k.app, rt.Host)
225 if err != nil {
226 var listErr *instanceOverlayListErr
227 if errors.As(err, &listErr) {
228 // Said ONCE per (account, app), not once per route: the
229 // listing this failed on is cached per app inside the
230 // loader, so every route of the app, across every
231 // environment it is bound to, fails identically and for
232 // the same reason; a line each would bury the cause under
233 // its own repetitions on a box with many routes.
234 ak := appKey{k.acct, k.app}
235 if !said[ak] {
236 said[ak] = true
237 // rt.Host anchors the line to a real route (the
238 // first one this pass happened to reach), so an
239 // operator scanning the log has something to grep
240 // for; every other route of this app is silently
241 // covered by the same line.
242 fmt.Printf(" %s/%s: instance config unresolved (seen at %s), env files left as-is: %v\n", k.acct, k.app, rt.Host, err)
243 }
244 } else {
245 // A per-host Get or parse failure, unlike the listing
246 // above, is specific to THIS route: the listing already
247 // succeeded (it named this host), so another route of
248 // the same app can have a perfectly good overlay, or a
249 // failure of its own for a different reason. Folding this
250 // into the app-level line would leave that route's stale
251 // credential file invisible, so it is reported every time.
252 fmt.Printf(" %s: instance config unresolved, env file left as-is: %v\n", rt.Host, err)
253 }
254 continue
255 }
256 }
257 m := r.m
258 if m == nil && inst == nil {
259 continue // never configured at all: this file is not ours to touch
260 }
261 // An overlay with no group base behind it is a wholesale replacement,
262 // not an overlay. it is the same state a bound route with no default
263 // bundle is in, reached without a binding. It gets the same guard:
264 // see syncHost.
265 defaultMissing := r.defaultMissing
266 if m == nil {
267 defaultMissing = true
268 }
269 m = applyInstanceOverlay(k.app, m, inst)
270 // Only unit-backed routes read /etc/carlos/apps — an exec-backed
271 // route's env comes from <DataDir>/<host>.env, which the activator
272 // owns (it also carries CARLOS_ADMIN_TOKEN, so it can't be blindly
273 // overwritten here). Materializing for one would write a file
274 // nothing reads and log a restart that restarted nothing; say so
275 // instead of succeeding silently. Spec §5 records this limitation.
276 // Delivery still happens for these routes — SyncExec, called from
277 // the agent's in-process configSyncLoop, not this pass.
278 if rt.Backing != registry.BackingUnit {
279 fmt.Printf(" %s: exec-backed — config delivered by the agent's in-process loop to /data/<host>.config.env, not this pass\n", rt.Host)
280 continue
281 }
282 if err := syncHostClaimed(ctx, selected[rt.Host], rt, m, defaultMissing, priv, envDir, restart, instETag, ack, callbacks); err != nil && hostWrites.Enabled {
283 return err
284 }
285 }
286 // An empty route list is never authority to delete anything:
287 // registry.Open silently creates an empty database when handed a wrong
288 // path or an unmounted /data, and reaping against that would wipe every
289 // env file on the box — including hand-staged ones this feature
290 // promises not to touch.
291 if len(routes) == 0 {
292 return nil
293 }
294 if !hostWrites.Enabled {
295 reap(routes, envDir)
296 } else {
297 reapSelectedEnvTemps(ctx, envDir, selected)
298 }
299 return nil
300}
301
302func reapSelectedEnvTemps(ctx context.Context, envDir string, selected map[string]hostwrite.Work) {
303 ents, err := os.ReadDir(envDir)
304 if err != nil {
305 return
306 }
307 for _, entry := range ents {
308 if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".env.tmp") {
309 continue
310 }
311 host := strings.TrimSuffix(entry.Name(), ".env.tmp")
312 work := selected[host]
313 if work == nil {
314 continue
315 }
316 if err := guarded(ctx, work, renderedIdentity(nil, false, false, false), func() error {
317 return os.Remove(filepath.Join(envDir, entry.Name()))
318 }); err != nil {
319 fmt.Printf(" %s: remove stale env temp file: %v\n", entry.Name(), err)
320 }
321 }
322}
323
324// reap deletes <envDir>/<host>.env for any host with no route row at all —
325// a decommissioned route's env file holds decrypted secrets and nothing
326// else on the box removes it. Keyed off the full route list, not the
327// syncable subset, so a route that is merely mid-provisioning keeps its
328// file. Routes are the source of truth for what runs here (the same
329// assumption release adoption's symlink repointing already makes), so a
330// host with zero rows is reaped immediately, with no grace period. A
331// missing or unreadable env dir just means there is nothing to reap.
332//
333// Config state is deliberately not an input here: whether an app has a
334// manifest, or whether a route's bound environment still exists in the
335// bucket, has no bearing on whether its file is orphaned. Deleting an
336// environment bundle must never delete a live route's env file — that route
337// keeps its last-good file and Sync logs the unresolved binding instead.
338func reap(routes []registry.Route, envDir string) {
339 ents, err := os.ReadDir(envDir)
340 if err != nil {
341 return
342 }
343 live := make(map[string]bool, len(routes))
344 for _, rt := range routes {
345 live[rt.Host] = true
346 }
347 for _, e := range ents {
348 if e.IsDir() {
349 continue
350 }
351 // A leftover atomic-write temp (crashed between write and rename)
352 // holds decrypted secrets and is stale by construction once reap
353 // runs — this pass's own writes all renamed or failed earlier,
354 // sequentially.
355 if strings.HasSuffix(e.Name(), ".env.tmp") {
356 if err := os.Remove(filepath.Join(envDir, e.Name())); err != nil {
357 fmt.Printf(" %s: remove stale env temp file: %v\n", e.Name(), err)
358 }
359 continue
360 }
361 if !strings.HasSuffix(e.Name(), ".env") {
362 continue
363 }
364 host := strings.TrimSuffix(e.Name(), ".env")
365 if live[host] {
366 continue
367 }
368 if err := os.Remove(filepath.Join(envDir, e.Name())); err != nil {
369 fmt.Printf(" %s: remove orphaned env file: %v\n", host, err)
370 continue
371 }
372 fmt.Printf(" %s: no route on this box, removed its env file\n", host)
373 }
374}
375
376// lostKeys reports which var names the file on disk holds that the manifest
377// about to replace it does not, sorted; parsed is false if cur is not
378// KEY=value throughout, in which case lost is meaningless and the caller must
379// treat the file as unsafe to overwrite.
380//
381// It compares against m.Vars rather than the rendered bytes on purpose: the
382// render is what the caller is deciding whether to WRITE, and this decision
383// has to be made before rendering (a refused route must never reach the
384// render-diff-write-restart path at all). RenderEnv emits exactly one
385// KEY=value line per var, so the two key sets are the same by construction —
386// except for the agent-owned names applyAgentVars adds to and removes from
387// the rendered file afterward, which is why they are skipped below. m is the
388// route's FULL merge, all three layers of it (default, environment,
389// instance), because the question is what the file would lose, not which
390// bundle a key came from.
391//
392// Blank lines and #-comments are ignored, matching how systemd's
393// EnvironmentFile reads these files: they carry no configuration, so their
394// loss is not the loss this guard exists to prevent. Anything else without an
395// "=" and a non-empty name makes the whole file unparseable rather than
396// silently skipped — a line this function does not understand may well be a
397// var, and guessing it is not is the unsafe direction.
398func lostKeys(cur []byte, m *Manifest) (lost []string, parsed bool) {
399 for _, line := range strings.Split(string(cur), "\n") {
400 s := strings.TrimSpace(line)
401 if s == "" || strings.HasPrefix(s, "#") {
402 continue
403 }
404 name, _, ok := strings.Cut(s, "=")
405 name = strings.TrimSpace(name)
406 if !ok || name == "" {
407 return nil, false
408 }
409 if agentOwned(name) {
410 // The render is post-processed by applyAgentVars, which appends
411 // CARLOS_PURGE=header to every file this sync writes: counting
412 // it as lost would refuse every previously-converged route.
413 continue
414 }
415 if _, kept := m.Vars[name]; !kept {
416 lost = append(lost, name)
417 }
418 }
419 sort.Strings(lost)
420 return lost, true
421}
422
423// syncable delegates to release.Adoptable, deliberately: config sync and
424// adopt must agree, to the character, on which routes are lifecycle-managed
425// (a known app, excluding platform-minted alias routes — see
426// release.Adoptable's own doc comment for why aliases are excluded), and a
427// second hand-copied predicate here is exactly the drift that would let the
428// two disagree.
429func syncable(rt registry.Route) bool {
430 return release.Adoptable(rt)
431}
432
433// UnitConfigApps lists the distinct (account, app) pairs having at least
434// one unit-backed syncable route on this box — the apps whose config the
435// ROOT adopt pass delivers (Sync + try-restart), which the agent itself
436// cannot. The fast loop uses it to decide when to request a root pass
437// (see configGenLoop): same dedupe-and-sort shape as ExecConfigApps, and
438// the two may overlap — an app can have both route shapes at once.
439//
440// Exported from THIS file, beside the predicate Sync itself gates on, for
441// the reason ExecConfigApps gives for living beside its own: an app the
442// loop watched but this file's sweep skipped would burn reads forever, and
443// one it didn't watch would silently fall back to the hourly timer.
444func UnitConfigApps(routes []registry.Route) [][2]string {
445 seen := map[[2]string]bool{}
446 var out [][2]string
447 for _, rt := range routes {
448 if !syncable(rt) || rt.Backing != registry.BackingUnit {
449 continue
450 }
451 k := [2]string{release.RouteAccount(rt), rt.App}
452 if seen[k] {
453 continue
454 }
455 seen[k] = true
456 out = append(out, k)
457 }
458 sort.Slice(out, func(i, j int) bool {
459 if out[i][0] != out[j][0] {
460 return out[i][0] < out[j][0]
461 }
462 return out[i][1] < out[j][1]
463 })
464 return out
465}
466
467// routeBinding is the Binding a route's config renders as: the same
468// (account, app) the sync passes resolved its manifest under.
469func routeBinding(rt registry.Route) Binding {
470 return Binding{Account: release.RouteAccount(rt), App: rt.App}
471}
472
473// syncHost converges one route's env file. It is called only with a manifest
474// that really resolved — the object (default bundle, named environment, or
475// both) exists in the bucket — so an empty manifest here means "every var
476// was unset" and converges the file to empty. The "never configured at all"
477// and "resolution failed" cases are both filtered out by Sync, before this:
478// either one reaching here would truncate a live env file.
479//
480// defaultMissing says the merged view is a bound environment's bundle ALONE
481// because the app has no config.json — see the guard below.
482//
483// instETag is the etag of the instance overlay this route's merge already
484// folded in (empty when the route has no overlay), carried through to the
485// ack unchanged — see ConfigAck.InstanceConfigETag.
486//
487// ack, if non-nil, is called once this route's file has actually changed
488// and a restart was attempted — never on the "up to date" early return,
489// since nothing was delivered on that pass and the ack already on file (if
490// any) from a previous pass still describes what the route is running.
491func syncHost(rt registry.Route, m *Manifest, defaultMissing bool, priv *ecdh.PrivateKey, envDir string, restart func(host string) error, instETag string, ack func(ConfigAck)) {
492 _ = syncHostClaimed(context.Background(), legacyHostWork{}, rt, m, defaultMissing, priv, envDir, restart, instETag, ack)
493}
494
495func syncHostClaimed(ctx context.Context, work hostwrite.Work, rt registry.Route, m *Manifest, defaultMissing bool, priv *ecdh.PrivateKey, envDir string, restart func(host string) error, instETag string, ack func(ConfigAck), options ...Callbacks) error {
496 callbacks := configCallbacks(work, restart, ack, options)
497 // The host is joined into a root-written path; a registry row carrying
498 // a separator must never place a file outside envDir. Same trust level
499 // as release's live/<host> symlinks, but this one is one line to pin.
500 if rt.Host == "" || rt.Host == "." || rt.Host == ".." || rt.Host != filepath.Base(rt.Host) {
501 fmt.Printf(" %q: not a bare hostname, refusing to derive an env file path from it\n", rt.Host)
502 return nil
503 }
504 path := filepath.Join(envDir, rt.Host+".env")
505 cur, err := os.ReadFile(path)
506 existed := err == nil
507 if err != nil && !os.IsNotExist(err) {
508 fmt.Printf(" %s: read %s: %v\n", rt.Host, path, err)
509 return err
510 }
511 // The same protection an UNBOUND route gets from Sync's "no manifest
512 // object at all ⇒ leave the file alone" skip, applied to the DEFAULT half
513 // of a bound route's merge (2026-08-02 review, CRITICAL 1). Binding an
514 // environment to an app that has never had a config.json makes the merged
515 // render the override bundle ALONE — so the documented migration step
516 // (create a small override, bind the routes) would replace every var a
517 // hand-staged file holds with just the override's, as root, and restart
518 // the unit.
519 //
520 // The test is KEY LOSS, not "a non-empty file exists" (round-2 review):
521 // after this function's own first write the file is non-empty, so the
522 // cruder test refused to replace content Sync itself authored and froze
523 // the route forever. Losing keys is the actual hazard; a changed value or
524 // an added key is ordinary convergence. It also self-heals — the moment
525 // the missing keys exist in a bundle, nothing is lost and the route
526 // converges with no further intervention.
527 //
528 // - default bundle EXISTS (even with zero vars): the operator has
529 // adopted bucket config for this app, the merged render is
530 // authoritative, and none of this applies.
531 // - default MISSING and the file holds keys the render drops: refuse
532 // this route, loudly, every pass. Those keys are not ours to delete.
533 // - default MISSING and nothing is lost (including an empty or absent
534 // file): converge.
535 //
536 // Known limitation, accepted with the ruling: while an app has no default
537 // bundle, REMOVING a key from its environment bundle is indistinguishable
538 // from the truncation hazard and is refused. The message names both ways
539 // out. A "# managed by carlos" marker line would separate the two cases
540 // properly, at the cost of changing the rendered file format.
541 if defaultMissing && existed && len(cur) > 0 {
542 lost, parsed := lostKeys(cur, m)
543 // Which overlay is standing alone. A bound route names its
544 // environment; an unbound one is here because this instance has its
545 // own config layer (senders design §6.2) and its app has no default
546 // bundle, and naming an empty environment would be a riddle.
547 alone := "this instance's own config layer"
548 if rt.Environment != "" {
549 alone = fmt.Sprintf("environment %q", rt.Environment)
550 }
551 switch {
552 case !parsed:
553 // Unparseable content has unknown keys, so nothing here can prove
554 // the render drops none of them. Assume the worst — this guard's
555 // whole job is to be wrong in the safe direction.
556 fmt.Printf(" %s: %s has no default config bundle, so the merged view is %s ALONE — "+
557 "refusing to replace %s, which this pass cannot parse as KEY=value and so cannot prove is safe to overwrite; "+
558 "import its config (`carlos env set --app %s KEY=value ...`) or remove the file\n",
559 rt.Host, rt.App, alone, path, rt.App)
560 return nil
561 case len(lost) > 0:
562 fmt.Printf(" %s: %s has no default config bundle, so the merged view is %s ALONE — "+
563 "refusing to drop %d var(s) already in %s (%s); import them (`carlos env set --app %s KEY=value ...`) "+
564 "and this route converges on the next pass, or remove the file if it is dead\n",
565 rt.Host, rt.App, alone, len(lost), path, strings.Join(lost, ", "), rt.App)
566 return nil
567 }
568 }
569 rendered, err := RenderEnv(m, priv, routeBinding(rt))
570 if err != nil {
571 fmt.Printf(" %s: render config: %v\n", rt.Host, err)
572 return err
573 }
574 // Spec §7.3: deliver CARLOS_PURGE=header to unit tenants the same way
575 // the exec backend already does for exec-backed ones (withoutVars /
576 // CARLOS_PURGE in internal/activator/backend_exec.go's Start) — strip
577 // any tenant-supplied agent-owned vars and append the agent's own.
578 // Deterministic and idempotent (agentvars.go), so the bytes.Equal
579 // check below still short-circuits the steady state ON EVERY PASS
580 // AFTER THE FIRST.
581 //
582 // The first pass after this ships is NOT a no-op, though: every
583 // existing <host>.env on the fleet gains this line, so bytes.Equal
584 // fails for every active unit tenant and this pass's restart(rt.Host)
585 // fires for each of them once — hibernated routes are unaffected
586 // (try-restart is a no-op on an inactive unit). That was weighed and
587 // accepted, not overlooked: the var can't take effect without a
588 // restart, unlike syncexec.go's execAddrConfigHeader, which declined
589 // exactly this trade for the socket-exec shape (a comment header
590 // alone isn't worth bouncing every instance on the fleet). Here the
591 // var itself is the payload, so the one-time fleet-wide restart is the
592 // feature landing, not a side effect of it.
593 rendered = applyAgentVars(rendered)
594 if existed && bytes.Equal(cur, rendered) {
595 return nil // up to date — stay quiet on the periodic tick
596 }
597 id := renderedIdentity(rendered, !defaultMissing, rt.Environment != "", instETag != "")
598 tmp := path + ".tmp"
599 if err := guarded(ctx, work, id, func() error { return os.WriteFile(tmp, rendered, 0o600) }); err != nil {
600 fmt.Printf(" %s: write %s: %v\n", rt.Host, path, err)
601 return err
602 }
603 if err := guarded(ctx, work, id, func() error { return os.Rename(tmp, path) }); err != nil {
604 _ = guarded(ctx, work, id, func() error { return os.Remove(tmp) })
605 fmt.Printf(" %s: rename %s: %v\n", rt.Host, path, err)
606 return err
607 }
608 // The config-adoption ack (spec §5): built from what THIS pass actually
609 // rendered and did, not re-derived later from the file on disk — the
610 // route's shape, the relay identities (if any) it just wrote, and
611 // whether the restart below woke a real process or found a hibernating
612 // one with nothing to wake.
613 a := ConfigAck{Schema: ConfigAckSchema, Host: rt.Host, Shape: shapeOf(rt), At: time.Now(), InstanceConfigETag: instETag}
614 if suffixes, err := relaySuffixesFromRelayVar(m, priv, routeBinding(rt)); err != nil {
615 // A CARLOS_RELAY entry that fails to decode is loud on its own —
616 // RenderEnv already refused this render if the failure were a
617 // decrypt error shared by every var, so this is specific to the
618 // relay var (a corrupt or foreign-shaped value) and must not stop
619 // the ack the rest of the route earned.
620 fmt.Printf(" %s: config updated, but CARLOS_RELAY could not be read for the config-ack: %v\n", rt.Host, err)
621 } else {
622 a.RelaySuffixes = suffixes
623 }
624 // No rt.Hibernate special case in the restart CALL itself: try-restart
625 // is a no-op on an inactive unit, so a hibernated route's file converges
626 // without waking it. The ack still has to say so, though — an inactive
627 // unit's try-restart returns success with nothing actually restarted,
628 // and reporting that as an ordinary "restarted" would tell a member
629 // their sleeping route already runs a key it will only pick up on wake.
630 if err := guarded(ctx, work, id, func() error { return nil }); err != nil {
631 return err
632 }
633 restartErr := callbacks.restart(ctx, hostwrite.Selection{Host: rt.Host, Work: work, Identity: id})
634 if callbacks.enabled && restartErr != nil {
635 return restartErr
636 }
637 switch err := restartErr; {
638 case err != nil:
639 fmt.Printf(" %s: config updated but restart failed: %v\n", rt.Host, err)
640 a.RestartError = err.Error()
641 case rt.Hibernate:
642 a.Hibernating = true
643 fmt.Printf(" %s: config updated, hibernating — delivered on wake\n", rt.Host)
644 default:
645 a.Restarted = true
646 fmt.Printf(" %s: config updated, restarted\n", rt.Host)
647 }
648 if ack != nil || callbacks.SelectedAck != nil {
649 if err := guarded(ctx, work, id, func() error { return nil }); err != nil {
650 return err
651 }
652 return callbacks.acknowledge(ctx, hostwrite.Selection{Host: rt.Host, Work: work, Identity: id}, a)
653 }
654 return nil
655}
656