diff --git a/.github/workflows/build-airgap-image-bundle.yml b/.github/workflows/build-airgap-image-bundle.yml index 72469322450f..0c4c3e9a0750 100644 --- a/.github/workflows/build-airgap-image-bundle.yml +++ b/.github/workflows/build-airgap-image-bundle.yml @@ -44,7 +44,7 @@ jobs: - name: "Cache :: Airgap image bundle :: Calculate cache key" id: cache-airgap-image-bundle-calc-key env: - HASH_VALUE: ${{ hashFiles('Makefile', 'airgap-images.txt', 'hack/image-bundler/*') }} + HASH_VALUE: ${{ hashFiles('Makefile', 'airgap-images.txt', 'cmd/airgap/*', 'pkg/airgap/*') }} run: | printf 'cache-key=build-airgap-image-bundle-%s-%s-%s\n' "$TARGET_OS" "$TARGET_ARCH" "$HASH_VALUE" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 7f270e62c472..554c8a05f360 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -322,7 +322,7 @@ jobs: id: cache-airgap-image-bundle uses: actions/cache@v4 with: - key: airgap-image-bundle-linux-${{ matrix.arch }}-${{ hashFiles('Makefile', 'airgap-images.txt', 'hack/image-bundler/*') }} + key: airgap-image-bundle-linux-${{ matrix.arch }}-${{ hashFiles('Makefile', 'airgap-images.txt', 'cmd/airgap/*', 'pkg/airgap/*') }} path: | airgap-images.txt airgap-image-bundle-linux-${{ matrix.arch }}.tar diff --git a/Makefile b/Makefile index 5c2825e6df47..9ab3eab2ab93 100644 --- a/Makefile +++ b/Makefile @@ -222,7 +222,7 @@ lint-go: .k0sbuild.docker-image.k0s go.sum bindata .PHONY: lint lint: lint-copyright lint-go -airgap-images.txt: k0s .k0sbuild.docker-image.k0s +airgap-images.txt: build .k0sbuild.docker-image.k0s $(GO_ENV) ./k0s airgap list-images --all > '$@' airgap-image-bundle-linux-amd64.tar: TARGET_PLATFORM := linux/amd64 @@ -230,15 +230,8 @@ airgap-image-bundle-linux-arm64.tar: TARGET_PLATFORM := linux/arm64 airgap-image-bundle-linux-arm.tar: TARGET_PLATFORM := linux/arm/v7 airgap-image-bundle-linux-amd64.tar \ airgap-image-bundle-linux-arm64.tar \ -airgap-image-bundle-linux-arm.tar: .k0sbuild.image-bundler.stamp airgap-images.txt - docker run --rm -i --privileged \ - -e TARGET_PLATFORM='$(TARGET_PLATFORM)' \ - '$(shell cat .k0sbuild.image-bundler.stamp)' < airgap-images.txt > '$@' - -.k0sbuild.image-bundler.stamp: hack/image-bundler/* embedded-bins/Makefile.variables - docker build --progress=plain --iidfile '$@' \ - --build-arg ALPINE_VERSION=$(alpine_patch_version) \ - -t k0sbuild.image-bundler -- hack/image-bundler +airgap-image-bundle-linux-arm.tar: build airgap-images.txt + ./k0s airgap -v bundle-images -o '$@' from-file airgap-images.txt .PHONY: $(smoketests) check-airgap check-ap-airgap: airgap-image-bundle-linux-$(HOST_ARCH).tar @@ -269,9 +262,7 @@ clean-docker-image: $(clean-iid-files) .PHONY: clean-airgap-image-bundles -clean-airgap-image-bundles: IID_FILES = .k0sbuild.image-bundler.stamp clean-airgap-image-bundles: - $(clean-iid-files) -rm airgap-images.txt -rm airgap-image-bundle-linux-amd64.tar airgap-image-bundle-linux-arm64.tar airgap-image-bundle-linux-arm.tar diff --git a/cmd/airgap/airgap.go b/cmd/airgap/airgap.go index 4c07343e9e7f..2c27a5b18a3d 100644 --- a/cmd/airgap/airgap.go +++ b/cmd/airgap/airgap.go @@ -17,6 +17,7 @@ limitations under the License. package airgap import ( + "github.com/sirupsen/logrus" "github.com/spf13/cobra" "github.com/k0sproject/k0s/pkg/config" @@ -25,10 +26,12 @@ import ( func NewAirgapCmd() *cobra.Command { cmd := &cobra.Command{ Use: "airgap", - Short: "Manage airgap setup", + Short: "Tooling for airgapped installations", } + log := logrus.StandardLogger() cmd.AddCommand(NewAirgapListImagesCmd()) + cmd.AddCommand(NewAirgapBundleImagesCmd(log)) cmd.PersistentFlags().AddFlagSet(config.FileInputFlag()) cmd.PersistentFlags().AddFlagSet(config.GetPersistentFlagSet()) return cmd diff --git a/cmd/airgap/bundleimages.go b/cmd/airgap/bundleimages.go new file mode 100644 index 000000000000..668960b685b4 --- /dev/null +++ b/cmd/airgap/bundleimages.go @@ -0,0 +1,264 @@ +/* +Copyright 2024 k0s authors + +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 airgap + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "os" + "os/signal" + "slices" + "strconv" + "strings" + "syscall" + + "github.com/distribution/reference" + "github.com/k0sproject/k0s/internal/pkg/file" + "github.com/k0sproject/k0s/pkg/airgap" + "github.com/k0sproject/k0s/pkg/config" + "golang.org/x/term" + + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" +) + +type imageBundleOpts struct { + bundler airgap.ImageBundler + outPath string + stdout func() io.Writer +} + +func NewAirgapBundleImagesCmd(log logrus.FieldLogger) *cobra.Command { + opts := imageBundleOpts{ + bundler: airgap.ImageBundler{ + Log: log, + }, + } + + cmd := &cobra.Command{ + Use: "bundle-images [flags] [file]", + Short: "Bundles images in a tarball needed for airgapped installations", + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + if err := config.CallParentPersistentPreRun(cmd, args); err != nil { + return err + } + if opts.outPath != "" { + return nil + } + + return enforceNoTerminal(cmd.OutOrStdout()) + }, + } + + cmd.PersistentFlags().StringVarP(&opts.outPath, "output", "o", "", "output file path (writes to standard output if omitted)") + cmd.Flags().Var((*insecureRegistryFlag)(&opts.bundler.InsecureRegistries), "insecure-registries", "one of "+strings.Join(insecureRegistryFlagValues[:], ", ")) + cmd.Flags().StringArrayVar(&opts.bundler.RegistriesConfigPaths, "registries-config", nil, "paths to the authentication files for image registries") + + opts.stdout = cmd.OutOrStdout + cmd.AddCommand(newFromConfigCommand(&opts)) + cmd.AddCommand(newFromStdinCommand(&opts)) + cmd.AddCommand(newFromFileCommand(&opts)) + return cmd +} + +func newFromConfigCommand(opts *imageBundleOpts) *cobra.Command { + var all bool + + cmd := &cobra.Command{ + Use: "from-config", + Short: "Bundles images for the current cluster configuration", + Long: `Bundles images for the current cluster configuration. +Builds the list of images in the same way as the list-images sub-command.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) (err error) { + cmdOpts, err := config.GetCmdOpts(cmd) + if err != nil { + return err + } + + clusterConfig, err := cmdOpts.K0sVars.NodeConfig() + if err != nil { + return fmt.Errorf("failed to get config: %w", err) + } + + var imageRefs []reference.Named + for image := range airgap.ImagesInSpec(clusterConfig.Spec, all) { + uri := image.URI() + ref, err := reference.ParseNormalizedNamed(uri) + if err != nil { + return fmt.Errorf("while parsing %q: %w", uri, err) + } + imageRefs = append(imageRefs, ref) + } + + return opts.runBundler(cmd.Context(), imageRefs) + }, + } + + cmd.Flags().BoolVarP(&all, "all", "a", false, "include all images, even if they are not used in the current configuration") + return cmd +} + +func newFromFileCommand(opts *imageBundleOpts) *cobra.Command { + return &cobra.Command{ + Use: "from-file [flags] file", + Short: "Bundles images read from the given file", + Long: `Bundles images read from the given file, line by line. Surrounding whitespace is +ignored, lines whose first non-whitespace character is a # are ignored.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) (err error) { + imageRefs, err := parseReferencesFromFile(args[0]) + if err != nil { + return err + } + return opts.runBundler(cmd.Context(), imageRefs) + }, + } +} + +func newFromStdinCommand(opts *imageBundleOpts) *cobra.Command { + return &cobra.Command{ + Use: "from-stdin", + Short: "Bundles images read from standard input", + Long: `Bundles images read from standard input, line by line. Surrounding whitespace is +ignored, lines whose first non-whitespace character is a # are ignored.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) (err error) { + imageRefs, err := parseReferencesFromReader(cmd.InOrStdin()) + if err != nil { + return err + } + return opts.runBundler(cmd.Context(), imageRefs) + }, + } +} + +func parseReferencesFromFile(path string) (_ []reference.Named, err error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer func() { err = errors.Join(err, f.Close()) }() + return parseReferencesFromReader(f) +} + +func parseReferencesFromReader(in io.Reader) ([]reference.Named, error) { + lines := bufio.NewScanner(in) + + var ( + imageRefs []reference.Named + lineNum uint + ) + for lines.Scan() { + lineNum++ + line := lines.Bytes() + if len(line) > 0 && line[0] != '#' { + image := string(line) + ref, err := reference.ParseNormalizedNamed(image) + if err != nil { + return nil, fmt.Errorf("while parsing line %d: %q: %w", lineNum, image, err) + } + imageRefs = append(imageRefs, ref) + } + } + if err := lines.Err(); err != nil { + return nil, err + } + + return imageRefs, nil +} + +func (o *imageBundleOpts) runBundler(ctx context.Context, refs []reference.Named) (err error) { + ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) + defer cancel() + + var out io.Writer + if o.outPath == "" { + out = o.stdout() + if err := enforceNoTerminal(out); err != nil { + return err + } + } else { + f, err := file.AtomicWithTarget(o.outPath).Open() + if err != nil { + return err + } + defer func() { + if err == nil { + err = f.Finish() + } else if closeErr := f.Close(); closeErr != nil { + err = errors.Join(err, closeErr) + } + }() + out = f + } + + buffered := bufio.NewWriter(out) + if err := o.bundler.Run(ctx, refs, out); err != nil { + return err + } + return buffered.Flush() +} + +func enforceNoTerminal(out io.Writer) error { + var isTerm bool + if conn, ok := out.(syscall.Conn); ok { + if raw, err := conn.SyscallConn(); err == nil { + raw.Control(func(fd uintptr) { + isTerm = term.IsTerminal(int(fd)) + }) + } + } + + if !isTerm { + return nil + } + + return errors.New("cowardly refusing to write binary data to a terminal") +} + +type insecureRegistryFlag airgap.InsecureRegistryKind + +var insecureRegistryFlagValues = [...]string{ + airgap.NoInsecureRegistry: "no", + airgap.SkipTLSVerifyRegistry: "skip-tls-verify", + airgap.PlainHTTPRegistry: "plain-http", +} + +func (i insecureRegistryFlag) String() string { + if i := int(i); i < len(insecureRegistryFlagValues) { + return insecureRegistryFlagValues[i] + } else { + return strconv.Itoa(i) + } +} + +func (i *insecureRegistryFlag) Set(value string) error { + idx := slices.Index(insecureRegistryFlagValues[:], value) + if idx >= 0 { + *i = insecureRegistryFlag(idx) + } + + return errors.New("must be one of " + strings.Join(insecureRegistryFlagValues[:], ", ")) +} + +func (insecureRegistryFlag) Type() string { + return "string" +} diff --git a/cmd/airgap/listimages.go b/cmd/airgap/listimages.go index 1291de8a22e7..ea34cef4b12f 100644 --- a/cmd/airgap/listimages.go +++ b/cmd/airgap/listimages.go @@ -17,6 +17,7 @@ limitations under the License. package airgap import ( + "bufio" "errors" "fmt" @@ -31,7 +32,7 @@ func NewAirgapListImagesCmd() *cobra.Command { cmd := &cobra.Command{ Use: "list-images", - Short: "List image names and version needed for air-gap install", + Short: "List image names and versions needed for airgapped installations", Example: `k0s airgap list-images`, RunE: func(cmd *cobra.Command, args []string) error { opts, err := config.GetCmdOpts(cmd) @@ -48,10 +49,11 @@ func NewAirgapListImagesCmd() *cobra.Command { return fmt.Errorf("failed to get config: %w", err) } - for _, uri := range airgap.GetImageURIs(clusterConfig.Spec, all) { - fmt.Fprintln(cmd.OutOrStdout(), uri) + out := bufio.NewWriter(cmd.OutOrStdout()) + for image := range airgap.ImagesInSpec(clusterConfig.Spec, all) { + fmt.Fprintln(out, image.URI()) } - return nil + return out.Flush() }, } cmd.Flags().AddFlagSet(config.FileInputFlag()) diff --git a/go.mod b/go.mod index f98e37c0650a..55577c96c4c8 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,7 @@ require ( github.com/containerd/cgroups/v3 v3.0.4 github.com/containerd/containerd v1.7.23 github.com/distribution/reference v0.6.0 + github.com/dustin/go-humanize v1.0.1 github.com/evanphx/json-patch v5.9.0+incompatible github.com/fsnotify/fsnotify v1.8.0 github.com/go-logr/logr v1.4.2 @@ -84,6 +85,11 @@ require ( sigs.k8s.io/yaml v1.4.0 ) +require ( + github.com/opencontainers/go-digest v1.0.0 + golang.org/x/term v0.26.0 +) + require ( dario.cat/mergo v1.0.1 // indirect github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 // indirect @@ -131,7 +137,6 @@ require ( github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c // indirect github.com/docker/go-metrics v0.0.1 // indirect github.com/docker/go-units v0.5.0 // indirect - github.com/dustin/go-humanize v1.0.1 // indirect github.com/emicklei/go-restful/v3 v3.11.0 // indirect github.com/evanphx/json-patch/v5 v5.9.0 // indirect github.com/exponent-io/jsonpath v0.0.0-20151013193312-d6023ce2651d // indirect @@ -209,7 +214,6 @@ require ( github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect - github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/runc v1.2.1 // indirect github.com/opencontainers/selinux v1.11.0 // indirect github.com/peterbourgon/diskv v2.0.1+incompatible // indirect @@ -260,7 +264,6 @@ require ( golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f // indirect golang.org/x/net v0.31.0 // indirect golang.org/x/oauth2 v0.23.0 // indirect - golang.org/x/term v0.26.0 // indirect golang.org/x/time v0.5.0 // indirect gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect google.golang.org/genproto v0.0.0-20240227224415-6ceb2ff114de // indirect diff --git a/hack/image-bundler/Dockerfile b/hack/image-bundler/Dockerfile deleted file mode 100644 index f9dbdf7b1f8d..000000000000 --- a/hack/image-bundler/Dockerfile +++ /dev/null @@ -1,6 +0,0 @@ -ARG ALPINE_VERSION -FROM docker.io/library/alpine:$ALPINE_VERSION - -RUN apk add --no-cache containerd containerd-ctr -COPY bundler.sh / -CMD /bundler.sh diff --git a/hack/image-bundler/bundler.sh b/hack/image-bundler/bundler.sh deleted file mode 100755 index 7e637b769ec3..000000000000 --- a/hack/image-bundler/bundler.sh +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env sh - -set -eu - -containerd &2 & -#shellcheck disable=SC2064 -trap "{ kill -- $! && wait -- $!; } || true" INT EXIT - -while ! ctr version /dev/null; do - kill -0 $! - echo containerd not yet available >&2 - sleep 1 -done - -echo containerd up >&2 - -set -- - -while read -r image; do - echo Fetching content of "$image" ... >&2 - out="$(ctr content fetch --platform "$TARGET_PLATFORM" -- "$image")" || { - code=$? - echo "$out" >&2 - exit $code - } - - set -- "$@" "$image" -done - -[ -n "$*" ] || { - echo No images provided via STDIN! >&2 - exit 1 -} - -echo Exporting images ... >&2 -ctr images export --platform "$TARGET_PLATFORM" -- - "$@" -echo Images exported. >&2 diff --git a/inttest/airgap/airgap_test.go b/inttest/airgap/airgap_test.go index 5455c3f6ada1..7df49b85cb79 100644 --- a/inttest/airgap/airgap_test.go +++ b/inttest/airgap/airgap_test.go @@ -91,7 +91,8 @@ func (s *AirgapSuite) TestK0sGetsUp() { ssh, err := s.SSH(ctx, s.WorkerNode(0)) s.Require().NoError(err) defer ssh.Disconnect() - for _, i := range airgap.GetImageURIs(v1beta1.DefaultClusterSpec(), true) { + for i := range airgap.ImagesInSpec(v1beta1.DefaultClusterSpec(), true) { + i := i.URI() output, err := ssh.ExecWithOutput(ctx, fmt.Sprintf(`k0s ctr i ls "name==%s"`, i)) s.Require().NoError(err) s.Require().Containsf(output, "io.cri-containerd.pinned=pinned", "expected %s image to have io.cri-containerd.pinned=pinned label", i) diff --git a/inttest/ap-airgap/airgap_test.go b/inttest/ap-airgap/airgap_test.go index ffb66db68011..608ad2a449c4 100644 --- a/inttest/ap-airgap/airgap_test.go +++ b/inttest/ap-airgap/airgap_test.go @@ -77,10 +77,11 @@ func (s *airgapSuite) SetupTest() { ssh, err := s.SSH(ctx, s.WorkerNode(0)) s.Require().NoError(err) defer ssh.Disconnect() - for _, i := range airgap.GetImageURIs(v1beta1.DefaultClusterSpec(), true) { - if strings.HasPrefix(i, constant.KubePauseContainerImage+":") { + for i := range airgap.ImagesInSpec(v1beta1.DefaultClusterSpec(), true) { + if i.Image == constant.KubePauseContainerImage { continue // The pause image is pinned by containerd itself } + i := i.URI() output, err := ssh.ExecWithOutput(ctx, fmt.Sprintf(`k0s ctr i ls "name==%s"`, i)) if s.NoErrorf(err, "Failed to check %s", i) { s.NotContains(output, "io.cri-containerd.pinned=pinned", "%s is already pinned", i) @@ -195,7 +196,8 @@ spec: ssh, err := s.SSH(ctx, s.WorkerNode(0)) s.Require().NoError(err) defer ssh.Disconnect() - for _, i := range airgap.GetImageURIs(v1beta1.DefaultClusterSpec(), true) { + for i := range airgap.ImagesInSpec(v1beta1.DefaultClusterSpec(), true) { + i := i.URI() output, err := ssh.ExecWithOutput(ctx, fmt.Sprintf(`k0s ctr i ls "name==%s"`, i)) if s.NoErrorf(err, "Failed to check %s", i) { s.Contains(output, "io.cri-containerd.pinned=pinned", "%s is not pinned", i) diff --git a/pkg/airgap/bundler.go b/pkg/airgap/bundler.go new file mode 100644 index 000000000000..676fe65dae63 --- /dev/null +++ b/pkg/airgap/bundler.go @@ -0,0 +1,389 @@ +/* +Copyright 2024 k0s authors + +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 airgap + +import ( + "archive/tar" + "bytes" + "cmp" + "context" + "crypto/tls" + "encoding/json" + "errors" + "fmt" + "io" + "maps" + "net/http" + "path" + "runtime" + "slices" + "sync" + + "github.com/k0sproject/k0s/internal/pkg/stringslice" + + "github.com/distribution/reference" + "github.com/dustin/go-humanize" + "github.com/opencontainers/go-digest" + ocispecs "github.com/opencontainers/image-spec/specs-go" + ocispecv1 "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/sirupsen/logrus" + "oras.land/oras-go/v2" + "oras.land/oras-go/v2/content" + "oras.land/oras-go/v2/errdef" + "oras.land/oras-go/v2/registry" + "oras.land/oras-go/v2/registry/remote" + "oras.land/oras-go/v2/registry/remote/auth" + "oras.land/oras-go/v2/registry/remote/credentials" +) + +type InsecureRegistryKind uint8 + +const ( + NoInsecureRegistry InsecureRegistryKind = iota + SkipTLSVerifyRegistry + PlainHTTPRegistry +) + +type ImageBundler struct { + Log logrus.FieldLogger + InsecureRegistries InsecureRegistryKind + RegistriesConfigPaths []string +} + +func (b *ImageBundler) Run(ctx context.Context, refs []reference.Named, out io.Writer) error { + var client *http.Client + if len := len(refs); len < 1 { + b.Log.Warn("No images to bundle") + } else { + b.Log.Infof("About to bundle %d images", len) + var close func() + client, close = newClient(b.InsecureRegistries == SkipTLSVerifyRegistry) + defer close() + } + + creds, err := newCredentials(b.RegistriesConfigPaths) + if err != nil { + return err + } + + newSource := func(ref reference.Named) oras.ReadOnlyTarget { + return &remote.Repository{ + Client: &auth.Client{ + Client: client, + Credential: creds, + }, + Reference: registry.Reference{ + Registry: reference.Domain(ref), + Repository: reference.Path(ref), + }, + PlainHTTP: b.InsecureRegistries == PlainHTTPRegistry, + } + } + + return bundleImages(ctx, b.Log, newSource, refs, out) +} + +func newCredentials(configPaths []string) (_ auth.CredentialFunc, err error) { + var store credentials.Store + var opts credentials.StoreOptions + + if len(configPaths) < 1 { + store, err = credentials.NewStoreFromDocker(opts) + if err != nil { + return nil, err + } + } else { + store, err = credentials.NewStore(configPaths[0], opts) + if err != nil { + return nil, err + } + if configPaths := configPaths[1:]; len(configPaths) > 0 { + otherStores := make([]credentials.Store, len(configPaths)) + for i, path := range configPaths { + otherStores[i], err = credentials.NewStore(path, opts) + if err != nil { + return nil, err + } + } + store = credentials.NewStoreWithFallbacks(store, otherStores...) + } + } + + return credentials.Credential(store), nil +} + +func newClient(insecureSkipTLSVerify bool) (_ *http.Client, close func()) { + // This transports is, by design, a trimmed down version of http's DefaultTransport. + // No need to have all those timeouts the default client brings in. + transport := &http.Transport{Proxy: http.ProxyFromEnvironment} + + if insecureSkipTLSVerify { + transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} + } + + return &http.Client{Transport: transport}, transport.CloseIdleConnections +} + +type sourceFactory func(ref reference.Named) oras.ReadOnlyTarget + +func bundleImages(ctx context.Context, log logrus.FieldLogger, newSource sourceFactory, refs []reference.Named, out io.Writer) error { + tarWriter := tar.NewWriter(out) + target := tarFileTarget{w: &tarBlobWriter{w: tarWriter}} + index := ocispecv1.Index{ + Versioned: ocispecs.Versioned{SchemaVersion: 2}, // FIXME constant somewhere? + MediaType: ocispecv1.MediaTypeImageIndex, + } + + for numRef, ref := range refs { + targetRefs, err := targetRefsFor(ref) + if err != nil { + return err + } + + log := log.WithFields(logrus.Fields{ + "image": fmt.Sprintf("%d/%d", numRef+1, len(refs)), + "name": ref, + }) + desc, err := bundleImage(ctx, log, newSource, ref, &target) + if err != nil { + return err + } + + // Store the image multiple times with all its possible target refs. + for _, targetRef := range targetRefs { + desc := desc // shallow copy + desc.Annotations = maps.Clone(desc.Annotations) + if desc.Annotations == nil { + desc.Annotations = make(map[string]string, 1) + } + desc.Annotations[ocispecv1.AnnotationRefName] = targetRef + index.Manifests = append(index.Manifests, desc) + } + } + + if err := writeTarJSON(tarWriter, ocispecv1.ImageIndexFile, index); err != nil { + return err + } + if err := writeTarJSON(tarWriter, ocispecv1.ImageLayoutFile, &ocispecv1.ImageLayout{ + Version: ocispecv1.ImageLayoutVersion, + }); err != nil { + return err + } + + return tarWriter.Close() +} + +// Calculates the target references for the given input reference. +func targetRefsFor(ref reference.Named) (targetRefs []string, _ error) { + // First the name as is + targetRefs = append(targetRefs, reference.TagNameOnly(ref).String()) + + nameOnly := reference.TrimNamed(ref) + + // Then as name:tag + if tagged, ok := ref.(reference.Tagged); ok { + tagged, err := reference.WithTag(nameOnly, tagged.Tag()) + if err != nil { + return nil, err + } + targetRefs = append(targetRefs, tagged.String()) + } + + // Then as name@digest + if digested, ok := ref.(reference.Digested); ok { + digested, err := reference.WithDigest(nameOnly, digested.Digest()) + if err != nil { + return nil, err + } + targetRefs = append(targetRefs, digested.String()) + } + + // Dedup the refs + return stringslice.Unique(targetRefs), nil +} + +func bundleImage(ctx context.Context, log logrus.FieldLogger, newSource sourceFactory, ref reference.Named, dst oras.Target) (ocispecv1.Descriptor, error) { + var srcRef string + if digested, ok := ref.(reference.Digested); ok { + srcRef = string(digested.Digest()) + } else if tagged, ok := ref.(reference.Tagged); ok { + srcRef = tagged.Tag() + } + + var opts oras.CopyOptions + opts.Concurrency = 1 // reproducible output + // Implement custom platform filtering. The default opts.WithTargetPlatform + // will throw away multi-arch image indexes and thus change image digests. + opts.FindSuccessors = func(ctx context.Context, fetcher content.Fetcher, desc ocispecv1.Descriptor) ([]ocispecv1.Descriptor, error) { + descs, err := content.Successors(ctx, fetcher, desc) + if err != nil { + return nil, err + } + + var platformDescs []ocispecv1.Descriptor + for _, desc := range descs { + p := desc.Platform + if p != nil && (p.Architecture != runtime.GOARCH || p.OS != runtime.GOOS) { + continue + } + platformDescs = append(platformDescs, desc) + } + + return platformDescs, nil + } + opts.PreCopy = func(ctx context.Context, desc ocispecv1.Descriptor) error { + log.WithField("digest", desc.Digest).Infof("Copying %s", humanize.IBytes(uint64(desc.Size))) + return nil + } + + return oras.Copy(ctx, newSource(ref), srcRef, dst, "", opts) +} + +func writeTarDir(w *tar.Writer, name string) error { + return w.WriteHeader(&tar.Header{ + Name: name + "/", + Typeflag: tar.TypeDir, + Mode: 0755, + }) +} + +func writeTarJSON(w *tar.Writer, name string, data any) error { + json, err := json.Marshal(data) + if err != nil { + return err + } + return writeTarFile(w, name, int64(len(json)), bytes.NewReader(json)) +} + +func writeTarFile(w *tar.Writer, name string, size int64, in io.Reader) error { + if err := w.WriteHeader(&tar.Header{ + Name: name, + Typeflag: tar.TypeReg, + Mode: 0644, + Size: size, + }); err != nil { + return err + } + + _, err := io.Copy(w, in) + return err +} + +type tarFileTarget struct { + mu sync.RWMutex + w *tarBlobWriter +} + +type tarBlobWriter struct { + w *tar.Writer + blobs []digest.Digest +} + +func (t *tarFileTarget) doLocked(exclusive bool, fn func(w *tarBlobWriter) error) (err error) { + if exclusive { + t.mu.Lock() + defer func() { + if err != nil { + t.w = nil + } + t.mu.Unlock() + }() + } else { + t.mu.RLock() + defer t.mu.RUnlock() + } + + if t.w == nil { + return errors.New("writer is broken") + } + + return fn(t.w) +} + +// Exists implements [oras.Target]. +func (t *tarFileTarget) Exists(ctx context.Context, target ocispecv1.Descriptor) (exists bool, _ error) { + err := t.doLocked(false, func(w *tarBlobWriter) error { + _, exists = slices.BinarySearch(w.blobs, target.Digest) + return nil + }) + return exists, err +} + +// Push implements [oras.Target]. +func (t *tarFileTarget) Push(ctx context.Context, expected ocispecv1.Descriptor, in io.Reader) (err error) { + d := expected.Digest + if err := d.Validate(); err != nil { + return err + } + + lockErr := t.doLocked(true, func(w *tarBlobWriter) error { + idx, exists := slices.BinarySearch(w.blobs, d) + if exists { + err = errdef.ErrAlreadyExists + return nil + } + + if len(w.blobs) < 1 { + if err := writeTarDir(w.w, ocispecv1.ImageBlobsDir); err != nil { + return err + } + } + + if (idx == 0 || w.blobs[idx-1].Algorithm() != d.Algorithm()) && + (idx >= len(w.blobs) || w.blobs[idx].Algorithm() != d.Algorithm()) { + dirName := path.Join(ocispecv1.ImageBlobsDir, d.Algorithm().String()) + if err := writeTarDir(w.w, dirName); err != nil { + return err + } + } + + blobName := path.Join(ocispecv1.ImageBlobsDir, d.Algorithm().String(), d.Hex()) + verify := content.NewVerifyReader(in, expected) + if err := writeTarFile(w.w, blobName, expected.Size, verify); err != nil { + return err + } + if err := verify.Verify(); err != nil { + return err + } + + w.blobs = slices.Insert(w.blobs, idx, d) + return nil + }) + + return cmp.Or(lockErr, err) +} + +// Tag implements [oras.Target]. +func (t *tarFileTarget) Tag(ctx context.Context, desc ocispecv1.Descriptor, reference string) error { + if exists, err := t.Exists(ctx, desc); err != nil { + return err + } else if !exists { + return errdef.ErrNotFound + } + + return nil // don't store tag information +} + +// Resolve implements [oras.Target]. +func (t *tarFileTarget) Resolve(ctx context.Context, reference string) (d ocispecv1.Descriptor, _ error) { + return d, fmt.Errorf("%w: Resolve(_, %q)", errdef.ErrUnsupported, reference) +} + +// Fetch implements [oras.Target]. +func (t *tarFileTarget) Fetch(ctx context.Context, target ocispecv1.Descriptor) (io.ReadCloser, error) { + return nil, fmt.Errorf("%w: Fetch(_, %v)", errdef.ErrUnsupported, target) +} diff --git a/pkg/airgap/images.go b/pkg/airgap/images.go index 55a2388822f1..3bc86e873795 100644 --- a/pkg/airgap/images.go +++ b/pkg/airgap/images.go @@ -17,43 +17,51 @@ limitations under the License. package airgap import ( + "iter" "runtime" "github.com/k0sproject/k0s/pkg/apis/k0s/v1beta1" "github.com/k0sproject/k0s/pkg/constant" ) -// GetImageURIs returns all image tags -func GetImageURIs(spec *v1beta1.ClusterSpec, all bool) []string { - pauseImage := v1beta1.ImageSpec{ - Image: constant.KubePauseContainerImage, - Version: constant.KubePauseContainerImageVersion, - } +// Yields all images in spec needed for airgapped installations. +func ImagesInSpec(spec *v1beta1.ClusterSpec, all bool) iter.Seq[v1beta1.ImageSpec] { + return func(yield func(v1beta1.ImageSpec) bool) { + for _, i := range []*v1beta1.ImageSpec{ + spec.Images.Calico.CNI, + spec.Images.Calico.KubeControllers, + spec.Images.Calico.Node, + spec.Images.CoreDNS, + spec.Images.Konnectivity, + spec.Images.KubeProxy, + spec.Images.KubeRouter.CNI, + spec.Images.KubeRouter.CNIInstaller, + spec.Images.MetricsServer, + } { + if i != nil && !yield(*i) { + return + } + } - imageURIs := []string{ - spec.Images.Calico.CNI.URI(), - spec.Images.Calico.KubeControllers.URI(), - spec.Images.Calico.Node.URI(), - spec.Images.CoreDNS.URI(), - spec.Images.Konnectivity.URI(), - spec.Images.KubeProxy.URI(), - spec.Images.KubeRouter.CNI.URI(), - spec.Images.KubeRouter.CNIInstaller.URI(), - spec.Images.MetricsServer.URI(), - pauseImage.URI(), - } + if !yield(v1beta1.ImageSpec{ + Image: constant.KubePauseContainerImage, + Version: constant.KubePauseContainerImageVersion, + }) { + return + } - if spec.Network != nil { - nllb := spec.Network.NodeLocalLoadBalancing - if nllb != nil && (all || nllb.IsEnabled()) { - switch nllb.Type { - case v1beta1.NllbTypeEnvoyProxy: - if runtime.GOARCH != "arm" && nllb.EnvoyProxy != nil && nllb.EnvoyProxy.Image != nil { - imageURIs = append(imageURIs, nllb.EnvoyProxy.Image.URI()) + if spec.Network != nil { + nllb := spec.Network.NodeLocalLoadBalancing + if nllb != nil && (all || nllb.IsEnabled()) { + switch nllb.Type { + case v1beta1.NllbTypeEnvoyProxy: + if runtime.GOARCH != "arm" && nllb.EnvoyProxy != nil && nllb.EnvoyProxy.Image != nil { + if !yield(*nllb.EnvoyProxy.Image) { + return + } + } } } } } - - return imageURIs }