Files
deploy-felhom-compose/TASK.md
T

43 KiB
Raw Blame History

TASK: v0.16.0 — Controller Self-Update

Overview

Implement a Watchtower-style self-update mechanism so the controller can update itself from the Settings page (or automatically on schedule). No automatic rollback — Docker's restart: unless-stopped + healthchecks.io monitoring is the safety net.

Flow: Check Gitea registry for new tags → pull image → update compose file → docker compose up -d → process dies → new container starts.

The SelfUpdateConfig struct already exists in config.go but has zero implementation. This task implements everything.


Part 1: New Package controller/internal/selfupdate/

Create directory controller/internal/selfupdate/ with 3 files.

1.1 version.go — Version parsing/comparison

package selfupdate

import (
	"fmt"
	"strconv"
	"strings"
)

// Version represents a semantic version (Major.Minor.Patch).
type Version struct {
	Major int
	Minor int
	Patch int
	Raw   string
}

// ParseVersion parses "X.Y.Z" or "vX.Y.Z". Returns error for "dev", "latest", or invalid formats.
func ParseVersion(s string) (Version, error) {
	s = strings.TrimPrefix(s, "v")
	if s == "dev" || s == "latest" || s == "" {
		return Version{}, fmt.Errorf("invalid version: %q", s)
	}
	parts := strings.SplitN(s, ".", 3)
	if len(parts) != 3 {
		return Version{}, fmt.Errorf("invalid version format: %q (expected X.Y.Z)", s)
	}
	major, err := strconv.Atoi(parts[0])
	if err != nil {
		return Version{}, fmt.Errorf("invalid major version: %w", err)
	}
	minor, err := strconv.Atoi(parts[1])
	if err != nil {
		return Version{}, fmt.Errorf("invalid minor version: %w", err)
	}
	patch, err := strconv.Atoi(parts[2])
	if err != nil {
		return Version{}, fmt.Errorf("invalid patch version: %w", err)
	}
	return Version{Major: major, Minor: minor, Patch: patch, Raw: s}, nil
}

// Compare returns -1 if a < b, 0 if a == b, 1 if a > b.
func (a Version) Compare(b Version) int {
	if a.Major != b.Major {
		if a.Major < b.Major {
			return -1
		}
		return 1
	}
	if a.Minor != b.Minor {
		if a.Minor < b.Minor {
			return -1
		}
		return 1
	}
	if a.Patch != b.Patch {
		if a.Patch < b.Patch {
			return -1
		}
		return 1
	}
	return 0
}

// String returns the version as "X.Y.Z".
func (v Version) String() string {
	return fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch)
}

1.2 state.go — Update audit state

The state file is an audit log persisted at {dataDir}/update-state.json. Used for startup verification and UI display. NOT for rollback.

package selfupdate

import (
	"encoding/json"
	"fmt"
	"log"
	"os"
	"path/filepath"
)

const stateFileName = "update-state.json"

// UpdateState tracks the last update attempt. Persisted to disk as audit log.
type UpdateState struct {
	Status          string `json:"status"`                    // "pending", "success", "failed"
	PreviousVersion string `json:"previous_version"`
	PreviousImage   string `json:"previous_image"`
	TargetVersion   string `json:"target_version"`
	TargetImage     string `json:"target_image"`
	InitiatedAt     string `json:"initiated_at"`              // RFC3339
	InitiatedBy     string `json:"initiated_by"`              // "manual" or "auto"
	CompletedAt     string `json:"completed_at,omitempty"`
	Error           string `json:"error,omitempty"`
}

// LoadState reads the update state file. Returns nil, nil if file doesn't exist.
func LoadState(dataDir string) (*UpdateState, error) {
	path := filepath.Join(dataDir, stateFileName)
	data, err := os.ReadFile(path)
	if err != nil {
		if os.IsNotExist(err) {
			return nil, nil
		}
		return nil, fmt.Errorf("reading state file: %w", err)
	}

	var state UpdateState
	if err := json.Unmarshal(data, &state); err != nil {
		return nil, fmt.Errorf("parsing state file: %w", err)
	}
	return &state, nil
}

// SaveState writes the state file atomically (write to .tmp, then rename).
func SaveState(dataDir string, state *UpdateState) error {
	path := filepath.Join(dataDir, stateFileName)
	tmpPath := path + ".tmp"

	data, err := json.MarshalIndent(state, "", "  ")
	if err != nil {
		return fmt.Errorf("marshaling state: %w", err)
	}

	if err := os.WriteFile(tmpPath, data, 0644); err != nil {
		return fmt.Errorf("writing temp state file: %w", err)
	}

	if err := os.Rename(tmpPath, path); err != nil {
		return fmt.Errorf("renaming state file: %w", err)
	}

	return nil
}

// ClearState removes the state file. Used for cleanup.
func ClearState(dataDir string, logger *log.Logger) {
	path := filepath.Join(dataDir, stateFileName)
	if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
		logger.Printf("[WARN] Failed to clear update state file: %v", err)
	}
}

1.3 updater.go — Core logic

