diff --git a/TASK.md b/TASK.md index 281da59..6f0cd08 100644 --- a/TASK.md +++ b/TASK.md @@ -1,134 +1,1283 @@ -# TASK: v0.15.7 - Fix backup page storage display & rename system drive label +# TASK: v0.16.0 — Controller Self-Update ## Overview -Two UI issues on the "Biztonsági mentés" (backup) page: +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. -1. **Missing drives in storage overview** — The backup page only shows two drives (SSD + a single "Külső HDD"), while the dashboard and monitoring pages correctly show all registered storage paths (e.g., SSD, USB HDD 1TB, SYS Storage 350G). This is because the backup page template uses `SystemInfo.HDDConfigured`/`HDDTotalGB`/etc. (which only supports one HDD) instead of `StorageBars` (which iterates all registered storage paths). +**Flow:** Check Gitea registry for new tags → pull image → update compose file → `docker compose up -d` → process dies → new container starts. -2. **System drive label** — The root partition is labeled "SSD (/)" on all pages. It should be labeled "Rendszer (/)" (Hungarian for "System") since the root filesystem isn't always on an SSD. +The `SelfUpdateConfig` struct already exists in `config.go` but has zero implementation. This task implements everything. --- -## Changes Required +## Part 1: New Package `controller/internal/selfupdate/` -### 1. Add `StorageBars` data to the backup handler +Create directory `controller/internal/selfupdate/` with 3 files. -**File:** `controller/internal/web/handlers.go` - -In the `backupsHandler` function (around line 432), the `StorageBars` data is NOT passed to the template (unlike `dashboardHandler` and `monitoringHandler` which both call `s.buildStorageBars()`). - -**Action:** Add this line after the `SystemInfo` assignment (around line 436): +### 1.1 `version.go` — Version parsing/comparison ```go -data["StorageBars"] = s.buildStorageBars() +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) +} ``` -Place it right after `data["SystemInfo"] = system.GetInfo(...)`, same pattern as `monitoringHandler` (line 395). +### 1.2 `state.go` — Update audit state -### 2. Update backup page template to use `StorageBars` +The state file is an **audit log** persisted at `{dataDir}/update-state.json`. Used for startup verification and UI display. NOT for rollback. -**File:** `controller/internal/web/templates/backups.html` +```go +package selfupdate -The "Tárhely áttekintés" section (lines 26-70) currently has: -- A hardcoded "SSD (/)" bar from `$.SystemInfo` -- A single "Külső HDD" bar gated by `{{if .HDDConfigured}}` +import ( + "encoding/json" + "fmt" + "log" + "os" + "path/filepath" +) -**Replace** lines 29-51 (the `
` block) with a pattern matching the monitoring page — show the system drive bar + iterate `StorageBars` for all registered storage paths: +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 -
- {{with $.SystemInfo}} -
-
- Rendszer (/) - {{fmtGB .DiskUsedGB}} / {{fmtGB .DiskTotalGB}} ({{printf "%.0f" .DiskPercent}}%) -
-
-
-
-
- {{range $.StorageBars}} -
-
- {{.Label}} - {{fmtGB .UsedGB}} / {{fmtGB .TotalGB}} ({{printf "%.0f" .Percent}}%) -
-
-
-
-
- {{end}} - {{end}} +
+ Controller verzió + {{.Version}}
``` -This replaces the old block that was: +**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 -
- {{with $.SystemInfo}} -
-
- SSD (/) - ... -
- ... -
- {{if .HDDConfigured}} -
-
- Külső HDD - ...HDDUsedGB...HDDTotalGB...HDDPercent... -
- ... -
- {{end}} - {{end}} + +
+

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}} +
+
+ + ``` -### 3. Rename "SSD" → "Rendszer" on the monitoring page +### 2.8 `controller/cmd/controller/main.go` -**File:** `controller/internal/web/templates/monitoring.html` +**A) Add import:** -On line 46, change: -```html - SSD (/) -``` -to: -```html - Rendszer (/) +```go + "gitea.dooplex.hu/admin/felhom-controller/internal/selfupdate" ``` -### 4. Rename "SSD tárhely" → "Rendszer (/)" on the dashboard page +And add `"path/filepath"` to imports if not already present. -**File:** `controller/internal/web/templates/dashboard.html` +**B) Create updater instance and verify startup (add after notifier creation, around line 221, before scheduler init):** -On line 61, change: -```html - SSD tárhely +```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) + } ``` -to: -```html - Rendszer (/) + +**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 | -|------|--------| -| `controller/internal/web/handlers.go` | Add `data["StorageBars"] = s.buildStorageBars()` in `backupsHandler` | -| `controller/internal/web/templates/backups.html` | Replace storage bars section to use `StorageBars` loop + rename "SSD (/)" → "Rendszer (/)" | -| `controller/internal/web/templates/monitoring.html` | Rename "SSD (/)" → "Rendszer (/)" | -| `controller/internal/web/templates/dashboard.html` | Rename "SSD tárhely" → "Rendszer (/)" | +| # | 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 & test +--- -After implementing, build (new version is v0.15.7) and deploy following the standard workflow in CLAUDE.md. +## Build & Deploy (v0.16.0) -**Verify:** -1. Open "Biztonsági mentés" page — should show "Rendszer (/)" + all registered storage paths (USB HDD 1TB, SYS Storage 350G) -2. Open "Rendszermonitor" page — should show "Rendszer (/)" instead of "SSD (/)" -3. Open "Vezérlőpult" page — should show "Rendszer (/)" instead of "SSD tárhely" +```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