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>
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user