This is the main file. Contains:

  • Registry check (HTTP GET to Gitea registry V2 API)
  • Update trigger (pull → replace compose → docker compose up -d)
  • Startup verification (check state file after restart)
  • Status for API/UI
package selfupdate

import (
	"bytes"
	"encoding/json"
	"fmt"
	"log"
	"net/http"
	"os"
	"os/exec"
	"regexp"
	"strings"
	"sync"
	"time"

	"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"`
}

// Updater manages controller self-updates.
type Updater struct {
	cfg         *config.SelfUpdateConfig
	gitCfg      *config.GitConfig
	currentVer  string
	dataDir     string
	composePath string // e.g., "/opt/docker/felhom-controller/docker-compose.yml"
	logger      *log.Logger

	mu            sync.Mutex
	latestVersion string
	lastCheck     *CheckResult
	updateRunning bool
	backupRunning func() bool
}

// NewUpdater creates a new Updater instance.
func NewUpdater(cfg *config.SelfUpdateConfig, gitCfg *config.GitConfig, currentVersion, dataDir, composePath string, logger *log.Logger) *Updater {
	return &Updater{
		cfg:         cfg,
		gitCfg:      gitCfg,
		currentVer:  currentVersion,
		dataDir:     dataDir,
		composePath: composePath,
		logger:      logger,
	}
}

// SetBackupRunningCheck sets the callback to check if a backup is in progress.
func (u *Updater) SetBackupRunningCheck(fn func() bool) {
	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] 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.queryRegistry()
	if err != nil {
		result.Error = fmt.Sprintf("Registry lekérdezés sikertelen: %v", err)
		u.logger.Printf("[WARN] 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
	}

	if latestVer.Compare(currentVer) > 0 {
		result.UpdateAvailable = true
	}

	u.mu.Lock()
	u.latestVersion = latestStr
	u.lastCheck = &result
	u.mu.Unlock()

	return result
}

// queryRegistry queries the Gitea Docker Registry V2 API for available tags.
// Returns the highest valid semver tag found.
func (u *Updater) queryRegistry() (string, error) {
	if u.gitCfg.Username == "" || u.gitCfg.Token == "" {
		return "", fmt.Errorf("registry hitelesítő adatok hiányoznak")
	}

	// Gitea registry V2: GET /v2/<owner>/<repo>/tags/list
	registryBase := strings.TrimSuffix(u.cfg.Image, "/"+imageName(u.cfg.Image))
	url := fmt.Sprintf("https://gitea.dooplex.hu/v2/%s/tags/list", registryImagePath(u.cfg.Image))

	req, err := http.NewRequest("GET", url, nil)
	if err != nil {
		return "", fmt.Errorf("creating request: %w", err)
	}
	req.SetBasicAuth(u.gitCfg.Username, u.gitCfg.Token)

	client := &http.Client{Timeout: 15 * time.Second}
	resp, err := client.Do(req)
	if err != nil {
		return "", fmt.Errorf("HTTP request failed: %w", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode == 401 {
		return "", fmt.Errorf("authentication failed (401)")
	}
	if resp.StatusCode != 200 {
		return "", fmt.Errorf("unexpected status: %d", resp.StatusCode)
	}

	var tagsResp struct {
		Name string   `json:"name"`
		Tags []string `json:"tags"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&tagsResp); err != nil {
		return "", fmt.Errorf("decoding response: %w", err)
	}

	// 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
}

// imageName extracts the repo name from a full image reference.
// e.g., "gitea.dooplex.hu/admin/felhom-controller" → "felhom-controller"
func imageName(image string) string {
	parts := strings.Split(image, "/")
	return parts[len(parts)-1]
}

// 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.mu.Lock()
	if u.updateRunning {
		u.mu.Unlock()
		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")
	}

	// Compose file accessible check
	if _, err := os.Stat(u.composePath); err != nil {
		u.mu.Unlock()
		return fmt.Errorf("docker-compose.yml nem elérhető: %w", err)
	}

	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] Starting self-update: %s → %s (initiated by: %s)", u.currentVer, targetVersion, initiatedBy)

	go u.performUpdate(targetVersion, targetImage, previousImage, initiatedBy)

	return nil
}

