Files
felhom-controller/controller/internal/selfupdate/updater_test.go
T
admin 3c1e91b5f0 v0.85.0: self-update reworked — in-guest pull + agent swap (Phase 1)
Replaces the dead in-container docker-compose self-update (composePath doesn't
exist in the LXC guest). The controller now docker-logins+pulls the target image
in-guest (shared socket, its registry token), then delegates the container swap to
the host agent (agentapi.SwapController -> POST /controller/swap), which owns the
restart + health-verify + rollback. Removed performUpdate compose flow /
updateComposeFile / composePath. NewUpdater takes an AgentSwapper. DryRun reports
agent_reachable + pull_capable. UI button + poll unchanged; latest-only.

Tests: up-to-date no-op / pull-fail agent-not-called / happy pull-then-swap /
no-agent unavailable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtXesNa2LGbMmE4DNL6SE7
2026-06-26 21:26:14 +02:00

133 lines
3.8 KiB
Go

package selfupdate
import (
"context"
"fmt"
"io"
"log"
"sync"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
)
const imageBase = "gitea.dooplex.hu/admin/felhom-controller"
type fakeAgent struct {
mu sync.Mutex
calls []string
err error
}
func (f *fakeAgent) SwapController(_ context.Context, image string) (agentapi.SwapResult, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.calls = append(f.calls, image)
return agentapi.SwapResult{Status: "swapping", PreviousImage: imageBase + ":0.77.0"}, f.err
}
func (f *fakeAgent) swapCalls() []string {
f.mu.Lock()
defer f.mu.Unlock()
return append([]string(nil), f.calls...)
}
func newTestUpdater(t *testing.T, current string, agent AgentSwapper) *Updater {
t.Helper()
cfg := &config.SelfUpdateConfig{Enabled: true, Image: imageBase}
git := &config.GitConfig{Username: "u", Token: "tok"}
return NewUpdater(cfg, git, current, t.TempDir(), agent, log.New(io.Discard, "", 0), false)
}
// waitDone polls until an in-flight update finishes (performUpdate runs async).
func waitDone(t *testing.T, u *Updater) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for u.IsUpdateRunning() {
if time.Now().After(deadline) {
t.Fatal("update did not finish in time")
}
time.Sleep(2 * time.Millisecond)
}
}
// Scenario C — already latest: no pull, no agent call.
func TestTriggerUpdate_UpToDate_NoPullNoAgent(t *testing.T) {
agent := &fakeAgent{}
u := newTestUpdater(t, "0.84.0", agent)
u.queryFn = func() (string, error) { return "0.84.0", nil }
pulled := false
u.pullFn = func(string) error { pulled = true; return nil }
err := u.TriggerUpdate("test")
if err == nil {
t.Fatal("expected 'no update' error when already latest")
}
if pulled {
t.Error("must NOT pull when up-to-date")
}
if n := len(agent.swapCalls()); n != 0 {
t.Errorf("agent called %d times, want 0 (up-to-date)", n)
}
}
// Scenario D — pull fails: agent never called, current controller untouched.
func TestPerformUpdate_PullFails_AgentNotCalled(t *testing.T) {
agent := &fakeAgent{}
u := newTestUpdater(t, "0.77.0", agent)
u.queryFn = func() (string, error) { return "0.84.0", nil }
u.pullFn = func(string) error { return fmt.Errorf("registry unreachable") }
if err := u.TriggerUpdate("test"); err != nil {
t.Fatalf("TriggerUpdate returned %v, want nil (async)", err)
}
waitDone(t, u)
if n := len(agent.swapCalls()); n != 0 {
t.Errorf("agent called %d times, want 0 (pull failed)", n)
}
st, _ := LoadState(u.dataDir)
if st == nil || st.Status != "failed" {
t.Errorf("state = %+v, want failed", st)
}
}
// Happy path — pull then delegate the swap to the agent with the right ref.
func TestPerformUpdate_Happy_PullThenSwap(t *testing.T) {
agent := &fakeAgent{}
u := newTestUpdater(t, "0.77.0", agent)
u.queryFn = func() (string, error) { return "0.84.0", nil }
var pulledImage string
u.pullFn = func(img string) error { pulledImage = img; return nil }
if err := u.TriggerUpdate("test"); err != nil {
t.Fatalf("TriggerUpdate returned %v, want nil", err)
}
waitDone(t, u)
want := imageBase + ":0.84.0"
if pulledImage != want {
t.Errorf("pulled %q, want %q", pulledImage, want)
}
calls := agent.swapCalls()
if len(calls) != 1 || calls[0] != want {
t.Errorf("agent swap calls = %v, want exactly [%q]", calls, want)
}
}
// No agent (un-provisioned guest) → update unavailable, never pulls.
func TestTriggerUpdate_NoAgent_Unavailable(t *testing.T) {
u := newTestUpdater(t, "0.77.0", nil)
u.queryFn = func() (string, error) { return "0.84.0", nil }
pulled := false
u.pullFn = func(string) error { pulled = true; return nil }
if err := u.TriggerUpdate("test"); err == nil {
t.Fatal("expected error when no agent is wired")
}
if pulled {
t.Error("must not pull when no agent")
}
}