podman/test/e2e/pull_chunked_test.go

430 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//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 cant 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 Podmans 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
}