// performUpdate runs the actual update steps in a goroutine.
func (u *Updater) performUpdate(targetVersion, targetImage, previousImage, initiatedBy string) {
	defer func() {
		u.mu.Lock()
		u.updateRunning = false
		u.mu.Unlock()
	}()

	// 1. Write pending state
	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] Failed to save update state: %v", err)
		return
	}

	// 2. Docker pull
	u.logger.Printf("[INFO] Pulling image: %s", targetImage)
	pullOut, pullErr := runCommand("docker", "pull", targetImage)
	if pullErr != nil {
		state.Status = "failed"
		state.Error = fmt.Sprintf("docker pull failed: %v — %s", pullErr, pullOut)
		state.CompletedAt = time.Now().UTC().Format(time.RFC3339)
		SaveState(u.dataDir, state)
		u.logger.Printf("[ERROR] Docker pull failed: %v — %s", pullErr, pullOut)
		return
	}
	u.logger.Printf("[INFO] Image pulled successfully: %s", targetImage)

	// 3. Update compose file (replace image tag)
	if err := u.updateComposeFile(targetImage); err != nil {
		state.Status = "failed"
		state.Error = fmt.Sprintf("compose update failed: %v", err)
		state.CompletedAt = time.Now().UTC().Format(time.RFC3339)
		SaveState(u.dataDir, state)
		u.logger.Printf("[ERROR] Compose file update failed: %v", err)
		return
	}
	u.logger.Printf("[INFO] Compose file updated with new image: %s", targetImage)

	// 4. Docker compose up -d (this kills the current container)
	u.logger.Printf("[INFO] Running docker compose up -d — container will restart")
	composeDir := strings.TrimSuffix(u.composePath, "/docker-compose.yml")
	upOut, upErr := runCommand("docker", "compose", "-f", u.composePath, "-p", "felhom-controller", "up", "-d")
	if upErr != nil {
		// If we get here, compose up failed but we already changed the image tag.
		// Log the error — the state file remains "pending" for manual investigation.
		u.logger.Printf("[ERROR] docker compose up -d failed: %v — %s (dir: %s)", upErr, upOut, composeDir)
		return
	}

	// If we're still alive after compose up -d, log it.
	// Normally this process should be killed when Docker replaces the container.
	u.logger.Printf("[WARN] Still running after docker compose up -d — expected to be replaced")
	time.Sleep(30 * time.Second)
	u.logger.Printf("[WARN] Still alive 30s after docker compose up -d")
}

// updateComposeFile reads the compose file, replaces the image tag, and writes it back atomically.
func (u *Updater) updateComposeFile(newImage string) error {
	data, err := os.ReadFile(u.composePath)
	if err != nil {
		return fmt.Errorf("reading compose file: %w", err)
	}

	// Replace image line: "image: gitea.dooplex.hu/admin/felhom-controller:..." → new image
	re := regexp.MustCompile(`(image:\s*)gitea\.dooplex\.hu/admin/felhom-controller:\S+`)
	newData := re.ReplaceAll(data, []byte("${1}"+newImage))

	if bytes.Equal(data, newData) {
		return fmt.Errorf("no image line found to replace in compose file")
	}

	// Atomic write: write to .tmp, then rename
	tmpPath := u.composePath + ".tmp"
	if err := os.WriteFile(tmpPath, newData, 0644); err != nil {
		return fmt.Errorf("writing temp compose file: %w", err)
	}
	if err := os.Rename(tmpPath, u.composePath); err != nil {
		return fmt.Errorf("renaming compose file: %w", err)
	}

	return nil
}

// 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 {
	state, err := LoadState(u.dataDir)
	if err != nil {
		u.logger.Printf("[WARN] Failed to load update state on startup: %v — clearing", err)
		ClearState(u.dataDir, u.logger)
		return nil
	}
	if state == nil || state.Status != "pending" {
		return nil
	}

	// 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] 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] 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] Post-update startup: version mismatch (expected %s, running %s)", state.TargetVersion, u.currentVer)
	}

	return state
}

// runCommand executes a command and returns combined stdout+stderr and error.
func runCommand(name string, args ...string) (string, error) {
	cmd := exec.Command(name, args...)
	var out bytes.Buffer
	cmd.Stdout = &out
	cmd.Stderr = &out
	err := cmd.Run()
	return out.String(), err
}

IMPORTANT: The registryBase variable on the line in queryRegistry() is unused — remove it. The URL construction should just use the hardcoded gitea.dooplex.hu host and registryImagePath(). Here's the corrected queryRegistry URL line:

url := fmt.Sprintf("https://gitea.dooplex.hu/v2/%s/tags/list", registryImagePath(u.cfg.Image))

(Remove the registryBase line entirely.)


Part 2: Changes to Existing Files

2.1 controller/internal/config/config.go

A) Add AutoUpdateTime field to SelfUpdateConfig (line ~121):

Change:

type SelfUpdateConfig struct {
	Enabled              bool   `yaml:"enabled"`
	CheckInterval        string `yaml:"check_interval"`
	Image                string `yaml:"image"`
	AutoUpdate           bool   `yaml:"auto_update"`
	HealthTimeoutSeconds int    `yaml:"health_timeout_seconds"`
}

To:

type SelfUpdateConfig struct {
	Enabled              bool   `yaml:"enabled"`
	CheckInterval        string `yaml:"check_interval"`
	Image                string `yaml:"image"`
	AutoUpdate           bool   `yaml:"auto_update"`
	AutoUpdateTime       string `yaml:"auto_update_time"`
	HealthTimeoutSeconds int    `yaml:"health_timeout_seconds"`
}

B) Add defaults in applyDefaults() (after line 208, near SelfUpdate.CheckInterval):

Add these two lines:

	d(&cfg.SelfUpdate.Image, "gitea.dooplex.hu/admin/felhom-controller")
	d(&cfg.SelfUpdate.AutoUpdateTime, "04:30")

