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...) } }