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:
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// RefreshConfig re-pulls the hub yaml and OVERWRITES controller.yaml, re-merging local_api from
|
||||
// bootstrap.json. Unlike MaybeIngest it is not first-boot-gated — it clobbers an existing config (the
|
||||
// hub is the source of truth for controller.yaml).
|
||||
func TestRefreshConfig_RePullsAndMerges(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
_, cfgPath := writeBootstrap(t, dir, goodBootstrapV2)
|
||||
|
||||
// Pre-existing (stale) controller.yaml that must be overwritten.
|
||||
if err := os.WriteFile(cfgPath, []byte("customer:\n id: cust-8200\n name: OLD\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var calls int
|
||||
var gotID, gotPass string
|
||||
pull := func(hubURL, customerID, pass string) (string, error) {
|
||||
calls++
|
||||
gotID, gotPass = customerID, pass
|
||||
return hubYAML, nil
|
||||
}
|
||||
|
||||
if err := RefreshConfig(cfgPath, testLogger(), pull); err != nil {
|
||||
t.Fatalf("RefreshConfig: %v", err)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Errorf("pull called %d times, want 1", calls)
|
||||
}
|
||||
if gotID != "cust-8200" || gotPass != "five-word-passphrase-here" {
|
||||
t.Errorf("pull args = (%q,%q), want (cust-8200, five-word-passphrase-here)", gotID, gotPass)
|
||||
}
|
||||
|
||||
out, err := os.ReadFile(cfgPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(out)
|
||||
if strings.Contains(s, "OLD") {
|
||||
t.Errorf("controller.yaml still contains stale content:\n%s", s)
|
||||
}
|
||||
if !strings.Contains(s, "CUSTKEY_FROM_HUB") {
|
||||
t.Errorf("controller.yaml missing hub-pulled api_key:\n%s", s)
|
||||
}
|
||||
// local_api re-merged from bootstrap.json (the hub yaml lacks it).
|
||||
if !strings.Contains(s, "PERGUESTTOKEN") || !strings.Contains(s, "192.168.0.162:8443") {
|
||||
t.Errorf("controller.yaml missing re-merged local_api:\n%s", s)
|
||||
}
|
||||
}
|
||||
|
||||
// Fail-safe: a failed pull leaves the existing controller.yaml UNCHANGED and returns an error (the
|
||||
// caller then keeps the current config and does not restart).
|
||||
func TestRefreshConfig_FailedPullLeavesConfigUnchanged(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
_, cfgPath := writeBootstrap(t, dir, goodBootstrapV2)
|
||||
original := "customer:\n id: cust-8200\n name: CURRENT\n"
|
||||
if err := os.WriteFile(cfgPath, []byte(original), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
pull := func(hubURL, customerID, pass string) (string, error) {
|
||||
return "", ErrPullTransient // hub unreachable
|
||||
}
|
||||
if err := RefreshConfig(cfgPath, testLogger(), pull); err == nil {
|
||||
t.Fatal("RefreshConfig should return an error on a failed pull")
|
||||
}
|
||||
out, _ := os.ReadFile(cfgPath)
|
||||
if string(out) != original {
|
||||
t.Errorf("controller.yaml changed despite failed pull:\n%s", string(out))
|
||||
}
|
||||
}
|
||||
|
||||
// Fail-safe: no bootstrap.json (e.g. a wizard-configured guest) → error, nothing written.
|
||||
func TestRefreshConfig_NoBootstrapErrors(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfgPath := dir + "/controller.yaml"
|
||||
t.Setenv("FELHOM_BOOTSTRAP_PATH", dir+"/does-not-exist.json")
|
||||
|
||||
called := false
|
||||
pull := func(hubURL, customerID, pass string) (string, error) { called = true; return hubYAML, nil }
|
||||
if err := RefreshConfig(cfgPath, testLogger(), pull); err == nil {
|
||||
t.Fatal("RefreshConfig should error when bootstrap.json is absent")
|
||||
}
|
||||
if called {
|
||||
t.Error("pull should not be called when bootstrap.json is absent")
|
||||
}
|
||||
if _, err := os.Stat(cfgPath); !os.IsNotExist(err) {
|
||||
t.Error("controller.yaml should not have been written")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user