2.2 controller/internal/notify/notifier.go

Add two convenience methods after the existing NotifyIntegrityFailed (around line 243):

// NotifyUpdateSuccess sends a notification about a successful controller update.
func (n *Notifier) NotifyUpdateSuccess(fromVer, toVer string) {
	n.Notify("update_success", "info",
		fmt.Sprintf("Controller frissítve: %s → %s", fromVer, toVer), "")
}

// NotifyUpdateFailed sends a notification about a failed controller update.
func (n *Notifier) NotifyUpdateFailed(targetVer, errMsg string) {
	n.Notify("update_failed", "warning",
		fmt.Sprintf("Controller frissítés sikertelen: %s — %s", targetVer, errMsg), "")
}

2.3 controller/internal/api/router.go

A) Add import for selfupdate package:

Add to imports:

	"gitea.dooplex.hu/admin/felhom-controller/internal/selfupdate"

B) Add updater field to Router struct (line ~32):

Add after the metricsStore field:

	updater          *selfupdate.Updater

C) Update NewRouter constructor (line 35):

Change from:

func NewRouter(cfg *config.Config, sett *settings.Settings, stackMgr *stacks.Manager, syncer *catalogsync.Syncer, cpuCollector *system.CPUCollector, backupMgr *backup.Manager, crossDrive *backup.CrossDriveRunner, metricsStore *metrics.MetricsStore, logger *log.Logger) *Router {
	return &Router{cfg: cfg, sett: sett, stackMgr: stackMgr, syncer: syncer, cpuCollector: cpuCollector, backupMgr: backupMgr, crossDriveRunner: crossDrive, metricsStore: metricsStore, logger: logger}
}

To:

func NewRouter(cfg *config.Config, sett *settings.Settings, stackMgr *stacks.Manager, syncer *catalogsync.Syncer, cpuCollector *system.CPUCollector, backupMgr *backup.Manager, crossDrive *backup.CrossDriveRunner, metricsStore *metrics.MetricsStore, updater *selfupdate.Updater, logger *log.Logger) *Router {
	return &Router{cfg: cfg, sett: sett, stackMgr: stackMgr, syncer: syncer, cpuCollector: cpuCollector, backupMgr: backupMgr, crossDriveRunner: crossDrive, metricsStore: metricsStore, updater: updater, logger: logger}
}

D) Add 3 route cases in ServeHTTP switch (before the default: case, around line 155):

	// GET /api/selfupdate/status
	case path == "/selfupdate/status" && req.Method == http.MethodGet:
		r.selfupdateStatus(w, req)

	// POST /api/selfupdate/check
	case path == "/selfupdate/check" && req.Method == http.MethodPost:
		r.selfupdateCheck(w, req)

	// POST /api/selfupdate/update
	case path == "/selfupdate/update" && req.Method == http.MethodPost:
		r.selfupdateTrigger(w, req)

E) Add 3 handler methods (at the end of the file, before writeJSON):

func (r *Router) selfupdateStatus(w http.ResponseWriter, _ *http.Request) {
	if r.updater == nil {
		writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]interface{}{"enabled": false}})
		return
	}
	writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: r.updater.GetStatus()})
}

func (r *Router) selfupdateCheck(w http.ResponseWriter, _ *http.Request) {
	if r.updater == nil {
		writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "Self-update not configured"})
		return
	}
	result := r.updater.CheckForUpdate()
	writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: result})
}

func (r *Router) selfupdateTrigger(w http.ResponseWriter, _ *http.Request) {
	if r.updater == nil {
		writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "Self-update not configured"})
		return
	}
	if err := r.updater.TriggerUpdate("manual"); err != nil {
		writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: err.Error()})
		return
	}
	r.logger.Println("[API] Manual self-update triggered")
	writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Frissítés elindítva"})
}

2.4 controller/internal/web/server.go

A) Add import for selfupdate:

	"gitea.dooplex.hu/admin/felhom-controller/internal/selfupdate"

B) Add updater field to Server struct (after notifier, around line 31):

	updater          *selfupdate.Updater

C) Update NewServer constructor (line 52):

Change from:

