From 3c1e91b5f009ac496ab0e75ab2d761524c58ca6b Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Fri, 26 Jun 2026 21:26:14 +0200 Subject: [PATCH] =?UTF-8?q?v0.85.0:=20self-update=20reworked=20=E2=80=94?= =?UTF-8?q?=20in-guest=20pull=20+=20agent=20swap=20(Phase=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01TtXesNa2LGbMmE4DNL6SE7 --- CHANGELOG.md | 22 ++ controller/cmd/controller/main.go | 13 +- controller/internal/agentapi/client.go | 47 ++++ controller/internal/selfupdate/updater.go | 237 +++++++++--------- .../internal/selfupdate/updater_test.go | 132 ++++++++++ 5 files changed, 325 insertions(+), 126 deletions(-) create mode 100644 controller/internal/selfupdate/updater_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index b1257b7..94a59fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ ## Changelog +### v0.85.0 — Self-update reworked: in-guest pull + agent swap (Phase 1) (2026-06-26) +- **Problem:** the self-update button was dead in the LXC architecture — `selfupdate/updater.go` drove + the old bare-metal flow (`docker compose -f /opt/docker/felhom-controller/docker-compose.yml up -d`), + a path that doesn't exist in the guest ("docker-compose.yml nem elérhető"). The stranded 0.77.0 demo + could detect 0.84 but not install it. +- **Fix (Phase 1):** the controller now **pulls** the target image in-guest (its existing registry token + via `docker login --password-stdin` → `docker pull` → `docker logout`, over the shared docker socket), + then **delegates the swap to the host agent** (`agentapi.Client.SwapController` → agent + `POST /controller/swap`). The agent — external to the controller container — rewrites + `/etc/felhom-controller-image`, restarts the bootstrap unit, verifies health, and **rolls back** if the + new controller doesn't come up. The controller never `docker rm`/recreates itself. +- **Removed** the dead compose path: `performUpdate`/`updateComposeFile`/`composePath` and the + `docker compose up -d` flow are gone. `DryRun` now reports `agent_reachable` + `pull_capable` instead of + `compose_writable`. +- Success/failure is detected on the **next boot** by the existing `VerifyStartup` (running version vs + target) — a rollback lands the previous version → "failed (version mismatch)". The UI button + poll + (`triggerUpdate`/`pollUntilBack`) are unchanged. **Latest-only** (no version picker — Phase 2). +- `agentapi`: new `SwapController` (202) + `SwapStatus`. `NewUpdater` takes an `AgentSwapper` (nil on an + un-provisioned guest → update unavailable) instead of a compose path. +- Tests (`internal/selfupdate/updater_test.go`): up-to-date → no pull/no agent; pull-fails → agent never + called; happy → pull then one `SwapController` with the right ref; no-agent → unavailable. + ### v0.84.0 — Show an app's auto-generated initial login on its page (catalog-driven) (2026-06-26) - **Problem:** some apps generate a random first-login password into a file at first boot (Crafty → `/crafty/app/config/default-creds.txt`) instead of taking it from a deploy field. Customers had to diff --git a/controller/cmd/controller/main.go b/controller/cmd/controller/main.go index e8374e9..fcaf82e 100644 --- a/controller/cmd/controller/main.go +++ b/controller/cmd/controller/main.go @@ -243,8 +243,17 @@ func main() { // --- Initialize self-updater --- var updater *selfupdate.Updater if cfg.SelfUpdate.Enabled { - composePath := filepath.Join(filepath.Dir(cfg.Paths.DataDir), "docker-compose.yml") - updater = selfupdate.NewUpdater(&cfg.SelfUpdate, &cfg.Git, Version, cfg.Paths.DataDir, composePath, logger, cfg.Logging.Level == "debug") + // Phase 1: the host agent performs the container swap. Build a (nil-able) agent client from the + // provisioned local-API config; when absent (un-provisioned guest) self-update is unavailable. + var swapAgent selfupdate.AgentSwapper + if cfg.LocalAPI.Endpoint != "" && cfg.LocalAPI.Token != "" { + if ac, err := agentapi.New(cfg.LocalAPI.Endpoint, cfg.LocalAPI.Token, cfg.LocalAPI.Fingerprint); err != nil { + logger.Printf("[WARN] Self-update: agent client init failed (%v) — updates unavailable", err) + } else { + swapAgent = ac + } + } + updater = selfupdate.NewUpdater(&cfg.SelfUpdate, &cfg.Git, Version, cfg.Paths.DataDir, swapAgent, logger, cfg.Logging.Level == "debug") updater.SetBackupRunningCheck(func() bool { return backupMgr != nil && backupMgr.IsRunning() }) diff --git a/controller/internal/agentapi/client.go b/controller/internal/agentapi/client.go index bd70215..31d8001 100644 --- a/controller/internal/agentapi/client.go +++ b/controller/internal/agentapi/client.go @@ -372,6 +372,53 @@ func (c *Client) GuestReboot(ctx context.Context) error { return err } +// SwapResult mirrors the agent's 202 from POST /controller/swap (agentic controller update, Phase 1). +type SwapResult struct { + Status string `json:"status"` // "swapping" + PreviousImage string `json:"previous_image"` // image before the swap (for the UI/log) + TargetImage string `json:"target_image"` +} + +// SwapController asks the agent to swap the in-guest controller to `image` (which the controller has +// already pulled into the guest's docker storage). The agent responds 202 and performs the swap+verify +// +rollback asynchronously, EXTERNALLY to this controller container (so it survives this process being +// killed by the swap). Latest-only is enforced by the caller (queryRegistry); the agent re-validates +// the ref shape. +func (c *Client) SwapController(ctx context.Context, image string) (SwapResult, error) { + var out SwapResult + body, err := c.post(ctx, "/controller/swap", map[string]string{"image": image}) + if err != nil { + return out, err + } + if err := json.Unmarshal(body, &out); err != nil { + return out, fmt.Errorf("agentapi: decode /controller/swap: %w", err) + } + return out, nil +} + +// SwapStatus mirrors the agent's GET /controller/swap/status (observability for the post-restart UI). +type SwapStatus struct { + State string `json:"state"` // none | swapping | done | failed + InFlight bool `json:"in_flight"` + Current string `json:"current"` + Previous string `json:"previous"` + Target string `json:"target"` + Error string `json:"error"` +} + +// SwapStatus reads the last/in-flight swap outcome for this guest. +func (c *Client) SwapStatus(ctx context.Context) (SwapStatus, error) { + var out SwapStatus + body, err := c.get(ctx, "/controller/swap/status") + if err != nil { + return out, err + } + if err := json.Unmarshal(body, &out); err != nil { + return out, fmt.Errorf("agentapi: decode /controller/swap/status: %w", err) + } + return out, nil +} + // EjectResult mirrors POST /disks/eject (the dependent-guest warning). type EjectResult struct { VMID int `json:"vmid"` diff --git a/controller/internal/selfupdate/updater.go b/controller/internal/selfupdate/updater.go index a426c4a..acdb2b1 100644 --- a/controller/internal/selfupdate/updater.go +++ b/controller/internal/selfupdate/updater.go @@ -2,17 +2,17 @@ package selfupdate import ( "bytes" + "context" "encoding/json" "fmt" "log" "net/http" - "os" "os/exec" - "regexp" "strings" "sync" "time" + "gitea.dooplex.hu/admin/felhom-controller/internal/agentapi" "gitea.dooplex.hu/admin/felhom-controller/internal/config" ) @@ -32,34 +32,53 @@ type UpdateStatus struct { LastState *UpdateState `json:"last_state,omitempty"` } +// AgentSwapper is the agent local-API capability the updater delegates the actual container swap to +// (agentic controller update, Phase 1). The agent — external to this controller container — rewrites +// /etc/felhom-controller-image, restarts the bootstrap unit, verifies health, and rolls back. Satisfied +// by *agentapi.Client; faked in tests. nil when this is not a provisioned guest (no agent) → updates +// are unavailable (the old in-container compose flow is GONE). +type AgentSwapper interface { + SwapController(ctx context.Context, image string) (agentapi.SwapResult, error) +} + // Updater manages controller self-updates. type Updater struct { - cfg *config.SelfUpdateConfig - gitCfg *config.GitConfig - currentVer string - dataDir string - composePath string // e.g., "/opt/docker/felhom-controller/docker-compose.yml" - logger *log.Logger - debug bool + cfg *config.SelfUpdateConfig + gitCfg *config.GitConfig + currentVer string + dataDir string + agent AgentSwapper // Phase 1: the host agent performs the swap (nil → updates unavailable) + logger *log.Logger + debug bool mu sync.Mutex latestVersion string lastCheck *CheckResult updateRunning bool backupRunning func() bool + + // Seams (default to the real implementations; overridden in tests to avoid network/docker). + queryFn func() (string, error) // resolve latest registry tag (default u.queryRegistry) + pullFn func(targetImage) error // pull the image in-guest (default u.pullImage) } -// NewUpdater creates a new Updater instance. -func NewUpdater(cfg *config.SelfUpdateConfig, gitCfg *config.GitConfig, currentVersion, dataDir, composePath string, logger *log.Logger, debug bool) *Updater { - return &Updater{ - cfg: cfg, - gitCfg: gitCfg, - currentVer: currentVersion, - dataDir: dataDir, - composePath: composePath, - logger: logger, - debug: debug, +// targetImage is a tiny named type so the pullFn seam reads clearly. +type targetImage = string + +// NewUpdater creates a new Updater instance. agent may be nil (un-provisioned guest → no self-update). +func NewUpdater(cfg *config.SelfUpdateConfig, gitCfg *config.GitConfig, currentVersion, dataDir string, agent AgentSwapper, logger *log.Logger, debug bool) *Updater { + u := &Updater{ + cfg: cfg, + gitCfg: gitCfg, + currentVer: currentVersion, + dataDir: dataDir, + agent: agent, + logger: logger, + debug: debug, } + u.queryFn = u.queryRegistry + u.pullFn = u.pullImage + return u } func (u *Updater) dbg(format string, args ...interface{}) { @@ -120,7 +139,7 @@ func (u *Updater) CheckForUpdate() CheckResult { } // Query registry - latestStr, err := u.queryRegistry() + latestStr, err := u.queryFn() if err != nil { result.Error = fmt.Sprintf("Registry lekérdezés sikertelen: %v", err) u.logger.Printf("[WARN] [selfupdate] Registry check failed: %v", err) @@ -241,9 +260,9 @@ type DryRunResult struct { CurrentVersion string `json:"current_version"` LatestVersion string `json:"latest_version"` UpdateAvailable bool `json:"update_available"` - ComposeWritable bool `json:"compose_writable"` - CurrentImageLine string `json:"current_image_line"` - NewImageLine string `json:"new_image_line"` + AgentReachable bool `json:"agent_reachable"` // the host agent (which performs the swap) is wired + PullCapable bool `json:"pull_capable"` // registry creds present for the in-guest pull + TargetImage string `json:"target_image"` // what we would pull + swap to BackupRunning bool `json:"backup_running"` Error string `json:"error,omitempty"` } @@ -254,7 +273,6 @@ func (u *Updater) DryRun() *DryRunResult { CurrentVersion: u.currentVer, } - // Check for update check := u.CheckForUpdate() result.LatestVersion = check.LatestVersion result.UpdateAvailable = check.UpdateAvailable @@ -263,33 +281,12 @@ func (u *Updater) DryRun() *DryRunResult { return result } - // Check compose file - data, err := os.ReadFile(u.composePath) - if err != nil { - result.Error = fmt.Sprintf("Compose fájl nem olvasható: %v", err) - return result - } - - // Find current image line - re := regexp.MustCompile(`(image:\s*)gitea\.dooplex\.hu/admin/felhom-controller:\S+`) - match := re.Find(data) - if match != nil { - result.CurrentImageLine = string(match) - } - - // Build new image line + // The new flow: pull in-guest, then the agent swaps. Report those two capabilities. + result.AgentReachable = u.agent != nil + result.PullCapable = u.gitCfg.Username != "" && u.gitCfg.Token != "" if check.UpdateAvailable { - result.NewImageLine = fmt.Sprintf("image: %s:%s", u.cfg.Image, check.LatestVersion) + result.TargetImage = fmt.Sprintf("%s:%s", u.cfg.Image, check.LatestVersion) } - - // Check writability - f, err := os.OpenFile(u.composePath, os.O_WRONLY, 0) - if err == nil { - f.Close() - result.ComposeWritable = true - } - - // Check backup running if u.backupRunning != nil { result.BackupRunning = u.backupRunning() } @@ -320,10 +317,11 @@ func (u *Updater) TriggerUpdate(initiatedBy string) error { return fmt.Errorf("Mentés fut, próbálja később") } - // Compose file accessible check - if _, err := os.Stat(u.composePath); err != nil { + // Agent reachable check — the host agent performs the swap; without it there is no update path + // (the old in-container docker-compose flow is removed). + if u.agent == nil { u.mu.Unlock() - return fmt.Errorf("docker-compose.yml nem elérhető: %w", err) + return fmt.Errorf("A frissítés nem érhető el (nincs gazda-ügynök)") } u.updateRunning = true @@ -350,7 +348,10 @@ func (u *Updater) TriggerUpdate(initiatedBy string) error { return nil } -// performUpdate runs the actual update steps in a goroutine. +// performUpdate runs the actual update in a goroutine: pull the target image IN-GUEST (shared docker +// socket, our registry token), then delegate the container SWAP to the host agent (which owns the +// restart + verify + rollback). This controller process is expected to be killed when the agent swaps; +// success/failure is detected on the NEXT boot by VerifyStartup (current version vs target). func (u *Updater) performUpdate(targetVersion, targetImage, previousImage, initiatedBy string) { defer func() { u.mu.Lock() @@ -359,7 +360,7 @@ func (u *Updater) performUpdate(targetVersion, targetImage, previousImage, initi }() u.dbg("performUpdate: starting — target=%s image=%s", targetVersion, targetImage) - // 1. Write pending state + // 1. Write pending state (VerifyStartup reads this on the next boot to mark success/failure). state := &UpdateState{ Status: "pending", PreviousVersion: u.currentVer, @@ -374,96 +375,72 @@ func (u *Updater) performUpdate(targetVersion, targetImage, previousImage, initi return } - // 2. Docker pull - u.dbg("performUpdate: step 2 — docker pull %s", targetImage) - u.logger.Printf("[INFO] [selfupdate] Pulling image: %s", targetImage) - pullStart := time.Now() - pullOut, pullErr := runCommand("docker", "pull", targetImage) - if pullErr != nil { + // 2. Pull the target image into the guest's docker storage (via the shared socket). Auth with the + // existing registry token (login → pull → logout). On failure the AGENT IS NEVER CALLED and the + // current controller keeps running untouched. + if err := u.pullFn(targetImage); err != nil { state.Status = "failed" - state.Error = fmt.Sprintf("docker pull failed: %v — %s", pullErr, pullOut) + state.Error = err.Error() state.CompletedAt = time.Now().UTC().Format(time.RFC3339) SaveState(u.dataDir, state) - u.logger.Printf("[ERROR] [selfupdate] Docker pull failed: %v — %s", pullErr, pullOut) + u.logger.Printf("[ERROR] [selfupdate] %v", err) return } - u.logger.Printf("[INFO] [selfupdate] Image pulled successfully: %s", targetImage) - u.dbg("performUpdate: docker pull completed in %s", time.Since(pullStart).Round(time.Millisecond)) + u.logger.Printf("[INFO] [selfupdate] Image pulled into guest: %s", targetImage) - // 3. Update compose file (replace image tag) - u.dbg("performUpdate: step 3 — updating compose file %s", u.composePath) - if err := u.updateComposeFile(targetImage); err != nil { + // 3. Delegate the swap to the host agent (it restarts the bootstrap unit + verifies + rolls back). + u.logger.Printf("[INFO] [selfupdate] Requesting agent controller swap → %s (this controller will restart)", targetImage) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + res, err := u.agent.SwapController(ctx, targetImage) + if err != nil { state.Status = "failed" - state.Error = fmt.Sprintf("compose update failed: %v", err) + state.Error = fmt.Sprintf("agent swap request failed: %v", err) state.CompletedAt = time.Now().UTC().Format(time.RFC3339) SaveState(u.dataDir, state) - u.logger.Printf("[ERROR] [selfupdate] Compose file update failed: %v", err) + u.logger.Printf("[ERROR] [selfupdate] Agent swap request failed: %v", err) return } - u.logger.Printf("[INFO] [selfupdate] Compose file updated with new image: %s", targetImage) - - // 4. Docker compose up -d (this kills the current container) - u.dbg("performUpdate: step 4 — docker compose up -d") - u.logger.Printf("[INFO] [selfupdate] Running docker compose up -d — container will restart") - composeDir := strings.TrimSuffix(u.composePath, "/docker-compose.yml") - upOut, upErr := runCommand("docker", "compose", "-f", u.composePath, "-p", "felhom-controller", "up", "-d") - if upErr != nil { - state.Status = "failed" - state.Error = fmt.Sprintf("docker compose up -d failed: %v — %s", upErr, upOut) - state.CompletedAt = time.Now().UTC().Format(time.RFC3339) - SaveState(u.dataDir, state) - u.logger.Printf("[ERROR] [selfupdate] docker compose up -d failed: %v — %s (dir: %s)", upErr, upOut, composeDir) - return - } - - // If we're still alive after compose up -d, log it. - // Normally this process should be killed when Docker replaces the container. - u.logger.Printf("[WARN] [selfupdate] Still running after docker compose up -d — expected to be replaced") - time.Sleep(30 * time.Second) - u.logger.Printf("[WARN] [selfupdate] Still alive 30s after docker compose up -d") + u.logger.Printf("[INFO] [selfupdate] Agent accepted swap (status=%s, previous=%s) — awaiting restart", res.Status, res.PreviousImage) + // We now expect to be killed by the agent's `docker rm -f` + `docker run`. If we survive (e.g. the + // agent's verify is still polling), just wait — the new container replaces us shortly. } -// updateComposeFile reads the compose file, replaces the image tag, and writes it back atomically. -func (u *Updater) updateComposeFile(newImage string) error { - u.logger.Printf("[INFO] [selfupdate] Updating compose file") - data, err := os.ReadFile(u.composePath) - if err != nil { - u.logger.Printf("[ERROR] [selfupdate] Failed to update compose file: %v", err) - return fmt.Errorf("reading compose file: %w", err) +// pullImage authenticates to the registry and pulls targetImage into the guest's docker storage. The +// token is passed via stdin (never argv) and the session is logged out afterwards. +func (u *Updater) pullImage(targetImage string) error { + if u.gitCfg.Username == "" || u.gitCfg.Token == "" { + return fmt.Errorf("docker pull: registry hitelesítő adatok hiányoznak") } - - // Replace image line: "image: gitea.dooplex.hu/admin/felhom-controller:..." → new image - re := regexp.MustCompile(`(image:\s*)gitea\.dooplex\.hu/admin/felhom-controller:\S+`) - - // Log old image line for debugging - oldMatch := re.Find(data) - if oldMatch != nil { - u.dbg("updateComposeFile: %q → %q", string(oldMatch), "image: "+newImage) - } else { - u.dbg("updateComposeFile: no matching image line found in %s", u.composePath) + host := registryHost(u.cfg.Image) + u.dbg("pullImage: docker login %s as %s", host, u.gitCfg.Username) + if out, err := runCommandStdin(u.gitCfg.Token, "docker", "login", host, "-u", u.gitCfg.Username, "--password-stdin"); err != nil { + return fmt.Errorf("docker login failed: %v — %s", err, out) } + defer func() { + if out, err := runCommand("docker", "logout", host); err != nil { + u.logger.Printf("[WARN] [selfupdate] docker logout failed: %v — %s", err, out) + } + }() - newData := re.ReplaceAll(data, []byte("${1}"+newImage)) - - if bytes.Equal(data, newData) { - u.logger.Printf("[ERROR] [selfupdate] Failed to update compose file: no image line found to replace") - return fmt.Errorf("no image line found to replace in compose file") + u.logger.Printf("[INFO] [selfupdate] Pulling image: %s", targetImage) + pullStart := time.Now() + if out, err := runCommand("docker", "pull", targetImage); err != nil { + return fmt.Errorf("docker pull failed: %v — %s", err, out) } - - // Atomic write: write to .tmp, then rename - tmpPath := u.composePath + ".tmp" - if err := os.WriteFile(tmpPath, newData, 0644); err != nil { - u.logger.Printf("[ERROR] [selfupdate] Failed to update compose file: %v", err) - return fmt.Errorf("writing temp compose file: %w", err) - } - if err := os.Rename(tmpPath, u.composePath); err != nil { - u.logger.Printf("[ERROR] [selfupdate] Failed to update compose file: %v", err) - return fmt.Errorf("renaming compose file: %w", err) - } - + u.dbg("pullImage: docker pull completed in %s", time.Since(pullStart).Round(time.Millisecond)) return nil } +// registryHost extracts the registry host from a full image reference +// ("gitea.dooplex.hu/admin/felhom-controller" → "gitea.dooplex.hu"). +func registryHost(image string) string { + if i := strings.IndexByte(image, '/'); i > 0 { + return image[:i] + } + return image +} + // VerifyStartup checks the update state file on startup. // Called once from main.go before the scheduler starts. // Returns the state if a pending update was detected, nil otherwise. @@ -522,3 +499,15 @@ func runCommand(name string, args ...string) (string, error) { return out.String(), err } +// runCommandStdin executes a command, feeding `stdin` on its standard input (used for +// `docker login --password-stdin` so the token is never in argv/ps). Returns combined output. +func runCommandStdin(stdin, name string, args ...string) (string, error) { + cmd := exec.Command(name, args...) + cmd.Stdin = strings.NewReader(stdin) + var out bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &out + err := cmd.Run() + return out.String(), err +} + diff --git a/controller/internal/selfupdate/updater_test.go b/controller/internal/selfupdate/updater_test.go new file mode 100644 index 0000000..58b5ead --- /dev/null +++ b/controller/internal/selfupdate/updater_test.go @@ -0,0 +1,132 @@ +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") + } +}