mirror of
https://github.com/siderolabs/image-factory.git
synced 2025-10-03 19:51:14 +02:00
352 lines
8.5 KiB
Go
352 lines
8.5 KiB
Go
// This Source Code Form is subject to the terms of the Mozilla Public
|
|
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
|
|
package artifacts
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/blang/semver/v4"
|
|
"github.com/google/go-containerregistry/pkg/name"
|
|
v1 "github.com/google/go-containerregistry/pkg/v1"
|
|
"github.com/google/go-containerregistry/pkg/v1/remote"
|
|
"github.com/siderolabs/gen/xerrors"
|
|
"go.uber.org/zap"
|
|
"golang.org/x/sync/singleflight"
|
|
)
|
|
|
|
// Manager supports loading, caching and serving Talos release artifacts.
|
|
type Manager struct { //nolint:govet
|
|
options Options
|
|
storagePath string
|
|
schematicsPath string
|
|
logger *zap.Logger
|
|
imageRegistry name.Registry
|
|
pullers map[Arch]*remote.Puller
|
|
|
|
sf singleflight.Group
|
|
|
|
officialExtensionsMu sync.Mutex
|
|
officialExtensions map[string][]ExtensionRef
|
|
|
|
officialOverlaysMu sync.Mutex
|
|
officialOverlays map[string][]OverlayRef
|
|
|
|
talosVersionsMu sync.Mutex
|
|
talosVersions []semver.Version
|
|
talosVersionsTimestamp time.Time
|
|
}
|
|
|
|
// NewManager creates a new artifacts manager.
|
|
func NewManager(logger *zap.Logger, options Options) (*Manager, error) {
|
|
tmpDir, err := os.MkdirTemp("", "image-factory")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create temporary directory: %w", err)
|
|
}
|
|
|
|
schematicsPath := filepath.Join(tmpDir, "schematics")
|
|
|
|
if err = os.Mkdir(schematicsPath, 0o700); err != nil {
|
|
return nil, fmt.Errorf("failed to create schematics directory: %w", err)
|
|
}
|
|
|
|
opts := []name.Option{}
|
|
if options.InsecureImageRegistry {
|
|
opts = append(opts, name.Insecure)
|
|
}
|
|
|
|
imageRegistry, err := name.NewRegistry(options.ImageRegistry, opts...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse image registry: %w", err)
|
|
}
|
|
|
|
pullers := make(map[Arch]*remote.Puller, 2)
|
|
|
|
for _, arch := range []Arch{ArchAmd64, ArchArm64} {
|
|
pullers[arch], err = remote.NewPuller(
|
|
append(
|
|
[]remote.Option{
|
|
remote.WithPlatform(v1.Platform{
|
|
Architecture: string(arch),
|
|
OS: "linux",
|
|
}),
|
|
},
|
|
options.RemoteOptions...,
|
|
)...,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create puller: %w", err)
|
|
}
|
|
}
|
|
|
|
return &Manager{
|
|
options: options,
|
|
storagePath: tmpDir,
|
|
schematicsPath: schematicsPath,
|
|
logger: logger,
|
|
imageRegistry: imageRegistry,
|
|
pullers: pullers,
|
|
}, nil
|
|
}
|
|
|
|
// Close the manager.
|
|
func (m *Manager) Close() error {
|
|
return os.RemoveAll(m.storagePath)
|
|
}
|
|
|
|
func (m *Manager) validateTalosVersion(ctx context.Context, version semver.Version) error {
|
|
availableVersion, err := m.GetTalosVersions(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get available Talos versions: %w", err)
|
|
}
|
|
|
|
if !slices.ContainsFunc(availableVersion, version.Equals) {
|
|
return xerrors.NewTaggedf[ErrNotFoundTag]("version %s is not available", version)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Get returns the artifact path for the given version, arch and kind.
|
|
func (m *Manager) Get(ctx context.Context, versionString string, arch Arch, kind Kind) (string, error) {
|
|
version, err := semver.Parse(versionString)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to parse version: %w", err)
|
|
}
|
|
|
|
if err = m.validateTalosVersion(ctx, version); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
tag := "v" + version.String()
|
|
|
|
// check if already extracted
|
|
if _, err = os.Stat(filepath.Join(m.storagePath, tag)); err != nil {
|
|
resultCh := m.sf.DoChan(tag, func() (any, error) {
|
|
return nil, m.fetchImager(tag)
|
|
})
|
|
|
|
// wait for the fetch to finish
|
|
select {
|
|
case result := <-resultCh:
|
|
if result.Err != nil {
|
|
return "", err
|
|
}
|
|
case <-ctx.Done():
|
|
return "", ctx.Err()
|
|
}
|
|
}
|
|
|
|
// build the path
|
|
path := filepath.Join(m.storagePath, tag, string(arch), string(kind))
|
|
|
|
_, err = os.Stat(path)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to find artifact: %w", err)
|
|
}
|
|
|
|
return path, nil
|
|
}
|
|
|
|
// GetTalosVersions returns a list of Talos versions available.
|
|
func (m *Manager) GetTalosVersions(ctx context.Context) ([]semver.Version, error) {
|
|
m.talosVersionsMu.Lock()
|
|
versions, timestamp := m.talosVersions, m.talosVersionsTimestamp
|
|
m.talosVersionsMu.Unlock()
|
|
|
|
if time.Since(timestamp) < m.options.TalosVersionRecheckInterval {
|
|
return versions, nil
|
|
}
|
|
|
|
resultCh := m.sf.DoChan("talos-versions", m.fetchTalosVersions)
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
case result := <-resultCh:
|
|
if result.Err != nil {
|
|
return nil, result.Err
|
|
}
|
|
}
|
|
|
|
m.talosVersionsMu.Lock()
|
|
versions = m.talosVersions
|
|
m.talosVersionsMu.Unlock()
|
|
|
|
return versions, nil
|
|
}
|
|
|
|
// GetOfficialExtensions returns a list of Talos extensions per Talos version available.
|
|
//
|
|
//nolint:dupl
|
|
func (m *Manager) GetOfficialExtensions(ctx context.Context, versionString string) ([]ExtensionRef, error) {
|
|
tag, err := m.parseTag(ctx, versionString)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
m.officialExtensionsMu.Lock()
|
|
extensions, ok := m.officialExtensions[tag]
|
|
m.officialExtensionsMu.Unlock()
|
|
|
|
if ok {
|
|
return extensions, nil
|
|
}
|
|
|
|
resultCh := m.sf.DoChan("extensions-"+tag, func() (any, error) {
|
|
return nil, m.fetchOfficialExtensions(tag)
|
|
})
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
case result := <-resultCh:
|
|
if result.Err != nil {
|
|
return nil, result.Err
|
|
}
|
|
}
|
|
|
|
m.officialExtensionsMu.Lock()
|
|
extensions = m.officialExtensions[tag]
|
|
m.officialExtensionsMu.Unlock()
|
|
|
|
return extensions, nil
|
|
}
|
|
|
|
// GetOfficialOverlays returns a list of overlays per Talos version available.
|
|
//
|
|
//nolint:dupl
|
|
func (m *Manager) GetOfficialOverlays(ctx context.Context, versionString string) ([]OverlayRef, error) {
|
|
tag, err := m.parseTag(ctx, versionString)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
m.officialOverlaysMu.Lock()
|
|
overlays, ok := m.officialOverlays[tag]
|
|
m.officialOverlaysMu.Unlock()
|
|
|
|
if ok {
|
|
return overlays, nil
|
|
}
|
|
|
|
resultCh := m.sf.DoChan("overlays-"+tag, func() (any, error) {
|
|
return nil, m.fetchOfficialOverlays(tag)
|
|
})
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
case result := <-resultCh:
|
|
if result.Err != nil {
|
|
return nil, result.Err
|
|
}
|
|
}
|
|
|
|
m.officialOverlaysMu.Lock()
|
|
overlays = m.officialOverlays[tag]
|
|
m.officialOverlaysMu.Unlock()
|
|
|
|
return overlays, nil
|
|
}
|
|
|
|
// GetInstallerImage pulls and stoers in OCI layout installer image.
|
|
func (m *Manager) GetInstallerImage(ctx context.Context, arch Arch, versionString string) (string, error) {
|
|
version, err := semver.ParseTolerant(versionString)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to parse version: %w", err)
|
|
}
|
|
|
|
if err = m.validateTalosVersion(ctx, version); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
tag := "v" + version.String()
|
|
|
|
ociPath := filepath.Join(m.storagePath, string(arch)+"-installer-"+tag)
|
|
|
|
// check if already fetched
|
|
if _, err := os.Stat(ociPath); err != nil {
|
|
resultCh := m.sf.DoChan(ociPath, func() (any, error) {
|
|
return nil, m.fetchInstallerImage(arch, tag, ociPath)
|
|
})
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
return "", ctx.Err()
|
|
case result := <-resultCh:
|
|
if result.Err != nil {
|
|
return "", result.Err
|
|
}
|
|
}
|
|
}
|
|
|
|
return ociPath, nil
|
|
}
|
|
|
|
// GetExtensionImage pulls and stores in OCI layout an extension image.
|
|
func (m *Manager) GetExtensionImage(ctx context.Context, arch Arch, ref ExtensionRef) (string, error) {
|
|
ociPath := filepath.Join(m.storagePath, string(arch)+"-"+ref.Digest)
|
|
|
|
// check if already fetched
|
|
if _, err := os.Stat(ociPath); err != nil {
|
|
resultCh := m.sf.DoChan(ociPath, func() (any, error) {
|
|
return nil, m.fetchExtensionImage(arch, ref, ociPath)
|
|
})
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
return "", ctx.Err()
|
|
case result := <-resultCh:
|
|
if result.Err != nil {
|
|
return "", result.Err
|
|
}
|
|
}
|
|
}
|
|
|
|
return ociPath, nil
|
|
}
|
|
|
|
// GetOverlayImage pulls and stores in OCI layout an overlay image.
|
|
func (m *Manager) GetOverlayImage(ctx context.Context, arch Arch, ref OverlayRef) (string, error) {
|
|
ociPath := filepath.Join(m.storagePath, string(arch)+"-"+ref.Digest)
|
|
|
|
// check if already fetched
|
|
if _, err := os.Stat(ociPath); err != nil {
|
|
resultCh := m.sf.DoChan(ociPath, func() (any, error) {
|
|
return nil, m.fetchOverlayImage(arch, ref, ociPath)
|
|
})
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
return "", ctx.Err()
|
|
case result := <-resultCh:
|
|
if result.Err != nil {
|
|
return "", result.Err
|
|
}
|
|
}
|
|
}
|
|
|
|
return ociPath, nil
|
|
}
|
|
|
|
func (m *Manager) parseTag(ctx context.Context, versionString string) (string, error) {
|
|
version, err := semver.ParseTolerant(versionString)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to parse version: %w", err)
|
|
}
|
|
|
|
if err = m.validateTalosVersion(ctx, version); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return "v" + version.String(), nil
|
|
}
|