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:
Andrey Smirnov 2023-09-07 18:12:44 +04:00
parent cf250cd103
commit b730f093a0
No known key found for this signature in database
GPG Key ID: FE042E3D4085A811
9 changed files with 218 additions and 20 deletions

View File

@ -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)
}

View 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()
}

View File

@ -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

View File

@ -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

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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

View File

@ -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)
})