diff --git a/.github/workflows/build-airgap-image-bundle.yml b/.github/workflows/build-airgap-image-bundle.yml index 72469322450f..31ea41ac3548 100644 --- a/.github/workflows/build-airgap-image-bundle.yml +++ b/.github/workflows/build-airgap-image-bundle.yml @@ -33,6 +33,11 @@ jobs: with: persist-credentials: false + - name: "Download :: k0s" + uses: actions/download-artifact@v4 + with: + name: k0s-linux-amd64 + - name: "Download :: Airgap image list" uses: actions/download-artifact@v4 with: @@ -44,7 +49,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" @@ -58,6 +63,7 @@ jobs: - name: "Build :: Airgap image bundle" if: steps.cache-airgap-image-bundle.outputs.cache-hit != 'true' run: | + chmod +x k0s mkdir -p "embedded-bins/staging/$TARGET_OS/bin" make --touch airgap-images.txt make "airgap-image-bundle-$TARGET_OS-$TARGET_ARCH.tar" 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 e00342a8972a..df23fdb3b0d9 100644 --- a/Makefile +++ b/Makefile @@ -238,15 +238,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: k0s airgap-images.txt + ./k0s airgap -v bundle-artifacts --platform='$(TARGET_PLATFORM)' -o '$@' 0 { + refs, err = parseArtifactRefsFromSeq(slices.Values(args)) + } else { + refs, err = parseArtifactRefsFromReader(cmd.InOrStdin()) + } + if err != nil { + return err + } + + 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/.gitattributes b/cmd/airgap/testdata/.gitattributes new file mode 100644 index 000000000000..43ddf5eee318 --- /dev/null +++ b/cmd/airgap/testdata/.gitattributes @@ -0,0 +1 @@ +oci-layout/blobs/** binary 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 000000000000..2596f7c7fcbe Binary files /dev/null and b/cmd/airgap/testdata/oci-layout/blobs/sha256/2c640c3bebc4bba061a05c7aeadee50344fa2bed3d912dcceb8c1b27f1330b49 differ diff --git a/cmd/airgap/testdata/oci-layout/blobs/sha256/4cbfb7be9baac76cc5dd2b44e9c8c671eb2247c295699cc1c0f69a4d93242206 b/cmd/airgap/testdata/oci-layout/blobs/sha256/4cbfb7be9baac76cc5dd2b44e9c8c671eb2247c295699cc1c0f69a4d93242206 new file mode 100644 index 000000000000..626016af535b --- /dev/null +++ b/cmd/airgap/testdata/oci-layout/blobs/sha256/4cbfb7be9baac76cc5dd2b44e9c8c671eb2247c295699cc1c0f69a4d93242206 @@ -0,0 +1,19 @@ +{ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "config": { + "mediaType": "application/vnd.oci.image.config.v1+json", + "digest": "sha256:19d3d41ccd3a337cbd19142c3a8304fad1a69ec8bb0129d49eb1bbc6bd0d12f1", + "size": 532 + }, + "layers": [ + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", + "digest": "sha256:2c640c3bebc4bba061a05c7aeadee50344fa2bed3d912dcceb8c1b27f1330b49", + "size": 116, + "annotations": { + "buildkit/rewritten-timestamp": "315532800" + } + } + ] +} diff --git a/cmd/airgap/testdata/oci-layout/blobs/sha256/6a7f603fb7cf5e494705bc22c3130cb819b9f3bbeb63106163dbcac294517404 b/cmd/airgap/testdata/oci-layout/blobs/sha256/6a7f603fb7cf5e494705bc22c3130cb819b9f3bbeb63106163dbcac294517404 new file mode 100644 index 000000000000..ebcac2ebd0dc --- /dev/null +++ b/cmd/airgap/testdata/oci-layout/blobs/sha256/6a7f603fb7cf5e494705bc22c3130cb819b9f3bbeb63106163dbcac294517404 @@ -0,0 +1,25 @@ +{ + "architecture": "arm", + "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" + ] + }, + "variant": "v7" +} diff --git a/cmd/airgap/testdata/oci-layout/blobs/sha256/9a280cc46a419001ca991d52ba03c12683d5997fbcda0fd6aa6dda4a21a87884 b/cmd/airgap/testdata/oci-layout/blobs/sha256/9a280cc46a419001ca991d52ba03c12683d5997fbcda0fd6aa6dda4a21a87884 new file mode 100644 index 000000000000..1350b64f012b --- /dev/null +++ b/cmd/airgap/testdata/oci-layout/blobs/sha256/9a280cc46a419001ca991d52ba03c12683d5997fbcda0fd6aa6dda4a21a87884 @@ -0,0 +1,34 @@ +{ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.index.v1+json", + "manifests": [ + { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": "sha256:e3f5758bd1bd52a64a10152876d1338016b9c2fda2052ff2550e067200382502", + "size": 561, + "platform": { + "architecture": "arm", + "os": "linux", + "variant": "v7" + } + }, + { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": "sha256:c259653916b1fea8bd000584e1f47499512acffd0c0db6e208bdaf4b644b33d6", + "size": 561, + "platform": { + "architecture": "amd64", + "os": "linux" + } + }, + { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": "sha256:4cbfb7be9baac76cc5dd2b44e9c8c671eb2247c295699cc1c0f69a4d93242206", + "size": 561, + "platform": { + "architecture": "arm64", + "os": "linux" + } + } + ] +} diff --git a/cmd/airgap/testdata/oci-layout/blobs/sha256/c259653916b1fea8bd000584e1f47499512acffd0c0db6e208bdaf4b644b33d6 b/cmd/airgap/testdata/oci-layout/blobs/sha256/c259653916b1fea8bd000584e1f47499512acffd0c0db6e208bdaf4b644b33d6 new file mode 100644 index 000000000000..aef59b389fdd --- /dev/null +++ b/cmd/airgap/testdata/oci-layout/blobs/sha256/c259653916b1fea8bd000584e1f47499512acffd0c0db6e208bdaf4b644b33d6 @@ -0,0 +1,19 @@ +{ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "config": { + "mediaType": "application/vnd.oci.image.config.v1+json", + "digest": "sha256:eab318ba111ebe88a3a3d7d8792a17f970a231107dbc130571ca6bd84d523f20", + "size": 532 + }, + "layers": [ + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", + "digest": "sha256:2c640c3bebc4bba061a05c7aeadee50344fa2bed3d912dcceb8c1b27f1330b49", + "size": 116, + "annotations": { + "buildkit/rewritten-timestamp": "315532800" + } + } + ] +} diff --git a/cmd/airgap/testdata/oci-layout/blobs/sha256/e3f5758bd1bd52a64a10152876d1338016b9c2fda2052ff2550e067200382502 b/cmd/airgap/testdata/oci-layout/blobs/sha256/e3f5758bd1bd52a64a10152876d1338016b9c2fda2052ff2550e067200382502 new file mode 100644 index 000000000000..d6979969cb2e --- /dev/null +++ b/cmd/airgap/testdata/oci-layout/blobs/sha256/e3f5758bd1bd52a64a10152876d1338016b9c2fda2052ff2550e067200382502 @@ -0,0 +1,19 @@ +{ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "config": { + "mediaType": "application/vnd.oci.image.config.v1+json", + "digest": "sha256:6a7f603fb7cf5e494705bc22c3130cb819b9f3bbeb63106163dbcac294517404", + "size": 549 + }, + "layers": [ + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", + "digest": "sha256:2c640c3bebc4bba061a05c7aeadee50344fa2bed3d912dcceb8c1b27f1330b49", + "size": 116, + "annotations": { + "buildkit/rewritten-timestamp": "315532800" + } + } + ] +} diff --git a/cmd/airgap/testdata/oci-layout/blobs/sha256/eab318ba111ebe88a3a3d7d8792a17f970a231107dbc130571ca6bd84d523f20 b/cmd/airgap/testdata/oci-layout/blobs/sha256/eab318ba111ebe88a3a3d7d8792a17f970a231107dbc130571ca6bd84d523f20 new file mode 100644 index 000000000000..6161d3e8ee54 --- /dev/null +++ b/cmd/airgap/testdata/oci-layout/blobs/sha256/eab318ba111ebe88a3a3d7d8792a17f970a231107dbc130571ca6bd84d523f20 @@ -0,0 +1,24 @@ +{ + "architecture": "amd64", + "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/content-types.json b/cmd/airgap/testdata/oci-layout/content-types.json new file mode 100644 index 000000000000..1c3c5bbc5a82 --- /dev/null +++ b/cmd/airgap/testdata/oci-layout/content-types.json @@ -0,0 +1,10 @@ +{ + "sha256:19d3d41ccd3a337cbd19142c3a8304fad1a69ec8bb0129d49eb1bbc6bd0d12f1": "application/vnd.oci.image.config.v1+json", + "sha256:2c640c3bebc4bba061a05c7aeadee50344fa2bed3d912dcceb8c1b27f1330b49": "application/vnd.oci.image.layer.v1.tar+gzip", + "sha256:4cbfb7be9baac76cc5dd2b44e9c8c671eb2247c295699cc1c0f69a4d93242206": "application/vnd.oci.image.manifest.v1+json", + "sha256:6a7f603fb7cf5e494705bc22c3130cb819b9f3bbeb63106163dbcac294517404": "application/vnd.oci.image.config.v1+json", + "sha256:9a280cc46a419001ca991d52ba03c12683d5997fbcda0fd6aa6dda4a21a87884": "application/vnd.oci.image.index.v1+json", + "sha256:c259653916b1fea8bd000584e1f47499512acffd0c0db6e208bdaf4b644b33d6": "application/vnd.oci.image.manifest.v1+json", + "sha256:e3f5758bd1bd52a64a10152876d1338016b9c2fda2052ff2550e067200382502": "application/vnd.oci.image.manifest.v1+json", + "sha256:eab318ba111ebe88a3a3d7d8792a17f970a231107dbc130571ca6bd84d523f20": "application/vnd.oci.image.config.v1+json" +} diff --git a/cmd/airgap/testdata/oci-layout/index.json b/cmd/airgap/testdata/oci-layout/index.json new file mode 100644 index 000000000000..5f069d0bd862 --- /dev/null +++ b/cmd/airgap/testdata/oci-layout/index.json @@ -0,0 +1,13 @@ +{ + "schemaVersion": 2, + "manifests": [ + { + "mediaType": "application/vnd.oci.image.index.v1+json", + "digest": "sha256:9a280cc46a419001ca991d52ba03c12683d5997fbcda0fd6aa6dda4a21a87884", + "size": 940, + "annotations": { + "org.opencontainers.image.ref.name": "hello:1980" + } + } + ] +} diff --git a/docs/airgap-install.md b/docs/airgap-install.md index 7896f3743080..a3d92b9034fd 100644 --- a/docs/airgap-install.md +++ b/docs/airgap-install.md @@ -1,74 +1,122 @@ -# Airgap install +# Airgapped Installation -You can install k0s in an environment with restricted Internet access. Airgap installation requires an image bundle, which contains all the needed container images. There are two options to get the image bundle: +You can install k0s in environments without Internet access. Airgapped +installations require an image bundle that contains all the container images +that would normally be pulled over the network. K0s uses so-called OCI archives +for this: Tarball representations of an [OCI Image Layout]. They allow for +multiple images to be packed into a single file. K0s will watch for image +bundles in the `/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 8373b0d7ed60..8f2f89220690 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/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/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"