controller v0.94.0: pull-based config-refresh (re-pull + self-restart on config_version change)

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
This commit is contained in:
2026-06-30 21:49:47 +02:00
parent 464b14f029
commit 419d3d0b4e
9 changed files with 432 additions and 0 deletions
@@ -166,6 +166,61 @@ func MaybeIngest(configPath string, cfg *config.Config, logger *log.Logger, pull
return reloaded
}
// RefreshConfig re-pulls controller.yaml from the hub and rewrites it, re-merging the per-guest
// local_api block — the config-refresh path (v0.26.0) invoked when the report ACK's config_version
// changes. Unlike MaybeIngest it is NOT idempotent and NOT first-boot-gated: it deliberately
// OVERWRITES the existing controller.yaml (the hub is the source of truth for it). It reads the
// credentials (customer id, hub url, retrieval passphrase, local_api) from the same read-only
// bootstrap.json mount the first-boot pull uses, so no secret is stashed elsewhere.
//
// Contract (acceptance §4, rule 4):
// - Source of truth: overwrites controller.yaml; NEVER touches settings.json (local state).
// - local_api: re-merged from bootstrap.json exactly as first boot does (the hub yaml lacks it).
// - Fail-safe: any failure (absent/invalid bootstrap, missing field, hub-unreachable pull, write
// error) returns an error and leaves the current controller.yaml UNCHANGED — the caller then
// keeps the current config and does not restart. A wizard-configured guest with no bootstrap.json
// returns an error here (nothing to pull from) and is simply left as-is.
func RefreshConfig(configPath string, logger *log.Logger, pull PullFunc) error {
bpath := Path()
data, err := os.ReadFile(bpath)
if err != nil {
return fmt.Errorf("read bootstrap %s: %w", bpath, err)
}
var b Bootstrap
if err := json.Unmarshal(data, &b); err != nil {
return fmt.Errorf("bootstrap %s not valid JSON: %w", bpath, err)
}
if b.Schema != SchemaV2 {
return fmt.Errorf("bootstrap unsupported schema %q (want %q)", b.Schema, SchemaV2)
}
if b.Customer.ID == "" || b.Hub.URL == "" || b.Hub.RetrievalPassword == "" {
return fmt.Errorf("bootstrap missing customer.id / hub.url / hub.retrieval_password")
}
if b.LocalAPI.Endpoint == "" || b.LocalAPI.Fingerprint == "" || b.LocalAPI.Token == "" {
return fmt.Errorf("bootstrap missing local_api.{endpoint,fingerprint,token}")
}
if pull == nil {
return fmt.Errorf("no pull function wired")
}
pulled, err := pullWithRetry(pull, b.Hub.URL, b.Customer.ID, b.Hub.RetrievalPassword, logger)
if err != nil {
return fmt.Errorf("hub config pull failed: %w", err)
}
merged, err := mergeLocalAPI(pulled, b.LocalAPI)
if err != nil {
return fmt.Errorf("merge local_api: %w", err)
}
if err := writeFileAtomic(configPath, merged); err != nil {
return fmt.Errorf("write %s: %w", configPath, err)
}
if logger != nil {
logger.Printf("[INFO] config-refresh: re-pulled controller.yaml from %s for %s, merged local_api (%s)",
b.Hub.URL, b.Customer.ID, b.LocalAPI.Endpoint)
}
return nil
}
// pullWithRetry calls pull once, then retries on transient (ErrPullTransient) failures only, with
// the pullRetryDelays backoff. Permanent failures (anything not ErrPullTransient) fail fast.
func pullWithRetry(pull PullFunc, hubURL, customerID, password string, logger *log.Logger) (string, error) {