1310a0ebd7
The controller honors an operator-enforced minimum version (FLOOR) on the hub report ACK and auto-updates to the floor when below it (managed default, no click), reusing the Phase 1 in-guest-pull + agent-swap + rollback. Latest stays the opt-in button; the floor is the auto-target, never latest. - pusher.go: PushResponse += min_controller_version, latest_version (existing ACK seam) - main.go: OnPushResponse → updater.SetFloor + MaybeAutoUpdate (rides report cycle) - updater.go: SetFloor/GetFloor + MaybeAutoUpdate reusing performUpdate (auto-floor); no-op at/above floor, floor>latest, dev/no-agent/backup; no flap (in-mem+persisted) - settings UI (HU): floor display + auto restart-poll during an auto-update - tests: below/at/floor>latest/no-flap/raised-floor; below-floor red-proof verified - no agent change (reuses Phase 1 POST /controller/swap) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FSZmmSFVzGwEzhYmxbkgBK
647 lines
23 KiB
Go
647 lines
23 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
|
|
|
|
// Phase 2 managed updates: the operator-enforced minimum version (FLOOR), learned from the hub
|
|
// report ACK, and the last floor we already auto-attempted (no flapping within this process; the
|
|
// persisted UpdateState guards across restarts). Auto-target is ALWAYS the floor, never latest.
|
|
floor string
|
|
lastAutoFloorAttempt string
|
|
|
|
// 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
|
|
}
|
|
|
|
// SetFloor records the operator-enforced minimum controller version (the managed-update FLOOR),
|
|
// learned from the hub's report ACK. Empty clears it (Phase 2 inert for this box). Cheap + safe to
|
|
// call every report; pair it with MaybeAutoUpdate to reconcile.
|
|
func (u *Updater) SetFloor(version string) {
|
|
u.mu.Lock()
|
|
defer u.mu.Unlock()
|
|
if version != u.floor {
|
|
u.dbg("SetFloor: floor %q → %q", u.floor, version)
|
|
}
|
|
u.floor = version
|
|
}
|
|
|
|
// GetFloor returns the current floor (for the UI / status).
|
|
func (u *Updater) GetFloor() string {
|
|
u.mu.Lock()
|
|
defer u.mu.Unlock()
|
|
return u.floor
|
|
}
|
|
|
|
// MaybeAutoUpdate auto-updates the controller to the FLOOR when the box is below it — the managed
|
|
// default (no customer click). It REUSES the Phase 1 performUpdate flow (pull in-guest → agent swap
|
|
// → rollback on failure); it never adds a second swap path and never touches the agent binary.
|
|
//
|
|
// It is a strict no-op unless ALL hold:
|
|
// - a floor is set AND the current version parses (a dev build can't compare),
|
|
// - current < floor (Scenario B: at/above floor does NOTHING — we must NOT chase latest here),
|
|
// - an agent is wired (it performs the swap) and no backup is running,
|
|
// - no swap is in flight, we haven't already auto-attempted this exact floor in-process, and the
|
|
// persisted state doesn't already record this floor as attempted (no flapping/storm across the
|
|
// report cycle or a restart),
|
|
// - the floor is a real, pullable tag: floor <= latest available in the registry. If the floor
|
|
// EXCEEDS the latest available (operator misconfig) we log a warning and do nothing.
|
|
//
|
|
// Auto-target is ALWAYS the floor, never latest. Runs right after the floor is set (post-report) —
|
|
// no new timer/endpoint.
|
|
func (u *Updater) MaybeAutoUpdate() {
|
|
u.mu.Lock()
|
|
floor := u.floor
|
|
if floor == "" {
|
|
u.mu.Unlock()
|
|
return
|
|
}
|
|
if u.agent == nil {
|
|
u.mu.Unlock()
|
|
u.dbg("maybeAutoUpdate: no agent — auto-update unavailable")
|
|
return
|
|
}
|
|
if u.updateRunning {
|
|
u.mu.Unlock()
|
|
u.dbg("maybeAutoUpdate: an update is already running — skip")
|
|
return
|
|
}
|
|
curVer, err := ParseVersion(u.currentVer)
|
|
if err != nil {
|
|
u.mu.Unlock()
|
|
u.dbg("maybeAutoUpdate: current %q not parseable (dev?) — skip", u.currentVer)
|
|
return
|
|
}
|
|
floorVer, err := ParseVersion(floor)
|
|
if err != nil {
|
|
u.mu.Unlock()
|
|
u.logger.Printf("[WARN] [selfupdate] Floor %q is not a valid version — ignoring", floor)
|
|
return
|
|
}
|
|
// Scenario B — at/above floor: NOTHING. Must NOT update just because latest > current (that's the
|
|
// customer's opt-in button, not the floor's job).
|
|
if curVer.Compare(floorVer) >= 0 {
|
|
u.mu.Unlock()
|
|
u.dbg("maybeAutoUpdate: current %s >= floor %s — no action", u.currentVer, floor)
|
|
return
|
|
}
|
|
// No flapping (in-process): one auto-update per below-floor condition.
|
|
if u.lastAutoFloorAttempt == floor {
|
|
u.mu.Unlock()
|
|
u.dbg("maybeAutoUpdate: already auto-attempted floor %s this process — skip", floor)
|
|
return
|
|
}
|
|
u.mu.Unlock()
|
|
|
|
// No flapping (across restart): if the persisted state already records an attempt at THIS floor,
|
|
// don't re-trigger. A failed+rolled-back auto-update restarts this process (losing the in-memory
|
|
// flag), so without this a persistent failure would retry every report. A 'success' would already
|
|
// be caught by the at/above check; we still guard it for completeness.
|
|
if st, _ := LoadState(u.dataDir); st != nil && st.TargetVersion == floor &&
|
|
(st.Status == "failed" || st.Status == "success") {
|
|
u.dbg("maybeAutoUpdate: persisted state already records floor %s (status=%s) — skip", floor, st.Status)
|
|
return
|
|
}
|
|
|
|
// Validate the floor is PULLABLE: it must not exceed the latest available registry tag. We do not
|
|
// chase a non-existent image. queryFn is the same registry lookup Phase 1 uses.
|
|
latestStr, err := u.queryFn()
|
|
if err != nil {
|
|
u.logger.Printf("[WARN] [selfupdate] Auto-update: registry check failed (%v) — deferring floor %s", err, floor)
|
|
return
|
|
}
|
|
latestVer, err := ParseVersion(latestStr)
|
|
if err != nil {
|
|
u.logger.Printf("[WARN] [selfupdate] Auto-update: registry returned invalid latest %q — deferring floor %s", latestStr, floor)
|
|
return
|
|
}
|
|
if floorVer.Compare(latestVer) > 0 {
|
|
u.logger.Printf("[WARN] [selfupdate] Auto-update: floor %s exceeds latest available %s (operator misconfig?) — doing nothing", floor, latestStr)
|
|
return
|
|
}
|
|
|
|
// Commit: re-check under lock (a concurrent report may have started one) and claim the run.
|
|
u.mu.Lock()
|
|
if u.updateRunning || u.lastAutoFloorAttempt == floor || u.floor != floor {
|
|
u.mu.Unlock()
|
|
return
|
|
}
|
|
if u.backupRunning != nil && u.backupRunning() {
|
|
u.mu.Unlock()
|
|
u.dbg("maybeAutoUpdate: backup running — defer floor %s (will retry next report)", floor)
|
|
return
|
|
}
|
|
u.updateRunning = true
|
|
u.lastAutoFloorAttempt = floor
|
|
u.mu.Unlock()
|
|
|
|
targetImage := fmt.Sprintf("%s:%s", u.cfg.Image, floor)
|
|
previousImage := fmt.Sprintf("%s:%s", u.cfg.Image, u.currentVer)
|
|
u.logger.Printf("[INFO] [selfupdate] Auto-update to FLOOR: %s → %s (managed, no customer action)", u.currentVer, floor)
|
|
go u.performUpdate(floor, targetImage, previousImage, "auto-floor")
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|