| 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. |
| 9 | package appconfig |
| 10 | |
| 11 | import ( |
| 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). |
| 31 | const 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. |
| 51 | func 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 | |
| 55 | func 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 | |
| 302 | func 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. |
| 338 | func 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. |
| 398 | func 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. |
| 429 | func 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. |
| 444 | func 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. |
| 469 | func 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. |
| 491 | func 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 | |
| 495 | func 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 | |