Files
felhom.eu/hub/internal/web/version.go
T
admin 36a7d1c162 feat: add controller update trigger + version checker (v0.1.8)
Hub now tracks controller_url from reports, periodically checks the Gitea
registry for the latest controller image version, and shows a "Trigger Update"
button on the customer detail page that proxies to the controller's self-update
API endpoint using the shared API key.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 18:16:38 +01:00

147 lines
3.7 KiB
Go

package web
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"strconv"
"strings"
"sync"
"time"
)
// VersionChecker periodically queries the Gitea Docker Registry V2 API
// for the latest controller image version tag.
type VersionChecker struct {
image string // e.g., "gitea.dooplex.hu/admin/felhom-controller"
username string
token string
checkInterval time.Duration
logger *log.Logger
mu sync.RWMutex
latestVersion string
lastCheck time.Time
lastError string
}
// NewVersionChecker creates a new VersionChecker.
func NewVersionChecker(image, username, token string, checkInterval time.Duration, logger *log.Logger) *VersionChecker {
return &VersionChecker{
image: image,
username: username,
token: token,
checkInterval: checkInterval,
logger: logger,
}
}
// Run starts the periodic version check loop. Call in a goroutine.
// It checks immediately on start, then every checkInterval.
func (vc *VersionChecker) Run(ctx context.Context) {
vc.check()
ticker := time.NewTicker(vc.checkInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
vc.check()
}
}
}
func (vc *VersionChecker) check() {
latest, err := vc.queryRegistry()
vc.mu.Lock()
defer vc.mu.Unlock()
vc.lastCheck = time.Now()
if err != nil {
vc.lastError = err.Error()
vc.logger.Printf("[WARN] Registry version check failed: %v", err)
return
}
vc.lastError = ""
vc.latestVersion = latest
vc.logger.Printf("[DEBUG] Registry version check: latest = %s", latest)
}
// LatestVersion returns the cached latest version string (e.g., "0.16.1"), or "" if unknown.
func (vc *VersionChecker) LatestVersion() string {
vc.mu.RLock()
defer vc.mu.RUnlock()
return vc.latestVersion
}
// queryRegistry queries the Gitea Docker Registry V2 API for available tags
// and returns the highest valid semver tag.
func (vc *VersionChecker) queryRegistry() (string, error) {
// Extract "owner/repo" from image reference
// e.g., "gitea.dooplex.hu/admin/felhom-controller" → "admin/felhom-controller"
imagePath := vc.image
if parts := strings.SplitN(vc.image, "/", 2); len(parts) == 2 {
imagePath = parts[1]
}
url := fmt.Sprintf("https://gitea.dooplex.hu/v2/%s/tags/list", imagePath)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return "", fmt.Errorf("creating request: %w", err)
}
req.SetBasicAuth(vc.username, vc.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 {
Tags []string `json:"tags"`
}
if err := json.NewDecoder(resp.Body).Decode(&tagsResp); err != nil {
return "", fmt.Errorf("decoding response: %w", err)
}
// Find the highest valid semver tag
var bestMajor, bestMinor, bestPatch int
found := false
for _, tag := range tagsResp.Tags {
tag = strings.TrimPrefix(tag, "v")
parts := strings.SplitN(tag, ".", 3)
if len(parts) != 3 {
continue
}
major, e1 := strconv.Atoi(parts[0])
minor, e2 := strconv.Atoi(parts[1])
patch, e3 := strconv.Atoi(parts[2])
if e1 != nil || e2 != nil || e3 != nil {
continue
}
if !found || major > bestMajor ||
(major == bestMajor && minor > bestMinor) ||
(major == bestMajor && minor == bestMinor && patch > bestPatch) {
bestMajor, bestMinor, bestPatch = major, minor, patch
found = true
}
}
if !found {
return "", fmt.Errorf("no valid semver tags found")
}
return fmt.Sprintf("%d.%d.%d", bestMajor, bestMinor, bestPatch), nil
}