2487681396
gofmt -w across the controller tree (46 files) so gofmt -l is empty — disarms the
formatting landmine where a targeted edit + accidental gofmt -w swept ~46 unrelated
files. Pure formatting: whitespace + gofmt's optional-semicolon removal in reflowed
inline closures. One doc comment reworded ('' -> 'the empty string') to avoid gofmt's
Go-1.19 doc-comment typographic substitition ('' -> curly quote) muddying its meaning.
No build/vet/test behavior change.
845 lines
30 KiB
Go
845 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
|
|
}
|