func NewServer(cfg *config.Config, stackMgr *stacks.Manager, cpuCollector *system.CPUCollector, backupMgr *backup.Manager, crossDrive *backup.CrossDriveRunner, sched *scheduler.Scheduler, sett *settings.Settings, alertMgr *AlertManager, notif *notify.Notifier, logger *log.Logger, version string) *Server {

To:

func NewServer(cfg *config.Config, stackMgr *stacks.Manager, cpuCollector *system.CPUCollector, backupMgr *backup.Manager, crossDrive *backup.CrossDriveRunner, sched *scheduler.Scheduler, sett *settings.Settings, alertMgr *AlertManager, notif *notify.Notifier, updater *selfupdate.Updater, logger *log.Logger, version string) *Server {

And add in the struct initialization:

		updater:          updater,

2.5 controller/internal/web/handlers.go

In settingsData() (line 897), add self-update data after data["HubEnabled"] (line 908):

	// Self-update status
	data["SelfUpdateEnabled"] = s.cfg.SelfUpdate.Enabled
	if s.updater != nil {
		status := s.updater.GetStatus()
		data["UpdateRunning"] = status.Running
		if status.LastCheck != nil {
			data["UpdateAvailable"] = status.LastCheck.UpdateAvailable
			data["LatestVersion"] = status.LastCheck.LatestVersion
			data["LastCheckTime"] = status.LastCheck.CheckedAt
			data["LastCheckError"] = status.LastCheck.Error
		}
		if status.LastState != nil {
			data["LastUpdateState"] = status.LastState
		}
		data["AutoUpdateEnabled"] = s.cfg.SelfUpdate.AutoUpdate
		data["AutoUpdateTime"] = s.cfg.SelfUpdate.AutoUpdateTime
	}

2.6 controller/internal/web/alerts.go

A) Change Refresh() signature (line 43):

Change from:

func (am *AlertManager) Refresh(report *monitor.HealthReport, cfg *config.Config, backupMgr *backup.Manager) {

To:

func (am *AlertManager) Refresh(report *monitor.HealthReport, cfg *config.Config, backupMgr *backup.Manager, updateAvailable bool, latestVersion string) {

B) Add update-available alert at the end of the function, before sortAlerts (around line 98):

After the "Backup disabled" alert block, add:

	// Update available
	if updateAvailable && latestVersion != "" {
		alerts = append(alerts, Alert{
			ID:       "update-available",
			Level:    "info",
			Message:  fmt.Sprintf("Új controller verzió elérhető: %s", latestVersion),
			Link:     "/settings",
			LinkText: "Frissítés",
		})
	}

2.7 controller/internal/web/templates/settings.html

A) Remove the version row from Section A (lines 59-62):

Delete these lines:

        <div class="settings-row">
            <span class="settings-label">Controller verzió</span>
            <span class="settings-value mono">{{.Version}}</span>
        </div>

B) Add new "Verzió és frissítés" card between Section A (</div> on line 64) and the "Adattárolók" section (line 66):

Insert this after the closing </div> of Section A and before <!-- Section: Storage Paths -->:

<!-- Section: Version & Update -->
<div class="settings-card">
    <h3>Verzió és frissítés</h3>
    <div class="settings-grid">
        <div class="settings-row">
            <span class="settings-label">Jelenlegi verzió</span>
            <span class="settings-value mono">{{.Version}}</span>
        </div>
        {{if .SelfUpdateEnabled}}
        {{if .LatestVersion}}
        <div class="settings-row">
            <span class="settings-label">Legújabb verzió</span>
            <span class="settings-value mono">
                {{.LatestVersion}}
                {{if .UpdateAvailable}}
                <span class="state-text-green" style="margin-left:0.5em;">● Frissítés elérhető</span>
                {{else}}
                <span style="margin-left:0.5em; color:#888;">— naprakész</span>
                {{end}}
            </span>
        </div>
        {{end}}
        {{if .LastCheckTime}}
        <div class="settings-row">
            <span class="settings-label">Utolsó ellenőrzés</span>
            <span class="settings-value mono">{{.LastCheckTime}}</span>
        </div>
        {{end}}
        {{if .LastCheckError}}
        <div class="settings-row">
            <span class="settings-label">Hiba</span>
            <span class="settings-value state-text-red">{{.LastCheckError}}</span>
        </div>
        {{end}}
        <div class="settings-row">
            <span class="settings-label">Automatikus frissítés</span>
            <span class="settings-value">
                {{if .AutoUpdateEnabled}}<span class="state-text-green">✅ Aktív</span> <span class="mono">({{.AutoUpdateTime}})</span>{{else}}{{end}}
            </span>
        </div>
        {{with .LastUpdateState}}
        <div class="settings-row">
            <span class="settings-label">Utolsó frissítés</span>
            <span class="settings-value">
                {{if eq .Status "success"}}<span class="state-text-green">✅ Sikeres</span> ({{.PreviousVersion}} → {{.TargetVersion}})
                {{else if eq .Status "failed"}}<span class="state-text-red">❌ Sikertelen</span> — {{.Error}}
                {{else if eq .Status "pending"}}<span class="state-text-yellow">⏳ Folyamatban</span>
                {{end}}
            </span>
        </div>
        {{end}}
        <div class="settings-row" style="padding-top: 0.5em;">
            <span class="settings-label"></span>
            <span class="settings-value">
                <button class="btn btn-secondary btn-sm" id="btn-check-update" onclick="checkUpdate()">Frissítés keresése</button>
                {{if .UpdateAvailable}}
                <button class="btn btn-primary btn-sm" id="btn-trigger-update" onclick="triggerUpdate()" style="margin-left:0.5em;">Frissítés telepítése</button>
                {{end}}
                <span id="update-status-msg" style="margin-left:0.5em; display:none;"></span>
            </span>
        </div>
        {{end}}
    </div>
</div>

