From dbad8954ada8e4c03eed06f63440bc3086ea40af Mon Sep 17 00:00:00 2001 From: Glyn Normington Date: Tue, 9 Apr 2019 13:36:29 +0100 Subject: [PATCH] Image relocation See `duffle relocate -h` for documentation. Adds the originalImage and originalDigest fields to images in both the image map and invocation images on the assumption that https://github.com/deislabs/cnab-spec/issues/157 will be implemented. Fixes https://github.com/deislabs/duffle/issues/668 --- Gopkg.lock | 61 +++-- Gopkg.toml | 4 + cmd/duffle/build.go | 25 +- cmd/duffle/relocate.go | 282 +++++++++++++++++++++++ cmd/duffle/relocate_test.go | 178 ++++++++++++++ cmd/duffle/root.go | 1 + cmd/duffle/testdata/relocate/bundle.json | 30 +++ pkg/bundle/bundle.go | 14 +- 8 files changed, 558 insertions(+), 37 deletions(-) create mode 100644 cmd/duffle/relocate.go create mode 100644 cmd/duffle/relocate_test.go create mode 100644 cmd/duffle/testdata/relocate/bundle.json diff --git a/Gopkg.lock b/Gopkg.lock index 47b37ec8..4edb3a0c 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -419,6 +419,26 @@ pruneopts = "NUT" revision = "4030bb1f1f0c35b30ca7009e9ebd06849dd45306" +[[projects]] + branch = "master" + digest = "1:dae60fba17f3bca4c009e374b766908babbd4beb41129777d46c5df3044a1eae" + name = "github.com/google/go-containerregistry" + packages = [ + "pkg/authn", + "pkg/name", + "pkg/v1", + "pkg/v1/empty", + "pkg/v1/layout", + "pkg/v1/partial", + "pkg/v1/random", + "pkg/v1/remote", + "pkg/v1/remote/transport", + "pkg/v1/types", + "pkg/v1/v1util", + ] + pruneopts = "NUT" + revision = "f1df91a4a813cbc183527dc7b9a31ea6454557b5" + [[projects]] branch = "master" digest = "1:52c5834e2bebac9030c97cc0798ac11c3aa8a39f098aeb419f142533da6cd3cc" @@ -696,6 +716,18 @@ revision = "5f041e8faa004a95c88a202771f4cc3e991971e6" version = "v2.0.1" +[[projects]] + branch = "master" + digest = "1:d570dee48c6a3b8a037a086e7f8565d103dcf4a567675d7f4f35378966161c54" + name = "github.com/pivotal/image-relocation" + packages = [ + "pkg/image", + "pkg/pathmapping", + "pkg/registry", + ] + pruneopts = "NUT" + revision = "75e3e3a1ca97aa343741039979012be403c963ec" + [[projects]] digest = "1:5cf3f025cbee5951a4ee961de067c8a89fc95a5adabead774f82822efabab121" name = "github.com/pkg/errors" @@ -753,14 +785,6 @@ pruneopts = "NUT" revision = "418d78d0b9a7b7de3a6bbc8a23def624cc977bb2" -[[projects]] - digest = "1:3fd3d634f6815f19ac4b2c5e16d28ec9aa4584d0bba25d1ee6c424d813cca22a" - name = "github.com/renstrom/fuzzysearch" - packages = ["fuzzy"] - pruneopts = "NUT" - revision = "b18e754edff4833912ef4dce9eaca885bd3f0de1" - version = "v1.0.1" - [[projects]] digest = "1:01252cd79aac70f16cac02a72a1067dd136e0ad6d5b597d0129cf74c739fd8d1" name = "github.com/sirupsen/logrus" @@ -869,17 +893,9 @@ [[projects]] branch = "master" - digest = "1:5a72a3eacbfef7b5ecf165c9e5b1f38f24c4a023a8c1490c138daf37eec9101b" + digest = "1:cb77e5934866333fa0784326a57e64c4da128001c94fbd1d29819d79bd3b1087" name = "golang.org/x/crypto" packages = [ - "cast5", - "openpgp", - "openpgp/armor", - "openpgp/clearsign", - "openpgp/elgamal", - "openpgp/errors", - "openpgp/packet", - "openpgp/s2k", "pbkdf2", "ssh/terminal", ] @@ -1196,7 +1212,6 @@ "github.com/docker/distribution/reference", "github.com/docker/docker/api/types", "github.com/docker/docker/api/types/container", - "github.com/docker/docker/api/types/mount", "github.com/docker/docker/api/types/strslice", "github.com/docker/docker/builder/dockerignore", "github.com/docker/docker/client", @@ -1212,20 +1227,20 @@ "github.com/gosuri/uitable", "github.com/oklog/ulid", "github.com/opencontainers/go-digest", - "github.com/renstrom/fuzzysearch/fuzzy", + "github.com/pivotal/image-relocation/pkg/image", + "github.com/pivotal/image-relocation/pkg/pathmapping", + "github.com/pivotal/image-relocation/pkg/registry", + "github.com/pkg/errors", "github.com/sirupsen/logrus", "github.com/spf13/cobra", "github.com/spf13/pflag", "github.com/spf13/viper", "github.com/stretchr/testify/assert", "github.com/technosophos/moniker", - "golang.org/x/crypto/openpgp", - "golang.org/x/crypto/openpgp/armor", - "golang.org/x/crypto/openpgp/clearsign", - "golang.org/x/crypto/openpgp/packet", "golang.org/x/net/context", "gopkg.in/AlecAivazis/survey.v1", "gopkg.in/yaml.v2", + "k8s.io/apimachinery/pkg/util/validation", ] solver-name = "gps-cdcl" solver-version = 1 diff --git a/Gopkg.toml b/Gopkg.toml index bba6298f..69013a24 100644 --- a/Gopkg.toml +++ b/Gopkg.toml @@ -53,3 +53,7 @@ [[constraint]] name = "github.com/docker/go" version = "1.5.1-1" + +[[constraint]] + branch = "master" + name = "github.com/pivotal/image-relocation" diff --git a/cmd/duffle/build.go b/cmd/duffle/build.go index c4a2280f..1216fcdd 100644 --- a/cmd/duffle/build.go +++ b/cmd/duffle/build.go @@ -149,15 +149,9 @@ func (b *buildCmd) run() (err error) { } func (b *buildCmd) writeBundle(bf *bundle.Bundle) (string, error) { - data, err := json.MarshalCanonical(bf) + data, digest, err := marshalBundle(bf) if err != nil { - return "", err - } - data = append(data, '\n') //TODO: why? - - digest, err := digest.OfBuffer(data) - if err != nil { - return "", fmt.Errorf("cannot compute digest from bundle: %v", err) + return "", fmt.Errorf("cannot marshal bundle: %v", err) } if b.outputFile != "" { @@ -169,6 +163,21 @@ func (b *buildCmd) writeBundle(bf *bundle.Bundle) (string, error) { return digest, ioutil.WriteFile(filepath.Join(b.home.Bundles(), digest), data, 0644) } +func marshalBundle(bf *bundle.Bundle) ([]byte, string, error) { + data, err := json.MarshalCanonical(bf) + if err != nil { + return nil, "", err + } + data = append(data, '\n') //TODO: why? + + digest, err := digest.OfBuffer(data) + if err != nil { + return nil, "", fmt.Errorf("cannot compute digest from bundle: %v", err) + } + + return data, digest, nil +} + func defaultDockerTLS() bool { return os.Getenv(dockerTLSEnvVar) != "" } diff --git a/cmd/duffle/relocate.go b/cmd/duffle/relocate.go new file mode 100644 index 00000000..21f7ce0c --- /dev/null +++ b/cmd/duffle/relocate.go @@ -0,0 +1,282 @@ +/* + * Copyright (c) 2019-Present Pivotal Software, Inc. All Rights Reserved. + * + * 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 main + +import ( + "fmt" + "io" + "io/ioutil" + "path/filepath" + "strconv" + "strings" + + "github.com/pivotal/image-relocation/pkg/image" + "github.com/pivotal/image-relocation/pkg/pathmapping" + "github.com/pivotal/image-relocation/pkg/registry" + + "github.com/deislabs/duffle/pkg/bundle" + "github.com/deislabs/duffle/pkg/duffle/home" + + "github.com/spf13/cobra" + "k8s.io/apimachinery/pkg/util/validation" +) + +const relocateDesc = ` +Relocates any docker and oci images referenced by a bundle, tags and pushes the images to a registry, and creates a new +bundle with an updated image map. + +The --repository-prefix flag determines the repositories for the relocated images. +Each image is tagged with a name starting with the given prefix and pushed to the repository. + +For example, if the repository-prefix is example.com/user, the image istio/proxyv2 is relocated +to a name starting with example.com/user/ and pushed to a repository hosted by example.com. +` + +type relocateCmd struct { + // args + inputBundle string + outputBundle string + + // flags + repoPrefix string + inputBundleIsFile bool + outputBundleIsFile bool + + // context + home home.Home + out io.Writer + + // dependencies + mapping pathmapping.PathMapping + registryClient registry.Client +} + +func newRelocateCmd(w io.Writer) *cobra.Command { + relocate := &relocateCmd{out: w} + + cmd := &cobra.Command{ + Use: "relocate [INPUT-BUNDLE] [OUTPUT-BUNDLE]", + Short: "relocate images in a CNAB bundle", + Long: relocateDesc, + Example: `duffle relocate helloworld hellorelocated --repository-prefix example.com/user +duffle relocate path/to/bundle.json relocatedbundle --repository-prefix example.com/user --input-bundle-is-file +duffle relocate helloworld path/to/relocatedbundle.json --repository-prefix example.com/user --output-bundle-is-file`, + Args: cobra.ExactArgs(2), + PreRunE: func(cmd *cobra.Command, args []string) error { + // validate --repository-prefix if it is set, otherwise allow flag omission to be diagnosed as such + if cmd.Flags().Changed("repository-prefix") { + if err := flagValidRepository("repository-prefix")(cmd); err != nil { + return err + } + } + + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + relocate.inputBundle = args[0] + relocate.outputBundle = args[1] + + relocate.home = home.Home(homePath()) + + relocate.mapping = pathmapping.FlattenRepoPath + relocate.registryClient = registry.NewRegistryClient() + + return relocate.run() + }, + } + + f := cmd.Flags() + f.BoolVarP(&relocate.inputBundleIsFile, "input-bundle-is-file", "", false, "Indicates that the input bundle source is a file path") + f.BoolVarP(&relocate.outputBundleIsFile, "output-bundle-is-file", "", false, "Indicates that the output bundle destination is a file path") + f.StringVarP(&relocate.repoPrefix, "repository-prefix", "r", "", "Prefix for relocated image names") + cmd.MarkFlagRequired("repository-prefix") + + return cmd +} + +func (r *relocateCmd) run() error { + bun, err := r.setup() + if err != nil { + return err + } + + if err := r.relocate(bun); err != nil { + return err + } + + if _, err := r.writeBundle(bun); err != nil { + return err + } + + return nil +} + +func (r *relocateCmd) relocate(bun *bundle.Bundle) error { + // mutate the input bundle to become the output bundle + if !r.outputBundleIsFile { + bun.Name = r.outputBundle + } + + ks := keys(bun.Images) + for _, k := range ks { + im := bun.Images[k] + if isOCI(im.ImageType) || isDocker(im.ImageType) { + // map the image name + n, err := image.NewName(im.Image) + if err != nil { + return err + } + rn := r.mapping(r.repoPrefix, n) + + // Preserve any tag + if tag := n.Tag(); tag != "" { + var err error + rn, err = rn.WithTag(tag) + if err != nil { + panic(err) // should never occur + } + } + + // Preserve any digest + if dig := n.Digest(); dig != image.EmptyDigest { + var err error + rn, err = rn.WithDigest(dig) + if err != nil { + panic(err) // should never occur + } + } + + // tag/push the image to its new repository + dig, err := r.registryClient.Copy(n, rn) + if err != nil { + return err + } + + // update the imagemap + im.OriginalImage = im.Image + if dig.String() != im.Digest { + im.OriginalDigest = im.Digest + } + im.Image = rn.String() + im.Digest = dig.String() + bun.Images[k] = im + } + } + + return nil +} + +func isOCI(imageType string) bool { + return imageType == "" || imageType == "oci" +} + +func isDocker(imageType string) bool { + return imageType == "docker" +} + +func keys(images map[string]bundle.Image) []string { + keys := []string{} + for k := range images { + keys = append(keys, k) + } + return keys +} + +func (r *relocateCmd) setup() (*bundle.Bundle, error) { + bundleFile, err := resolveBundleFilePath(r.inputBundle, r.home.String(), r.inputBundleIsFile) + if err != nil { + return nil, err + } + + bun, err := loadBundle(bundleFile) + if err != nil { + return nil, err + } + + if err = bun.Validate(); err != nil { + return nil, err + } + + return bun, nil +} + +func (r *relocateCmd) writeBundle(bf *bundle.Bundle) (string, error) { + data, digest, err := marshalBundle(bf) + if err != nil { + return "", fmt.Errorf("cannot marshal bundle: %v", err) + } + + if r.outputBundleIsFile { + if err := ioutil.WriteFile(r.outputBundle, data, 0644); err != nil { + return "", fmt.Errorf("cannot write bundle to %s: %v", r.outputBundle, err) + } + return digest, nil + } + + if err := ioutil.WriteFile(filepath.Join(r.home.Bundles(), digest), data, 0644); err != nil { + return "", fmt.Errorf("cannot store bundle : %v", err) + + } + + // record the new bundle in repositories.json + if err := recordBundleReference(r.home, bf.Name, bf.Version, digest); err != nil { + return "", fmt.Errorf("cannot record bundle: %v", err) + } + + return digest, nil +} + +type flagsValidator func(cmd *cobra.Command) error + +func flagValidRepository(flagName string) flagsValidator { + return func(cmd *cobra.Command) error { + repositoryValue := cmd.Flag(flagName).Value.String() + + if strings.HasSuffix(repositoryValue, "/") || strings.Contains(repositoryValue, "//") { + return fmt.Errorf("invalid repository: %s", repositoryValue) + } + + for i, part := range strings.Split(repositoryValue, "/") { + if i != 0 { + if strings.ContainsAny(part, ":@\" ") { + return fmt.Errorf("invalid repository: %s", repositoryValue) + } + continue + } + + authorityParts := strings.Split(part, ":") + if len(authorityParts) > 2 { + return fmt.Errorf("invalid repository hostname: %s", part) + } + if errs := validation.IsDNS1123Subdomain(authorityParts[0]); len(errs) > 0 { + return fmt.Errorf("invalid repository hostname: %s", strings.Join(errs, "; ")) + } + if len(authorityParts) == 2 { + portNumber, err := strconv.Atoi(authorityParts[1]) + if err != nil { + return fmt.Errorf("invalid repository port number: %s", authorityParts[1]) + } + + if errs := validation.IsValidPortNum(portNumber); len(errs) > 0 { + return fmt.Errorf("invalid repository port number: %s", strings.Join(errs, "; ")) + } + } + } + + return nil + } +} diff --git a/cmd/duffle/relocate_test.go b/cmd/duffle/relocate_test.go new file mode 100644 index 00000000..5e74d664 --- /dev/null +++ b/cmd/duffle/relocate_test.go @@ -0,0 +1,178 @@ +/* + * Copyright (c) 2019-Present Pivotal Software, Inc. All Rights Reserved. + * + * 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 main + +import ( + "io/ioutil" + "os" + "path" + "path/filepath" + "testing" + + "github.com/pivotal/image-relocation/pkg/image" + "github.com/pivotal/image-relocation/pkg/registry" + + "github.com/deislabs/duffle/pkg/duffle/home" +) + +const testRepositoryPrefix = "example.com/user" + +func TestRelocateFileToFile(t *testing.T) { + const ( + originalImageNameA = "deislabs/duffle@sha256:4d41eeb38fb14266b7c0461ef1ef0b2f8c05f41cd544987a259a9d92cdad2540" + relocatedImageNameA = "example.com/user/deislabs/duffle/relocated@sha256:4d41eeb38fb14266b7c0461ef1ef0b2f8c05f41cd544987a259a9d92cdad2540" + imageDigestA = "sha256:4d41eeb38fb14266b7c0461ef1ef0b2f8c05f41cd544987a259a9d92cdad2540" + + originalImageNameB = "deislabs/duffle:0.1.0-ralpha.5-englishrose" + relocatedImageNameB = "example.com/user/deislabs/duffle/relocated:0.1.0-ralpha.5-englishrose" + originalImageDigestB = "sha256:14d6134d892aeccb7e142557fe746ccd0a8f736a747c195ef04c9f3f0f0bbd49" + relocatedImageDigestB = "sha256:deadbeef892aeccb7e142557fe746ccd0a8f736a747c195ef04c9f3f0f0bbd49" + ) + + duffleHome, err := ioutil.TempDir("", "dufflehome") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(duffleHome) + + work, err := ioutil.TempDir("", "relocatetest") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(work) + + outputBundle := filepath.Join(work, "relocated.json") + + cmd := &relocateCmd{ + inputBundle: "testdata/relocate/bundle.json", + outputBundle: outputBundle, + + repoPrefix: testRepositoryPrefix, + inputBundleIsFile: true, + outputBundleIsFile: true, + + home: home.Home(duffleHome), + out: ioutil.Discard, + + mapping: func(repoPrefix string, originalImage image.Name) image.Name { + if repoPrefix != testRepositoryPrefix { + t.Fatalf("Unexpected repository prefix %s", repoPrefix) + } + // naïve test mapping + rn, err := image.NewName(path.Join(testRepositoryPrefix, originalImage.Path(), "relocated")) + if err != nil { + t.Fatal(err) + } + return rn + }, + registryClient: &mockRegClient{ + copyStub: func(source image.Name, target image.Name) (image.Digest, error) { + oinA, err := image.NewName(originalImageNameA) + if err != nil { + t.Fatal(err) + } + oinB, err := image.NewName(originalImageNameB) + if err != nil { + t.Fatal(err) + } + switch source { + case oinA: + if target.String() == relocatedImageNameA { + return image.NewDigest(imageDigestA) + } + case oinB: + if target.String() == relocatedImageNameB { + // check behaviour if digest is modified, even though this is not normally expected + return image.NewDigest(relocatedImageDigestB) + } + default: + t.Fatalf("unexpected source %v", source) + } + t.Fatalf("unexpected mapping from %v to %v", source, target) + return image.EmptyDigest, nil // unreachable + }, + }, + } + + if err := cmd.run(); err != nil { + t.Fatal(err) + } + + // check output bundle + bundleFile, err := resolveBundleFilePath(outputBundle, "", true) + if err != nil { + t.Fatal(err) + } + + bun, err := loadBundle(bundleFile) + if err != nil { + t.Fatal(err) + } + + if err = bun.Validate(); err != nil { + t.Fatal(err) + } + + assertImage := func(t *testing.T, i string, expectedOriginalImageName string, expectedImageName string, + expectedOriginalDigest string, expectedDigest string) { + img := bun.Images[i] + + actualImageName := img.Image + if actualImageName != expectedImageName { + t.Fatalf("output bundle has image %s with unexpected name: %q (expected %q)", i, actualImageName, + expectedImageName) + } + + actualOriginalImageName := img.OriginalImage + if actualOriginalImageName != expectedOriginalImageName { + t.Fatalf("output bundle has image %s with unexpected original name: %q (expected %q)", i, + actualOriginalImageName, expectedOriginalImageName) + } + + actualDigest := img.Digest + if actualDigest != expectedDigest { + t.Fatalf("output bundle has image %s with unexpected digest: %q (expected %q)", i, actualDigest, + expectedDigest) + } + + actualOriginalDigest := img.OriginalDigest + if actualOriginalDigest != expectedOriginalDigest { + t.Fatalf("output bundle has image %s with unexpected original digest: %q (expected %q)", i, + actualOriginalDigest, expectedOriginalDigest) + } + } + + assertImage(t, "a", originalImageNameA, relocatedImageNameA, "", imageDigestA) + assertImage(t, "b", originalImageNameB, relocatedImageNameB, originalImageDigestB, relocatedImageDigestB) + assertImage(t, "c", "", "c", "", "") +} + +type mockRegClient struct { + copyStub func(source image.Name, target image.Name) (image.Digest, error) + digestStub func(n image.Name) (image.Digest, error) + newLayoutStub func(path string) (registry.Layout, error) + readLayoutStub func(path string) (registry.Layout, error) +} + +func (r *mockRegClient) Digest(n image.Name) (image.Digest, error) { return r.digestStub(n) } +func (r *mockRegClient) Copy(src image.Name, tgt image.Name) (image.Digest, error) { + return r.copyStub(src, tgt) +} +func (r *mockRegClient) NewLayout(path string) (registry.Layout, error) { return r.newLayoutStub(path) } +func (r *mockRegClient) ReadLayout(path string) (registry.Layout, error) { + return r.readLayoutStub(path) +} diff --git a/cmd/duffle/root.go b/cmd/duffle/root.go index ec5ab3e8..e905127c 100644 --- a/cmd/duffle/root.go +++ b/cmd/duffle/root.go @@ -46,6 +46,7 @@ func newRootCmd(outputRedirect io.Writer) *cobra.Command { cmd.AddCommand(newInitCmd(outLog)) cmd.AddCommand(newShowCmd(outLog)) cmd.AddCommand(newListCmd(outLog)) + cmd.AddCommand(newRelocateCmd(outLog)) cmd.AddCommand(newVersionCmd(outLog)) cmd.AddCommand(newInstallCmd(outLog)) cmd.AddCommand(newStatusCmd(outLog)) diff --git a/cmd/duffle/testdata/relocate/bundle.json b/cmd/duffle/testdata/relocate/bundle.json new file mode 100644 index 00000000..1772c2c9 --- /dev/null +++ b/cmd/duffle/testdata/relocate/bundle.json @@ -0,0 +1,30 @@ +{ + "name": "testrelocate", + "version": "0.1", + "description": "a bundle with images", + "invocationImages": [ + { + "image": "deislabs/helloworld-cnab:675c2daf7449ae58927f251acdd88acb9d1e9767", + "imageType": "docker" + } + ], + "images": { + "a": { + "description": "digested oci", + "image": "deislabs/duffle@sha256:4d41eeb38fb14266b7c0461ef1ef0b2f8c05f41cd544987a259a9d92cdad2540", + "digest": "sha256:4d41eeb38fb14266b7c0461ef1ef0b2f8c05f41cd544987a259a9d92cdad2540", + "imageType": "oci" + }, + "b": { + "description": "tagged docker", + "image": "deislabs/duffle:0.1.0-ralpha.5-englishrose", + "digest": "sha256:14d6134d892aeccb7e142557fe746ccd0a8f736a747c195ef04c9f3f0f0bbd49", + "imageType": "docker" + }, + "c": { + "description": "neither oci nor docker", + "image": "c", + "imageType": "c" + } + } +} diff --git a/pkg/bundle/bundle.go b/pkg/bundle/bundle.go index edcd5778..5b0f0307 100644 --- a/pkg/bundle/bundle.go +++ b/pkg/bundle/bundle.go @@ -65,12 +65,14 @@ type LocationRef struct { // BaseImage contains fields shared across image types type BaseImage struct { - ImageType string `json:"imageType" mapstructure:"imageType"` - Image string `json:"image" mapstructure:"image"` - Digest string `json:"digest,omitempty" mapstructure:"digest"` - Size uint64 `json:"size,omitempty" mapstructure:"size"` - Platform *ImagePlatform `json:"platform,omitempty" mapstructure:"platform"` - MediaType string `json:"mediaType,omitempty" mapstructure:"mediaType"` + ImageType string `json:"imageType" mapstructure:"imageType"` + Image string `json:"image" mapstructure:"image"` + OriginalImage string `json:"originalImage" mapstructure:"originalImage"` + Digest string `json:"digest,omitempty" mapstructure:"digest"` + OriginalDigest string `json:"originalDigest,omitempty" mapstructure:"originalDigest"` + Size uint64 `json:"size,omitempty" mapstructure:"size"` + Platform *ImagePlatform `json:"platform,omitempty" mapstructure:"platform"` + MediaType string `json:"mediaType,omitempty" mapstructure:"mediaType"` } // ImagePlatform indicates what type of platform an image is built for