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
514 lines
17 KiB
Go
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
|
|
}
|
|
|