<script>
function checkUpdate() {
    var btn = document.getElementById('btn-check-update');
    var msg = document.getElementById('update-status-msg');
    btn.disabled = true;
    btn.textContent = 'Ellenőrzés...';
    msg.style.display = 'none';
    fetch('/api/selfupdate/check', {method:'POST'})
        .then(function(r) { return r.json(); })
        .then(function(data) {
            if (data.ok) {
                location.reload();
            } else {
                msg.textContent = data.error || 'Hiba történt';
                msg.style.display = 'inline';
                btn.disabled = false;
                btn.textContent = 'Frissítés keresése';
            }
        })
        .catch(function() {
            msg.textContent = 'Kapcsolódási hiba';
            msg.style.display = 'inline';
            btn.disabled = false;
            btn.textContent = 'Frissítés keresése';
        });
}

function triggerUpdate() {
    if (!confirm('Biztosan frissíti a controllert?\n\nA folyamat alatt a vezérlőpult rövid időre elérhetetlenné válik.')) return;
    var btn = document.getElementById('btn-trigger-update');
    var checkBtn = document.getElementById('btn-check-update');
    var msg = document.getElementById('update-status-msg');
    btn.disabled = true;
    btn.textContent = 'Frissítés...';
    if (checkBtn) checkBtn.disabled = true;
    msg.textContent = 'Frissítés folyamatban...';
    msg.style.display = 'inline';
    fetch('/api/selfupdate/update', {method:'POST'})
        .then(function(r) { return r.json(); })
        .then(function(data) {
            if (data.ok) {
                msg.textContent = 'Újraindulás...';
                pollUntilBack();
            } else {
                msg.textContent = data.error || 'Hiba történt';
                btn.disabled = false;
                btn.textContent = 'Frissítés telepítése';
                if (checkBtn) checkBtn.disabled = false;
            }
        })
        .catch(function() {
            msg.textContent = 'Kapcsolódási hiba';
            pollUntilBack();
        });
}

function pollUntilBack() {
    var iv = setInterval(function() {
        fetch('/api/health')
            .then(function(r) {
                if (r.ok) {
                    clearInterval(iv);
                    location.reload();
                }
            })
            .catch(function() {});
    }, 3000);
}
</script>

2.8 controller/cmd/controller/main.go

A) Add import:

	"gitea.dooplex.hu/admin/felhom-controller/internal/selfupdate"

And add "path/filepath" to imports if not already present.

B) Create updater instance and verify startup (add after notifier creation, around line 221, before scheduler init):

	// --- Initialize self-updater ---
	var updater *selfupdate.Updater
	if cfg.SelfUpdate.Enabled {
		composePath := filepath.Join(filepath.Dir(cfg.Paths.DataDir), "docker-compose.yml")
		updater = selfupdate.NewUpdater(&cfg.SelfUpdate, &cfg.Git, Version, cfg.Paths.DataDir, composePath, logger)
		updater.SetBackupRunningCheck(func() bool {
			return backupMgr != nil && backupMgr.IsRunning()
		})
		// Check for post-update state (did a previous update succeed or fail?)
		if state := updater.VerifyStartup(); state != nil {
			if state.Status == "success" {
				notifier.NotifyUpdateSuccess(state.PreviousVersion, state.TargetVersion)
			} else if state.Status == "failed" {
				notifier.NotifyUpdateFailed(state.TargetVersion, state.Error)
			}
		}
		logger.Printf("[INFO] Self-update enabled (check every %s, auto-update: %v, auto-update time: %s)",
			cfg.SelfUpdate.CheckInterval, cfg.SelfUpdate.AutoUpdate, cfg.SelfUpdate.AutoUpdateTime)
	}

C) Register scheduler jobs (add after the existing monitoring/backup scheduler jobs, before the hub pusher section):

	// Self-update scheduler jobs
	if cfg.SelfUpdate.Enabled && updater != nil {
		// Periodic version check (populates UI, never triggers update)
		checkInterval, ciErr := time.ParseDuration(cfg.SelfUpdate.CheckInterval)
		if ciErr != nil {
			checkInterval = 6 * time.Hour
		}
		sched.Every("selfupdate-check", checkInterval, func(ctx context.Context) error {
			result := updater.CheckForUpdate()
			if result.UpdateAvailable {
				logger.Printf("[INFO] Update available: %s -> %s", result.CurrentVersion, result.LatestVersion)
			}
			return nil
		})

		// Auto-update (daily, fires after typical backup completion)
		if cfg.SelfUpdate.AutoUpdate {
			sched.Daily("selfupdate-auto", cfg.SelfUpdate.AutoUpdateTime, func(ctx context.Context) error {
				result := updater.CheckForUpdate()
				if !result.UpdateAvailable {
					return nil
				}
				if err := updater.TriggerUpdate("auto"); err != nil {
					logger.Printf("[WARN] Auto-update skipped: %v", err)
				}
				return nil
			})
		}
	}

