Files
felhom-controller/controller/internal/selfupdate/updater.go
T
admin b25ca60ab7 v0.112.0: self-update without credentials — anonymous Docker v2 token flow
Root cause (live on Peti's box): the updater piggybacked on Git Sync creds and refused when absent,
but the registry serves the public package anonymously (verified 2026-07-10). Credentials become what
they were meant to be — optional, for private catalogs only.

- queryRegistry: both creds empty → anonymous flow (plain GET → parse WWW-Authenticate realm/service
  from the header, never hardcoded → credential-free token → Bearer retry); creds present → BasicAuth
  path unchanged; half-configured pair → loud incomplete-credentials error
- pullImage: no creds → skip docker login entirely (docker's native anonymous flow); denied anonymous
  access → clear 'registry denied anonymous access — a private registry requires Git Sync credentials'
- settings page: 'Registry: nyilvános (hitelesítés nélkül) / hitelesített' mode line — credential-less
  is no longer an error state; DryRun.PullCapable counts anonymous as capable
- tests: fake registry httptest token dance (zero creds, no auth on token request, correct scope),
  creds path unchanged (BasicAuth, no dance), both denial paths, WWW-Authenticate parser table
  (quoted/bare/order/comma-in-quotes/missing-realm), fake-runner pull tests (no login invoked
  anonymously; login/pull/logout order + stdin token with creds; partial creds refuse)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
2026-07-10 17:52:03 +02:00

846 lines
30 KiB
Go

package selfupdate
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
neturl "net/url"
"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)
// registryBase overrides the registry API base ("https://<host>") in tests (httptest is http://).
registryBase string
}
// 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
}
// errAnonymousDenied is the LOUD, actionable error for a registry that refuses the
// anonymous flow — distinct from the old (wrong) "credentials missing" refusal.
var errAnonymousDenied = fmt.Errorf("registry denied anonymous access — a private registry requires Git Sync credentials")
// RegistryAnonymous reports whether the updater talks to the registry without credentials.
// Credential-less is a SUPPORTED mode (public package — the registry's anonymous token
// flow covers it), not an error state; credentials exist for private catalogs only.
func (u *Updater) RegistryAnonymous() bool {
return u.gitCfg.Username == "" && u.gitCfg.Token == ""
}
// registryBaseURL returns the scheme+host of the registry API, derived from the image
// reference ("gitea.dooplex.hu/admin/felhom-controller" → "https://gitea.dooplex.hu").
// Overridable via registryBase in tests (httptest serves plain http).
func (u *Updater) registryBaseURL() string {
if u.registryBase != "" {
return u.registryBase
}
return "https://" + registryHost(u.cfg.Image)
}
// queryRegistry queries the Docker Registry V2 API for available tags and returns the
// highest valid semver tag. With credentials it uses BasicAuth (private catalogs,
// unchanged); with NO credentials it performs the registry's anonymous token dance
// (public packages need no configured credentials at all).
func (u *Updater) queryRegistry() (string, error) {
anonymous := u.RegistryAnonymous()
if !anonymous && (u.gitCfg.Username == "" || u.gitCfg.Token == "") {
return "", fmt.Errorf("hiányos registry hitelesítő adatok (felhasználónév és token együtt szükséges)")
}
// Registry V2: GET /v2/<owner>/<repo>/tags/list
tagsURL := fmt.Sprintf("%s/v2/%s/tags/list", u.registryBaseURL(), registryImagePath(u.cfg.Image))
client := &http.Client{Timeout: 15 * time.Second}
if anonymous {
return u.queryRegistryAnonymous(client, tagsURL)
}
u.dbg("queryRegistry: url=%s user=%s", tagsURL, u.gitCfg.Username)
req, err := http.NewRequest("GET", tagsURL, nil)
if err != nil {
return "", fmt.Errorf("creating request: %w", err)
}
req.SetBasicAuth(u.gitCfg.Username, u.gitCfg.Token)
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)
}
return u.decodeHighestTag(resp.Body)
}
// queryRegistryAnonymous is the Docker Registry v2 anonymous flow: plain GET → 401 with a
// WWW-Authenticate Bearer challenge → fetch a token from the ADVERTISED realm with no
// credentials → retry tags/list with the Bearer. The realm/service come from the header —
// never hardcoded (registry-agnostic). A registry that denies the anonymous token (a
// truly private one) surfaces errAnonymousDenied, not "credentials missing".
func (u *Updater) queryRegistryAnonymous(client *http.Client, tagsURL string) (string, error) {
u.dbg("queryRegistry: anonymous mode, url=%s", tagsURL)
resp, err := client.Get(tagsURL)
if err != nil {
return "", fmt.Errorf("HTTP request failed: %w", err)
}
defer resp.Body.Close()
u.dbg("queryRegistry: anonymous plain GET HTTP %d", resp.StatusCode)
if resp.StatusCode == 200 {
// Registry serves tags with no token at all — done.
return u.decodeHighestTag(resp.Body)
}
if resp.StatusCode != 401 {
return "", fmt.Errorf("unexpected status: %d", resp.StatusCode)
}
realm, service, err := parseWWWAuthenticate(resp.Header.Get("WWW-Authenticate"))
if err != nil {
return "", fmt.Errorf("registry 401 without a usable WWW-Authenticate challenge: %w", err)
}
token, err := u.fetchAnonymousToken(client, realm, service)
if err != nil {
return "", err
}
req, err := http.NewRequest("GET", tagsURL, nil)
if err != nil {
return "", fmt.Errorf("creating request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+token)
resp2, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("HTTP request failed: %w", err)
}
defer resp2.Body.Close()
u.dbg("queryRegistry: anonymous tags/list with Bearer HTTP %d", resp2.StatusCode)
if resp2.StatusCode == 401 || resp2.StatusCode == 403 {
return "", errAnonymousDenied
}
if resp2.StatusCode != 200 {
return "", fmt.Errorf("unexpected status: %d", resp2.StatusCode)
}
return u.decodeHighestTag(resp2.Body)
}
// fetchAnonymousToken GETs the challenge's realm with service+pull-scope and NO
// credentials, returning the Bearer token. A denial here means a private registry.
func (u *Updater) fetchAnonymousToken(client *http.Client, realm, service string) (string, error) {
q := neturl.Values{}
if service != "" {
q.Set("service", service)
}
q.Set("scope", "repository:"+registryImagePath(u.cfg.Image)+":pull")
sep := "?"
if strings.Contains(realm, "?") {
sep = "&"
}
tokenURL := realm + sep + q.Encode()
u.dbg("queryRegistry: anonymous token request: %s", tokenURL)
resp, err := client.Get(tokenURL)
if err != nil {
return "", fmt.Errorf("token request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
u.dbg("queryRegistry: anonymous token denied: HTTP %d", resp.StatusCode)
return "", errAnonymousDenied
}
var tr struct {
Token string `json:"token"`
AccessToken string `json:"access_token"`
}
if err := json.NewDecoder(resp.Body).Decode(&tr); err != nil {
return "", fmt.Errorf("decoding token response: %w", err)
}
tok := tr.Token
if tok == "" {
tok = tr.AccessToken
}
if tok == "" {
return "", errAnonymousDenied
}
return tok, nil
}
// parseWWWAuthenticate extracts realm and service from a Bearer challenge like
// `Bearer realm="https://host/v2/token",service="container_registry"`. Values may be
// quoted or bare; parameter order is not assumed; commas inside quotes are respected.
// The realm is REQUIRED (it IS the token endpoint — never hardcoded); a missing service
// is tolerated (omitted from the token query).
func parseWWWAuthenticate(h string) (realm, service string, err error) {
trimmed := strings.TrimSpace(h)
if trimmed == "" {
return "", "", fmt.Errorf("empty WWW-Authenticate header")
}
if len(trimmed) < 7 || !strings.EqualFold(trimmed[:6], "bearer") || trimmed[6] != ' ' {
return "", "", fmt.Errorf("not a Bearer challenge: %q", h)
}
for _, part := range splitAuthParams(trimmed[7:]) {
kv := strings.SplitN(part, "=", 2)
if len(kv) != 2 {
continue
}
key := strings.ToLower(strings.TrimSpace(kv[0]))
val := strings.Trim(strings.TrimSpace(kv[1]), `"`)
switch key {
case "realm":
realm = val
case "service":
service = val
}
}
if realm == "" {
return "", "", fmt.Errorf("no realm in Bearer challenge: %q", h)
}
return realm, service, nil
}
// splitAuthParams splits `k="v",k2=v2` on commas, respecting quoted sections.
func splitAuthParams(s string) []string {
var parts []string
var cur strings.Builder
inQuote := false
for _, r := range s {
switch {
case r == '"':
inQuote = !inQuote
cur.WriteRune(r)
case r == ',' && !inQuote:
parts = append(parts, cur.String())
cur.Reset()
default:
cur.WriteRune(r)
}
}
if cur.Len() > 0 {
parts = append(parts, cur.String())
}
return parts
}
// decodeHighestTag parses a tags/list response body and returns the highest semver tag.
func (u *Updater) decodeHighestTag(body io.Reader) (string, error) {
var tagsResp struct {
Name string `json:"name"`
Tags []string `json:"tags"`
}
if err := json.NewDecoder(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"` // in-guest pull path available (full creds OR anonymous; false = half-configured creds)
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.
// Pull is capable both with full credentials AND fully anonymous (public package);
// only a half-configured credential pair (misconfig) makes it incapable.
result.AgentReachable = u.agent != nil
result.PullCapable = (u.gitCfg.Username != "" && u.gitCfg.Token != "") || u.RegistryAnonymous()
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 pulls targetImage into the guest's docker storage. With credentials it
// logs in first (token via stdin, never argv; logged out afterwards). Without
// credentials it skips login entirely — docker's native anonymous flow covers public
// packages; a genuinely-denied anonymous pull surfaces docker's own error.
func (u *Updater) pullImage(targetImage string) error {
hasCreds := u.gitCfg.Username != "" && u.gitCfg.Token != ""
if !hasCreds && (u.gitCfg.Username != "" || u.gitCfg.Token != "") {
return fmt.Errorf("docker pull: hiányos registry hitelesítő adatok (felhasználónév és token együtt szükséges)")
}
host := registryHost(u.cfg.Image)
if hasCreds {
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)
}
}()
} else {
u.dbg("pullImage: no credentials configured — anonymous pull (public package)")
}
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.
// Package var so tests can fake the docker CLI (no real exec in unit tests).
var runCommand = func(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.
// Package var so tests can fake the docker CLI.
var runCommandStdin = func(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
}