1284 lines
43 KiB
Markdown
1284 lines
43 KiB
Markdown
# 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
|