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
130 lines
4.0 KiB
Go
130 lines
4.0 KiB
Go
package report
|
|
|
|
import (
|
|
"errors"
|
|
"io"
|
|
"log"
|
|
"testing"
|
|
)
|
|
|
|
// recorder collects the side effects a Reconcile would cause, so each case asserts exactly what
|
|
// happened (pull/record/restart) without a real hub, filesystem, or process exit.
|
|
type recorder struct {
|
|
applied int
|
|
recorded []int
|
|
refreshes int
|
|
refreshErr error
|
|
restarts int
|
|
recordErr error
|
|
}
|
|
|
|
func (r *recorder) refresher() *ConfigRefresher {
|
|
return &ConfigRefresher{
|
|
Applied: func() int { return r.applied },
|
|
Record: func(v int) error {
|
|
if r.recordErr != nil {
|
|
return r.recordErr
|
|
}
|
|
r.recorded = append(r.recorded, v)
|
|
r.applied = v
|
|
return nil
|
|
},
|
|
Refresh: func() error {
|
|
r.refreshes++
|
|
return r.refreshErr
|
|
},
|
|
Restart: func() { r.restarts++ },
|
|
Logger: log.New(io.Discard, "", 0),
|
|
}
|
|
}
|
|
|
|
// A version change re-pulls, records the new version, then restarts (record BEFORE restart).
|
|
func TestReconcile_VersionChange_RefreshRecordRestart(t *testing.T) {
|
|
r := &recorder{applied: 1}
|
|
r.refresher().Reconcile(2)
|
|
|
|
if r.refreshes != 1 {
|
|
t.Errorf("refreshes = %d, want 1", r.refreshes)
|
|
}
|
|
if len(r.recorded) != 1 || r.recorded[0] != 2 {
|
|
t.Errorf("recorded = %v, want [2]", r.recorded)
|
|
}
|
|
if r.restarts != 1 {
|
|
t.Errorf("restarts = %d, want 1", r.restarts)
|
|
}
|
|
}
|
|
|
|
// RED-PROOF for the no-restart-storm guard: when the ACK version == the applied version, Reconcile
|
|
// must do nothing — no refresh, no restart. (Drop the `ackVersion == applied` guard in Reconcile and
|
|
// this test fails: it would refresh + restart on every report.)
|
|
func TestReconcile_SameVersion_NoOp(t *testing.T) {
|
|
r := &recorder{applied: 5}
|
|
r.refresher().Reconcile(5)
|
|
|
|
if r.refreshes != 0 {
|
|
t.Errorf("refreshes = %d, want 0 (unchanged version must not re-pull)", r.refreshes)
|
|
}
|
|
if r.restarts != 0 {
|
|
t.Errorf("restarts = %d, want 0 (unchanged version must NOT restart — restart storm)", r.restarts)
|
|
}
|
|
if len(r.recorded) != 0 {
|
|
t.Errorf("recorded = %v, want [] (nothing to record)", r.recorded)
|
|
}
|
|
}
|
|
|
|
// First-ever ACK (nothing recorded yet): record the baseline WITHOUT restarting or re-pulling — the
|
|
// box already came up on the first-boot pull.
|
|
func TestReconcile_FirstRun_RecordsBaselineNoRestart(t *testing.T) {
|
|
r := &recorder{applied: 0}
|
|
r.refresher().Reconcile(3)
|
|
|
|
if r.refreshes != 0 {
|
|
t.Errorf("refreshes = %d, want 0 (baseline must not re-pull)", r.refreshes)
|
|
}
|
|
if r.restarts != 0 {
|
|
t.Errorf("restarts = %d, want 0 (baseline must not restart)", r.restarts)
|
|
}
|
|
if len(r.recorded) != 1 || r.recorded[0] != 3 {
|
|
t.Errorf("recorded = %v, want [3] (baseline recorded)", r.recorded)
|
|
}
|
|
}
|
|
|
|
// A failed pull keeps the current config: do NOT record, do NOT restart (retried next cycle).
|
|
func TestReconcile_FailedPull_NoRecordNoRestart(t *testing.T) {
|
|
r := &recorder{applied: 1, refreshErr: errors.New("hub unreachable")}
|
|
r.refresher().Reconcile(2)
|
|
|
|
if r.refreshes != 1 {
|
|
t.Errorf("refreshes = %d, want 1 (attempted)", r.refreshes)
|
|
}
|
|
if r.restarts != 0 {
|
|
t.Errorf("restarts = %d, want 0 (failed pull must not restart)", r.restarts)
|
|
}
|
|
if len(r.recorded) != 0 {
|
|
t.Errorf("recorded = %v, want [] (failed pull must not record — version stays so it retries)", r.recorded)
|
|
}
|
|
}
|
|
|
|
// ackVersion == 0 (old hub / report-only customer) is a no-op.
|
|
func TestReconcile_ZeroVersion_NoOp(t *testing.T) {
|
|
r := &recorder{applied: 4}
|
|
r.refresher().Reconcile(0)
|
|
if r.refreshes != 0 || r.restarts != 0 || len(r.recorded) != 0 {
|
|
t.Errorf("zero version should be a no-op; got refreshes=%d restarts=%d recorded=%v", r.refreshes, r.restarts, r.recorded)
|
|
}
|
|
}
|
|
|
|
// If recording the applied version fails after a successful pull, skip the restart (avoid a loop:
|
|
// a restart without a recorded version would re-pull + restart forever).
|
|
func TestReconcile_RecordFails_SkipsRestart(t *testing.T) {
|
|
r := &recorder{applied: 1, recordErr: errors.New("disk full")}
|
|
r.refresher().Reconcile(2)
|
|
|
|
if r.refreshes != 1 {
|
|
t.Errorf("refreshes = %d, want 1", r.refreshes)
|
|
}
|
|
if r.restarts != 0 {
|
|
t.Errorf("restarts = %d, want 0 (must not restart if the version couldn't be recorded)", r.restarts)
|
|
}
|
|
}
|