36a7d1c162
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>
147 lines
3.7 KiB
Go
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
|
|
}
|