// Sync is the host-side convergence sweep: for every (app, environment) the // box's registry runs, resolve the merged config manifest once and // materialize the result into every route's EnvironmentFile. Shaped after // release.Pass (resolve once per group, converge once per route) but with // none of that function's staging/symlink/bake machinery — config isn't // versioned. The group is (Account, App, Environment): a route's bound // environment (environments.go, pipelines design §4a) is an overlay on the // app's default bundle, so two routes of one app can resolve differently. package appconfig import ( "bytes" "context" "crypto/ecdh" "errors" "fmt" "os" "path/filepath" "sort" "strings" "time" "github.com/carlosframework/platform/internal/hostwrite" "github.com/carlosframework/platform/internal/registry" "github.com/carlosframework/platform/internal/release" ) // DefaultEnvDir is where carlos-app@.service's EnvironmentFile directive // looks for a route's env file in production // (EnvironmentFile=-/etc/carlos/apps/%i.env, %i = route Host). const DefaultEnvDir = "/etc/carlos/apps" // Sync resolves each route's merged config once per distinct // (Account, App, Environment) among reg's routes, then for every route // renders /.env and, if it changed, writes it and // restarts that route's unit via restart. Die-free by design, like // release.Pass: a fetch or resolve failure for one app+environment logs and // continues rather than aborting the whole sweep. // // The cache key carries Environment as well as (Account, App) because two // routes of ONE app bound to different environments resolve to different // manifests (pipelines design §4a): keying on (acct, app) alone would render // whichever route was seen first into both files. Distinct environments cost // one extra resolve each, still one pass over the routes. // // ack, if non-nil, is called once per unit-backed route this pass actually // converges (wrote a changed file and attempted a restart) with that // route's config-adoption acknowledgement (spec §5, configack.go) — nil // means no acks, the shape every test and the exec-only loops that never // touch /etc/carlos/apps use. 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 { return SyncWithCallbacks(ctx, reg, s, priv, envDir, Callbacks{Restart: restart, Ack: ack}, options...) } func SyncWithCallbacks(ctx context.Context, reg *registry.Registry, s release.Store, priv *ecdh.PrivateKey, envDir string, callbacks Callbacks, options ...hostwrite.Config) error { restart, ack := callbacks.Restart, callbacks.Ack routes, err := reg.List() if err != nil { return err } hostWrites, err := hostWriteOption(options) if err != nil { return err } callbacks.enabled = hostWrites.Enabled selected := map[string]hostwrite.Work{} for _, rt := range routes { if !syncable(rt) || rt.Backing != registry.BackingUnit { 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 } type groupKey struct{ acct, app, env string } // A resolution is one of three states, and they are NOT interchangeable: // a manifest to converge to; "this app was never configured" (nil, nil), // which leaves the file alone; and "resolution failed" (nil, err), which // also leaves the file alone but says so, loudly, per route. Never fold a // failure into an empty manifest: an empty render differs from whatever // is on disk, so it would truncate a live route's env file as root and // restart the unit on it. type resolution struct { m *Manifest err error // defaultMissing marks a bound route's merged view as the named // bundle ALONE, because the app has no config.json at all. Such a // render is not an overlay, it is a wholesale replacement, so // syncHost refuses to let it drop keys the file on disk already holds // (see its doc comment; 2026-08-02 review, CRITICAL 1 and round 2). // The materialization loop sets the same flag for an UNBOUND route // whose only config is its instance overlay (senders design §6.2), // which is the same replacement reached by a different door. defaultMissing bool } resolved := map[groupKey]resolution{} // The default bundle is read once per (acct, app) and reused across that // app's environments: every bound route needs it twice over — LoadMerged // merges it, and the CRITICAL 1 guard turns on whether it exists at all. 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; the first has to leave a // host's hand-staged env file untouched and the second has to converge it // to empty. LoadMerged makes the same fold, which is why this read exists // alongside it rather than being replaced by it. 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 { // Reported per route below rather than here (where PR #9 printed // one line per app): the operator's question on a fetch failure // is which routes are now running on a stale file, and the answer // is per route either way. 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 } for _, rt := range routes { if !syncable(rt) { 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 { resolved[k] = resolution{err: def.err} continue } if k.env == "" { if !def.found { resolved[k] = resolution{} 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 must stay contained to the routes bound to it. No // never-configured case here: an app can lack a default bundle and // still resolve (environment-only), and if the environment itself is // missing that is the error above, not silence. 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, defaultMissing: !def.found} } // The instance layer (senders design §6.2), resolved per route AFTER the // group base above. The group is still (Account, App, Environment) and // the default bundle is still read once per app: only the third layer is // per host, and it costs one listing per app plus a Get for each // instance that actually has an overlay. 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{} for _, rt := range routes { if !syncable(rt) { continue } k := groupKey{release.RouteAccount(rt), rt.App, rt.Environment} r := resolved[k] if r.err != nil { // Loud, per route, every pass: this route is running on a stale // env file until an operator fixes it. Its file is left exactly // as it is — nothing below this line runs for it. env := rt.Environment if env == "" { env = "default" } fmt.Printf(" %s: config unresolved (environment %q), env file left as-is: %v\n", rt.Host, env, r.err) continue } // The instance overlay wins per key over the group's merge, and is // fetched BEFORE the m == nil skip below: an unbound route of an app // with no default bundle used to resolve to "never configured" and // stop here, but with an overlay it now resolves to something (senders // design §6.2), so the skip has to see the overlay first. // // Fetched ONLY for unit-backed routes (final review, plan 4): an // exec-backed route never reaches syncHost below — SyncExec renders // its overlay and reports its own listing/Get failures on it — so a // Get paid here would be charged and discarded for every exec-backed // route that has one, which is exactly the shape step 5 targets. A // Get or parse failure paid here for such a route would also print // "env file left as-is" about a file this pass never writes, a claim // SyncExec already makes correctly on its own. Deliberately NOT // hoisting the rt.Backing check up to gate this whole block, though: // that would make the "exec-backed" print below fire for an // exec-backed route with no config at all, which it does not do // today — the check stays exactly where it always was, and only the // overlay fetch itself is narrowed. var inst *Manifest var instETag string if rt.Backing == registry.BackingUnit { var err error inst, instETag, err = overlayFor(k.acct, k.app, rt.Host) if err != nil { var listErr *instanceOverlayListErr if errors.As(err, &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), so an // operator scanning the log has something 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), env files left as-is: %v\n", k.acct, k.app, rt.Host, err) } } 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 // credential file invisible, so it is reported every time. fmt.Printf(" %s: instance config unresolved, env file left as-is: %v\n", rt.Host, err) } continue } } m := r.m if m == nil && inst == nil { continue // never configured at all: this file is not ours to touch } // An overlay with no group base behind it is a wholesale replacement, // not an overlay. it is the same state a bound route with no default // bundle is in, reached without a binding. It gets the same guard: // see syncHost. defaultMissing := r.defaultMissing if m == nil { defaultMissing = true } m = applyInstanceOverlay(k.app, m, inst) // Only unit-backed routes read /etc/carlos/apps — an exec-backed // route's env comes from /.env, which the activator // owns (it also carries CARLOS_ADMIN_TOKEN, so it can't be blindly // overwritten here). Materializing for one would write a file // nothing reads and log a restart that restarted nothing; say so // instead of succeeding silently. Spec §5 records this limitation. // Delivery still happens for these routes — SyncExec, called from // the agent's in-process configSyncLoop, not this pass. if rt.Backing != registry.BackingUnit { fmt.Printf(" %s: exec-backed — config delivered by the agent's in-process loop to /data/.config.env, not this pass\n", rt.Host) continue } if err := syncHostClaimed(ctx, selected[rt.Host], rt, m, defaultMissing, priv, envDir, restart, instETag, ack, callbacks); err != nil && hostWrites.Enabled { return err } } // An empty route list is never authority to delete anything: // registry.Open silently creates an empty database when handed a wrong // path or an unmounted /data, and reaping against that would wipe every // env file on the box — including hand-staged ones this feature // promises not to touch. if len(routes) == 0 { return nil } if !hostWrites.Enabled { reap(routes, envDir) } else { reapSelectedEnvTemps(ctx, envDir, selected) } return nil } func reapSelectedEnvTemps(ctx context.Context, envDir string, selected map[string]hostwrite.Work) { ents, err := os.ReadDir(envDir) if err != nil { return } for _, entry := range ents { if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".env.tmp") { continue } host := strings.TrimSuffix(entry.Name(), ".env.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(envDir, entry.Name())) }); err != nil { fmt.Printf(" %s: remove stale env temp file: %v\n", entry.Name(), err) } } } // reap deletes /.env for any host with no route row at all — // a decommissioned route's env file holds decrypted secrets and nothing // else on the box removes it. Keyed off the full route list, not the // syncable subset, so a route that is merely mid-provisioning keeps its // file. Routes are the source of truth for what runs here (the same // assumption release adoption's symlink repointing already makes), so a // host with zero rows is reaped immediately, with no grace period. A // missing or unreadable env dir just means there is nothing to reap. // // Config state is deliberately not an input here: whether an app has a // manifest, or whether a route's bound environment still exists in the // bucket, has no bearing on whether its file is orphaned. Deleting an // environment bundle must never delete a live route's env file — that route // keeps its last-good file and Sync logs the unresolved binding instead. func reap(routes []registry.Route, envDir string) { ents, err := os.ReadDir(envDir) 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 } // A leftover atomic-write temp (crashed between write and rename) // holds decrypted secrets and is stale by construction once reap // runs — this pass's own writes all renamed or failed earlier, // sequentially. if strings.HasSuffix(e.Name(), ".env.tmp") { if err := os.Remove(filepath.Join(envDir, e.Name())); err != nil { fmt.Printf(" %s: remove stale env temp file: %v\n", e.Name(), err) } continue } if !strings.HasSuffix(e.Name(), ".env") { continue } host := strings.TrimSuffix(e.Name(), ".env") if live[host] { continue } if err := os.Remove(filepath.Join(envDir, e.Name())); err != nil { fmt.Printf(" %s: remove orphaned env file: %v\n", host, err) continue } fmt.Printf(" %s: no route on this box, removed its env file\n", host) } } // lostKeys reports which var names the file on disk holds that the manifest // about to replace it does not, sorted; parsed is false if cur is not // KEY=value throughout, in which case lost is meaningless and the caller must // treat the file as unsafe to overwrite. // // It compares against m.Vars rather than the rendered bytes on purpose: the // render is what the caller is deciding whether to WRITE, and this decision // has to be made before rendering (a refused route must never reach the // render-diff-write-restart path at all). RenderEnv emits exactly one // KEY=value line per var, so the two key sets are the same by construction — // except for the agent-owned names applyAgentVars adds to and removes from // the rendered file afterward, which is why they are skipped below. m is the // route's FULL merge, all three layers of it (default, environment, // instance), because the question is what the file would lose, not which // bundle a key came from. // // Blank lines and #-comments are ignored, matching how systemd's // EnvironmentFile reads these files: they carry no configuration, so their // loss is not the loss this guard exists to prevent. Anything else without an // "=" and a non-empty name makes the whole file unparseable rather than // silently skipped — a line this function does not understand may well be a // var, and guessing it is not is the unsafe direction. func lostKeys(cur []byte, m *Manifest) (lost []string, parsed bool) { for _, line := range strings.Split(string(cur), "\n") { s := strings.TrimSpace(line) if s == "" || strings.HasPrefix(s, "#") { continue } name, _, ok := strings.Cut(s, "=") name = strings.TrimSpace(name) if !ok || name == "" { return nil, false } if agentOwned(name) { // The render is post-processed by applyAgentVars, which appends // CARLOS_PURGE=header to every file this sync writes: counting // it as lost would refuse every previously-converged route. continue } if _, kept := m.Vars[name]; !kept { lost = append(lost, name) } } sort.Strings(lost) return lost, true } // syncable delegates to release.Adoptable, deliberately: config sync and // adopt must agree, to the character, on which routes are lifecycle-managed // (a known app, excluding platform-minted alias routes — see // release.Adoptable's own doc comment for why aliases are excluded), and a // second hand-copied predicate here is exactly the drift that would let the // two disagree. func syncable(rt registry.Route) bool { return release.Adoptable(rt) } // UnitConfigApps lists the distinct (account, app) pairs having at least // one unit-backed syncable route on this box — the apps whose config the // ROOT adopt pass delivers (Sync + try-restart), which the agent itself // cannot. The fast loop uses it to decide when to request a root pass // (see configGenLoop): same dedupe-and-sort shape as ExecConfigApps, and // the two may overlap — an app can have both route shapes at once. // // Exported from THIS file, beside the predicate Sync itself gates on, for // the reason ExecConfigApps gives for living beside its own: an app the // loop watched but this file's sweep skipped would burn reads forever, and // one it didn't watch would silently fall back to the hourly timer. func UnitConfigApps(routes []registry.Route) [][2]string { seen := map[[2]string]bool{} var out [][2]string for _, rt := range routes { if !syncable(rt) || rt.Backing != registry.BackingUnit { continue } k := [2]string{release.RouteAccount(rt), rt.App} if seen[k] { continue } seen[k] = true out = append(out, k) } 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 } // routeBinding is the Binding a route's config renders as: the same // (account, app) the sync passes resolved its manifest under. func routeBinding(rt registry.Route) Binding { return Binding{Account: release.RouteAccount(rt), App: rt.App} } // syncHost converges one route's env file. It is called only with a manifest // that really resolved — the object (default bundle, named environment, or // both) exists in the bucket — so an empty manifest here means "every var // was unset" and converges the file to empty. The "never configured at all" // and "resolution failed" cases are both filtered out by Sync, before this: // either one reaching here would truncate a live env file. // // defaultMissing says the merged view is a bound environment's bundle ALONE // because the app has no config.json — see the guard below. // // 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 // and a restart was attempted — never on the "up to date" early return, // since nothing was delivered on that pass and the ack already on file (if // any) from a previous pass still describes what the route is running. func syncHost(rt registry.Route, m *Manifest, defaultMissing bool, priv *ecdh.PrivateKey, envDir string, restart func(host string) error, instETag string, ack func(ConfigAck)) { _ = syncHostClaimed(context.Background(), legacyHostWork{}, rt, m, defaultMissing, priv, envDir, restart, instETag, ack) } 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 { callbacks := configCallbacks(work, restart, ack, options) // The host is joined into a root-written path; a registry row carrying // a separator must never place a file outside envDir. Same trust level // as release's live/ symlinks, but this one is one line to pin. if rt.Host == "" || rt.Host == "." || rt.Host == ".." || rt.Host != filepath.Base(rt.Host) { fmt.Printf(" %q: not a bare hostname, refusing to derive an env file path from it\n", rt.Host) return nil } path := filepath.Join(envDir, rt.Host+".env") 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 err } // The same protection an UNBOUND route gets from Sync's "no manifest // object at all ⇒ leave the file alone" skip, applied to the DEFAULT half // of a bound route's merge (2026-08-02 review, CRITICAL 1). Binding an // environment to an app that has never had a config.json makes the merged // render the override bundle ALONE — so the documented migration step // (create a small override, bind the routes) would replace every var a // hand-staged file holds with just the override's, as root, and restart // the unit. // // The test is KEY LOSS, not "a non-empty file exists" (round-2 review): // after this function's own first write the file is non-empty, so the // cruder test refused to replace content Sync itself authored and froze // the route forever. Losing keys is the actual hazard; a changed value or // an added key is ordinary convergence. It also self-heals — the moment // the missing keys exist in a bundle, nothing is lost and the route // converges with no further intervention. // // - default bundle EXISTS (even with zero vars): the operator has // adopted bucket config for this app, the merged render is // authoritative, and none of this applies. // - default MISSING and the file holds keys the render drops: refuse // this route, loudly, every pass. Those keys are not ours to delete. // - default MISSING and nothing is lost (including an empty or absent // file): converge. // // Known limitation, accepted with the ruling: while an app has no default // bundle, REMOVING a key from its environment bundle is indistinguishable // from the truncation hazard and is refused. The message names both ways // out. A "# managed by carlos" marker line would separate the two cases // properly, at the cost of changing the rendered file format. if defaultMissing && existed && len(cur) > 0 { lost, parsed := lostKeys(cur, m) // Which overlay is standing alone. A bound route names its // environment; an unbound one is here because this instance has its // own config layer (senders design §6.2) and its app has no default // bundle, and naming an empty environment would be a riddle. alone := "this instance's own config layer" if rt.Environment != "" { alone = fmt.Sprintf("environment %q", rt.Environment) } switch { case !parsed: // Unparseable content has unknown keys, so nothing here can prove // the render drops none of them. Assume the worst — this guard's // whole job is to be wrong in the safe direction. fmt.Printf(" %s: %s has no default config bundle, so the merged view is %s ALONE — "+ "refusing to replace %s, which this pass cannot parse as KEY=value and so cannot prove is safe to overwrite; "+ "import its config (`carlos env set --app %s KEY=value ...`) or remove the file\n", rt.Host, rt.App, alone, path, rt.App) return nil case len(lost) > 0: fmt.Printf(" %s: %s has no default config bundle, so the merged view is %s ALONE — "+ "refusing to drop %d var(s) already in %s (%s); import them (`carlos env set --app %s KEY=value ...`) "+ "and this route converges on the next pass, or remove the file if it is dead\n", rt.Host, rt.App, alone, len(lost), path, strings.Join(lost, ", "), rt.App) return nil } } rendered, err := RenderEnv(m, priv, routeBinding(rt)) if err != nil { fmt.Printf(" %s: render config: %v\n", rt.Host, err) return err } // Spec §7.3: deliver CARLOS_PURGE=header to unit tenants the same way // the exec backend already does for exec-backed ones (withoutVars / // CARLOS_PURGE in internal/activator/backend_exec.go's Start) — strip // any tenant-supplied agent-owned vars and append the agent's own. // Deterministic and idempotent (agentvars.go), so the bytes.Equal // check below still short-circuits the steady state ON EVERY PASS // AFTER THE FIRST. // // The first pass after this ships is NOT a no-op, though: every // existing .env on the fleet gains this line, so bytes.Equal // fails for every active unit tenant and this pass's restart(rt.Host) // fires for each of them once — hibernated routes are unaffected // (try-restart is a no-op on an inactive unit). That was weighed and // accepted, not overlooked: the var can't take effect without a // restart, unlike syncexec.go's execAddrConfigHeader, which declined // exactly this trade for the socket-exec shape (a comment header // alone isn't worth bouncing every instance on the fleet). Here the // var itself is the payload, so the one-time fleet-wide restart is the // feature landing, not a side effect of it. rendered = applyAgentVars(rendered) if existed && bytes.Equal(cur, rendered) { return nil // up to date — stay quiet on the periodic tick } id := renderedIdentity(rendered, !defaultMissing, rt.Environment != "", instETag != "") 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 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 err } // The config-adoption ack (spec §5): built from what THIS pass actually // rendered and did, not re-derived later from the file on disk — the // route's shape, the relay identities (if any) it just wrote, and // whether the restart below woke a real process or found a hibernating // one with nothing to wake. 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 { // A CARLOS_RELAY entry that fails to decode is loud on its own — // RenderEnv already refused this render if the failure were a // decrypt error shared by every var, so this is specific to the // relay var (a corrupt or foreign-shaped value) and must not stop // the ack the rest of the route earned. 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 } // No rt.Hibernate special case in the restart CALL itself: try-restart // is a no-op on an inactive unit, so a hibernated route's file converges // without waking it. The ack still has to say so, though — an inactive // unit's try-restart returns success with nothing actually restarted, // and reporting that as an ordinary "restarted" would tell a member // their sleeping route already runs a key it will only pick up on wake. if err := guarded(ctx, work, id, func() error { return nil }); err != nil { return err } restartErr := callbacks.restart(ctx, hostwrite.Selection{Host: rt.Host, Work: work, Identity: id}) if callbacks.enabled && restartErr != nil { return restartErr } switch err := restartErr; { case err != nil: fmt.Printf(" %s: config updated but restart failed: %v\n", rt.Host, err) a.RestartError = err.Error() case rt.Hibernate: a.Hibernating = true fmt.Printf(" %s: config updated, hibernating — delivered on wake\n", rt.Host) default: a.Restarted = true fmt.Printf(" %s: config updated, restarted\n", rt.Host) } if ack != nil || callbacks.SelectedAck != nil { 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 }