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
This commit is contained in:
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user