Files
wild-cloud/api/internal/factory/factory.go
Paul Payne 11c875a513 fix: Resolve all golangci-lint errors across API codebase
Handle unchecked errors (errcheck), fix nil-deref false positives (SA5011),
suppress deprecated-but-functional API warnings (SA1019), remove unused code,
and use fmt.Fprintf over WriteString(fmt.Sprintf(...)).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-24 21:52:59 +00:00

293 lines
8.0 KiB
Go

// Package factory provides a client for the Talos Image Factory API (factory.talos.dev).
// It handles version discovery, schematic retrieval, and schematic-version compatibility validation.
package factory
import (
"encoding/json"
"fmt"
"io"
"net/http"
"sort"
"strings"
"sync"
"time"
"gopkg.in/yaml.v3"
)
// Client wraps the Talos Image Factory API
type Client struct {
baseURL string
httpClient *http.Client
cache *versionCache
}
type versionCache struct {
mu sync.RWMutex
versions []string
fetchedAt time.Time
ttl time.Duration
}
// Extension represents a Talos system extension available for a version
type Extension struct {
Name string `json:"name"`
Ref string `json:"ref"`
Digest string `json:"digest"`
Author string `json:"author"`
Description string `json:"description"`
}
// Schematic represents a Talos Image Factory schematic
type Schematic struct {
Customization struct {
SystemExtensions struct {
OfficialExtensions []string `yaml:"officialExtensions"`
} `yaml:"systemExtensions"`
} `yaml:"customization"`
}
// ValidationResult describes whether a schematic is compatible with a Talos version
type ValidationResult struct {
Valid bool `json:"valid"`
MissingExtensions []string `json:"missingExtensions,omitempty"`
}
// NewClient creates a new Image Factory client
func NewClient() *Client {
return &Client{
baseURL: "https://factory.talos.dev",
httpClient: &http.Client{
Timeout: 10 * time.Second,
},
cache: &versionCache{
ttl: 1 * time.Hour,
},
}
}
// GetVersions returns all available Talos versions from the Image Factory.
// Results are cached for 1 hour.
func (c *Client) GetVersions() ([]string, error) {
c.cache.mu.RLock()
if c.cache.versions != nil && time.Since(c.cache.fetchedAt) < c.cache.ttl {
versions := make([]string, len(c.cache.versions))
copy(versions, c.cache.versions)
c.cache.mu.RUnlock()
return versions, nil
}
c.cache.mu.RUnlock()
resp, err := c.httpClient.Get(c.baseURL + "/versions")
if err != nil {
return nil, fmt.Errorf("fetching versions: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("fetching versions: status %d", resp.StatusCode)
}
var versions []string
if err := json.NewDecoder(resp.Body).Decode(&versions); err != nil {
return nil, fmt.Errorf("decoding versions: %w", err)
}
c.cache.mu.Lock()
c.cache.versions = versions
c.cache.fetchedAt = time.Now()
c.cache.mu.Unlock()
result := make([]string, len(versions))
copy(result, versions)
return result, nil
}
// GetStableVersions returns recent stable Talos versions, filtered and sorted.
// Excludes alpha/beta releases and keeps only the last 3 minor version lines.
func (c *Client) GetStableVersions() ([]string, error) {
all, err := c.GetVersions()
if err != nil {
return nil, err
}
return FilterStableVersions(all, 3), nil
}
// FilterStableVersions filters out pre-release versions, keeps the last N minor
// version lines, and sorts descending. Exported for testing.
func FilterStableVersions(versions []string, minorLines int) []string {
// Filter out pre-release versions
var stable []string
for _, v := range versions {
if !strings.Contains(v, "-alpha") && !strings.Contains(v, "-beta") {
stable = append(stable, v)
}
}
// Sort descending by semver
sort.Slice(stable, func(i, j int) bool {
return CompareVersions(stable[i], stable[j]) > 0
})
if minorLines <= 0 {
return stable
}
// Keep only the last N minor version lines
seen := make(map[string]bool)
var result []string
for _, v := range stable {
minor := extractMinorLine(v)
if minor == "" {
continue
}
if !seen[minor] {
if len(seen) >= minorLines {
break
}
seen[minor] = true
}
result = append(result, v)
}
return result
}
// GetExtensions returns official system extensions available for a Talos version
func (c *Client) GetExtensions(version string) ([]Extension, error) {
resp, err := c.httpClient.Get(fmt.Sprintf("%s/version/%s/extensions/official", c.baseURL, version))
if err != nil {
return nil, fmt.Errorf("fetching extensions: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("fetching extensions for %s: status %d", version, resp.StatusCode)
}
var extensions []Extension
if err := json.NewDecoder(resp.Body).Decode(&extensions); err != nil {
return nil, fmt.Errorf("decoding extensions: %w", err)
}
return extensions, nil
}
// GetSchematic retrieves a schematic by its content-addressed ID
func (c *Client) GetSchematic(id string) (*Schematic, error) {
resp, err := c.httpClient.Get(fmt.Sprintf("%s/schematics/%s", c.baseURL, id))
if err != nil {
return nil, fmt.Errorf("fetching schematic: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, fmt.Errorf("schematic %s not found", id)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("fetching schematic %s: status %d", id, resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("reading schematic response: %w", err)
}
var schematic Schematic
if err := yaml.Unmarshal(body, &schematic); err != nil {
return nil, fmt.Errorf("parsing schematic YAML: %w", err)
}
return &schematic, nil
}
// ValidateSchematic checks whether a schematic's extensions are available for a given Talos version.
// An empty schematic (no extensions) is always valid.
func (c *Client) ValidateSchematic(schematicID, version string) (*ValidationResult, error) {
schematic, err := c.GetSchematic(schematicID)
if err != nil {
return nil, fmt.Errorf("getting schematic: %w", err)
}
requiredExtensions := schematic.Customization.SystemExtensions.OfficialExtensions
if len(requiredExtensions) == 0 {
return &ValidationResult{Valid: true}, nil
}
extensions, err := c.GetExtensions(version)
if err != nil {
return nil, fmt.Errorf("getting extensions for %s: %w", version, err)
}
// Build set of available extension names
// Schematics store short names like "siderolabs/gvisor" which match the Name field,
// not the Ref field which is a full OCI reference like "ghcr.io/siderolabs/gvisor:v1.11.5"
available := make(map[string]bool)
for _, ext := range extensions {
available[ext.Name] = true
}
// Check each required extension
var missing []string
for _, name := range requiredExtensions {
if !available[name] {
missing = append(missing, name)
}
}
return &ValidationResult{
Valid: len(missing) == 0,
MissingExtensions: missing,
}, nil
}
// GetInstallerImage returns the OCI installer image reference for a schematic and version.
// Used with talosctl upgrade --image. OCI references must not include the https:// scheme.
func (c *Client) GetInstallerImage(schematicID, version string) string {
host := strings.TrimPrefix(strings.TrimPrefix(c.baseURL, "https://"), "http://")
return fmt.Sprintf("%s/installer/%s:%s", host, schematicID, version)
}
// CompareVersions compares two semver strings. Returns >0 if a > b, <0 if a < b, 0 if equal.
func CompareVersions(a, b string) int {
partsA := ParseVersion(a)
partsB := ParseVersion(b)
for i := range 3 {
if partsA[i] != partsB[i] {
return partsA[i] - partsB[i]
}
}
return 0
}
// ParseVersion extracts major, minor, patch from a version string like "v1.12.3"
func ParseVersion(v string) [3]int {
v = strings.TrimPrefix(v, "v")
// Strip any pre-release suffix
if idx := strings.IndexByte(v, '-'); idx >= 0 {
v = v[:idx]
}
var parts [3]int
_, _ = fmt.Sscanf(v, "%d.%d.%d", &parts[0], &parts[1], &parts[2])
return parts
}
// MinorVersionGap returns the absolute difference in minor versions between two version strings.
// For example, "v1.11.2" and "v1.13.0" returns 2.
func MinorVersionGap(a, b string) int {
partsA := ParseVersion(a)
partsB := ParseVersion(b)
gap := partsA[1] - partsB[1]
if gap < 0 {
gap = -gap
}
return gap
}
// extractMinorLine returns the "major.minor" portion of a version (e.g., "v1.12.3" -> "1.12")
func extractMinorLine(v string) string {
parts := ParseVersion(v)
return fmt.Sprintf("%d.%d", parts[0], parts[1])
}