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>
293 lines
8.0 KiB
Go
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])
|
|
}
|