//go:build linux || freebsd package integration import ( "bytes" "encoding/hex" "encoding/json" "fmt" "math/rand/v2" "os" "path/filepath" "regexp" "slices" "strings" . "github.com/containers/podman/v5/test/utils" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" . "github.com/onsi/gomega/gexec" "github.com/opencontainers/go-digest" imgspecv1 "github.com/opencontainers/image-spec/specs-go/v1" ) func pullChunkedTests() { // included in pull_test.go, must use a Ginkgo DSL at the top level // This must use a Serial decorator because it uses (podman system reset), // affecting all other concurrent Podman runners. Plausibly we could just delete all images/layers // from the test-private store, but we also need to delete BlobInfoCache, and that // is currently not private to each test run. Describe("podman pull chunked images", Serial, func() { // We collect the detailed output of commands, and try to only print it on failure. // This is, nominally, a built-in feature of Ginkgo, but we run the tests with -vv, making the // full output always captured in a log. So, here, we need to conditionalize explicitly. var lastPullOutput bytes.Buffer ReportAfterEach(func(ctx SpecContext, report SpecReport) { if report.Failed() { AddReportEntry("last pull operation", lastPullOutput.String()) } }) It("uses config-based image IDs and enforces DiffID matching", func() { if podmanTest.ImageCacheFS == "vfs" { Skip("VFS does not support chunked pulls") // We could still test that we enforce DiffID correctness. } if os.Getenv("CI_DESIRED_COMPOSEFS") != "" { // With convert_images, we use the partial pulls for all layers, and do not exercise the full pull code path. Skip("The composefs configuration (with convert_images = true) interferes with this test's image ID expectations") } SkipIfRemote("(podman system reset) is required for this test") if isRootless() { err := podmanTest.RestoreArtifact(REGISTRY_IMAGE) Expect(err).ToNot(HaveOccurred()) } lock := GetPortLock(pullChunkedRegistryPort) defer lock.Unlock() pullChunkedRegistryPrefix := "docker://localhost:" + pullChunkedRegistryPort + "/" imageDir := filepath.Join(tempdir, "images") err := os.MkdirAll(imageDir, 0o700) Expect(err).NotTo(HaveOccurred()) var chunkedNormal, chunkedMismatch, chunkedMissing, chunkedEmpty, nonchunkedNormal, nonchunkedMismatch, nonchunkedMissing, nonchunkedEmpty, schema1 *pullChunkedTestImage By("Preparing test images", func() { pullChunkedStartRegistry() chunkedNormal = &pullChunkedTestImage{ registryRef: pullChunkedRegistryPrefix + "chunked-normal", dirPath: filepath.Join(imageDir, "chunked-normal"), } chunkedNormalContentPath := "chunked-normal-image-content" err := os.WriteFile(filepath.Join(podmanTest.TempDir, chunkedNormalContentPath), []byte(fmt.Sprintf("content-%d", rand.Int64())), 0o600) Expect(err).NotTo(HaveOccurred()) chunkedNormalContainerFile := fmt.Sprintf("FROM scratch\nADD %s /content", chunkedNormalContentPath) podmanTest.BuildImage(chunkedNormalContainerFile, chunkedNormal.localTag(), "true") podmanTest.PodmanExitCleanly("push", "-q", "--tls-verify=false", "--force-compression", "--compression-format=zstd:chunked", chunkedNormal.localTag(), chunkedNormal.registryRef) skopeo := SystemExec("skopeo", []string{"copy", "-q", "--preserve-digests", "--all", "--src-tls-verify=false", chunkedNormal.registryRef, "dir:" + chunkedNormal.dirPath}) skopeo.WaitWithDefaultTimeout() Expect(skopeo).Should(ExitCleanly()) jq := SystemExec("jq", []string{"-r", ".config.digest", filepath.Join(chunkedNormal.dirPath, "manifest.json")}) jq.WaitWithDefaultTimeout() Expect(jq).Should(ExitCleanly()) cd, err := digest.Parse(jq.OutputToString()) Expect(err).NotTo(HaveOccurred()) chunkedNormal.configDigest = cd schema1 = &pullChunkedTestImage{ registryRef: pullChunkedRegistryPrefix + "schema1", dirPath: filepath.Join(imageDir, "schema1"), configDigest: "", } skopeo = SystemExec("skopeo", []string{"copy", "-q", "--format=v2s1", "--dest-compress=true", "--dest-compress-format=gzip", "dir:" + chunkedNormal.dirPath, "dir:" + schema1.dirPath}) skopeo.WaitWithDefaultTimeout() Expect(skopeo).Should(ExitCleanly()) createChunkedImage := func(name string, editDiffIDs func([]digest.Digest) []digest.Digest) *pullChunkedTestImage { name = "chunked-" + name res := pullChunkedTestImage{ registryRef: pullChunkedRegistryPrefix + name, dirPath: filepath.Join(imageDir, name), } cmd := SystemExec("cp", []string{"-a", chunkedNormal.dirPath, res.dirPath}) cmd.WaitWithDefaultTimeout() Expect(cmd).Should(ExitCleanly()) configBytes, err := os.ReadFile(filepath.Join(chunkedNormal.dirPath, chunkedNormal.configDigest.Encoded())) Expect(err).NotTo(HaveOccurred()) configBytes = editJSON(configBytes, func(config *imgspecv1.Image) { config.RootFS.DiffIDs = editDiffIDs(config.RootFS.DiffIDs) }) res.configDigest = digest.FromBytes(configBytes) err = os.WriteFile(filepath.Join(res.dirPath, res.configDigest.Encoded()), configBytes, 0o600) Expect(err).NotTo(HaveOccurred()) manifestBytes, err := os.ReadFile(filepath.Join(chunkedNormal.dirPath, "manifest.json")) Expect(err).NotTo(HaveOccurred()) manifestBytes = editJSON(manifestBytes, func(manifest *imgspecv1.Manifest) { manifest.Config.Digest = res.configDigest manifest.Config.Size = int64(len(configBytes)) }) err = os.WriteFile(filepath.Join(res.dirPath, "manifest.json"), manifestBytes, 0o600) Expect(err).NotTo(HaveOccurred()) return &res } createNonchunkedImage := func(name string, input *pullChunkedTestImage) *pullChunkedTestImage { name = "nonchunked-" + name res := pullChunkedTestImage{ registryRef: pullChunkedRegistryPrefix + name, dirPath: filepath.Join(imageDir, name), configDigest: input.configDigest, } cmd := SystemExec("cp", []string{"-a", input.dirPath, res.dirPath}) cmd.WaitWithDefaultTimeout() Expect(cmd).Should(ExitCleanly()) manifestBytes, err := os.ReadFile(filepath.Join(input.dirPath, "manifest.json")) Expect(err).NotTo(HaveOccurred()) manifestBytes = editJSON(manifestBytes, func(manifest *imgspecv1.Manifest) { manifest.Layers = slices.Clone(manifest.Layers) for i := range manifest.Layers { delete(manifest.Layers[i].Annotations, "io.github.containers.zstd-chunked.manifest-checksum") } }) err = os.WriteFile(filepath.Join(res.dirPath, "manifest.json"), manifestBytes, 0o600) Expect(err).NotTo(HaveOccurred()) return &res } chunkedMismatch = createChunkedImage("mismatch", func(diffIDs []digest.Digest) []digest.Digest { modified := slices.Clone(diffIDs) digestBytes, err := hex.DecodeString(diffIDs[0].Encoded()) Expect(err).NotTo(HaveOccurred()) digestBytes[len(digestBytes)-1] ^= 1 modified[0] = digest.NewDigestFromEncoded(diffIDs[0].Algorithm(), hex.EncodeToString(digestBytes)) return modified }) chunkedMissing = createChunkedImage("missing", func(diffIDs []digest.Digest) []digest.Digest { return nil }) chunkedEmpty = createChunkedImage("empty", func(diffIDs []digest.Digest) []digest.Digest { res := make([]digest.Digest, len(diffIDs)) for i := range res { res[i] = "" } return res }) nonchunkedNormal = createNonchunkedImage("normal", chunkedNormal) nonchunkedMismatch = createNonchunkedImage("mismatch", chunkedMismatch) nonchunkedMissing = createNonchunkedImage("missing", chunkedMissing) nonchunkedEmpty = createNonchunkedImage("empty", chunkedEmpty) pullChunkedStopRegistry() }) // The actual test for _, c := range []struct { img *pullChunkedTestImage insecureStorage bool fresh pullChunkedExpectation reuse pullChunkedExpectation onSuccess []string onFailure []string }{ // == Pulls of chunked images { img: chunkedNormal, fresh: pullChunkedExpectation{success: []string{"Created zstd:chunked differ for blob"}}, // Is a partial pull reuse: pullChunkedExpectation{success: []string{"Skipping blob .*already present"}}, }, { img: chunkedMismatch, fresh: pullChunkedExpectation{failure: []string{ "Created zstd:chunked differ for blob", // Is a partial pull "partial pull of blob.*uncompressed digest of layer.*is.*config claims", }}, reuse: pullChunkedExpectation{failure: []string{"trying to reuse blob.*layer.*does not match config's DiffID"}}, }, { img: chunkedMissing, fresh: pullChunkedExpectation{success: []string{ "Failed to retrieve partial blob: DiffID value for layer .* is unknown or explicitly empty", // Partial pull rejected "Detected compression format zstd", // Non-partial pull happens }}, reuse: pullChunkedExpectation{success: []string{ "Not using TOC .* to look for layer reuse: DiffID value for layer .* is unknown or explicitly empty", // Partial pull reuse rejected "Skipping blob .*already present", // Non-partial reuse happens }}, }, { img: chunkedEmpty, fresh: pullChunkedExpectation{success: []string{ "Failed to retrieve partial blob: DiffID value for layer .* is unknown or explicitly empty", // Partial pull rejected "Detected compression format zstd", // Non-partial pull happens }}, reuse: pullChunkedExpectation{success: []string{ "Not using TOC .* to look for layer reuse: DiffID value for layer .* is unknown or explicitly empty", // Partial pull reuse rejected "Skipping blob .*already present", // Non-partial reuse happens }}, }, // == Pulls of images without zstd-chunked metadata (although the layer files are actually zstd:chunked, so blob digest match chunkedNormal and trigger reuse) { img: nonchunkedNormal, fresh: pullChunkedExpectation{success: []string{ "Failed to retrieve partial blob: no TOC found and convert_images is not configured", // Partial pull not possible "Detected compression format zstd", // Non-partial pull happens }}, reuse: pullChunkedExpectation{success: []string{"Skipping blob .*already present"}}, }, { img: nonchunkedMismatch, fresh: pullChunkedExpectation{failure: []string{ "Failed to retrieve partial blob: no TOC found and convert_images is not configured", // Partial pull not possible "Detected compression format zstd", // Non-partial pull happens "writing blob: layer .* does not match config's DiffID", }}, reuse: pullChunkedExpectation{failure: []string{"trying to reuse blob.*layer.*does not match config's DiffID"}}, }, { img: nonchunkedMissing, fresh: pullChunkedExpectation{success: []string{ "Failed to retrieve partial blob: no TOC found and convert_images is not configured", // Partial pull not possible "Detected compression format zstd", // Non-partial pull happens }}, reuse: pullChunkedExpectation{success: []string{"Skipping blob .*already present"}}, // Non-partial reuse happens }, { img: nonchunkedEmpty, fresh: pullChunkedExpectation{success: []string{ "Failed to retrieve partial blob: no TOC found and convert_images is not configured", // Partial pull not possible "Detected compression format zstd", // Non-partial pull happens }}, reuse: pullChunkedExpectation{success: []string{"Skipping blob .*already present"}}, // Non-partial reuse happens }, // == Pulls of chunked images with insecure_allow_unpredictable_image_contents // NOTE: This tests current behavior, but we don't promise users that insecure_allow_unpredictable_image_contents is any faster // nor that it sets any particular image IDs. { img: chunkedNormal, insecureStorage: true, fresh: pullChunkedExpectation{success: []string{ "Created zstd:chunked differ for blob", // Is a partial pull "Ordinary storage image ID .*; a layer was looked up by TOC, so using image ID .*", }}, reuse: pullChunkedExpectation{success: []string{ "Skipping blob .*already present", "Ordinary storage image ID .*; a layer was looked up by TOC, so using image ID .*", }}, }, { // WARNING: It happens to be the case that with insecure_allow_unpredictable_image_contents , images with non-matching DiffIDs // can be pulled in these situations. // WE ARE MAKING NO PROMISES THAT THEY WILL WORK. The images are invalid and need to be fixed. // Today, in other situations (e.g. after pulling nonchunkedNormal), c/image will know the uncompressed digest despite insecure_allow_unpredictable_image_contents, // and reject the image as not matching the config. // As implementations change, the conditions when images with invalid DiffIDs will / will not work may also change, without // notice. img: chunkedMismatch, insecureStorage: true, fresh: pullChunkedExpectation{success: []string{ "Created zstd:chunked differ for blob", // Is a partial pull "Ordinary storage image ID .*; a layer was looked up by TOC, so using image ID .*", }}, reuse: pullChunkedExpectation{success: []string{ "Skipping blob .*already present", "Ordinary storage image ID .*; a layer was looked up by TOC, so using image ID .*", }}, }, { img: chunkedMissing, insecureStorage: true, fresh: pullChunkedExpectation{success: []string{ "Failed to retrieve partial blob: DiffID value for layer .* is unknown or explicitly empty", // Partial pull rejected (the storage option does not actually make a difference) "Detected compression format zstd", // Non-partial pull happens }}, reuse: pullChunkedExpectation{success: []string{ "Not using TOC .* to look for layer reuse: DiffID value for layer .* is unknown or explicitly empty", // Partial pull reuse rejected (the storage option does not actually make a difference) "Skipping blob .*already present", // Non-partial reuse happens }}, }, { img: chunkedEmpty, insecureStorage: true, fresh: pullChunkedExpectation{success: []string{ "Failed to retrieve partial blob: DiffID value for layer .* is unknown or explicitly empty", // Partial pull rejected (the storage option does not actually make a difference) "Detected compression format zstd", // Non-partial pull happens }}, reuse: pullChunkedExpectation{success: []string{ "Not using TOC .* to look for layer reuse: DiffID value for layer .* is unknown or explicitly empty", // Partial pull reuse rejected (the storage option does not actually make a difference) "Skipping blob .*already present", // Non-partial reuse happens }}, }, // Schema1 { img: schema1, fresh: pullChunkedExpectation{success: []string{ "Failed to retrieve partial blob: no TOC found and convert_images is not configured", // Partial pull not possible "Detected compression format gzip", // Non-partial pull happens }}, reuse: pullChunkedExpectation{success: []string{"Skipping blob .*already present"}}, }, // == No tests of estargz images (Podman can’t create them) } { testDescription := "Testing " + c.img.registryRef if c.insecureStorage { testDescription += " with insecure config" } By(testDescription, func() { // Do each test with a clean slate: no layer metadata known, no blob info cache. // Annoyingly, we have to re-start and re-populate the registry as well. podmanTest.PodmanExitCleanly("system", "reset", "-f") pullChunkedStartRegistry() c.img.push() chunkedNormal.push() // Test fresh pull c.fresh.testPull(c.img, c.insecureStorage, &lastPullOutput) podmanTest.PodmanExitCleanly("--pull-option=enable_partial_images=true", fmt.Sprintf("--pull-option=insecure_allow_unpredictable_image_contents=%v", c.insecureStorage), "pull", "-q", "--tls-verify=false", chunkedNormal.registryRef) // Test pull after chunked layers are already known, to trigger the layer reuse code c.reuse.testPull(c.img, c.insecureStorage, &lastPullOutput) pullChunkedStopRegistry() }) } }) }) } const pullChunkedRegistryPort = "5013" // pullChunkedStartRegistry creates a registry listening at pullChunkedRegistryPort within the current Podman environment. func pullChunkedStartRegistry() { podmanTest.PodmanExitCleanly("run", "-d", "--name", "registry", "--rm", "-p", pullChunkedRegistryPort+":5000", "-e", "REGISTRY_COMPATIBILITY_SCHEMA1_ENABLED=true", REGISTRY_IMAGE, "/entrypoint.sh", "/etc/docker/registry/config.yml") if !WaitContainerReady(podmanTest, "registry", "listening on", 20, 1) { Fail("Cannot start docker registry.") } } // pullChunkedStopRegistry stops a registry started by pullChunkedStartRegistry. func pullChunkedStopRegistry() { podmanTest.PodmanExitCleanly("stop", "registry") } // pullChunkedTestImage centralizes data about a single test image in pullChunkedTests. type pullChunkedTestImage struct { registryRef, dirPath string configDigest digest.Digest // "" for a schema1 image } // localTag returns the tag used for the image in Podman’s storage (without the docker:// prefix) func (img *pullChunkedTestImage) localTag() string { return strings.TrimPrefix(img.registryRef, "docker://") } // push copies the image from dirPath to registryRef. func (img *pullChunkedTestImage) push() { skopeo := SystemExec("skopeo", []string{"copy", "-q", fmt.Sprintf("--preserve-digests=%v", img.configDigest != ""), "--all", "--dest-tls-verify=false", "dir:" + img.dirPath, img.registryRef}) skopeo.WaitWithDefaultTimeout() Expect(skopeo).Should(ExitCleanly()) } // pullChunkedExpectations records the expected output of a single "podman pull" command. type pullChunkedExpectation struct { success []string // Expected debug log strings; should succeed if != nil failure []string // Expected debug log strings; should fail if != nil } // testPull performs one pull // It replaces *lastPullOutput with the output of the current command func (expectation *pullChunkedExpectation) testPull(image *pullChunkedTestImage, insecureStorage bool, lastPullOutput *bytes.Buffer) { lastPullOutput.Reset() session := podmanTest.PodmanWithOptions(PodmanExecOptions{ FullOutputWriter: lastPullOutput, }, "--log-level=debug", "--pull-option=enable_partial_images=true", fmt.Sprintf("--pull-option=insecure_allow_unpredictable_image_contents=%v", insecureStorage), "pull", "--tls-verify=false", image.registryRef) session.WaitWithDefaultTimeout() log := session.ErrorToString() if expectation.success != nil { Expect(session).Should(Exit(0)) for _, s := range expectation.success { Expect(regexp.MatchString(".*"+s+".*", log)).To(BeTrue(), s) } if image.configDigest != "" && !insecureStorage { s2 := podmanTest.PodmanExitCleanly("image", "inspect", "--format={{.ID}}", image.localTag()) Expect(s2.OutputToString()).Should(Equal(image.configDigest.Encoded())) } } else { Expect(session).Should(Exit(125)) for _, s := range expectation.failure { Expect(regexp.MatchString(".*"+s+".*", log)).To(BeTrue(), s) } } } // editJSON modifies a JSON-formatted input using the provided edit function. func editJSON[T any](input []byte, edit func(*T)) []byte { var value T err = json.Unmarshal(input, &value) Expect(err).NotTo(HaveOccurred()) edit(&value) res, err := json.Marshal(value) Expect(err).NotTo(HaveOccurred()) return res }