| index e03ee76da..5eedb0a43 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -157,8 +157,8 @@ func RunAgent(args []string) { |
| log.Printf("agent: hibernation activator armed (bucket %s, idle %s)", cfg.Bucket, cfg.Idle) |
| } |
| |
| - go adoptLoop(reg) |
| - go canaryAdoptLoop(reg) |
| + go adoptLoop(reg, svc) |
| + go canaryAdoptLoop(reg, svc) |
| go statusLoop(reg) |
| go domainsLoop(reg, peekAcmeCache(args)) |
| go instancesLoop(reg) |
| @@ -240,7 +240,10 @@ func materializeHook(reg *registry.Registry, dd string) func(context.Context, st |
| // config disables the loop with one log line — an agent with no release store |
| // is a valid box (routes managed by hand, adopted by the timer unit, or not |
| // versioned at all). |
| -func adoptLoop(reg *registry.Registry) { |
| +// The activator is taken so the pass can cycle exec-backed instances the way |
| +// only this process can (see makeRestartRoute); nil is fine — a box with no |
| +// hibernation armed simply has no children to bounce. |
| +func adoptLoop(reg *registry.Registry, svc *activator.Service) { |
| ctx := context.Background() |
| store, err := openDeployStore(ctx) |
| if err != nil { |
| @@ -253,6 +256,7 @@ func adoptLoop(reg *registry.Registry) { |
| return |
| } |
| optDir := release.OptDir() |
| + restart := makeRestartRoute(reg, systemctlTryRestart, bounceFn(svc)) |
| log.Printf("agent: adopt loop armed (hourly + jitter)") |
| // First pass shortly after boot (Eleven's OnBootSec=5min, compressed — |
| // the edge is already up by the time this fires), then hourly + jitter. |
| @@ -279,7 +283,7 @@ func adoptLoop(reg *registry.Registry) { |
| // OnStageBlocked, by contrast, is exactly right here: it fires only |
| // when this sandboxed process cannot write the version store, and all |
| // it does is ask the root pass to do the part this one cannot. |
| - if err := release.Pass(ctx, reg, store, pub, optDir, restartRoute, |
| + if err := release.Pass(ctx, reg, store, pub, optDir, restart, |
| release.BakeDir(dataDir()), release.OnStageBlocked(stageRequester(dataDir()))); err != nil { |
| log.Printf("agent: adopt pass: %v", err) |
| } |
| @@ -658,7 +662,10 @@ func configGenLoop(reg *registry.Registry, svc *activator.Service, dd string) { |
| // Stable/beta routes stay on adoptLoop's hourly cadence: they're |
| // bake-gated for a day or three regardless, so faster polling would buy |
| // nothing there. |
| -func canaryAdoptLoop(reg *registry.Registry) { |
| +// Takes the activator for the same reason adoptLoop does: a promoted canary |
| +// on a warm exec instance must bounce onto its new binary, not wait for |
| +// hibernation. Nil is fine. |
| +func canaryAdoptLoop(reg *registry.Registry, svc *activator.Service) { |
| ctx := context.Background() |
| store, err := openDeployStore(ctx) |
| if err != nil { |
| @@ -669,6 +676,7 @@ func canaryAdoptLoop(reg *registry.Registry) { |
| return // ditto |
| } |
| optDir := release.OptDir() |
| + restart := makeRestartRoute(reg, systemctlTryRestart, bounceFn(svc)) |
| log.Printf("agent: canary adopt loop armed (2s + jitter, canary/* routes only)") |
| for { |
| time.Sleep(2*time.Second + rand.N(time.Second)) |
| @@ -695,7 +703,7 @@ func canaryAdoptLoop(reg *registry.Registry) { |
| // the one step of that pickup this process cannot perform. Throttled |
| // inside stageRequester — this tick is 2-3 seconds, the request is at |
| // most one a minute. |
| - if err := release.PassFilter(ctx, reg, store, pub, optDir, restartRoute, isCanaryRoute, |
| + if err := release.PassFilter(ctx, reg, store, pub, optDir, restart, isCanaryRoute, |
| release.BakeDir(dataDir()), release.OnStageBlocked(stageRequester(dataDir()))); err != nil { |
| log.Printf("agent: canary adopt pass: %v", err) |
| } |
| @@ -1219,21 +1227,71 @@ func statusTick(ctx context.Context, s release.Store, reg *registry.Registry, p |
| return status.PublishFleet(ctx, s, fleet) |
| } |
| |
| -// restartRoute cycles a route's process onto its freshly-repointed live |
| -// symlink. Only systemd-managed routes need anything done here: hibernating |
| -// hosts never reach this (release.Pass gives them the symlink-only path), and |
| -// an awake activator child is converged by the sweep's ConvergeDecision — so |
| -// for those returning nil is the signal. For unit-managed hosts, try-restart |
| -// cycles the unit iff it's running, best-effort: a missing or stopped unit is |
| -// not this pass's problem, and must not poison the build for other hosts. |
| -func restartRoute(host string) error { |
| +// makeRestartRoute builds the adopt pass's restart callback, dispatched on |
| +// the route's backing: a unit tenant is try-restarted (a no-op when the unit |
| +// isn't running — hibernators are never woken by this), while an exec child |
| +// is Bounced through the activator, the same graceful cycle config sync |
| +// uses. systemctl cannot reach a child of THIS process, which is why |
| +// exec-backed warm instances used to keep serving the old binary until they |
| +// happened to hibernate: the symlink was repointed under them and nothing |
| +// cycled the process. |
| +// |
| +// Hosts the registry cannot answer for take the unit path: try-restart of a |
| +// nonexistent unit is a logged no-op, whereas a bounce of a process we might |
| +// not own is not. A nil bounce (no activator armed on this box) makes the |
| +// exec path a no-op rather than a fallthrough to systemctl — there is no |
| +// carlos-app@ unit behind an exec route to try-restart. |
| +// |
| +// Errors are logged, never returned: a failed restart must not poison the |
| +// adopted version for the other hosts in the pass (release/adopt.go's rule). |
| +func makeRestartRoute(reg *registry.Registry, tryRestart func(host string), bounce func(host string) bool) func(host string) error { |
| + return func(host string) error { |
| + rt, err := reg.Get(host) |
| + if err != nil { |
| + log.Printf("adopt: registry lookup %s: %v (restarting as a unit)", host, err) |
| + } |
| + if err == nil && rt != nil && rt.Backing != registry.BackingUnit { |
| + if bounce != nil && bounce(host) { |
| + log.Printf("adopt: bounced exec instance %s onto its new version", host) |
| + } |
| + return nil |
| + } |
| + tryRestart(host) |
| + return nil |
| + } |
| +} |
| + |
| +// systemctlTryRestart is makeRestartRoute's production unit path: cycle the |
| +// route's carlos-app@ unit iff it's running, best-effort. A missing or |
| +// stopped unit is not this pass's problem — hibernating hosts never reach |
| +// here at all (release.Pass gives them the symlink-only path). |
| +func systemctlTryRestart(host string) { |
| out, err := exec.Command("systemctl", "try-restart", activator.UnitName(host)).CombinedOutput() |
| if err != nil { |
| log.Printf("adopt: try-restart carlos-app@%s: %v: %s (continuing)", host, err, strings.TrimSpace(string(out))) |
| } |
| +} |
| + |
| +// restartRoute is the root adopt pass's restart callback (RunAdopt, out of |
| +// carlos-adopt.service): pure systemctl, no bounce. That process has no |
| +// activator and no children of its own — the exec instances belong to the |
| +// agent, and another process's children are not its to stop. |
| +func restartRoute(host string) error { |
| + systemctlTryRestart(host) |
| return nil |
| } |
| |
| +// bounceFn adapts an activator to makeRestartRoute's bounce parameter, |
| +// nil-safe: a box with no activator armed gets a nil callback, which |
| +// makeRestartRoute reads as "nothing running to cycle" rather than falling |
| +// back to a systemctl path that could not work for an exec route anyway. |
| +func bounceFn(svc *activator.Service) func(host string) bool { |
| + if svc == nil { |
| + return nil |
| + } |
| + return svc.Bounce |
| +} |
| + |
| // openDeployStore opens the release store the box adopts from: |
| // CARLOS_DEPLOYMENT_BUCKET (S3) or CARLOS_DEPLOYMENT_DIR (a directory — |
| // tests and single-box no-AWS deployments). Mirrors cli.go's openStore, but |