# 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///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
Controller verzió {{.Version}}
``` **B) Add new "Verzió és frissítés" card between Section A (`` on line 64) and the "Adattárolók" section (line 66):** Insert this after the closing `` of Section A and before ``: ```html

Verzió és frissítés

Jelenlegi verzió {{.Version}}
{{if .SelfUpdateEnabled}} {{if .LatestVersion}}
Legújabb verzió {{.LatestVersion}} {{if .UpdateAvailable}} ● Frissítés elérhető {{else}} — naprakész {{end}}
{{end}} {{if .LastCheckTime}}
Utolsó ellenőrzés {{.LastCheckTime}}
{{end}} {{if .LastCheckError}}
Hiba {{.LastCheckError}}
{{end}}
Automatikus frissítés {{if .AutoUpdateEnabled}}✅ Aktív ({{.AutoUpdateTime}}){{else}}–{{end}}
{{with .LastUpdateState}}
Utolsó frissítés {{if eq .Status "success"}}✅ Sikeres ({{.PreviousVersion}} → {{.TargetVersion}}) {{else if eq .Status "failed"}}❌ Sikertelen — {{.Error}} {{else if eq .Status "pending"}}⏳ Folyamatban {{end}}
{{end}}
{{if .UpdateAvailable}} {{end}}
{{end}}
``` ### 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 ` 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 "" && 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 --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 " # 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 "` returns version info 8. Without auth: `curl -X POST https://felhom.demo-felhom.eu/api/selfupdate/check` returns 401