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 }