feat: add controller self-update mechanism (v0.16.0)
New selfupdate package: version parsing, audit state file, updater with
Gitea registry V2 check, docker pull + compose rewrite + compose up flow.
- API: /api/selfupdate/{status,check,update} with session+bearer auth
- UI: Settings "Verzió és frissítés" card with check/install buttons + JS polling
- Scheduler: periodic check (6h default) + optional daily auto-update
- Notifications: success/failure on post-update startup verification
- Alert: info banner when update available
- docker-compose.yml: add directory bind mount for compose file access
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user