95c821deb2
Add detailed [DEBUG] logging to every controller module when logging.level is set to "debug". Each module with stateful debug uses SetDebug(bool) wired from main.go. Covers stacks, backup, cloudflare, integrations, system, monitor, settings, scheduler, web handlers, storage, metrics, API, selfupdate, and assets. Also includes the app export/import (.fab bundles) feature from v0.32.0 and its debug page integration. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
517 lines
16 KiB
Go
517 lines
16 KiB
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
|
|
debug bool
|
|
|
|
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, debug bool) *Updater {
|
|
return &Updater{
|
|
cfg: cfg,
|
|
gitCfg: gitCfg,
|
|
currentVer: currentVersion,
|
|
dataDir: dataDir,
|
|
composePath: composePath,
|
|
logger: logger,
|
|
debug: debug,
|
|
}
|
|
}
|
|
|
|
func (u *Updater) dbg(format string, args ...interface{}) {
|
|
if u.debug {
|
|
u.logger.Printf("[DEBUG] [selfupdate] "+format, args...)
|
|
}
|
|
}
|
|
|
|
// SetBackupRunningCheck sets the callback to check if a backup is in progress.
|
|
func (u *Updater) SetBackupRunningCheck(fn func() bool) {
|
|
u.mu.Lock()
|
|
defer u.mu.Unlock()
|
|
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
|
|
}
|
|
|
|
cmp := latestVer.Compare(currentVer)
|
|
if cmp > 0 {
|
|
result.UpdateAvailable = true
|
|
}
|
|
|
|
u.dbg("version comparison: current=%s (%d.%d.%d), latest=%s (%d.%d.%d), cmp=%d, updateAvailable=%v",
|
|
u.currentVer, currentVer.Major, currentVer.Minor, currentVer.Patch,
|
|
latestStr, latestVer.Major, latestVer.Minor, latestVer.Patch,
|
|
cmp, result.UpdateAvailable)
|
|
|
|
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))
|
|
|
|
u.dbg("queryRegistry: url=%s user=%s", url, u.gitCfg.Username)
|
|
|
|
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 {
|
|
u.dbg("queryRegistry: HTTP request failed: %v", err)
|
|
return "", fmt.Errorf("HTTP request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
u.dbg("queryRegistry: HTTP %d", resp.StatusCode)
|
|
|
|
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)
|
|
}
|
|
|
|
u.dbg("queryRegistry: %d tags returned: %v", len(tagsResp.Tags), tagsResp.Tags)
|
|
|
|
// 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
|
|
}
|
|
|
|
// DryRunResult holds the result of a self-update dry run.
|
|
type DryRunResult struct {
|
|
CurrentVersion string `json:"current_version"`
|
|
LatestVersion string `json:"latest_version"`
|
|
UpdateAvailable bool `json:"update_available"`
|
|
ComposeWritable bool `json:"compose_writable"`
|
|
CurrentImageLine string `json:"current_image_line"`
|
|
NewImageLine string `json:"new_image_line"`
|
|
BackupRunning bool `json:"backup_running"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// DryRun checks for updates and reports what would happen without performing any changes.
|
|
func (u *Updater) DryRun() *DryRunResult {
|
|
result := &DryRunResult{
|
|
CurrentVersion: u.currentVer,
|
|
}
|
|
|
|
// Check for update
|
|
check := u.CheckForUpdate()
|
|
result.LatestVersion = check.LatestVersion
|
|
result.UpdateAvailable = check.UpdateAvailable
|
|
if check.Error != "" {
|
|
result.Error = check.Error
|
|
return result
|
|
}
|
|
|
|
// Check compose file
|
|
data, err := os.ReadFile(u.composePath)
|
|
if err != nil {
|
|
result.Error = fmt.Sprintf("Compose fájl nem olvasható: %v", err)
|
|
return result
|
|
}
|
|
|
|
// Find current image line
|
|
re := regexp.MustCompile(`(image:\s*)gitea\.dooplex\.hu/admin/felhom-controller:\S+`)
|
|
match := re.Find(data)
|
|
if match != nil {
|
|
result.CurrentImageLine = string(match)
|
|
}
|
|
|
|
// Build new image line
|
|
if check.UpdateAvailable {
|
|
result.NewImageLine = fmt.Sprintf("image: %s:%s", u.cfg.Image, check.LatestVersion)
|
|
}
|
|
|
|
// Check writability
|
|
f, err := os.OpenFile(u.composePath, os.O_WRONLY, 0)
|
|
if err == nil {
|
|
f.Close()
|
|
result.ComposeWritable = true
|
|
}
|
|
|
|
// Check backup running
|
|
if u.backupRunning != nil {
|
|
result.BackupRunning = u.backupRunning()
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// 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.dbg("TriggerUpdate: initiatedBy=%s currentVer=%s", initiatedBy, u.currentVer)
|
|
u.mu.Lock()
|
|
if u.updateRunning {
|
|
u.mu.Unlock()
|
|
u.dbg("TriggerUpdate: rejected — update already running")
|
|
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)
|
|
u.dbg("TriggerUpdate: target=%s image=%s previousImage=%s", targetVersion, targetImage, previousImage)
|
|
|
|
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()
|
|
}()
|
|
|
|
u.dbg("performUpdate: starting — target=%s image=%s", targetVersion, targetImage)
|
|
// 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.dbg("performUpdate: step 2 — docker pull %s", targetImage)
|
|
u.logger.Printf("[INFO] Pulling image: %s", targetImage)
|
|
pullStart := time.Now()
|
|
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)
|
|
u.dbg("performUpdate: docker pull completed in %s", time.Since(pullStart).Round(time.Millisecond))
|
|
|
|
// 3. Update compose file (replace image tag)
|
|
u.dbg("performUpdate: step 3 — updating compose file %s", u.composePath)
|
|
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.dbg("performUpdate: step 4 — docker compose up -d")
|
|
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 {
|
|
state.Status = "failed"
|
|
state.Error = fmt.Sprintf("docker compose up -d failed: %v — %s", upErr, upOut)
|
|
state.CompletedAt = time.Now().UTC().Format(time.RFC3339)
|
|
SaveState(u.dataDir, state)
|
|
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+`)
|
|
|
|
// Log old image line for debugging
|
|
oldMatch := re.Find(data)
|
|
if oldMatch != nil {
|
|
u.dbg("updateComposeFile: %q → %q", string(oldMatch), "image: "+newImage)
|
|
} else {
|
|
u.dbg("updateComposeFile: no matching image line found in %s", u.composePath)
|
|
}
|
|
|
|
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 {
|
|
u.dbg("VerifyStartup: checking update state in %s", u.dataDir)
|
|
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" {
|
|
u.dbg("VerifyStartup: no pending update (state=%v)", state)
|
|
return nil
|
|
}
|
|
u.dbg("VerifyStartup: pending update found — target=%s previous=%s", state.TargetVersion, state.PreviousVersion)
|
|
|
|
// 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
|
|
}
|
|
|