7c05b59708
gates / gates (push) Successful in 24s
179 Hungarian sentences were built deep inside a package with fmt.Errorf and printed by whoever caught them: too late to translate where they are shown, too early where they are made. Every one now carries its key across that gap. ZERO Hungarian error literals remain. util.MsgError does three things at once, each earned: - Error() is the Hungarian, byte for byte, so every un-converted printer is unchanged; - errors.Is answers for the kind AND for a wrapped cause (KindErrorf dropped the cause); - an error ARGUMENT renders recursively, so "formázás sikertelen: %w" translates whole. A foreign error — restic, docker, ssh, the stdlib — prints verbatim. It is not ours. 76 display sites go through errText, and TestNoErrErrorInPageOutput convicts any that do not. memoryVerdict returns an error rather than a sentence, so the deploy's 409 and the household's language come from one value; UpdateRefusal gained a Cause to carry it. Plurals, one rule, stated once: a key with .one/.other takes its COUNT first. Not a per-call-site flag — the producer somebody forgot would read "3 app is not running". The guard caught a real key collision (alert.deadapp.one) the day the rule landed. TWO DEFECTS FOUND IN MY OWN TOOLING, recorded rather than quietly fixed. The bulk converter silently dropped multi-line concatenations, damaging 7 producers — and the parity gate could not see it, because every surviving fragment WAS a real base literal while the CALL had lost text; two behaviour tests caught it. And the counting script was case-sensitive, so it said "0 left" while five remained. MinAgent: 0.131.0 (unchanged). No hub release needed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
846 lines
30 KiB
Go
846 lines
30 KiB
Go
package selfupdate
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
|
|
"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 "", util.MsgError("err.selfupdate.hianyos_registry_hitelesito_adatok_felhasznalonev_es")
|
|
}
|
|
|
|
// 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 util.MsgError("err.selfupdate.frissites_mar_folyamatban")
|
|
}
|
|
|
|
// Dev version check
|
|
if _, err := ParseVersion(u.currentVer); err != nil {
|
|
u.mu.Unlock()
|
|
return util.MsgError("err.selfupdate.dev_verzio_nem_frissitheto")
|
|
}
|
|
|
|
// Backup running check
|
|
if u.backupRunning != nil && u.backupRunning() {
|
|
u.mu.Unlock()
|
|
return util.MsgError("err.selfupdate.mentes_fut_probalja_kesobb")
|
|
}
|
|
|
|
// 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 util.MsgError("err.selfupdate.a_frissites_nem_erheto_el_nincs")
|
|
}
|
|
|
|
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 util.MsgError("err.selfupdate.nincs_elerheto_frissites")
|
|
}
|
|
|
|
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 util.MsgError("err.selfupdate.docker_pull_hianyos_registry_hitelesito_adatok")
|
|
}
|
|
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
|
|
}
|