| package image | |
| import ( | |
| "archive/tar" | |
| "encoding/json" | |
| "io" | |
| "io/fs" | |
| "os" | |
| "path/filepath" | |
| "reflect" | |
| "sort" | |
| "strings" | |
| "testing" | |
| "time" | |
| "github.com/cpuguy83/tar2go" | |
| containertypes "github.com/docker/docker/api/types/container" | |
| "github.com/docker/docker/api/types/versions" | |
| "github.com/docker/docker/integration/internal/build" | |
| "github.com/docker/docker/integration/internal/container" | |
| "github.com/docker/docker/internal/testutils" | |
| "github.com/docker/docker/internal/testutils/specialimage" | |
| "github.com/docker/docker/pkg/archive" | |
| "github.com/docker/docker/testutil/fakecontext" | |
| "github.com/opencontainers/go-digest" | |
| ocispec "github.com/opencontainers/image-spec/specs-go/v1" | |
| "gotest.tools/v3/assert" | |
| "gotest.tools/v3/assert/cmp" | |
| is "gotest.tools/v3/assert/cmp" | |
| "gotest.tools/v3/skip" | |
| ) | |
| type imageSaveManifestEntry struct { | |
| Config string | |
| RepoTags []string | |
| Layers []string | |
| } | |
| func tarIndexFS(t *testing.T, rdr io.Reader) fs.FS { | |
| t.Helper() | |
| dir := t.TempDir() | |
| f, err := os.Create(filepath.Join(dir, "image.tar")) | |
| assert.NilError(t, err) | |
| // Do not close at the end of this function otherwise the indexer won't work | |
| t.Cleanup(func() { f.Close() }) | |
| _, err = io.Copy(f, rdr) | |
| assert.NilError(t, err) | |
| return tar2go.NewIndex(f).FS() | |
| } | |
| func TestSaveCheckTimes(t *testing.T) { | |
| ctx := setupTest(t) | |
| t.Parallel() | |
| client := testEnv.APIClient() | |
| const repoName = "busybox:latest" | |
| img, _, err := client.ImageInspectWithRaw(ctx, repoName) | |
| assert.NilError(t, err) | |
| rdr, err := client.ImageSave(ctx, []string{repoName}) | |
| assert.NilError(t, err) | |
| tarfs := tarIndexFS(t, rdr) | |
| dt, err := fs.ReadFile(tarfs, "manifest.json") | |
| assert.NilError(t, err) | |
| var ls []imageSaveManifestEntry | |
| assert.NilError(t, json.Unmarshal(dt, &ls)) | |
| assert.Assert(t, cmp.Len(ls, 1)) | |
| info, err := fs.Stat(tarfs, ls[0].Config) | |
| assert.NilError(t, err) | |
| created, err := time.Parse(time.RFC3339, img.Created) | |
| assert.NilError(t, err) | |
| if testEnv.UsingSnapshotter() { | |
| // containerd archive export sets the mod time to zero. | |
| assert.Check(t, is.Equal(info.ModTime(), time.Unix(0, 0))) | |
| } else { | |
| assert.Check(t, is.Equal(info.ModTime().Format(time.RFC3339), created.Format(time.RFC3339))) | |
| } | |
| } | |
| // Regression test for https://github.com/moby/moby/issues/47065 | |
| func TestSaveOCI(t *testing.T) { | |
| skip.If(t, versions.LessThan(testEnv.DaemonAPIVersion(), "1.44"), "OCI layout support was introduced in v25") | |
| ctx := setupTest(t) | |
| client := testEnv.APIClient() | |
| const busybox = "busybox:latest" | |
| inspectBusybox, _, err := client.ImageInspectWithRaw(ctx, busybox) | |
| assert.NilError(t, err) | |
| type testCase struct { | |
| image string | |
| expectedOCIRef string | |
| expectedContainerdRef string | |
| } | |
| testCases := []testCase{ | |
| // Busybox by tagged name | |
| {image: busybox, expectedContainerdRef: "docker.io/library/busybox:latest", expectedOCIRef: "latest"}, | |
| // Busybox by ID | |
| {image: inspectBusybox.ID}, | |
| } | |
| if testEnv.DaemonInfo.OSType != "windows" { | |
| multiLayerImage := specialimage.Load(ctx, t, client, specialimage.MultiLayer) | |
| // Multi-layer image | |
| testCases = append(testCases, testCase{image: multiLayerImage, expectedContainerdRef: "docker.io/library/multilayer:latest", expectedOCIRef: "latest"}) | |
| } | |
| // Busybox frozen image will have empty RepoDigests when loaded into the | |
| // graphdriver image store so we can't use it. | |
| // This will work with the containerd image store though. | |
| if len(inspectBusybox.RepoDigests) > 0 { | |
| // Digested reference | |
| testCases = append(testCases, testCase{ | |
| image: inspectBusybox.RepoDigests[0], | |
| }) | |
| } | |
| for _, tc := range testCases { | |
| tc := tc | |
| t.Run(tc.image, func(t *testing.T) { | |
| // Get information about the original image. | |
| inspect, _, err := client.ImageInspectWithRaw(ctx, tc.image) | |
| assert.NilError(t, err) | |
| rdr, err := client.ImageSave(ctx, []string{tc.image}) | |
| assert.NilError(t, err) | |
| defer rdr.Close() | |
| tarfs := tarIndexFS(t, rdr) | |
| indexData, err := fs.ReadFile(tarfs, "index.json") | |
| assert.NilError(t, err, "failed to read index.json") | |
| var index ocispec.Index | |
| assert.NilError(t, json.Unmarshal(indexData, &index), "failed to unmarshal index.json") | |
| // All test images are single-platform, so they should have only one manifest. | |
| assert.Assert(t, is.Len(index.Manifests, 1)) | |
| manifestData, err := fs.ReadFile(tarfs, "blobs/sha256/"+index.Manifests[0].Digest.Encoded()) | |
| assert.NilError(t, err) | |
| var manifest ocispec.Manifest | |
| assert.NilError(t, json.Unmarshal(manifestData, &manifest)) | |
| t.Run("Manifest", func(t *testing.T) { | |
| assert.Check(t, is.Len(manifest.Layers, len(inspect.RootFS.Layers))) | |
| var digests []string | |
| // Check if layers referenced by the manifest exist in the archive | |
| // and match the layers from the original image. | |
| for _, l := range manifest.Layers { | |
| layerPath := "blobs/sha256/" + l.Digest.Encoded() | |
| stat, err := fs.Stat(tarfs, layerPath) | |
| assert.NilError(t, err) | |
| assert.Check(t, is.Equal(l.Size, stat.Size())) | |
| f, err := tarfs.Open(layerPath) | |
| assert.NilError(t, err) | |
| layerDigest, err := testutils.UncompressedTarDigest(f) | |
| f.Close() | |
| assert.NilError(t, err) | |
| digests = append(digests, layerDigest.String()) | |
| } | |
| assert.Check(t, is.DeepEqual(digests, inspect.RootFS.Layers)) | |
| }) | |
| t.Run("Config", func(t *testing.T) { | |
| configData, err := fs.ReadFile(tarfs, "blobs/sha256/"+manifest.Config.Digest.Encoded()) | |
| assert.NilError(t, err) | |
| var config ocispec.Image | |
| assert.NilError(t, json.Unmarshal(configData, &config)) | |
| var diffIDs []string | |
| for _, l := range config.RootFS.DiffIDs { | |
| diffIDs = append(diffIDs, l.String()) | |
| } | |
| assert.Check(t, is.DeepEqual(diffIDs, inspect.RootFS.Layers)) | |
| }) | |
| t.Run("Containerd image name", func(t *testing.T) { | |
| assert.Check(t, is.Equal(index.Manifests[0].Annotations["io.containerd.image.name"], tc.expectedContainerdRef)) | |
| }) | |
| t.Run("OCI reference tag", func(t *testing.T) { | |
| assert.Check(t, is.Equal(index.Manifests[0].Annotations["org.opencontainers.image.ref.name"], tc.expectedOCIRef)) | |
| }) | |
| }) | |
| } | |
| } | |
| func TestSaveRepoWithMultipleImages(t *testing.T) { | |
| ctx := setupTest(t) | |
| client := testEnv.APIClient() | |
| makeImage := func(from string, tag string) string { | |
| id := container.Create(ctx, t, client, func(cfg *container.TestContainerConfig) { | |
| cfg.Config.Image = from | |
| cfg.Config.Cmd = []string{"true"} | |
| }) | |
| res, err := client.ContainerCommit(ctx, id, containertypes.CommitOptions{Reference: tag}) | |
| assert.NilError(t, err) | |
| err = client.ContainerRemove(ctx, id, containertypes.RemoveOptions{Force: true}) | |
| assert.NilError(t, err) | |
| return res.ID | |
| } | |
| busyboxImg, _, err := client.ImageInspectWithRaw(ctx, "busybox:latest") | |
| assert.NilError(t, err) | |
| const repoName = "foobar-save-multi-images-test" | |
| const tagFoo = repoName + ":foo" | |
| const tagBar = repoName + ":bar" | |
| idFoo := makeImage("busybox:latest", tagFoo) | |
| idBar := makeImage("busybox:latest", tagBar) | |
| idBusybox := busyboxImg.ID | |
| rdr, err := client.ImageSave(ctx, []string{repoName, "busybox:latest"}) | |
| assert.NilError(t, err) | |
| defer rdr.Close() | |
| tarfs := tarIndexFS(t, rdr) | |
| dt, err := fs.ReadFile(tarfs, "manifest.json") | |
| assert.NilError(t, err) | |
| var mfstLs []imageSaveManifestEntry | |
| assert.NilError(t, json.Unmarshal(dt, &mfstLs)) | |
| actual := make([]string, 0, len(mfstLs)) | |
| for _, m := range mfstLs { | |
| actual = append(actual, strings.TrimPrefix(m.Config, "blobs/sha256/")) | |
| // make sure the blob actually exists | |
| _, err = fs.Stat(tarfs, m.Config) | |
| assert.Check(t, err) | |
| } | |
| expected := []string{idBusybox, idFoo, idBar} | |
| // prefixes are not in tar | |
| for i := range expected { | |
| expected[i] = digest.Digest(expected[i]).Encoded() | |
| } | |
| // With snapshotters, ID of the image is the ID of the manifest/index | |
| // With graphdrivers, ID of the image is the ID of the image config | |
| if testEnv.UsingSnapshotter() { | |
| // ID of image won't match the Config ID from manifest.json | |
| // Just check if manifests exist in blobs | |
| for _, blob := range expected { | |
| _, err = fs.Stat(tarfs, "blobs/sha256/"+blob) | |
| assert.Check(t, err) | |
| } | |
| } else { | |
| sort.Strings(actual) | |
| sort.Strings(expected) | |
| assert.Assert(t, cmp.DeepEqual(actual, expected), "archive does not contains the right layers: got %v, expected %v", actual, expected) | |
| } | |
| } | |
| func TestSaveDirectoryPermissions(t *testing.T) { | |
| skip.If(t, testEnv.DaemonInfo.OSType == "windows", "Test is looking at linux specific details") | |
| ctx := setupTest(t) | |
| client := testEnv.APIClient() | |
| layerEntries := []string{"opt/", "opt/a/", "opt/a/b/", "opt/a/b/c"} | |
| layerEntriesAUFS := []string{"./", ".wh..wh.aufs", ".wh..wh.orph/", ".wh..wh.plnk/", "opt/", "opt/a/", "opt/a/b/", "opt/a/b/c"} | |
| dockerfile := `FROM busybox | |
| RUN adduser -D user && mkdir -p /opt/a/b && chown -R user:user /opt/a | |
| RUN touch /opt/a/b/c && chown user:user /opt/a/b/c` | |
| imgID := build.Do(ctx, t, client, fakecontext.New(t, t.TempDir(), fakecontext.WithDockerfile(dockerfile))) | |
| rdr, err := client.ImageSave(ctx, []string{imgID}) | |
| assert.NilError(t, err) | |
| defer rdr.Close() | |
| tarfs := tarIndexFS(t, rdr) | |
| dt, err := fs.ReadFile(tarfs, "manifest.json") | |
| assert.NilError(t, err) | |
| var mfstLs []imageSaveManifestEntry | |
| assert.NilError(t, json.Unmarshal(dt, &mfstLs)) | |
| var found bool | |
| for _, p := range mfstLs[0].Layers { | |
| var entriesSansDev []string | |
| f, err := tarfs.Open(p) | |
| assert.NilError(t, err) | |
| entries, err := listTar(f) | |
| f.Close() | |
| assert.NilError(t, err) | |
| for _, e := range entries { | |
| if !strings.Contains(e, "dev/") { | |
| entriesSansDev = append(entriesSansDev, e) | |
| } | |
| } | |
| assert.NilError(t, err, "encountered error while listing tar entries: %s", err) | |
| if reflect.DeepEqual(entriesSansDev, layerEntries) || reflect.DeepEqual(entriesSansDev, layerEntriesAUFS) { | |
| found = true | |
| break | |
| } | |
| } | |
| assert.Assert(t, found, "failed to find the layer with the right content listing") | |
| } | |
| func listTar(f io.Reader) ([]string, error) { | |
| // If using the containerd snapshotter, the tar file may be compressed | |
| dec, err := archive.DecompressStream(f) | |
| if err != nil { | |
| return nil, err | |
| } | |
| defer dec.Close() | |
| tr := tar.NewReader(dec) | |
| var entries []string | |
| for { | |
| th, err := tr.Next() | |
| if err == io.EOF { | |
| // end of tar archive | |
| return entries, nil | |
| } | |
| if err != nil { | |
| return entries, err | |
| } | |
| entries = append(entries, th.Name) | |
| } | |
| } |