mirror of
https://github.com/siderolabs/image-factory.git
synced 2025-12-05 17:41:32 +01:00
feat: add a virtual extension with flavor ID to generated assets
This appends a "virtual" (built on the fly) extension which contains
flavor ID to all boot assets of Talos.
This allows to easily identify which flavor of Talos which asset was
built with.
E.g.:
```
$ talosctl -n 172.20.0.2 get extensions -i
NODE NAMESPACE TYPE ID VERSION NAME VERSION
runtime ExtensionStatus 0 1 flavor 376567988ad370138ad8b2698212367b8edcb69b5fd68c80be1f2ec7d603b4ba
```
```yaml
node:
metadata:
namespace: runtime
type: ExtensionStatuses.runtime.talos.dev
id: 0
version: 1
owner: runtime.ExtensionStatusController
phase: running
created: 2023-09-07T14:06:03Z
updated: 2023-09-07T14:06:03Z
spec:
image: 0.sqsh
metadata:
name: flavor
version: 376567988ad370138ad8b2698212367b8edcb69b5fd68c80be1f2ec7d603b4ba
author: Image Service
description: Virtual extension which specifies the flavor of the image built with Image Service.
compatibility:
talos:
version: '>= 1.0.0'
```
And (as an empty file):
```
$ talosctl -n 172.20.0.2 ls /usr/local/share/flavor/
NODE NAME
172.20.0.2 .
172.20.0.2 376567988ad370138ad8b2698212367b8edcb69b5fd68c80be1f2ec7d603b4ba
```
Signed-off-by: Andrey Smirnov <andrey.smirnov@siderolabs.com>
This commit is contained in:
parent
cf250cd103
commit
b730f093a0
@ -68,7 +68,7 @@ func RunService(ctx context.Context, logger *zap.Logger, opts Options) error {
|
||||
return fmt.Errorf("failed to parse external installer repository: %w", err)
|
||||
}
|
||||
|
||||
frontendHTTP, err := frontendhttp.NewFrontend(logger, configService, assetBuilder, frontendOptions)
|
||||
frontendHTTP, err := frontendhttp.NewFrontend(logger, configService, assetBuilder, artifactsManager, frontendOptions)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize HTTP frontend: %w", err)
|
||||
}
|
||||
|
||||
141
internal/artifacts/flavor.go
Normal file
141
internal/artifacts/flavor.go
Normal file
@ -0,0 +1,141 @@
|
||||
// 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 (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/siderolabs/talos/pkg/machinery/extensions"
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/siderolabs/image-service/pkg/flavor"
|
||||
)
|
||||
|
||||
// GetFlavorExtension returns a path to the tarball with "virtual" extension matching a specified flavor.
|
||||
func (m *Manager) GetFlavorExtension(ctx context.Context, flavor *flavor.Flavor) (string, error) {
|
||||
flavorID, err := flavor.ID()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
extensionPath := filepath.Join(m.flavorsPath, flavorID+".tar")
|
||||
|
||||
resultCh := m.flavorsSingleFlight.DoChan(flavorID, func() (any, error) {
|
||||
return nil, m.buildFlavorExtension(flavorID, extensionPath)
|
||||
})
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
case result := <-resultCh:
|
||||
if result.Err != nil {
|
||||
return "", result.Err
|
||||
}
|
||||
|
||||
return extensionPath, nil
|
||||
}
|
||||
}
|
||||
|
||||
// flavorExtension builds a "virtual" extension matching a specified flavor.
|
||||
func flavorExtension(flavorID string) (io.Reader, error) {
|
||||
manifest := extensions.Manifest{
|
||||
Version: "v1alpha1",
|
||||
Metadata: extensions.Metadata{
|
||||
Name: "flavor",
|
||||
Version: flavorID,
|
||||
Author: "Image Service",
|
||||
Description: "Virtual extension which specifies the flavor of the image built with Image Service.",
|
||||
Compatibility: extensions.Compatibility{
|
||||
Talos: extensions.Constraint{
|
||||
Version: ">= 1.0.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
manifestBytes, err := yaml.Marshal(manifest)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal manifest: %w", err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
tw := tar.NewWriter(&buf)
|
||||
|
||||
if err = tw.WriteHeader(&tar.Header{
|
||||
Name: "manifest.yaml",
|
||||
Typeflag: tar.TypeReg,
|
||||
Mode: 0o644,
|
||||
Size: int64(len(manifestBytes)),
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("failed to write manifest header: %w", err)
|
||||
}
|
||||
|
||||
if _, err = tw.Write(manifestBytes); err != nil {
|
||||
return nil, fmt.Errorf("failed to write manifest: %w", err)
|
||||
}
|
||||
|
||||
for _, path := range []string{
|
||||
"rootfs/",
|
||||
"rootfs/usr/",
|
||||
"rootfs/usr/local/",
|
||||
"rootfs/usr/local/share/",
|
||||
"rootfs/usr/local/share/flavor/",
|
||||
} {
|
||||
if err = tw.WriteHeader(&tar.Header{
|
||||
Name: path,
|
||||
Typeflag: tar.TypeDir,
|
||||
Mode: 0o755,
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("failed to write rootfs header: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err = tw.WriteHeader(&tar.Header{
|
||||
Name: filepath.Join("rootfs/usr/local/share/flavor", flavorID), // empty file
|
||||
Typeflag: tar.TypeReg,
|
||||
Mode: 0o755,
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("failed to write rootfs header: %w", err)
|
||||
}
|
||||
|
||||
if err = tw.Close(); err != nil {
|
||||
return nil, fmt.Errorf("failed to close tar writer: %w", err)
|
||||
}
|
||||
|
||||
return &buf, nil
|
||||
}
|
||||
|
||||
// buildFlavorExtension builds a flavor extension tarball.
|
||||
func (m *Manager) buildFlavorExtension(flavorID, extensionPath string) error {
|
||||
tarball, err := flavorExtension(flavorID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to build flavor layer: %w", err)
|
||||
}
|
||||
|
||||
f, err := os.Create(extensionPath + ".tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create extension tarball: %w", err)
|
||||
}
|
||||
|
||||
defer f.Close() //nolint:errcheck
|
||||
|
||||
_, err = io.Copy(f, tarball)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write extension tarball: %w", err)
|
||||
}
|
||||
|
||||
if err = os.Rename(extensionPath+".tmp", extensionPath); err != nil {
|
||||
return fmt.Errorf("failed to rename extension tarball: %w", err)
|
||||
}
|
||||
|
||||
return f.Close()
|
||||
}
|
||||
@ -13,14 +13,18 @@ import (
|
||||
|
||||
"github.com/blang/semver/v4"
|
||||
"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
|
||||
flavorsPath string
|
||||
logger *zap.Logger
|
||||
|
||||
flavorsSingleFlight singleflight.Group
|
||||
|
||||
fetcherMu sync.Mutex
|
||||
fetchers map[string]*fetcher
|
||||
}
|
||||
@ -32,9 +36,16 @@ func NewManager(logger *zap.Logger, options Options) (*Manager, error) {
|
||||
return nil, fmt.Errorf("failed to create temporary directory: %w", err)
|
||||
}
|
||||
|
||||
flavorsPath := filepath.Join(tmpDir, "flavors")
|
||||
|
||||
if err = os.Mkdir(flavorsPath, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("failed to create flavors directory: %w", err)
|
||||
}
|
||||
|
||||
return &Manager{
|
||||
options: options,
|
||||
storagePath: tmpDir,
|
||||
flavorsPath: flavorsPath,
|
||||
logger: logger,
|
||||
fetchers: map[string]*fetcher{},
|
||||
}, nil
|
||||
|
||||
@ -19,6 +19,7 @@ import (
|
||||
"go.uber.org/zap"
|
||||
"golang.org/x/sync/singleflight"
|
||||
|
||||
"github.com/siderolabs/image-service/internal/artifacts"
|
||||
"github.com/siderolabs/image-service/internal/asset"
|
||||
flvr "github.com/siderolabs/image-service/internal/flavor"
|
||||
"github.com/siderolabs/image-service/internal/flavor/storage"
|
||||
@ -28,14 +29,15 @@ import (
|
||||
|
||||
// Frontend is the HTTP frontend.
|
||||
type Frontend struct {
|
||||
router *httprouter.Router
|
||||
flavorService *flvr.Service
|
||||
assetBuilder *asset.Builder
|
||||
logger *zap.Logger
|
||||
puller *remote.Puller
|
||||
pusher *remote.Pusher
|
||||
sf singleflight.Group
|
||||
options Options
|
||||
router *httprouter.Router
|
||||
flavorService *flvr.Service
|
||||
assetBuilder *asset.Builder
|
||||
artifactsManager *artifacts.Manager
|
||||
logger *zap.Logger
|
||||
puller *remote.Puller
|
||||
pusher *remote.Pusher
|
||||
sf singleflight.Group
|
||||
options Options
|
||||
}
|
||||
|
||||
// Options configures the HTTP frontend.
|
||||
@ -49,13 +51,14 @@ type Options struct {
|
||||
}
|
||||
|
||||
// NewFrontend creates a new HTTP frontend.
|
||||
func NewFrontend(logger *zap.Logger, flavorService *flvr.Service, assetBuilder *asset.Builder, opts Options) (*Frontend, error) {
|
||||
func NewFrontend(logger *zap.Logger, flavorService *flvr.Service, assetBuilder *asset.Builder, artifactsManager *artifacts.Manager, opts Options) (*Frontend, error) {
|
||||
frontend := &Frontend{
|
||||
router: httprouter.New(),
|
||||
flavorService: flavorService,
|
||||
assetBuilder: assetBuilder,
|
||||
logger: logger.With(zap.String("frontend", "http")),
|
||||
options: opts,
|
||||
router: httprouter.New(),
|
||||
flavorService: flavorService,
|
||||
assetBuilder: assetBuilder,
|
||||
artifactsManager: artifactsManager,
|
||||
logger: logger.With(zap.String("frontend", "http")),
|
||||
options: opts,
|
||||
}
|
||||
|
||||
var err error
|
||||
|
||||
@ -46,7 +46,7 @@ func (f *Frontend) handleImage(ctx context.Context, w http.ResponseWriter, r *ht
|
||||
return fmt.Errorf("error parsing profile from path: %w", err)
|
||||
}
|
||||
|
||||
prof, err = profile.EnhanceFromFlavor(prof, flavor, versionTag)
|
||||
prof, err = profile.EnhanceFromFlavor(ctx, prof, flavor, f.artifactsManager, versionTag)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error enhancing profile from flavor: %w", err)
|
||||
}
|
||||
|
||||
@ -53,7 +53,7 @@ func (f *Frontend) handlePXE(ctx context.Context, w http.ResponseWriter, _ *http
|
||||
return fmt.Errorf("error parsing profile from path: %w", err)
|
||||
}
|
||||
|
||||
prof, err = profile.EnhanceFromFlavor(prof, flavor, versionTag)
|
||||
prof, err = profile.EnhanceFromFlavor(ctx, prof, flavor, f.artifactsManager, versionTag)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error enhancing profile from flavor: %w", err)
|
||||
}
|
||||
|
||||
@ -198,7 +198,7 @@ func (f *Frontend) buildInstallImage(ctx context.Context, img requestedImage, fl
|
||||
for _, arch := range []artifacts.Arch{artifacts.ArchAmd64, artifacts.ArchArm64} {
|
||||
prof := profile.InstallerProfile(img.SecureBoot(), arch)
|
||||
|
||||
prof, err := profile.EnhanceFromFlavor(prof, flavor, versionTag)
|
||||
prof, err := profile.EnhanceFromFlavor(ctx, prof, flavor, f.artifactsManager, versionTag)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error enhancing profile from flavor: %w", err)
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
package profile
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/siderolabs/gen/xerrors"
|
||||
@ -226,8 +227,13 @@ func InstallerProfile(secureboot bool, arch artifacts.Arch) profile.Profile {
|
||||
return prof
|
||||
}
|
||||
|
||||
// FlavorExtensionProducer is a function which produces a flavor extension tarballs.
|
||||
type FlavorExtensionProducer interface {
|
||||
GetFlavorExtension(context.Context, *flavor.Flavor) (string, error)
|
||||
}
|
||||
|
||||
// EnhanceFromFlavor enhances the profile with the flavor.
|
||||
func EnhanceFromFlavor(prof profile.Profile, flavor *flavor.Flavor, versionTag string) (profile.Profile, error) {
|
||||
func EnhanceFromFlavor(ctx context.Context, prof profile.Profile, flavor *flavor.Flavor, flavorExtensionProducer FlavorExtensionProducer, versionTag string) (profile.Profile, error) {
|
||||
if len(flavor.Customization.SystemExtensions.OfficialExtensions) > 0 {
|
||||
// TODO: implement me
|
||||
return prof, xerrors.NewTaggedf[InvalidErrorTag]("system extensions are not supported yet")
|
||||
@ -238,6 +244,14 @@ func EnhanceFromFlavor(prof profile.Profile, flavor *flavor.Flavor, versionTag s
|
||||
prof.Customization.ExtraKernelArgs = append(prof.Customization.ExtraKernelArgs, flavor.Customization.ExtraKernelArgs...)
|
||||
}
|
||||
|
||||
// append flavor extension
|
||||
flavorExtensionPath, err := flavorExtensionProducer.GetFlavorExtension(ctx, flavor)
|
||||
if err != nil {
|
||||
return prof, err
|
||||
}
|
||||
|
||||
prof.Input.SystemExtensions = append(prof.Input.SystemExtensions, profile.ContainerAsset{TarballPath: flavorExtensionPath})
|
||||
|
||||
prof.Version = versionTag
|
||||
|
||||
return prof, nil
|
||||
|
||||
@ -12,6 +12,7 @@ import (
|
||||
"github.com/siderolabs/talos/pkg/imager/profile"
|
||||
"github.com/siderolabs/talos/pkg/machinery/constants"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/net/context"
|
||||
|
||||
"github.com/siderolabs/image-service/internal/artifacts"
|
||||
imageprofile "github.com/siderolabs/image-service/internal/profile"
|
||||
@ -209,9 +210,23 @@ func TestParseFromPath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
type mockFlavorExtensionProducer struct{}
|
||||
|
||||
func (mockFlavorExtensionProducer) GetFlavorExtension(_ context.Context, flavor *flavor.Flavor) (string, error) {
|
||||
id, err := flavor.ID()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s.tar", id), nil
|
||||
}
|
||||
|
||||
func TestEnhanceFromFlavor(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
|
||||
for _, test := range []struct { //nolint:govet
|
||||
name string
|
||||
baseProfile profile.Profile
|
||||
@ -230,6 +245,13 @@ func TestEnhanceFromFlavor(t *testing.T) {
|
||||
Platform: constants.PlatformMetal,
|
||||
SecureBoot: pointer.To(false),
|
||||
Version: "v1.5.0",
|
||||
Input: profile.Input{
|
||||
SystemExtensions: []profile.ContainerAsset{
|
||||
{
|
||||
TarballPath: "376567988ad370138ad8b2698212367b8edcb69b5fd68c80be1f2ec7d603b4ba.tar",
|
||||
},
|
||||
},
|
||||
},
|
||||
Output: profile.Output{
|
||||
Kind: profile.OutKindImage,
|
||||
OutFormat: profile.OutFormatXZ,
|
||||
@ -257,6 +279,13 @@ func TestEnhanceFromFlavor(t *testing.T) {
|
||||
Customization: profile.CustomizationProfile{
|
||||
ExtraKernelArgs: []string{"noapic", "nolapic"},
|
||||
},
|
||||
Input: profile.Input{
|
||||
SystemExtensions: []profile.ContainerAsset{
|
||||
{
|
||||
TarballPath: "9cba8e32753f91a16c1837ab8abf356af021706ef284aef07380780177d9a06c.tar",
|
||||
},
|
||||
},
|
||||
},
|
||||
Output: profile.Output{
|
||||
Kind: profile.OutKindImage,
|
||||
OutFormat: profile.OutFormatXZ,
|
||||
@ -271,7 +300,7 @@ func TestEnhanceFromFlavor(t *testing.T) {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
actualProfile, err := imageprofile.EnhanceFromFlavor(test.baseProfile, &test.flavor, test.versionString)
|
||||
actualProfile, err := imageprofile.EnhanceFromFlavor(ctx, test.baseProfile, &test.flavor, mockFlavorExtensionProducer{}, test.versionString)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, test.expectedProfile, actualProfile)
|
||||
})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user