Files
felhom-controller/controller/internal/selfupdate/updater.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

514 lines
17 KiB
Go

package selfupdate
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"os/exec"
"strings"
"sync"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
)
// CheckResult holds the result of a version check.
type CheckResult struct {
CurrentVersion string `json:"current_version"`
LatestVersion string `json:"latest_version"`
UpdateAvailable bool `json:"update_available"`
Error string `json:"error,omitempty"`
CheckedAt string `json:"checked_at"`
}
// UpdateStatus is the complete status returned by the API.
type UpdateStatus struct {
Running bool `json:"running"`
LastCheck *CheckResult `json:"last_check,omitempty"`
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
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)
}
// 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{}) {
if u.debug {
u.logger.Printf("[DEBUG] [selfupdate] "+format, args...)
}
}
// SetBackupRunningCheck sets the callback to check if a backup is in progress.
func (u *Updater) SetBackupRunningCheck(fn func() bool) {
u.mu.Lock()
defer u.mu.Unlock()
u.backupRunning = fn
}
// IsUpdateRunning returns true if an update is currently in progress.
func (u *Updater) IsUpdateRunning() bool {
u.mu.Lock()
defer u.mu.Unlock()
return u.updateRunning
}
// GetStatus returns the current update status for API/UI.
func (u *Updater) GetStatus() UpdateStatus {
u.mu.Lock()
lastCheck := u.lastCheck
running := u.updateRunning
u.mu.Unlock()
state, err := LoadState(u.dataDir)
if err != nil {
u.logger.Printf("[WARN] [selfupdate] Failed to load update state: %v", err)
}
return UpdateStatus{
Running: running,
LastCheck: lastCheck,
LastState: state,
}
}
// CheckForUpdate queries the Gitea registry for the latest version tag.
// Caches the result. Thread-safe.
func (u *Updater) CheckForUpdate() CheckResult {
result := CheckResult{
CurrentVersion: u.currentVer,
CheckedAt: time.Now().UTC().Format(time.RFC3339),
}
// Dev version can't check for updates
currentVer, err := ParseVersion(u.currentVer)
if err != nil {
result.Error = "Dev verzió nem ellenőrizhető"
u.mu.Lock()
u.lastCheck = &result
u.mu.Unlock()
return result
}
// Query registry
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)
u.mu.Lock()
u.lastCheck = &result
u.mu.Unlock()
return result
}
result.LatestVersion = latestStr
latestVer, err := ParseVersion(latestStr)
if err != nil {
result.Error = fmt.Sprintf("Érvénytelen verzió a registry-ben: %s", latestStr)
u.mu.Lock()
u.lastCheck = &result
u.mu.Unlock()
return result
}
cmp := latestVer.Compare(currentVer)
if cmp > 0 {
result.UpdateAvailable = true
u.logger.Printf("[INFO] [selfupdate] Update available: %s → %s", u.currentVer, latestStr)
} else {
u.logger.Printf("[INFO] [selfupdate] Current version %s is up to date", u.currentVer)
}
u.dbg("version comparison: current=%s (%d.%d.%d), latest=%s (%d.%d.%d), cmp=%d, updateAvailable=%v",
u.currentVer, currentVer.Major, currentVer.Minor, currentVer.Patch,
latestStr, latestVer.Major, latestVer.Minor, latestVer.Patch,
cmp, result.UpdateAvailable)
u.mu.Lock()
u.latestVersion = latestStr
u.lastCheck = &result
u.mu.Unlock()
return result
}
// queryRegistry queries the Gitea Docker Registry V2 API for available tags.
// Returns the highest valid semver tag found.
func (u *Updater) queryRegistry() (string, error) {
if u.gitCfg.Username == "" || u.gitCfg.Token == "" {
return "", fmt.Errorf("registry hitelesítő adatok hiányoznak")
}
// Gitea registry V2: GET /v2/<owner>/<repo>/tags/list
url := fmt.Sprintf("https://gitea.dooplex.hu/v2/%s/tags/list", registryImagePath(u.cfg.Image))
u.dbg("queryRegistry: url=%s user=%s", url, u.gitCfg.Username)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return "", fmt.Errorf("creating request: %w", err)
}
req.SetBasicAuth(u.gitCfg.Username, u.gitCfg.Token)
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(req)
if err != nil {
u.dbg("queryRegistry: HTTP request failed: %v", err)
return "", fmt.Errorf("HTTP request failed: %w", err)
}
defer resp.Body.Close()
u.dbg("queryRegistry: HTTP %d", resp.StatusCode)
if resp.StatusCode == 401 {
return "", fmt.Errorf("authentication failed (401)")
}
if resp.StatusCode != 200 {
return "", fmt.Errorf("unexpected status: %d", resp.StatusCode)
}
var tagsResp struct {
Name string `json:"name"`
Tags []string `json:"tags"`
}
if err := json.NewDecoder(resp.Body).Decode(&tagsResp); err != nil {
return "", fmt.Errorf("decoding response: %w", err)
}
u.dbg("queryRegistry: %d tags returned: %v", len(tagsResp.Tags), tagsResp.Tags)
// Find highest semver tag
var highest *Version
for _, tag := range tagsResp.Tags {
v, err := ParseVersion(tag)
if err != nil {
continue // skip non-semver tags ("latest", "dev", etc.)
}
if highest == nil || v.Compare(*highest) > 0 {
highest = &v
}
}
if highest == nil {
return "", fmt.Errorf("no valid semver tags found")
}
return highest.String(), nil
}
// registryImagePath extracts the "owner/repo" from a full image reference.
// e.g., "gitea.dooplex.hu/admin/felhom-controller" → "admin/felhom-controller"
func registryImagePath(image string) string {
// Remove registry host
parts := strings.SplitN(image, "/", 2)
if len(parts) == 2 {
return parts[1]
}
return image
}
// DryRunResult holds the result of a self-update dry run.
type DryRunResult struct {
CurrentVersion string `json:"current_version"`
LatestVersion string `json:"latest_version"`
UpdateAvailable bool `json:"update_available"`
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"`
}
// DryRun checks for updates and reports what would happen without performing any changes.
func (u *Updater) DryRun() *DryRunResult {
result := &DryRunResult{
CurrentVersion: u.currentVer,
}
check := u.CheckForUpdate()
result.LatestVersion = check.LatestVersion
result.UpdateAvailable = check.UpdateAvailable
if check.Error != "" {
result.Error = check.Error
return result
}
// 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.TargetImage = fmt.Sprintf("%s:%s", u.cfg.Image, check.LatestVersion)
}
if u.backupRunning != nil {
result.BackupRunning = u.backupRunning()
}
return result
}
// TriggerUpdate starts the self-update process. Returns error immediately if
// preconditions fail. The actual update runs in a goroutine.
func (u *Updater) TriggerUpdate(initiatedBy string) error {
u.dbg("TriggerUpdate: initiatedBy=%s currentVer=%s", initiatedBy, u.currentVer)
u.mu.Lock()
if u.updateRunning {
u.mu.Unlock()
u.dbg("TriggerUpdate: rejected — update already running")
return fmt.Errorf("Frissítés már folyamatban")
}
// Dev version check
if _, err := ParseVersion(u.currentVer); err != nil {
u.mu.Unlock()
return fmt.Errorf("Dev verzió nem frissíthető")
}
// Backup running check
if u.backupRunning != nil && u.backupRunning() {
u.mu.Unlock()
return fmt.Errorf("Mentés fut, próbálja később")
}
// 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("A frissítés nem érhető el (nincs gazda-ügynök)")
}
u.updateRunning = true
u.mu.Unlock()
// Check for update (or use cached)
result := u.CheckForUpdate()
if !result.UpdateAvailable {
u.mu.Lock()
u.updateRunning = false
u.mu.Unlock()
return fmt.Errorf("Nincs elérhető frissítés")
}
targetVersion := result.LatestVersion
targetImage := fmt.Sprintf("%s:%s", u.cfg.Image, targetVersion)
previousImage := fmt.Sprintf("%s:%s", u.cfg.Image, u.currentVer)
u.logger.Printf("[INFO] [selfupdate] Starting self-update: %s → %s (initiated by: %s)", u.currentVer, targetVersion, initiatedBy)
u.dbg("TriggerUpdate: target=%s image=%s previousImage=%s", targetVersion, targetImage, previousImage)
go u.performUpdate(targetVersion, targetImage, previousImage, initiatedBy)
return nil
}
// 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()
u.updateRunning = false
u.mu.Unlock()
}()
u.dbg("performUpdate: starting — target=%s image=%s", targetVersion, targetImage)
// 1. Write pending state (VerifyStartup reads this on the next boot to mark success/failure).
state := &UpdateState{
Status: "pending",
PreviousVersion: u.currentVer,
PreviousImage: previousImage,
TargetVersion: targetVersion,
TargetImage: targetImage,
InitiatedAt: time.Now().UTC().Format(time.RFC3339),
InitiatedBy: initiatedBy,
}
if err := SaveState(u.dataDir, state); err != nil {
u.logger.Printf("[ERROR] [selfupdate] Failed to save update state: %v", err)
return
}
// 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 = err.Error()
state.CompletedAt = time.Now().UTC().Format(time.RFC3339)
SaveState(u.dataDir, state)
u.logger.Printf("[ERROR] [selfupdate] %v", err)
return
}
u.logger.Printf("[INFO] [selfupdate] Image pulled into guest: %s", targetImage)
// 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("agent swap request failed: %v", err)
state.CompletedAt = time.Now().UTC().Format(time.RFC3339)
SaveState(u.dataDir, state)
u.logger.Printf("[ERROR] [selfupdate] Agent swap request failed: %v", err)
return
}
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.
}
// 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")
}
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)
}
}()
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)
}
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.
func (u *Updater) VerifyStartup() *UpdateState {
u.dbg("VerifyStartup: checking update state in %s", u.dataDir)
state, err := LoadState(u.dataDir)
if err != nil {
u.logger.Printf("[WARN] [selfupdate] Failed to load update state on startup: %v — clearing", err)
ClearState(u.dataDir, u.logger)
return nil
}
if state == nil || state.Status != "pending" {
u.dbg("VerifyStartup: no pending update (state=%v)", state)
return nil
}
u.dbg("VerifyStartup: pending update found — target=%s previous=%s", state.TargetVersion, state.PreviousVersion)
// Compare current version with target
currentVer, curErr := ParseVersion(u.currentVer)
targetVer, tgtErr := ParseVersion(state.TargetVersion)
if curErr != nil || tgtErr != nil {
state.Status = "failed"
state.Error = "Version parse error on startup verification"
state.CompletedAt = time.Now().UTC().Format(time.RFC3339)
SaveState(u.dataDir, state)
u.logger.Printf("[WARN] [selfupdate] Post-update startup: version parse error (current=%s, target=%s)", u.currentVer, state.TargetVersion)
return state
}
if currentVer.Compare(targetVer) == 0 {
// Success — we're running the target version
state.Status = "success"
state.CompletedAt = time.Now().UTC().Format(time.RFC3339)
SaveState(u.dataDir, state)
u.logger.Printf("[INFO] [selfupdate] Post-update startup: update successful (%s → %s)", state.PreviousVersion, state.TargetVersion)
} else {
// Version mismatch — update may have failed
state.Status = "failed"
state.Error = fmt.Sprintf("Version mismatch: expected %s, running %s", state.TargetVersion, u.currentVer)
state.CompletedAt = time.Now().UTC().Format(time.RFC3339)
SaveState(u.dataDir, state)
u.logger.Printf("[WARN] [selfupdate] Post-update startup: version mismatch (expected %s, running %s)", state.TargetVersion, u.currentVer)
}
return state
}
// runCommand executes a command and returns combined stdout+stderr and error.
func runCommand(name string, args ...string) (string, error) {
cmd := exec.Command(name, args...)
var out bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &out
err := cmd.Run()
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
}