D) Add initial version check in the startup goroutine (add after the hub report section, around line 408, inside the go func() block):

		// Initial self-update check (so settings page shows version info quickly)
		if updater != nil {
			time.Sleep(25 * time.Second) // Additional delay after hub report
			result := updater.CheckForUpdate()
			if result.UpdateAvailable {
				logger.Printf("[INFO] Startup: update available %s -> %s", result.CurrentVersion, result.LatestVersion)
			} else if result.Error != "" {
				logger.Printf("[DEBUG] Startup version check: %s", result.Error)
			}
		}

E) Update the alertMgr.Refresh() calls to include update params.

There are 2 calls to alertMgr.Refresh() in main.go:

  1. In the system-health scheduler job (around line 255):
		alertMgr.Refresh(healthReport, cfg, backupMgr)

Change to:

		updateAvailable := false
		latestVersion := ""
		if updater != nil {
			status := updater.GetStatus()
			if status.LastCheck != nil {
				updateAvailable = status.LastCheck.UpdateAvailable
				latestVersion = status.LastCheck.LatestVersion
			}
		}
		alertMgr.Refresh(healthReport, cfg, backupMgr, updateAvailable, latestVersion)
  1. In the initial alert refresh goroutine (around line 434):
		alertMgr.Refresh(report, cfg, backupMgr)

Change to:

		alertMgr.Refresh(report, cfg, backupMgr, false, "")

(On initial startup, we haven't checked for updates yet, so pass false/"")

F) Update NewRouter call (line 439):

Change from:

	apiRouter := api.NewRouter(cfg, sett, stackMgr, syncer, cpuCollector, backupMgr, crossDriveRunner, metricsStore, logger)

To:

	apiRouter := api.NewRouter(cfg, sett, stackMgr, syncer, cpuCollector, backupMgr, crossDriveRunner, metricsStore, updater, logger)

G) Update NewServer call (line 442):

Change from:

	webServer := web.NewServer(cfg, stackMgr, cpuCollector, backupMgr, crossDriveRunner, sched, sett, alertMgr, notifier, logger, Version)

To:

	webServer := web.NewServer(cfg, stackMgr, cpuCollector, backupMgr, crossDriveRunner, sched, sett, alertMgr, notifier, updater, logger, Version)

2.9 controller/docker-compose.yml

Replace the config + data volume mounts (lines 17-20):

Change from:

      # Controller config
      - /opt/docker/felhom-controller/controller.yaml:/opt/docker/felhom-controller/controller.yaml:ro
      # Controller persistent data (sessions, restic cache, restic password)
      - controller-data:/opt/docker/felhom-controller/data

To:

      # Controller directory (compose file access for self-update)
      - /opt/docker/felhom-controller:/opt/docker/felhom-controller
      # Controller config (read-only override on top of directory mount)
      - /opt/docker/felhom-controller/controller.yaml:/opt/docker/felhom-controller/controller.yaml:ro
      # Controller persistent data (named volume override on top of directory mount)
      - controller-data:/opt/docker/felhom-controller/data

Mount order matters: directory mount first, then named volume overrides data/, then read-only file overrides controller.yaml.

2.10 API Key Auth for Self-Update Endpoints

Currently all /api/ routes (except /api/health) are behind session auth. For external triggering (build workflow, hub), the selfupdate endpoints need to also accept the hub API key as a bearer token.

A) In controller/cmd/controller/main.go, register /api/selfupdate/ separately in the mux (around line 454):

Add this BEFORE the generic /api/ route (mux uses longest prefix match):

	// Self-update API — accepts session auth OR hub API key (for external triggering)
	mux.Handle("/api/selfupdate/", selfUpdateAuthMiddleware(cfg, webServer, http.HandlerFunc(apiRouter.ServeHTTP)))

So the mux block becomes:

	mux.HandleFunc("/api/health", apiRouter.HealthHandler)
	mux.Handle("/api/storage/", webServer.RequireAuth(http.HandlerFunc(webServer.ServeStorageAPI)))
	mux.Handle("/api/selfupdate/", selfUpdateAuthMiddleware(cfg, webServer, http.HandlerFunc(apiRouter.ServeHTTP)))
	mux.Handle("/api/", webServer.RequireAuth(http.HandlerFunc(apiRouter.ServeHTTP)))

B) Add the middleware function in main.go (at the bottom of the file, before or after the existing helper functions):

// selfUpdateAuthMiddleware allows access via session auth (normal UI) OR hub API key bearer token (external).
func selfUpdateAuthMiddleware(cfg *config.Config, webServer *web.Server, next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// Check bearer token first (for external API calls: hub, build scripts)
		if auth := r.Header.Get("Authorization"); strings.HasPrefix(auth, "Bearer ") {
			token := strings.TrimPrefix(auth, "Bearer ")
			if token != "" && cfg.Hub.APIKey != "" && token == cfg.Hub.APIKey {
				next.ServeHTTP(w, r)
				return
			}
		}
		// Fall back to session auth
		webServer.RequireAuth(next).ServeHTTP(w, r)
	})
}

This means:

  • UI buttons work via session cookie (unchanged)
  • External callers (build script, hub) can use Authorization: Bearer <hub_api_key> header
  • If neither is valid, returns 401

Part 3: Compose Path Calculation

The compose file path is computed from the data dir in main.go:

