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, } } // 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 } cmp := latestVer.Compare(currentVer) if cmp > 0 { result.UpdateAvailable = true } if u.debug { u.logger.Printf("[DEBUG] [SELFUPDATE] Version comparison: current=%s, latest=%s, cmp=%d, updateAvailable=%v", u.currentVer, latestStr, 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///tags/list url := fmt.Sprintf("https://gitea.dooplex.hu/v2/%s/tags/list", registryImagePath(u.cfg.Image)) if u.debug { u.logger.Printf("[DEBUG] [SELFUPDATE] Registry API 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 { 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) } if u.debug { u.logger.Printf("[DEBUG] [SELFUPDATE] Registry returned %d tags: %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 } // 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] } // 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.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+`) if u.debug { // Log old image line for debugging oldMatch := re.Find(data) if oldMatch != nil { u.logger.Printf("[DEBUG] [SELFUPDATE] Compose file edit: %q → %q", string(oldMatch), "image: "+newImage) } } 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 }