From 1f3302ee0c572daa737a313adfd10647c2b31bb6 Mon Sep 17 00:00:00 2001 From: Tom Wieczorek Date: Fri, 13 Dec 2024 12:58:11 +0100 Subject: [PATCH 1/2] Introduce k0s airgap bundle-artifacts This sub-command introduces a "k0s native" way to create airgap artifact bundles. The current way to do this requires either a Docker or containerd daemon. Even then, there are certain scenarios where the created bundles don't contain all the necessary tags, i.e. when images are referenced by both their tag and their digest. Signed-off-by: Tom Wieczorek --- cmd/airgap/airgap.go | 18 +- cmd/airgap/bundleartifacts.go | 195 ++++++++ cmd/airgap/bundleartifacts_test.go | 191 ++++++++ cmd/airgap/listimages.go | 4 +- cmd/airgap/listimages_test.go | 4 +- ...2c3a8304fad1a69ec8bb0129d49eb1bbc6bd0d12f1 | 24 + ...7aeadee50344fa2bed3d912dcceb8c1b27f1330b49 | Bin 0 -> 116 bytes ...44e9c8c671eb2247c295699cc1c0f69a4d93242206 | 19 + ...22c3130cb819b9f3bbeb63106163dbcac294517404 | 25 + ...52ba03c12683d5997fbcda0fd6aa6dda4a21a87884 | 34 ++ ...84e1f47499512acffd0c0db6e208bdaf4b644b33d6 | 19 + ...2876d1338016b9c2fda2052ff2550e067200382502 | 19 + ...d8792a17f970a231107dbc130571ca6bd84d523f20 | 24 + .../testdata/oci-layout/content-types.json | 10 + cmd/airgap/testdata/oci-layout/index.json | 13 + docs/airgap-install.md | 140 ++++-- go.mod | 6 +- mkdocs.yml | 2 +- pkg/airgap/ociartifactsbundler.go | 452 ++++++++++++++++++ pkg/component/worker/ocibundle.go | 2 +- 20 files changed, 1140 insertions(+), 61 deletions(-) create mode 100644 cmd/airgap/bundleartifacts.go create mode 100644 cmd/airgap/bundleartifacts_test.go create mode 100644 cmd/airgap/testdata/oci-layout/blobs/sha256/19d3d41ccd3a337cbd19142c3a8304fad1a69ec8bb0129d49eb1bbc6bd0d12f1 create mode 100644 cmd/airgap/testdata/oci-layout/blobs/sha256/2c640c3bebc4bba061a05c7aeadee50344fa2bed3d912dcceb8c1b27f1330b49 create mode 100644 cmd/airgap/testdata/oci-layout/blobs/sha256/4cbfb7be9baac76cc5dd2b44e9c8c671eb2247c295699cc1c0f69a4d93242206 create mode 100644 cmd/airgap/testdata/oci-layout/blobs/sha256/6a7f603fb7cf5e494705bc22c3130cb819b9f3bbeb63106163dbcac294517404 create mode 100644 cmd/airgap/testdata/oci-layout/blobs/sha256/9a280cc46a419001ca991d52ba03c12683d5997fbcda0fd6aa6dda4a21a87884 create mode 100644 cmd/airgap/testdata/oci-layout/blobs/sha256/c259653916b1fea8bd000584e1f47499512acffd0c0db6e208bdaf4b644b33d6 create mode 100644 cmd/airgap/testdata/oci-layout/blobs/sha256/e3f5758bd1bd52a64a10152876d1338016b9c2fda2052ff2550e067200382502 create mode 100644 cmd/airgap/testdata/oci-layout/blobs/sha256/eab318ba111ebe88a3a3d7d8792a17f970a231107dbc130571ca6bd84d523f20 create mode 100644 cmd/airgap/testdata/oci-layout/content-types.json create mode 100644 cmd/airgap/testdata/oci-layout/index.json create mode 100644 pkg/airgap/ociartifactsbundler.go diff --git a/cmd/airgap/airgap.go b/cmd/airgap/airgap.go index 4c07343e9e7f..3be76810dd43 100644 --- a/cmd/airgap/airgap.go +++ b/cmd/airgap/airgap.go @@ -17,18 +17,28 @@ limitations under the License. package airgap import ( - "github.com/spf13/cobra" - "github.com/k0sproject/k0s/pkg/config" + + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" ) func NewAirgapCmd() *cobra.Command { cmd := &cobra.Command{ Use: "airgap", - Short: "Manage airgap setup", + Short: "Tooling for airgapped installations", + Long: `Tooling for airgapped installations. + +For example, to create an image bundle that contains the images required for +the current configuration, use the following command: + + k0s airgap list-images | k0s airgap bundle-artifacts -v -o image-bundle.tar +`, } - cmd.AddCommand(NewAirgapListImagesCmd()) + log := logrus.StandardLogger() + cmd.AddCommand(newAirgapListImagesCmd()) + cmd.AddCommand(newAirgapBundleArtifactsCmd(log, nil)) cmd.PersistentFlags().AddFlagSet(config.FileInputFlag()) cmd.PersistentFlags().AddFlagSet(config.GetPersistentFlagSet()) return cmd diff --git a/cmd/airgap/bundleartifacts.go b/cmd/airgap/bundleartifacts.go new file mode 100644 index 000000000000..f45a2ff21f16 --- /dev/null +++ b/cmd/airgap/bundleartifacts.go @@ -0,0 +1,195 @@ +/* +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" + "errors" + "fmt" + "io" + "iter" + "os" + "os/signal" + "slices" + "strconv" + "strings" + "syscall" + + "github.com/k0sproject/k0s/internal/pkg/file" + "github.com/k0sproject/k0s/pkg/airgap" + + "k8s.io/kubectl/pkg/util/term" + + "github.com/containerd/platforms" + "github.com/distribution/reference" + imagespecv1 "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" +) + +func newAirgapBundleArtifactsCmd(log logrus.FieldLogger, rewriteBundleRef airgap.RewriteRefFunc) *cobra.Command { + var ( + outPath string + platform = platforms.DefaultSpec() + bundler = airgap.OCIArtifactsBundler{ + Log: log, + RewriteTarget: rewriteBundleRef, + } + ) + + cmd := &cobra.Command{ + Use: "bundle-artifacts [flags] [names...]", + Short: "Bundles artifacts needed for airgapped installations into a tarball", + Long: `Bundles artifacts needed for airgapped installations into a tarball. Fetches the +artifacts from their OCI registries and bundles them into an OCI Image Layout +archive (written to standard output by default). Reads names from standard input +if no names are given on the command line.`, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) + defer cancel() + + cmd.SilenceUsage = true + + bundler.PlatformMatcher = platforms.Only(platform) + + var ( + refs []reference.Named + err error + ) + if len(args) > 0 { + refs, err = parseArtifactRefsFromSeq(slices.Values(args)) + } else { + refs, err = parseArtifactRefsFromReader(cmd.InOrStdin()) + } + if err != nil { + return err + } + + var out io.Writer + if outPath == "" { + out = cmd.OutOrStdout() + if term.IsTerminal(out) { + return errors.New("cowardly refusing to write binary data to a terminal") + } + } else { + f, openErr := file.AtomicWithTarget(outPath).Open() + if openErr != nil { + return openErr + } + 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 := bundler.Run(ctx, refs, out); err != nil { + return err + } + return buffered.Flush() + }, + } + + cmd.Flags().StringVarP(&outPath, "output", "o", "", "output file path (writes to standard output if omitted)") + cmd.Flags().Var((*insecureRegistryFlag)(&bundler.InsecureRegistries), "insecure-registries", "one of "+strings.Join(insecureRegistryFlagValues[:], ", ")) + cmd.Flags().Var((*platformFlag)(&platform), "platform", "the platform to export") + cmd.Flags().StringArrayVar(&bundler.RegistriesConfigPaths, "registries-config", nil, "paths to the authentication files for OCI registries (uses the standard Docker config if omitted)") + + return cmd +} + +func parseArtifactRefsFromReader(in io.Reader) ([]reference.Named, error) { + words := bufio.NewScanner(in) + words.Split(bufio.ScanWords) + refs, err := parseArtifactRefsFromSeq(func(yield func(string) bool) { + for words.Scan() { + if !yield(words.Text()) { + return + } + } + }) + if err := errors.Join(err, words.Err()); err != nil { + return nil, err + } + + return refs, nil +} + +func parseArtifactRefsFromSeq(refs iter.Seq[string]) (collected []reference.Named, _ error) { + for ref := range refs { + parsed, err := reference.ParseNormalizedNamed(ref) + if err != nil { + return nil, fmt.Errorf("while parsing %s: %w", ref, err) + } + collected = append(collected, parsed) + } + return collected, nil +} + +type insecureRegistryFlag airgap.InsecureOCIRegistryKind + +var insecureRegistryFlagValues = [...]string{ + airgap.NoInsecureOCIRegistry: "no", + airgap.SkipTLSVerifyOCIRegistry: "skip-tls-verify", + airgap.PlainHTTPOCIRegistry: "plain-http", +} + +func (insecureRegistryFlag) Type() string { + return "string" +} + +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 { + *(*airgap.InsecureOCIRegistryKind)(i) = airgap.InsecureOCIRegistryKind(idx) + return nil + } + + return errors.New("must be one of " + strings.Join(insecureRegistryFlagValues[:], ", ")) +} + +type platformFlag imagespecv1.Platform + +func (p *platformFlag) Type() string { + return "string" +} + +func (p *platformFlag) String() string { + return platforms.FormatAll(*(*imagespecv1.Platform)(p)) +} + +func (p *platformFlag) Set(value string) error { + platform, err := platforms.Parse(value) + if err != nil { + return err + } + *(*imagespecv1.Platform)(p) = platform + return nil +} diff --git a/cmd/airgap/bundleartifacts_test.go b/cmd/airgap/bundleartifacts_test.go new file mode 100644 index 000000000000..11f61186e7bf --- /dev/null +++ b/cmd/airgap/bundleartifacts_test.go @@ -0,0 +1,191 @@ +/* +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 ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path" + "path/filepath" + "strings" + "testing" + "testing/iotest" + + internalio "github.com/k0sproject/k0s/internal/io" + + "github.com/distribution/reference" + "github.com/opencontainers/go-digest" + imagespecv1 "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/sirupsen/logrus" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBundleArtifactsCmd_RejectsCertificate(t *testing.T) { + t.Parallel() + + log := logrus.New() + log.Out = io.Discard + var stderr strings.Builder + + registry := startFakeRegistry(t, false) + + underTest := newAirgapBundleArtifactsCmd(log, nil) + underTest.SetIn(strings.NewReader(path.Join(registry, "hello:1980"))) + underTest.SetOut(internalio.WriterFunc(func(d []byte) (int, error) { + assert.Fail(t, "Expected no writes to standard output", "Written: %s", d) + return 0, assert.AnError + })) + underTest.SetErr(&stderr) + + err := underTest.Execute() + + expected := "tls: failed to verify certificate: x509: certificate signed by unknown authority" + assert.ErrorContains(t, err, registry) + assert.ErrorContains(t, err, expected) + assert.Contains(t, stderr.String(), expected) +} + +func TestBundleArtifactsCmd_WithPlatforms(t *testing.T) { + log := logrus.New() + log.Out = io.Discard + + for _, insecureRegistriesFlag := range []string{"skip-tls-verify", "plain-http"} { + t.Run(insecureRegistriesFlag, func(t *testing.T) { + registry := startFakeRegistry(t, insecureRegistriesFlag == "plain-http") + ref := registry + "/hello:1980" + + // Need to rewrite the artifact name to get reproducible output. + rewriteBundleRef := func(sourceRef reference.Named) (targetRef reference.Named) { + if sourceRef.String() == ref { + targetRef, err := reference.ParseNamed("registry.example.com/hello:1980") + if assert.NoError(t, err) { + return targetRef + } + } + return sourceRef + } + + for platform, digest := range map[string]string{ + "linux/amd64": "7c7a6255a6bdf5ae9cb5e717852a34180b124dc15ba29e1f922459613c206e68", + "linux/arm64": "ae78a79237689e234ba5272130a9739ae64fe9df349aee363b4491fd98cb5cf1", + "linux/arm/v7": "44e355bbfb4c874b28aa6e6773481d2f64bc03d37aa793a988209a6bd5911a6d", + } { + t.Run(platform, func(t *testing.T) { + hasher := sha256.New() + underTest := newAirgapBundleArtifactsCmd(log, rewriteBundleRef) + underTest.SetArgs([]string{ + "--insecure-registries", insecureRegistriesFlag, + "--platform", platform, + ref, + }) + underTest.SetIn(iotest.ErrReader(errors.New("unexpected read from standard input"))) + underTest.SetOut(hasher) + underTest.SetErr(internalio.WriterFunc(func(d []byte) (int, error) { + assert.Fail(t, "Expected no writes to standard error", "Written: %s", d) + return 0, assert.AnError + })) + + require.NoError(t, underTest.Execute()) + assert.Equal(t, digest, hex.EncodeToString(hasher.Sum(nil))) + }) + } + }) + } +} + +func startFakeRegistry(t *testing.T, plainHTTP bool) string { + manifests := make(map[string]digest.Digest) + var contentTypes map[digest.Digest]string + if data, err := os.ReadFile(filepath.Join("testdata", "oci-layout", imagespecv1.ImageIndexFile)); assert.NoError(t, err) { + var index imagespecv1.Index + require.NoError(t, json.Unmarshal(data, &index)) + for _, manifest := range index.Manifests { + name := manifest.Annotations[imagespecv1.AnnotationRefName] + if name != "" { + manifests[name] = manifest.Digest + } + } + } + if data, err := os.ReadFile(filepath.Join("testdata", "oci-layout", "content-types.json")); assert.NoError(t, err) { + require.NoError(t, json.Unmarshal(data, &contentTypes)) + } + + mux := http.NewServeMux() + mux.HandleFunc("/v2/{name}/{kind}/{ident}", func(w http.ResponseWriter, r *http.Request) { + var dgst digest.Digest + switch r.PathValue("kind") { + case "manifests": + var found bool + name := r.PathValue("name") + ":" + r.PathValue("ident") + if dgst, found = manifests[name]; found { + break + } + fallthrough + case "blobs": + dgst = digest.Digest(r.PathValue("ident")) + if err := dgst.Validate(); err == nil { + break + } + fallthrough + default: + w.WriteHeader(http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", contentTypes[dgst]) + path := filepath.Join("testdata", "oci-layout", "blobs", dgst.Algorithm().String(), dgst.Hex()) + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + w.WriteHeader(http.StatusNotFound) + return + } + assert.NoError(t, err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + _, err = w.Write(data) + assert.NoError(t, err) + }) + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Log(r.Proto, r.Method, r.RequestURI) + mux.ServeHTTP(w, r) + }) + + var server *httptest.Server + if plainHTTP { + server = httptest.NewServer(handler) + } else { + server = httptest.NewTLSServer(handler) + } + t.Cleanup(server.Close) + + url, err := url.Parse(server.URL) + require.NoError(t, err) + return path.Join(url.Host, url.Path) +} diff --git a/cmd/airgap/listimages.go b/cmd/airgap/listimages.go index 842ad5ce1705..15dd20cce86a 100644 --- a/cmd/airgap/listimages.go +++ b/cmd/airgap/listimages.go @@ -25,12 +25,12 @@ import ( "github.com/spf13/cobra" ) -func NewAirgapListImagesCmd() *cobra.Command { +func newAirgapListImagesCmd() *cobra.Command { var all bool 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) diff --git a/cmd/airgap/listimages_test.go b/cmd/airgap/listimages_test.go index be20ffc85a87..97ed4dae87fa 100644 --- a/cmd/airgap/listimages_test.go +++ b/cmd/airgap/listimages_test.go @@ -46,7 +46,7 @@ func TestAirgapListImages(t *testing.T) { t.Run("HonorsIOErrors", func(t *testing.T) { var writes uint - underTest := NewAirgapListImagesCmd() + underTest := newAirgapListImagesCmd() underTest.SetIn(iotest.ErrReader(errors.New("unexpected read from standard input"))) underTest.SilenceUsage = true // Cobra writes usage to stdout on errors 🤔 underTest.SetOut(internalio.WriterFunc(func(p []byte) (int, error) { @@ -127,7 +127,7 @@ func newAirgapListImagesCmdWithConfig(t *testing.T, config string, args ...strin require.NoError(t, os.WriteFile(configFile, []byte(config), 0644)) out, err = new(strings.Builder), new(strings.Builder) - cmd := NewAirgapListImagesCmd() + cmd := newAirgapListImagesCmd() cmd.SetArgs(append([]string{"--config=" + configFile}, args...)) cmd.SetIn(iotest.ErrReader(errors.New("unexpected read from standard input"))) cmd.SetOut(out) diff --git a/cmd/airgap/testdata/oci-layout/blobs/sha256/19d3d41ccd3a337cbd19142c3a8304fad1a69ec8bb0129d49eb1bbc6bd0d12f1 b/cmd/airgap/testdata/oci-layout/blobs/sha256/19d3d41ccd3a337cbd19142c3a8304fad1a69ec8bb0129d49eb1bbc6bd0d12f1 new file mode 100644 index 000000000000..f5754e34ca05 --- /dev/null +++ b/cmd/airgap/testdata/oci-layout/blobs/sha256/19d3d41ccd3a337cbd19142c3a8304fad1a69ec8bb0129d49eb1bbc6bd0d12f1 @@ -0,0 +1,24 @@ +{ + "architecture": "arm64", + "config": { + "Env": [ + "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + ], + "WorkingDir": "/" + }, + "created": "1980-01-01T00:00:00Z", + "history": [ + { + "created": "1980-01-01T00:00:00Z", + "created_by": "COPY /hello /hello # buildkit", + "comment": "buildkit.dockerfile.v0" + } + ], + "os": "linux", + "rootfs": { + "type": "layers", + "diff_ids": [ + "sha256:fc5206b05a82c863b06199219265bba69c4c4952b01a843254873a29a85a717a" + ] + } +} diff --git a/cmd/airgap/testdata/oci-layout/blobs/sha256/2c640c3bebc4bba061a05c7aeadee50344fa2bed3d912dcceb8c1b27f1330b49 b/cmd/airgap/testdata/oci-layout/blobs/sha256/2c640c3bebc4bba061a05c7aeadee50344fa2bed3d912dcceb8c1b27f1330b49 new file mode 100644 index 0000000000000000000000000000000000000000..2596f7c7fcbef282587b6718a3262a0cefd64826 GIT binary patch literal 116 zcmb2|=3oGW|EE08o;;bpK-T~Sj13ttv$3(g*wWZIQLwpj;>4L7S57pHxO3sai7PFR z2OEyPV`{$iXwnfSHs-FkPT%hcFv>0X5nd2rU}j=!YPQM1;DJ/images` folder will automatically import them into +the container runtime. -- Use a ready-made image bundle, which is created for each k0s release. It can be downloaded from the [releases page](https://github.com/k0sproject/k0s/releases/latest). -- Create your own image bundle. In this case, you can easily customize the bundle to also include container images, which are not used by default in k0s. +There are several ways to obtain an image bundle: -## Prerequisites +- Use the pre-built image bundles for different target platforms that are + created for each k0s release. They contain all the images for the default k0s + [image configuration](configuration.md#specimages) and can be downloaded from + the [GitHub releases page]. +- Create your own image bundle. In this case, you can easily customize the + bundle to include container images that are not used by default in k0s. -In order to create your own image bundle, you need: +**Note:** When importing image bundles, k0s uses ["loose" platform +matching](https://pkg.go.dev/github.com/containerd/platforms@v0.2.1#Only). For +example, on arm/v8, k0s will also import arm/v7, arm/v6, and arm/v5 images. This +means that your bundle can contain multi-arch images, and the import will be +done using platform compatibility. -- A working cluster with at least one controller that will be used to build the - image bundle. See the [Quick Start Guide] for more information. -- The containerd CLI management tool `ctr`, installed on the worker node. See - the [containerd Getting Started Guide] for more information. +[OCI Image Layout]: https://github.com/opencontainers/image-spec/blob/v1.0/image-layout.md +[GitHub releases page]: https://github.com/k0sproject/k0s/releases/v{{{ extra.k8s_version }}}+k0s.0 -[Quick Start Guide]: install.md -[containerd Getting Started Guide]: https://github.com/containerd/containerd/blob/v1.7.24/docs/getting-started.md +## Creating image bundles -## 1. Create your own image bundle (optional) +### Using k0s builtin tooling -k0s/containerd uses OCI (Open Container Initiative) bundles for airgap installation. OCI bundles must be uncompressed. As OCI bundles are built specifically for each architecture, create an OCI bundle that uses the same processor architecture (x86-64, ARM64, ARMv7) as on the target system. +k0s ships with the [`k0s airgap`](cli/k0s_airgap.md) sub-command, which is +dedicated for tooling for airgapped environments. It allows for listing the +required images for a given configuration, as well as bundling them into an OCI +Image Layout archive. -k0s offers two methods for creating OCI bundles, one using Docker and the other using a previously set up k0s worker. - -**Note:** When importing the image bundle k0s uses containerd "loose" [platform matching](https://pkg.go.dev/github.com/containerd/containerd/platforms#Only). For arm/v8, it will also match arm/v7, arm/v6 and arm/v5. This means that your bundle can contain multi arch images and the import will be done using platform compatibility. - -### Docker - -1. Pull the images. +1. Create the list of images required by k0s. ```shell - k0s airgap list-images | xargs -I{} docker pull {} + k0s airgap list-images --all >airgap-images.txt ``` -2. Create a bundle. +2. Review this list and edit it according to your needs. + +3. Create the image bundle. ```shell - docker image save $(k0s airgap list-images | xargs) -o bundle_file + k0s airgap bundle-artifacts -v -o image-bundle.tar airgap-images.txt + ``` + +2. Review this list and edit it according to your needs. + +3. Pull the images. + + ```shell + xargs -I{} docker pull {} user: ubuntu - keyPath: /path/.ssh/id_rsa + keyPath: /path/to/.ssh/id_rsa # uploadBinary: # When true the k0s binaries are cached and uploaded @@ -95,19 +143,21 @@ spec: ssh: address: user: ubuntu - keyPath: /path/.ssh/id_rsa + keyPath: /path/to/.ssh/id_rsa uploadBinary: true files: # This airgap bundle file will be uploaded from the k0sctl # host to the specified directory on the target host - - src: /local/path/to/bundle-file/airgap-bundle-amd64.tar - dstDir: /var/lib/k0s/images/ + - src: /path/to/airgap-bundle-amd64.tar + dstDir: /var/lib/k0s/images perm: 0755 ``` -## 3. Ensure pull policy in the k0s.yaml (optional) +## Disable image pulling (optional) -Use the following `k0s.yaml` to ensure that containerd does not pull images for k0s components from the Internet at any time. +Use the following k0s configuration to ensure that all pods and pod templates +managed by k0s contain an `imagePullPolicy` of `Never`, ensuring that no images +are pulled from the Internet at any time. ```yaml apiVersion: k0s.k0sproject.io/v1beta1 @@ -118,9 +168,3 @@ spec: images: default_pull_policy: Never ``` - -## 4. Set up the controller and worker nodes - -Refer to the [Manual Install](k0s-multi-node.md) for information on setting up the controller and worker nodes locally. Alternatively, you can use [k0sctl](k0sctl-install.md). - -**Note**: During the worker start up k0s imports all bundles from the `$K0S_DATA_DIR/images` before starting `kubelet`. diff --git a/go.mod b/go.mod index 12babc1baae6..40ed101c901c 100644 --- a/go.mod +++ b/go.mod @@ -16,7 +16,9 @@ require ( github.com/cloudflare/cfssl v1.6.4 github.com/containerd/cgroups/v3 v3.0.4 github.com/containerd/containerd v1.7.24 + github.com/containerd/platforms v0.2.1 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 @@ -31,6 +33,7 @@ require ( github.com/mesosphere/toml-merge v0.2.0 github.com/mitchellh/go-homedir v1.1.0 github.com/olekukonko/tablewriter v0.0.5 + github.com/opencontainers/go-digest v1.0.0 github.com/opencontainers/image-spec v1.1.0 github.com/opencontainers/runtime-spec v1.2.0 github.com/otiai10/copy v1.14.0 @@ -111,7 +114,6 @@ require ( github.com/containerd/go-cni v1.1.9 // indirect github.com/containerd/go-runc v1.1.0 // indirect github.com/containerd/log v0.1.0 // indirect - github.com/containerd/platforms v0.2.1 // indirect github.com/containerd/ttrpc v1.2.5 // indirect github.com/containerd/typeurl/v2 v2.1.1 // indirect github.com/containernetworking/cni v1.1.2 // indirect @@ -130,7 +132,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 @@ -208,7 +209,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.3 // indirect github.com/opencontainers/selinux v1.11.0 // indirect github.com/peterbourgon/diskv v2.0.1+incompatible // indirect diff --git a/mkdocs.yml b/mkdocs.yml index 0a41aebf5744..44903fdcd992 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -22,7 +22,7 @@ nav: - Windows (experimental): experimental-windows.md - Raspberry Pi 4: raspberry-pi4.md - Ansible Playbook: examples/ansible-playbook.md - - Airgap Install: airgap-install.md + - Airgapped Installation: airgap-install.md - Using custom CA certificate (advanced): custom-ca.md - System Requirements: system-requirements.md - External runtime dependencies: external-runtime-deps.md diff --git a/pkg/airgap/ociartifactsbundler.go b/pkg/airgap/ociartifactsbundler.go new file mode 100644 index 000000000000..3bdc29585582 --- /dev/null +++ b/pkg/airgap/ociartifactsbundler.go @@ -0,0 +1,452 @@ +/* +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" + "os" + "path" + "slices" + "sync" + + "github.com/containerd/platforms" + "github.com/k0sproject/k0s/internal/pkg/stringslice" + "github.com/k0sproject/k0s/pkg/k0scontext" + + "github.com/distribution/reference" + "github.com/dustin/go-humanize" + "github.com/opencontainers/go-digest" + imagespecs "github.com/opencontainers/image-spec/specs-go" + imagespecv1 "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 InsecureOCIRegistryKind uint8 + +const ( + NoInsecureOCIRegistry InsecureOCIRegistryKind = iota + SkipTLSVerifyOCIRegistry + PlainHTTPOCIRegistry +) + +type RewriteRefFunc func(sourceRef reference.Named) (targetRef reference.Named) + +type OCIArtifactsBundler struct { + Log logrus.FieldLogger + InsecureRegistries InsecureOCIRegistryKind + RegistriesConfigPaths []string // uses the standard Docker config if empty + PlatformMatcher platforms.MatchComparer + RewriteTarget RewriteRefFunc +} + +func (b *OCIArtifactsBundler) 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 artifacts to bundle") + } else { + b.Log.Infof("About to bundle %d artifacts", len) + var close func() + client, close = newHttpClient(b.InsecureRegistries == SkipTLSVerifyOCIRegistry) + defer close() + } + + creds, err := newOCICredentials(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 == PlainHTTPOCIRegistry, + } + } + + copyOpts := oras.CopyOptions{ + CopyGraphOptions: oras.CopyGraphOptions{ + Concurrency: 1, // reproducible output + FindSuccessors: findSuccessors(b.PlatformMatcher), + PreCopy: func(ctx context.Context, desc imagespecv1.Descriptor) error { + log := k0scontext.ValueOr(ctx, b.Log) + log = log.WithField("digest", desc.Digest) + if desc.Platform != nil { + log = log.WithField("platform", platforms.FormatAll(*desc.Platform)) + } + log.Info("Fetching ", humanize.IBytes(uint64(desc.Size))) + return nil + }, + }, + } + + tarWriter := tar.NewWriter(out) + target := ociLayoutArchive{w: &ociLayoutArchiveWriter{tar: tarWriter}} + index := imagespecv1.Index{ + Versioned: imagespecs.Versioned{SchemaVersion: 2}, + MediaType: imagespecv1.MediaTypeImageIndex, + } + + for numRef, ref := range refs { + ref := reference.TagNameOnly(ref) + log := b.Log.WithFields(logrus.Fields{ + "artifact": fmt.Sprintf("%d/%d", numRef+1, len(refs)), + "name": ref, + }) + ctx := k0scontext.WithValue[logrus.FieldLogger](ctx, log) + source := newSource(ref) + copyOpts.MapRoot = nil + var srcRef string + if tagged, ok := reference.TagNameOnly(ref).(reference.Tagged); ok { + srcRef = tagged.Tag() + } + if digested, ok := ref.(reference.Digested); ok { + expectedDigest := digested.Digest() + if srcRef == "" { + srcRef = expectedDigest.String() + } else { + // Pull via tag, but ensure that it matches the digest! + copyOpts.MapRoot = func(_ context.Context, _ content.ReadOnlyStorage, root imagespecv1.Descriptor) (d imagespecv1.Descriptor, _ error) { + if root.Digest == expectedDigest { + return root, nil + } + return d, fmt.Errorf("%w for %s: %s", content.ErrMismatchedDigest, ref, root.Digest) + } + } + } + desc, err := oras.Copy(ctx, source, srcRef, &target, "", copyOpts) + if err != nil { + return err + } + + // Store the artifact multiple times with all its possible names. + targetRef := ref + if b.RewriteTarget != nil { + targetRef = b.RewriteTarget(ref) + } + targetRefNames, err := targetRefNamesFor(targetRef) + if err != nil { + return err + } + for _, name := range targetRefNames { + log.WithField("digest", desc.Digest).Info("Tagging ", name) + desc := desc // shallow copy + desc.Annotations = maps.Clone(desc.Annotations) + if desc.Annotations == nil { + desc.Annotations = make(map[string]string, 1) + } + desc.Annotations[imagespecv1.AnnotationRefName] = name + index.Manifests = append(index.Manifests, desc) + } + } + + if err := writeTarJSON(tarWriter, imagespecv1.ImageIndexFile, 0644, index); err != nil { + return err + } + if err := writeTarJSON(tarWriter, imagespecv1.ImageLayoutFile, 0444, &imagespecv1.ImageLayout{ + Version: imagespecv1.ImageLayoutVersion, + }); err != nil { + return err + } + + return tarWriter.Close() +} + +func newHttpClient(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 +} + +func newOCICredentials(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 +} + +// Implement custom platform filtering. The default +// [oras.CopyOptions.WithTargetPlatform] will throw away multi-arch image +// indexes and thus change artifact digests. +func findSuccessors(platformMatcher platforms.MatchComparer) func(context.Context, content.Fetcher, imagespecv1.Descriptor) ([]imagespecv1.Descriptor, error) { + if platformMatcher == nil { + platformMatcher = platforms.Default() + } + return func(ctx context.Context, fetcher content.Fetcher, desc imagespecv1.Descriptor) ([]imagespecv1.Descriptor, error) { + descs, err := content.Successors(ctx, fetcher, desc) + if err != nil { + return nil, err + } + + var platformDescs []imagespecv1.Descriptor + for _, desc := range descs { + if desc.Platform != nil && !platformMatcher.Match(*desc.Platform) { + continue + } + platformDescs = append(platformDescs, desc) + } + + retainBestPlatformOnly(&platformDescs, platformMatcher.Less) + return platformDescs, nil + } +} + +func retainBestPlatformOnly(descs *[]imagespecv1.Descriptor, isBetter func(imagespecv1.Platform, imagespecv1.Platform) bool) { + // Sort the descriptors: The ones without platform first, + // then the ones with platforms, better first. + slices.SortFunc(*descs, func(l, r imagespecv1.Descriptor) int { + lp, rp := l.Platform, r.Platform + switch { + case lp == nil: + if rp == nil { + return 0 + } + return -1 + case rp == nil: + return 1 + case isBetter(*lp, *rp): + return -1 + case isBetter(*rp, *lp): + return 1 + default: + return 0 + } + }) + + // Truncate the descriptors: Retain all platformless descriptors, + // plus the first (best) one with a platform. + bestIdx := slices.IndexFunc(*descs, func(d imagespecv1.Descriptor) bool { return d.Platform != nil }) + if bestIdx >= 0 { + *descs = (*descs)[:bestIdx+1] + } +} + +// Calculates the target references for the given input reference. +func targetRefNamesFor(ref reference.Named) (targetRefs []string, _ error) { + // First the name as is, if it's not _only_ the name + if !reference.IsNameOnly(ref) { + targetRefs = append(targetRefs, 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 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, mode os.FileMode, data any) error { + json, err := json.Marshal(data) + if err != nil { + return err + } + return writeTarFile(w, name, mode, int64(len(json)), bytes.NewReader(json)) +} + +func writeTarFile(w *tar.Writer, name string, mode os.FileMode, size int64, in io.Reader) error { + if err := w.WriteHeader(&tar.Header{ + Name: name, + Typeflag: tar.TypeReg, + Mode: int64(mode), + Size: size, + }); err != nil { + return err + } + + _, err := io.Copy(w, in) + return err +} + +type ociLayoutArchive struct { + mu sync.RWMutex + w *ociLayoutArchiveWriter +} + +type ociLayoutArchiveWriter struct { + tar *tar.Writer + blobs []digest.Digest +} + +func (t *ociLayoutArchive) doSynchronized(exclusive bool, fn func(w *ociLayoutArchiveWriter) 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 (a *ociLayoutArchive) Exists(ctx context.Context, target imagespecv1.Descriptor) (exists bool, _ error) { + err := a.doSynchronized(false, func(w *ociLayoutArchiveWriter) error { + _, exists = slices.BinarySearch(w.blobs, target.Digest) + return nil + }) + return exists, err +} + +// Push implements [oras.Target]. +func (a *ociLayoutArchive) Push(ctx context.Context, expected imagespecv1.Descriptor, in io.Reader) (err error) { + d := expected.Digest + if err := d.Validate(); err != nil { + return err + } + + lockErr := a.doSynchronized(true, func(w *ociLayoutArchiveWriter) error { + idx, exists := slices.BinarySearch(w.blobs, d) + if exists { + err = errdef.ErrAlreadyExists + return nil + } + + if len(w.blobs) < 1 { + if err := writeTarDir(w.tar, imagespecv1.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(imagespecv1.ImageBlobsDir, d.Algorithm().String()) + if err := writeTarDir(w.tar, dirName); err != nil { + return err + } + } + + blobName := path.Join(imagespecv1.ImageBlobsDir, d.Algorithm().String(), d.Hex()) + verify := content.NewVerifyReader(in, expected) + if err := writeTarFile(w.tar, blobName, 0444, 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 (a *ociLayoutArchive) Tag(ctx context.Context, desc imagespecv1.Descriptor, reference string) error { + if exists, err := a.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 (a *ociLayoutArchive) Resolve(ctx context.Context, reference string) (d imagespecv1.Descriptor, _ error) { + return d, fmt.Errorf("%w: Resolve(_, %q)", errdef.ErrUnsupported, reference) +} + +// Fetch implements [oras.Target]. +func (a *ociLayoutArchive) Fetch(ctx context.Context, target imagespecv1.Descriptor) (io.ReadCloser, error) { + return nil, fmt.Errorf("%w: Fetch(_, %v)", errdef.ErrUnsupported, target) +} diff --git a/pkg/component/worker/ocibundle.go b/pkg/component/worker/ocibundle.go index 7883623e7ea6..d502a88d0268 100644 --- a/pkg/component/worker/ocibundle.go +++ b/pkg/component/worker/ocibundle.go @@ -29,7 +29,7 @@ import ( "github.com/avast/retry-go" "github.com/containerd/containerd" "github.com/containerd/containerd/images" - "github.com/containerd/containerd/platforms" + "github.com/containerd/platforms" "github.com/fsnotify/fsnotify" "github.com/sirupsen/logrus" From f5a45a142580eae2924f086d3ce61c41ee2de383 Mon Sep 17 00:00:00 2001 From: Tom Wieczorek Date: Fri, 13 Dec 2024 13:05:26 +0100 Subject: [PATCH 2/2] Replace image-bundler with k0s airgap bundle-artifacts Dog-food the new sub-command in the k0s build itself. Signed-off-by: Tom Wieczorek --- .../workflows/build-airgap-image-bundle.yml | 2 +- .github/workflows/go.yml | 2 +- Makefile | 15 ++------ hack/image-bundler/Dockerfile | 6 --- hack/image-bundler/bundler.sh | 37 ------------------- 5 files changed, 5 insertions(+), 57 deletions(-) delete mode 100644 hack/image-bundler/Dockerfile delete mode 100755 hack/image-bundler/bundler.sh 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/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