composePath := filepath.Join(filepath.Dir(cfg.Paths.DataDir), "docker-compose.yml")

Since cfg.Paths.DataDir defaults to /opt/docker/felhom-controller/data, filepath.Dir() gives /opt/docker/felhom-controller, and the compose file is at /opt/docker/felhom-controller/docker-compose.yml.


Summary of all file changes

# File Change
NEW controller/internal/selfupdate/version.go Version parsing/comparison (ParseVersion, Compare)
NEW controller/internal/selfupdate/state.go Update state file I/O (LoadState, SaveState, ClearState)
NEW controller/internal/selfupdate/updater.go Core logic: registry check, update trigger, startup verify
1 controller/internal/config/config.go Add AutoUpdateTime field + defaults for Image and AutoUpdateTime
2 controller/internal/notify/notifier.go Add NotifyUpdateSuccess() and NotifyUpdateFailed()
3 controller/internal/api/router.go Add updater field + 3 API endpoints
4 controller/internal/web/server.go Add updater field to struct + constructor
5 controller/internal/web/handlers.go Add self-update data to settingsData()
6 controller/internal/web/alerts.go Add updateAvailable params to Refresh() + info alert
7 controller/internal/web/templates/settings.html Move version to new card + buttons + JS
8 controller/cmd/controller/main.go Wire updater, scheduler jobs, startup verify, update callers, API key auth middleware, selfupdate mux route
9 controller/docker-compose.yml Add directory mount for self-update

Build & Deploy (v0.16.0)

SSH=/c/Windows/System32/OpenSSH/ssh.exe

# 1. Commit and push
cd /e/git/deploy-felhom-compose
git add -A && git commit -m "feat: add controller self-update (v0.16.0)" && git push

# 2. Build
$SSH kisfenyo@192.168.0.180 "cd ~/build/felhom-controller && git -C ~/git/deploy-felhom-compose pull && ./build.sh 0.16.0 --push"

# 3. Deploy — ONE-TIME: manually add directory mount to remote compose before first deploy
# SSH into demo node and edit docker-compose.yml to add the directory mount:
#   - /opt/docker/felhom-controller:/opt/docker/felhom-controller
# (See Part 2.9 for exact format)
# Then deploy:
$SSH kisfenyo@192.168.0.162 "cd /opt/docker/felhom-controller && sudo docker pull gitea.dooplex.hu/admin/felhom-controller:0.16.0 && sudo sed -i 's|image: gitea.dooplex.hu/admin/felhom-controller:.*|image: gitea.dooplex.hu/admin/felhom-controller:0.16.0|' docker-compose.yml && sudo docker compose up -d"

# 4. Verify
$SSH kisfenyo@192.168.0.162 "docker ps --filter name=felhom-controller --format '{{.Image}} {{.Status}}'"
$SSH kisfenyo@192.168.0.162 "docker logs felhom-controller --tail 30"

After v0.16.0 is deployed with the directory mount, future updates can be triggered from the Settings page or via API.

Post-v0.16.0 Build & Deploy Workflow

After v0.16.0, steps 3-4 change. The build server push is the same, but deploy uses the self-update API instead of manual SSH docker commands:

SSH=/c/Windows/System32/OpenSSH/ssh.exe

# 1. Commit and push (unchanged)
cd /e/git/deploy-felhom-compose
git add -A && git commit -m "<message>" && git push

# 2. Build + push image (unchanged)
$SSH kisfenyo@192.168.0.180 "cd ~/build/felhom-controller && git -C ~/git/deploy-felhom-compose pull && ./build.sh <NEW_VERSION> --push"

# 3. Trigger self-update via API (replaces manual docker pull + sed + compose up)
curl -s -X POST https://felhom.demo-felhom.eu/api/selfupdate/update \
  -H "Authorization: Bearer <HUB_API_KEY>"

# 4. Wait ~10s for container restart, then verify
sleep 10
curl -s https://felhom.demo-felhom.eu/api/health
$SSH kisfenyo@192.168.0.162 "docker ps --filter name=felhom-controller --format '{{.Image}} {{.Status}}'"

IMPORTANT: CLAUDE.md needs to be updated with this new workflow after v0.16.0 is deployed. The old SSH-based docker pull + sed + docker compose up -d deploy step should be replaced with the curl API call shown above.


Verification

  1. Open Settings page → "Verzió és frissítés" card shows current version
  2. Click "Frissítés keresése" → queries registry, shows latest version (or error)
  3. If update available, "Frissítés telepítése" button appears
  4. Startup logs show [INFO] Self-update enabled (check every 6h, auto-update: false, auto-update time: 04:30)
  5. If a previous update was pending, startup logs show success/failure
  6. Alert banner shows "Új controller verzió elérhető" with link to /settings when update is available
  7. API key auth works: curl -X POST https://felhom.demo-felhom.eu/api/selfupdate/check -H "Authorization: Bearer <HUB_API_KEY>" returns version info
  8. Without auth: curl -X POST https://felhom.demo-felhom.eu/api/selfupdate/check returns 401