Files
deploy-felhom-compose/TASK.md
T

1284 lines
43 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
```go
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.
```go
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
```go
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:
```go
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:
```go
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:
```go
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:
```go
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):
```go
// 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:
```go
"gitea.dooplex.hu/admin/felhom-controller/internal/selfupdate"
```
**B) Add `updater` field to Router struct (line ~32):**
Add after the `metricsStore` field:
```go
updater *selfupdate.Updater
```
**C) Update `NewRouter` constructor (line 35):**
Change from:
```go
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:
```go
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):**
```go
// 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`):**
```go
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:**
```go
"gitea.dooplex.hu/admin/felhom-controller/internal/selfupdate"
```
**B) Add `updater` field to Server struct (after `notifier`, around line 31):**
```go
updater *selfupdate.Updater
```
**C) Update `NewServer` constructor (line 52):**
Change from:
```go
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:
```go
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:
```go
updater: updater,
```
### 2.5 `controller/internal/web/handlers.go`
**In `settingsData()` (line 897), add self-update data after `data["HubEnabled"]` (line 908):**
```go
// 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:
```go
func (am *AlertManager) Refresh(report *monitor.HealthReport, cfg *config.Config, backupMgr *backup.Manager) {
```
To:
```go
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:
```go
// 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:
```html
<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 -->`:
```html
<!-- 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:**
```go
"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):**
```go
// --- 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):**
```go
// 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):**
```go
// 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):
```go
alertMgr.Refresh(healthReport, cfg, backupMgr)
```
Change to:
```go
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)
```
2. In the initial alert refresh goroutine (around line 434):
```go
alertMgr.Refresh(report, cfg, backupMgr)
```
Change to:
```go
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:
```go
apiRouter := api.NewRouter(cfg, sett, stackMgr, syncer, cpuCollector, backupMgr, crossDriveRunner, metricsStore, logger)
```
To:
```go
apiRouter := api.NewRouter(cfg, sett, stackMgr, syncer, cpuCollector, backupMgr, crossDriveRunner, metricsStore, updater, logger)
```
**G) Update `NewServer` call (line 442):**
Change from:
```go
webServer := web.NewServer(cfg, stackMgr, cpuCollector, backupMgr, crossDriveRunner, sched, sett, alertMgr, notifier, logger, Version)
```
To:
```go
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:
```yaml
# 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:
```yaml
# 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):
```go
// 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:
```go
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):**
```go
// 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:
```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)
```bash
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:
```bash
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