From cd3bee5406300f784db3682c5b29a7c802c1bbc0 Mon Sep 17 00:00:00 2001 From: dbw7 Date: Mon, 22 Jun 2026 22:47:20 -0400 Subject: [PATCH] airgap handling --- internal/cli/action/cache.go | 41 +++++++++++++++++++++++++++++++ internal/cli/action/customize.go | 24 ++++++++++++------ internal/cli/cmd/cache.go | 40 ++++++++++++++++++++++++++++++ internal/cli/cmd/common.go | 18 +++++++++++++- internal/cli/cmd/customize.go | 33 ++++++++++++++++++++++--- internal/config/manager.go | 30 ++++++++++++++++------ internal/config/systemd_sysext.go | 2 +- pkg/extractor/extractor.go | 22 ++++++++++++++--- pkg/unpack/oci.go | 16 +++++++++++- pkg/unpack/unpacker.go | 20 +++++++++++++++ 10 files changed, 220 insertions(+), 26 deletions(-) create mode 100644 internal/cli/action/cache.go create mode 100644 internal/cli/cmd/cache.go diff --git a/internal/cli/action/cache.go b/internal/cli/action/cache.go new file mode 100644 index 00000000..246c31aa --- /dev/null +++ b/internal/cli/action/cache.go @@ -0,0 +1,41 @@ +/* +Copyright © 2025-2026 SUSE LLC +SPDX-License-Identifier: Apache-2.0 + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + + +package action + +import ( + "fmt" + "path/filepath" +) + +func resolveCacheDir(cache bool, cacheDir string) (string, error) { + if !cache { + return "", nil + } + + if cacheDir == "" { + return "", fmt.Errorf("cache directory not set") + } + + abs, err := filepath.Abs(cacheDir) + if err != nil { + return "", fmt.Errorf("resolving cache directory %q: %w", cacheDir, err) + } + + return abs, nil +} diff --git a/internal/cli/action/customize.go b/internal/cli/action/customize.go index b8cbcf9b..ced1b3c6 100644 --- a/internal/cli/action/customize.go +++ b/internal/cli/action/customize.go @@ -76,7 +76,13 @@ func Customize(ctx context.Context, cmd *cli.Command) error { ctxCancel, cancelFunc := signal.NotifyContext(ctx, syscall.SIGTERM, syscall.SIGINT) defer cancelFunc() - customizeRunner, err := setupCustomizeRunner(ctxCancel, system, args, output) + cacheDir, err := resolveCacheDir(args.Cache, args.CacheDir) + if err != nil { + logger.Error("Resolving cache directory failed") + return err + } + + customizeRunner, err := setupCustomizeRunner(ctxCancel, system, args, output, cacheDir) if err != nil { logger.Error("Setting up customization runner failed") return err @@ -115,20 +121,21 @@ func setupCustomizeRunner( s *sys.System, args *cmdpkg.CustomizeFlags, output config.Output, + cacheDir string, ) (*customize.Runner, error) { - extr, err := setupFileExtractor(ctx, s, output, args.Local) + extr, err := setupFileExtractor(ctx, s, output, cacheDir, args.Offline) if err != nil { return nil, fmt.Errorf("setting up file extractor: %w", err) } return &customize.Runner{ System: s, - ConfigManager: setupConfigManager(s, args.ConfigDir, output, args.Local), + ConfigManager: setupConfigManager(s, args.ConfigDir, output, args.Airgap, cacheDir, args.Offline), FileExtractor: extr, }, nil } -func setupConfigManager(s *sys.System, configDir string, output config.Output, local bool) *config.Manager { +func setupConfigManager(s *sys.System, configDir string, output config.Output, airgap bool, cacheDir string, offline bool) *config.Manager { valuesResolver := &helm.ValuesResolver{ FS: s.FS(), ValuesDir: v0.Dir(configDir).HelmValuesDir(), @@ -138,11 +145,13 @@ func setupConfigManager(s *sys.System, configDir string, output config.Output, l s, config.NewHelm(s.FS(), valuesResolver, s.Logger(), output.OverlaysDir()), config.WithDownloadFunc(http.DownloadFile), - config.WithLocal(local), + config.WithAirgap(airgap), + config.WithCacheDir(cacheDir), + config.WithOffline(offline), ) } -func setupFileExtractor(ctx context.Context, s *sys.System, outDir config.Output, local bool) (extr *extractor.OCIFileExtractor, err error) { +func setupFileExtractor(ctx context.Context, s *sys.System, outDir config.Output, cacheDir string, offline bool) (extr *extractor.OCIFileExtractor, err error) { const isoSearchGlob = "/iso/*default-iso*.iso" if err := vfs.MkdirAll(s.FS(), outDir.ISOStoreDir(), vfs.DirPerm); err != nil { @@ -154,7 +163,8 @@ func setupFileExtractor(ctx context.Context, s *sys.System, outDir config.Output extractor.WithStore(outDir.ISOStoreDir()), extractor.WithFS(s.FS()), extractor.WithContext(ctx), - extractor.WithLocal(local), + extractor.WithCacheDir(cacheDir), + extractor.WithOffline(offline), ) } diff --git a/internal/cli/cmd/cache.go b/internal/cli/cmd/cache.go new file mode 100644 index 00000000..90a4a6e8 --- /dev/null +++ b/internal/cli/cmd/cache.go @@ -0,0 +1,40 @@ +/* +Copyright © 2025-2026 SUSE LLC +SPDX-License-Identifier: Apache-2.0 + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cmd + +import ( + "fmt" + "path/filepath" +) + +const defaultCacheDir = "/elemental-cache" + +func ValidateCacheFlags(cache bool, cacheDir string, offline bool) error { + if cache { + return nil + } + + if offline { + return fmt.Errorf("--offline requires --cache") + } + if cacheDir != "" && filepath.Clean(cacheDir) != defaultCacheDir { + return fmt.Errorf("--cache-dir requires --cache") + } + + return nil +} diff --git a/internal/cli/cmd/common.go b/internal/cli/cmd/common.go index d48e8fc6..933743fb 100644 --- a/internal/cli/cmd/common.go +++ b/internal/cli/cmd/common.go @@ -20,7 +20,23 @@ package cmd const ( // --local flag name and description localFlg = "local" - localDesc = "Load OCI images from the local container storage instead of a remote registry" + localDesc = "Load OCI images from local container storage instead of a remote registry" + + // --airgap flag name and description + airgapFlg = "airgap" + airgapDesc = "Embed container images for air-gapped Kubernetes deployments" + + // --cache flag name and description + cacheFlg = "cache" + cacheDesc = "Use the OCI image cache while preparing build artifacts" + + // --cache-dir flag name and description + cacheDirFlg = "cache-dir" + cacheDirDesc = "Path to the OCI image cache directory" + + // --offline flag name and description + offlineFlg = "offline" + offlineDesc = "Use only cached OCI images without pulling from remote registries" // --verify flag name and description verifyFlg = "verify" diff --git a/internal/cli/cmd/customize.go b/internal/cli/cmd/customize.go index 3c49f912..2a8e5ed9 100644 --- a/internal/cli/cmd/customize.go +++ b/internal/cli/cmd/customize.go @@ -33,7 +33,10 @@ type CustomizeFlags struct { Mode string Platform string MediaType string - Local bool + Airgap bool + Cache bool + CacheDir string + Offline bool } var CustomizeArgs CustomizeFlags @@ -49,6 +52,10 @@ func NewCustomizeCommand(appName string, action func(context.Context, *cli.Comma return ctx, cli.Exit("Error: Unsupported --mode option.", 1) } + if err := ValidateCacheFlags(CustomizeArgs.Cache, CustomizeArgs.CacheDir, CustomizeArgs.Offline); err != nil { + return ctx, cli.Exit(fmt.Sprintf("Error: %v.", err), 1) + } + return ctx, nil }, Action: action, @@ -86,9 +93,27 @@ func NewCustomizeCommand(appName string, action func(context.Context, *cli.Comma Value: fmt.Sprintf("linux/%s", runtime.GOARCH), }, &cli.BoolFlag{ - Name: localFlg, - Usage: localDesc, - Destination: &CustomizeArgs.Local, + Name: airgapFlg, + Usage: airgapDesc, + Destination: &CustomizeArgs.Airgap, + Value: true, + }, + &cli.BoolFlag{ + Name: cacheFlg, + Usage: cacheDesc, + Destination: &CustomizeArgs.Cache, + Value: true, + }, + &cli.StringFlag{ + Name: cacheDirFlg, + Usage: cacheDirDesc, + Destination: &CustomizeArgs.CacheDir, + Value: defaultCacheDir, + }, + &cli.BoolFlag{ + Name: offlineFlg, + Usage: offlineDesc, + Destination: &CustomizeArgs.Offline, }, }, } diff --git a/internal/config/manager.go b/internal/config/manager.go index a90a6215..6b051de1 100644 --- a/internal/config/manager.go +++ b/internal/config/manager.go @@ -44,8 +44,10 @@ type releaseManifestResolver interface { } type Manager struct { - system *sys.System - local bool + system *sys.System + airgap bool + cacheDir string + offline bool rmResolver releaseManifestResolver downloadFile downloadFunc @@ -73,9 +75,21 @@ func WithUnpackFunc(u unpackFunc) Opts { } } -func WithLocal(local bool) Opts { +func WithAirgap(airgap bool) Opts { return func(m *Manager) { - m.local = local + m.airgap = airgap + } +} + +func WithCacheDir(cacheDir string) Opts { + return func(m *Manager) { + m.cacheDir = cacheDir + } +} + +func WithOffline(offline bool) Opts { + return func(m *Manager) { + m.offline = offline } } @@ -95,7 +109,7 @@ func NewManager(sys *sys.System, helm helmConfigurator, opts ...Opts) *Manager { if m.unpackImage == nil { m.unpackImage = func(ctx context.Context, imageRef, destDir string) error { - unpacker := unpack.NewOCIUnpacker(sys, imageRef, unpack.WithLocalOCI(m.local)) + unpacker := unpack.NewOCIUnpacker(sys, imageRef, unpack.WithCacheDirOCI(m.cacheDir), unpack.WithOfflineOCI(m.offline)) _, err := unpacker.Unpack(ctx, destDir) return err } @@ -108,7 +122,7 @@ func NewManager(sys *sys.System, helm helmConfigurator, opts ...Opts) *Manager { // and returns the resolved release manifest from said configuration. func (m *Manager) ConfigureComponents(ctx context.Context, conf *image.Configuration, output Output) (rm *resolver.ResolvedManifest, err error) { if m.rmResolver == nil { - defaultResolver, err := defaultManifestResolver(m.system.FS(), output, m.local) + defaultResolver, err := defaultManifestResolver(m.system.FS(), output, m.cacheDir, m.offline) if err != nil { return nil, fmt.Errorf("using default release manifest resolver: %w", err) } @@ -151,7 +165,7 @@ func (m *Manager) ConfigureComponents(ctx context.Context, conf *image.Configura return rm, nil } -func defaultManifestResolver(fs vfs.FS, out Output, local bool) (res *resolver.Resolver, err error) { +func defaultManifestResolver(fs vfs.FS, out Output, cacheDir string, offline bool) (res *resolver.Resolver, err error) { const ( globPattern = "release_manifest*.yaml" ) @@ -166,7 +180,7 @@ func defaultManifestResolver(fs vfs.FS, out Output, local bool) (res *resolver.R return nil, fmt.Errorf("creating release manifest store '%s': %w", manifestsDir, err) } - extr, err := extractor.New(searchPaths, extractor.WithStore(manifestsDir), extractor.WithLocal(local)) + extr, err := extractor.New(searchPaths, extractor.WithStore(manifestsDir), extractor.WithCacheDir(cacheDir), extractor.WithOffline(offline)) if err != nil { return nil, fmt.Errorf("initializing OCI release manifest extractor: %w", err) } diff --git a/internal/config/systemd_sysext.go b/internal/config/systemd_sysext.go index 88b701e1..4f41f181 100644 --- a/internal/config/systemd_sysext.go +++ b/internal/config/systemd_sysext.go @@ -85,7 +85,7 @@ func (m *Manager) unpackExtension(ctx context.Context, extension api.SystemdExte _ = fs.RemoveAll(tempDir) }() - unpacker := unpack.NewOCIUnpacker(m.system, extension.Image, unpack.WithLocalOCI(m.local)) + unpacker := unpack.NewOCIUnpacker(m.system, extension.Image, unpack.WithCacheDirOCI(m.cacheDir), unpack.WithOfflineOCI(m.offline)) if _, err = unpacker.Unpack(ctx, tempDir); err != nil { return fmt.Errorf("unpacking extension: %w", err) } diff --git a/pkg/extractor/extractor.go b/pkg/extractor/extractor.go index 1fb2aae6..42e6ef75 100644 --- a/pkg/extractor/extractor.go +++ b/pkg/extractor/extractor.go @@ -31,15 +31,15 @@ import ( type OCIUnpacker interface { // Unpack unpacks the file system of a given OCI image to the specified destination // and returns its digest - Unpack(ctx context.Context, uri, dest string, local bool) (digest string, err error) + Unpack(ctx context.Context, uri, dest string, local bool, cacheDir string, offline bool) (digest string, err error) } type ociUnpacker struct { system *sys.System } -func (o *ociUnpacker) Unpack(ctx context.Context, uri, dest string, local bool) (digest string, err error) { - unpacker := unpack.NewOCIUnpacker(o.system, uri, unpack.WithLocalOCI(local)) +func (o *ociUnpacker) Unpack(ctx context.Context, uri, dest string, local bool, cacheDir string, offline bool) (digest string, err error) { + unpacker := unpack.NewOCIUnpacker(o.system, uri, unpack.WithLocalOCI(local), unpack.WithCacheDirOCI(cacheDir), unpack.WithOfflineOCI(offline)) return unpacker.Unpack(ctx, dest) } @@ -59,6 +59,8 @@ type OCIFileExtractor struct { fs vfs.FS ctx context.Context local bool + cacheDir string + offline bool } type OCIFileExtractorOpts func(o *OCIFileExtractor) @@ -93,6 +95,18 @@ func WithLocal(local bool) OCIFileExtractorOpts { } } +func WithCacheDir(cacheDir string) OCIFileExtractorOpts { + return func(r *OCIFileExtractor) { + r.cacheDir = cacheDir + } +} + +func WithOffline(offline bool) OCIFileExtractorOpts { + return func(r *OCIFileExtractor) { + r.offline = offline + } +} + func New(searchPaths []string, opts ...OCIFileExtractorOpts) (*OCIFileExtractor, error) { extr := &OCIFileExtractor{ searchPaths: searchPaths, @@ -144,7 +158,7 @@ func (o *OCIFileExtractor) ExtractFrom(uri string) (path string, err error) { _ = o.fs.RemoveAll(unpackDir) }() - digest, err := o.unpacker.Unpack(o.ctx, uri, unpackDir, o.local) + digest, err := o.unpacker.Unpack(o.ctx, uri, unpackDir, o.local, o.cacheDir, o.offline) if err != nil { return "", fmt.Errorf("unpacking oci image: %w", err) } diff --git a/pkg/unpack/oci.go b/pkg/unpack/oci.go index a4c18615..1d00aaee 100644 --- a/pkg/unpack/oci.go +++ b/pkg/unpack/oci.go @@ -25,13 +25,13 @@ import ( "path/filepath" "time" + "github.com/cenkalti/backoff/v4" "github.com/schollz/progressbar/v3" "github.com/suse/elemental/v3/pkg/containerd" "github.com/suse/elemental/v3/pkg/sys" "github.com/suse/elemental/v3/pkg/sys/vfs" - backoff "github.com/cenkalti/backoff/v4" "github.com/google/go-containerregistry/pkg/authn" "github.com/google/go-containerregistry/pkg/name" containerregistry "github.com/google/go-containerregistry/pkg/v1" @@ -51,6 +51,8 @@ type OCI struct { s *sys.System platformRef string local bool + cacheDir string + offline bool verify bool imageRef string rsyncFlags []string @@ -72,6 +74,18 @@ func WithVerifyOCI(verify bool) OCIOpt { } } +func WithCacheDirOCI(cacheDir string) OCIOpt { + return func(o *OCI) { + o.cacheDir = cacheDir + } +} + +func WithOfflineOCI(offline bool) OCIOpt { + return func(o *OCI) { + o.offline = offline + } +} + func WithPlatformRefOCI(platform string) OCIOpt { return func(o *OCI) { o.platformRef = platform diff --git a/pkg/unpack/unpacker.go b/pkg/unpack/unpacker.go index 77bcef17..bbc9100b 100644 --- a/pkg/unpack/unpacker.go +++ b/pkg/unpack/unpacker.go @@ -66,6 +66,26 @@ func WithVerify(verify bool) Opt { } } +func WithCacheDir(cacheDir string) Opt { + return func(srcType deployment.ImageSrcType, o *options) { + switch srcType { + case deployment.OCI: + o.ociOpts = append(o.ociOpts, WithCacheDirOCI(cacheDir)) + default: + } + } +} + +func WithOffline(offline bool) Opt { + return func(srcType deployment.ImageSrcType, o *options) { + switch srcType { + case deployment.OCI: + o.ociOpts = append(o.ociOpts, WithOfflineOCI(offline)) + default: + } + } +} + func WithPlatformRef(platform string) Opt { return func(srcType deployment.ImageSrcType, o *options) { switch srcType {