3c1e91b5f0
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
133 lines
3.8 KiB
Go
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")
|
|
}
|
|
}
|