419d3d0b4e
PushResponse.ConfigVersion from the report ACK; ConfigRefresher reconciles vs. the last-applied version (settings.applied_config_version) and on a change calls bootstrap.RefreshConfig (re-pull controller.yaml + re-merge local_api) then GracefulSelfRestart. First-run records baseline (no restart); unchanged = no-op (no storm); failed pull keeps config + retries. Companion to hub v0.26.0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HxLA1mZurFq9kt8hneFeCs
76 lines
3.6 KiB
Go
76 lines
3.6 KiB
Go
package report
|
|
|
|
import "log"
|
|
|
|
// ConfigRefresher reconciles the hub-advertised config_version (from the report ACK) against the
|
|
// controller's last-applied version and, on a change, re-pulls controller.yaml and self-restarts —
|
|
// the pull-based config-delivery path (the hub never connects into the box; this rides the existing
|
|
// report cycle exactly like the Phase 2 version floor).
|
|
//
|
|
// All side effects are injected so the reconcile is unit-testable without a real hub / filesystem /
|
|
// process exit:
|
|
// - Applied reads the persisted last-applied config_version (0 = none recorded yet).
|
|
// - Record persists a newly-applied config_version.
|
|
// - Refresh re-pulls controller.yaml from the hub and writes it (re-merging local_api). It must
|
|
// NOT touch settings.json. A hub-unreachable / write failure returns an error.
|
|
// - Restart triggers the graceful self-restart (process exit → Docker restart → fresh config).
|
|
type ConfigRefresher struct {
|
|
Applied func() int
|
|
Record func(int) error
|
|
Refresh func() error
|
|
Restart func()
|
|
Logger *log.Logger
|
|
}
|
|
|
|
// Reconcile applies the config-refresh decision for one report ACK. Rules (acceptance B + safety §5):
|
|
// - ackVersion == 0 → no-op (hub didn't advertise; old hub / no config row).
|
|
// - no version recorded yet → record the baseline WITHOUT restarting (first-ever ACK; the
|
|
// first-boot pull already fetched the current config).
|
|
// - ackVersion == applied → no-op (no change; this is what prevents a restart storm — after a
|
|
// refresh, applied == ackVersion so the next report is a no-op).
|
|
// - ackVersion != applied → Refresh; on success Record THEN Restart (record-before-restart so
|
|
// the post-restart process sees it applied); on a failed Refresh keep the current config, do NOT
|
|
// record, do NOT restart — retried on the next report cycle.
|
|
func (cr *ConfigRefresher) Reconcile(ackVersion int) {
|
|
if ackVersion == 0 {
|
|
return // hub didn't advertise a config_version
|
|
}
|
|
applied := cr.Applied()
|
|
if applied == 0 {
|
|
// First-ever ACK carrying a config_version: record the baseline, do NOT restart (the box came
|
|
// up on the first-boot pull, which already has the current config). Mirrors the floor's
|
|
// first-run-records-baseline.
|
|
if err := cr.Record(ackVersion); err != nil {
|
|
cr.logf("[WARN] config-refresh: failed to record baseline config_version=%d: %v", ackVersion, err)
|
|
return
|
|
}
|
|
cr.logf("[INFO] config-refresh: baseline config_version=%d recorded (no restart)", ackVersion)
|
|
return
|
|
}
|
|
if ackVersion == applied {
|
|
return // no change
|
|
}
|
|
cr.logf("[INFO] config-refresh: hub config_version=%d != applied=%d — re-pulling controller.yaml", ackVersion, applied)
|
|
if err := cr.Refresh(); err != nil {
|
|
// Fail-safe: keep the current config, do NOT record, do NOT restart — retry next cycle.
|
|
cr.logf("[WARN] config-refresh: re-pull failed: %v — keeping current config, will retry next report", err)
|
|
return
|
|
}
|
|
// Record BEFORE restarting so the freshly-started process sees the version as applied and does not
|
|
// loop. (The restart is delayed, so the record persists first.)
|
|
if err := cr.Record(ackVersion); err != nil {
|
|
cr.logf("[WARN] config-refresh: applied config but failed to record version=%d: %v — skipping restart to avoid a loop", ackVersion, err)
|
|
return
|
|
}
|
|
cr.logf("[INFO] config-refresh: applied config_version=%d — self-restarting to load it", ackVersion)
|
|
if cr.Restart != nil {
|
|
cr.Restart()
|
|
}
|
|
}
|
|
|
|
func (cr *ConfigRefresher) logf(format string, args ...interface{}) {
|
|
if cr.Logger != nil {
|
|
cr.Logger.Printf(format, args...)
|
|
}
|
|
}
|