diff --git a/index.go b/index.go new file mode 100644 index 00000000..691ad20a --- /dev/null +++ b/index.go @@ -0,0 +1,47 @@ +package imgutil + +import ( + "fmt" + "strings" + + "github.com/google/go-containerregistry/pkg/v1/types" +) + +type ImageIndex interface { + // getters + + Name() string + + // modifiers + Add(repoName string) error + Remove(repoName string) error + Save(additionalNames ...string) error +} + +func (t MediaTypes) IndexManifestType() types.MediaType { + switch t { + case OCITypes: + return types.OCIImageIndex + case DockerTypes: + return types.DockerManifestList + default: + return "" + } +} + +type SaveIndexDiagnostic struct { + ImageIndexName string + Cause error +} + +type SaveIndexError struct { + Errors []SaveIndexDiagnostic +} + +func (e SaveIndexError) Error() string { + var errors []string + for _, d := range e.Errors { + errors = append(errors, fmt.Sprintf("[%s: %s]", d.ImageIndexName, d.Cause.Error())) + } + return fmt.Sprintf("failed to write image to the following tags: %s", strings.Join(errors, ",")) +} diff --git a/local/index.go b/local/index.go new file mode 100644 index 00000000..02ef16ce --- /dev/null +++ b/local/index.go @@ -0,0 +1,236 @@ +package local + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/pkg/errors" + + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/name" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/match" + "github.com/google/go-containerregistry/pkg/v1/mutate" + "github.com/google/go-containerregistry/pkg/v1/remote" +) + +type ImageIndex struct { + repoName string + path string + index v1.ImageIndex +} + +// Add appends a new image manifest to the local ImageIndex/ManifestList. +// We have not implemented nested indexes yet. +// See specification for more info: +// https://github.com/opencontainers/image-spec/blob/0b40f0f367c396cc5a7d6a2e8c8842271d3d3844/image-index.md#image-index-property-descriptions +func (i *ImageIndex) Add(repoName string) error { + ref, err := name.ParseReference(repoName) + if err != nil { + return err + } + + desc, err := remote.Get(ref, remote.WithAuthFromKeychain(authn.DefaultKeychain)) + if err != nil { + return err + } + + img, err := desc.Image() + if err != nil { + return err + } + + cfg, err := img.ConfigFile() + + if err != nil { + return errors.Wrapf(err, "getting config file for image %q", repoName) + } + if cfg == nil { + return fmt.Errorf("missing config for image %q", repoName) + } + + platform := v1.Platform{} + platform.Architecture = cfg.Architecture + platform.OS = cfg.OS + + desc.Descriptor.Platform = &platform + + indexRef, err := name.ParseReference(i.repoName) + if err != nil { + return err + } + + // Check if the image is in the same repository as the index + // If it is in a different repository then copy the image to + // the same repository as the index + if ref.Context().Name() != indexRef.Context().Name() { + imgRefName := indexRef.Context().Name() + "@" + desc.Digest.Algorithm + ":" + desc.Digest.Hex + imgRef, err := name.ParseReference(imgRefName) + if err != nil { + return err + } + + err = remote.Write(imgRef, img, remote.WithAuthFromKeychain(authn.DefaultKeychain)) + if err != nil { + return errors.Wrapf(err, "failed to copy image '%s' to index repository", imgRef.Name()) + } + } + + i.index = mutate.AppendManifests(i.index, mutate.IndexAddendum{Add: img, Descriptor: desc.Descriptor}) + + return nil +} + +// Remove method removes the specified manifest from the local index +func (i *ImageIndex) Remove(repoName string) error { + ref, err := name.ParseReference(repoName) + if err != nil { + return err + } + + desc, err := remote.Get(ref, remote.WithAuthFromKeychain(authn.DefaultKeychain)) + if err != nil { + return err + } + + i.index = mutate.RemoveManifests(i.index, match.Digests(desc.Digest)) + + return nil +} + +// Delete method removes the specified index from the local storage +func (i *ImageIndex) Delete(additionalNames ...string) error { + _, err := name.ParseReference(i.repoName) + if err != nil { + return err + } + + manifestPath := filepath.Join(i.path, makeFileSafeName(i.repoName)) + err = os.Remove(manifestPath) + if err != nil { + return err + } + + return nil +} + +// Save stores the ImageIndex manifest information in a plain text in the ined file in JSON format. +func (i *ImageIndex) Save(additionalNames ...string) error { + indexManifest, err := i.index.IndexManifest() + if err != nil { + return err + } + + rawManifest, err := json.MarshalIndent(indexManifest, "", " ") + if err != nil { + return err + } + + manifestDir := filepath.Join(i.path, makeFileSafeName(i.repoName)) + + err = os.WriteFile(manifestDir, rawManifest, os.ModePerm) + if err != nil { + return err + } + + return nil +} + +// Change a reference name string into a valid file name +// Ex: cnbs/sample-package:hello-multiarch-universe +// to cnbs_sample-package-hello-multiarch-universe +func makeFileSafeName(ref string) string { + fileName := strings.ReplaceAll(ref, ":", "-") + return strings.ReplaceAll(fileName, "/", "_") +} + +func (i *ImageIndex) Name() string { + return i.repoName +} + +// Fields which are allowed to be annotated in a local index +type AnnotateFields struct { + Architecture string + OS string + Variant string +} + +// AnnotateManifest changes the fields of the local index which +// are not empty string in the provided AnnotateField structure. +func (i *ImageIndex) AnnotateManifest(manifestName string, opts AnnotateFields) error { + path := filepath.Join(i.path, makeFileSafeName(i.repoName)) + + manifest, err := i.index.IndexManifest() + if err != nil { + return err + } + + ref, err := name.ParseReference(manifestName) + if err != nil { + return err + } + + desc, err := remote.Get(ref, remote.WithAuthFromKeychain(authn.DefaultKeychain)) + if err != nil { + return err + } + + for i, iDesc := range manifest.Manifests { + if iDesc.Digest.String() == desc.Digest.String() { + if opts.Architecture != "" { + manifest.Manifests[i].Platform.Architecture = opts.Architecture + } + + if opts.OS != "" { + manifest.Manifests[i].Platform.OS = opts.OS + } + + if opts.Variant != "" { + manifest.Manifests[i].Platform.Variant = opts.Variant + } + + data, err := json.Marshal(manifest) + if err != nil { + return err + } + + err = os.WriteFile(path, data, os.ModePerm) + if err != nil { + return err + } + + return nil + } + } + + return errors.Errorf("Manifest %s not found", manifestName) +} + +// GetIndexManifest will look for a file the given index in the specified path and +// if found it will return a v1.IndexManifest. +// It is assumed that the local index file name is derived using makeFileSafeName() +func GetIndexManifest(repoName string, path string) (v1.IndexManifest, error) { + var manifest v1.IndexManifest + + _, err := name.ParseReference(repoName) + if err != nil { + return manifest, err + } + + manifestDir := filepath.Join(path, makeFileSafeName(repoName)) + + jsonFile, err := os.ReadFile(manifestDir) + if err != nil { + return manifest, errors.Wrapf(err, "Reading local index %q in path %q", repoName, path) + } + + err = json.Unmarshal(jsonFile, &manifest) + if err != nil { + return manifest, errors.Wrapf(err, "Decoding local index %q", repoName) + } + + return manifest, nil +} diff --git a/local/index_options.go b/local/index_options.go new file mode 100644 index 00000000..226d080f --- /dev/null +++ b/local/index_options.go @@ -0,0 +1,30 @@ +package local + +import ( + v1 "github.com/google/go-containerregistry/pkg/v1" + + "github.com/buildpacks/imgutil" +) + +type ImageIndexOption func(*indexOptions) error + +type indexOptions struct { + mediaTypes imgutil.MediaTypes + manifest v1.IndexManifest +} + +// WithIndexMediaTypes lets a caller set the desired media types for the index manifest +func WithIndexMediaTypes(requested imgutil.MediaTypes) ImageIndexOption { + return func(opts *indexOptions) error { + opts.mediaTypes = requested + return nil + } +} + +// WithManifest uses an existing v1.IndexManifest as a base to create the index +func WithManifest(manifest v1.IndexManifest) ImageIndexOption { + return func(opts *indexOptions) error { + opts.manifest = manifest + return nil + } +} diff --git a/local/new_index.go b/local/new_index.go new file mode 100644 index 00000000..30081234 --- /dev/null +++ b/local/new_index.go @@ -0,0 +1,110 @@ +package local + +import ( + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/name" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/empty" + "github.com/google/go-containerregistry/pkg/v1/mutate" + "github.com/google/go-containerregistry/pkg/v1/remote" + "github.com/google/go-containerregistry/pkg/v1/types" + + "github.com/buildpacks/imgutil" +) + +func NewIndex(repoName string, path string, ops ...ImageIndexOption) (*ImageIndex, error) { + ref, err := name.ParseReference(repoName, name.WeakValidation) + if err != nil { + return nil, err + } + + indexOpts := &indexOptions{} + for _, op := range ops { + if err := op(indexOpts); err != nil { + return nil, err + } + } + + // If WithManifest option is given, create an index using + // the provided v1.IndexManifest + if len(indexOpts.manifest.Manifests) != 0 { + index, err := emptyIndex(indexOpts.manifest.MediaType) + if err != nil { + return nil, err + } + + for _, manifest := range indexOpts.manifest.Manifests { + img, _ := emptyImage(imgutil.Platform{ + Architecture: manifest.Platform.Architecture, + OS: manifest.Platform.OS, + OSVersion: manifest.Platform.OSVersion, + }) + index = mutate.AppendManifests(index, mutate.IndexAddendum{Add: img, Descriptor: manifest}) + } + + idx := &ImageIndex{ + repoName: repoName, + path: path, + index: index, + } + + return idx, nil + } + + // If index already exists in registry, use it as a base + desc, err := remote.Get(ref, remote.WithAuthFromKeychain(authn.DefaultKeychain)) + if err == nil { + index, err := desc.ImageIndex() + if err != nil { + return nil, err + } + + idx := &ImageIndex{ + repoName: repoName, + path: path, + index: index, + } + + return idx, nil + } + + mediaType := defaultMediaType() + if indexOpts.mediaTypes.IndexManifestType() != "" { + mediaType = indexOpts.mediaTypes + } + + index, err := emptyIndex(mediaType.IndexManifestType()) + if err != nil { + return nil, err + } + + ridx := &ImageIndex{ + repoName: repoName, + path: path, + index: index, + } + + return ridx, nil +} + +func emptyIndex(mediaType types.MediaType) (v1.ImageIndex, error) { + return mutate.IndexMediaType(empty.Index, mediaType), nil +} + +func emptyImage(platform imgutil.Platform) (v1.Image, error) { + cfg := &v1.ConfigFile{ + Architecture: platform.Architecture, + OS: platform.OS, + OSVersion: platform.OSVersion, + RootFS: v1.RootFS{ + Type: "layers", + DiffIDs: []v1.Hash{}, + }, + } + + return mutate.ConfigFile(empty.Image, cfg) +} + +func defaultMediaType() imgutil.MediaTypes { + return imgutil.DockerTypes +} diff --git a/remote/index.go b/remote/index.go new file mode 100644 index 00000000..5704aee8 --- /dev/null +++ b/remote/index.go @@ -0,0 +1,150 @@ +package remote + +import ( + "fmt" + + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/name" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/match" + "github.com/google/go-containerregistry/pkg/v1/mutate" + "github.com/google/go-containerregistry/pkg/v1/remote" + "github.com/google/go-containerregistry/pkg/v1/types" + "github.com/pkg/errors" + + "github.com/buildpacks/imgutil" +) + +type ImageIndex struct { + keychain authn.Keychain + repoName string + index v1.ImageIndex + registrySettings map[string]registrySetting +} + +// modfiers + +// Add appends a new image manifest to the remote ImageIndex/ManifestList. +// We have not implemented nested indexes yet. +// See specification for more info: +// https://github.com/opencontainers/image-spec/blob/0b40f0f367c396cc5a7d6a2e8c8842271d3d3844/image-index.md#image-index-property-descriptions +func (i *ImageIndex) Add(repoName string) error { + ref, err := name.ParseReference(repoName) + if err != nil { + return err + } + + // Fetch image descriptor from registry + desc, err := remote.Get(ref, remote.WithAuthFromKeychain(authn.DefaultKeychain)) + if err != nil { + return errors.Wrapf(err, "error fetching %s from registry", repoName) + } + + img, err := desc.Image() + if err != nil { + return err + } + + // Get the image configuration file + cfg, err := img.ConfigFile() + + if err != nil { + return errors.Wrapf(err, "getting config file for image %q", repoName) + } + if cfg == nil { + return fmt.Errorf("missing config for image %q", repoName) + } + + platform := v1.Platform{} + platform.Architecture = cfg.Architecture + platform.OS = cfg.OS + + desc.Descriptor.Platform = &platform + + i.index = mutate.AppendManifests(i.index, mutate.IndexAddendum{Add: img, Descriptor: desc.Descriptor}) + + return nil +} + +// Remove method removes the specified manifest from the index +func (i *ImageIndex) Remove(repoName string) error { + ref, err := name.ParseReference(repoName) + if err != nil { + return err + } + + desc, err := remote.Get(ref, remote.WithAuthFromKeychain(authn.DefaultKeychain)) + if err != nil { + return err + } + + i.index = mutate.RemoveManifests(i.index, match.Digests(desc.Digest)) + + return nil +} + +// Save pushes the ImageIndex to the image reference obtained from index name. +func (i *ImageIndex) Save(additionalNames ...string) error { + return i.SaveAs(i.Name(), additionalNames...) +} + +func (i *ImageIndex) SaveAs(name string, additionalNames ...string) error { + allNames := append([]string{name}, additionalNames...) + + var diagnostics []imgutil.SaveIndexDiagnostic + for _, n := range allNames { + if err := i.doSave(n); err != nil { + diagnostics = append(diagnostics, imgutil.SaveIndexDiagnostic{ImageIndexName: n, Cause: err}) + } + } + if len(diagnostics) > 0 { + return imgutil.SaveIndexError{Errors: diagnostics} + } + + return nil +} + +func (i *ImageIndex) doSave(indexName string) error { + reg := getRegistry(i.repoName, i.registrySettings) + ref, auth, err := referenceForRepoName(i.keychain, indexName, reg.insecure) + if err != nil { + return err + } + + iManifest, err := i.index.IndexManifest() + if err != nil { + return err + } + + // This for loop will check if all the referenced manifests have the plaform information. + // This is OPTIONAL if the target is plaform independent. + // Current implementation does not allow to push an index without platform information. + for _, j := range iManifest.Manifests { + switch j.MediaType { + case types.OCIManifestSchema1, types.DockerManifestSchema2: + if j.Platform.Architecture == "" || j.Platform.OS == "" { + return errors.Errorf("manifest with digest %s is missing either OS or Architecture information to be pushed to a registry", j.Digest) + } + } + } + + return remote.WriteIndex(ref, i.index, remote.WithAuth(auth)) +} + +func (i *ImageIndex) Name() string { + return i.repoName +} + +// This structure is used to expose methods that we only need for testing. +type ImageIndexTest struct { + ImageIndex +} + +func (i *ImageIndexTest) MediaType() (types.MediaType, error) { + mediaType, err := i.ImageIndex.index.MediaType() + if err != nil { + return "", err + } + + return mediaType, nil +} diff --git a/remote/index_options.go b/remote/index_options.go new file mode 100644 index 00000000..4c13f135 --- /dev/null +++ b/remote/index_options.go @@ -0,0 +1,30 @@ +package remote + +import ( + v1 "github.com/google/go-containerregistry/pkg/v1" + + "github.com/buildpacks/imgutil" +) + +type ImageIndexOption func(*indexOptions) error + +type indexOptions struct { + mediaTypes imgutil.MediaTypes + manifest v1.IndexManifest +} + +// WithIndexMediaTypes lets a caller set the desired media types for the index manifest +func WithIndexMediaTypes(requested imgutil.MediaTypes) ImageIndexOption { + return func(opts *indexOptions) error { + opts.mediaTypes = requested + return nil + } +} + +// WithManifest uses an existing v1.IndexManifest as a base to create the index +func WithManifest(manifest v1.IndexManifest) ImageIndexOption { + return func(opts *indexOptions) error { + opts.manifest = manifest + return nil + } +} diff --git a/remote/index_test.go b/remote/index_test.go new file mode 100644 index 00000000..ea99fc2d --- /dev/null +++ b/remote/index_test.go @@ -0,0 +1,193 @@ +package remote_test + +import ( + "fmt" + "io" + "log" + "os" + "strings" + "testing" + + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/registry" + "github.com/google/go-containerregistry/pkg/v1/types" + "github.com/sclevine/spec" + "github.com/sclevine/spec/report" + + "github.com/buildpacks/imgutil" + "github.com/buildpacks/imgutil/remote" + h "github.com/buildpacks/imgutil/testhelpers" +) + +func newTestIndexName(providedPrefix ...string) string { + prefix := "pack-index-test" + if len(providedPrefix) > 0 { + prefix = providedPrefix[0] + } + + return dockerRegistry.RepoName(prefix + "-" + h.RandString(10)) +} + +func TestIndex(t *testing.T) { + dockerConfigDir, err := os.MkdirTemp("", "test.docker.config.dir") + h.AssertNil(t, err) + defer os.RemoveAll(dockerConfigDir) + + sharedRegistryHandler := registry.New(registry.Logger(log.New(io.Discard, "", log.Lshortfile))) + dockerRegistry = h.NewDockerRegistry(h.WithAuth(dockerConfigDir), h.WithSharedHandler(sharedRegistryHandler)) + + dockerRegistry.SetInaccessible("cnbs/no-image-in-this-name") + + dockerRegistry.Start(t) + defer dockerRegistry.Stop(t) + + os.Setenv("DOCKER_CONFIG", dockerRegistry.DockerDirectory) + defer os.Unsetenv("DOCKER_CONFIG") + + spec.Run(t, "Index", testIndex, spec.Sequential(), spec.Report(report.Terminal{})) +} + +func testIndex(t *testing.T, when spec.G, it spec.S) { + when("#NewIndex", func() { + when("index name is invalid", func() { + it("return error", func() { + _, err := remote.NewIndex("-.bad-@!mage", authn.DefaultKeychain) + h.AssertError(t, err, "could not parse reference: -.bad-@!mage") + }) + }) + + when("index name is valid", func() { + it("create index with the specified name", func() { + image := newTestIndexName() + idxt, err := remote.NewIndex(image, authn.DefaultKeychain) + h.AssertNil(t, err) + h.AssertEq(t, image, idxt.Name()) + }) + }) + + when("no options specified", func() { + it("uses DockerManifestList as default mediatype", func() { + idxt, _ := remote.NewIndexTest(newTestIndexName(), authn.DefaultKeychain) + mediatype, _ := idxt.MediaType() + h.AssertEq(t, mediatype, types.DockerManifestList) + }) + }) + + when("when index is found in registry", func() { + it("use the index found in registry as base", func() { + }) + }) + + when("#WithIndexMediaTypes", func() { + it("create index with the specified mediatype", func() { + idxt, err := remote.NewIndexTest( + newTestIndexName(), + authn.DefaultKeychain, + remote.WithIndexMediaTypes(imgutil.OCITypes)) + h.AssertNil(t, err) + + mediatype, err := idxt.MediaType() + h.AssertNil(t, err) + h.AssertEq(t, mediatype, types.OCIImageIndex) + }) + }) + }) + + when("#Add", func() { + when("manifest is not in registry", func() { + it("error (timeout) fetching manifest", func() { + idx, err := remote.NewIndex("cnbs/test-index", authn.DefaultKeychain) + h.AssertNil(t, err) + + manifestName := dockerRegistry.RepoName("cnbs/no-image-in-this-name") + err = idx.Add(manifestName) + h.AssertError(t, err, fmt.Sprintf("error fetching %s from registry", manifestName)) + }) + }) + + when("manifest name is invalid", func() { + it("error parsing reference", func() { + idx, err := remote.NewIndex("some-bad-repo", authn.DefaultKeychain) + h.AssertNil(t, err) + + manifestName := dockerRegistry.RepoName("cnbs/bad-@!mage") + err = idx.Add(manifestName) + h.AssertError(t, err, fmt.Sprintf("could not parse reference: %s", manifestName)) + }) + }) + + when("manifest is in registry", func() { + it("append manifest to index", func() { + idx, err := remote.NewIndex("cnbs/test-index", authn.DefaultKeychain) + h.AssertNil(t, err) + + manifestName := dockerRegistry.RepoName("cnbs/test-image:arm") + img, err := remote.NewImage( + manifestName, + authn.DefaultKeychain, + remote.WithDefaultPlatform(imgutil.Platform{ + Architecture: "arm", + OS: "linux", + }), + ) + h.AssertNil(t, err) + h.AssertNil(t, img.Save()) + + err = idx.Add(manifestName) + h.AssertNil(t, err) + }) + }) + }) + + when("#Save", func() { + when("manifest plaform fields are missing", func() { + it("error storing in registry", func() { + indexName := dockerRegistry.RepoName("cnbs/test-index-not-valid") + idx, err := remote.NewIndex(indexName, authn.DefaultKeychain) + h.AssertNil(t, err) + + manifestName := dockerRegistry.RepoName("cnbs/test-image:arm") + img, err := remote.NewImage( + manifestName, + authn.DefaultKeychain, + remote.WithDefaultPlatform(imgutil.Platform{ + Architecture: "", + OS: "linux", + }), + ) + h.AssertNil(t, err) + h.AssertNil(t, img.Save()) + + h.AssertNil(t, idx.Add(manifestName)) + + a := strings.Split(idx.Save().Error(), " ") + + h.AssertContains(t, a, "missing", "OS", "Architecture") + }) + }) + + when("index is valid to push", func() { + it("store index in registry", func() { + indexName := dockerRegistry.RepoName("cnbs/test-index-valid") + idx, err := remote.NewIndex(indexName, authn.DefaultKeychain) + h.AssertNil(t, err) + + manifestName := dockerRegistry.RepoName("cnbs/test-image:arm-linux") + img, err := remote.NewImage( + manifestName, + authn.DefaultKeychain, + remote.WithDefaultPlatform(imgutil.Platform{ + Architecture: "arm", + OS: "linux", + }), + ) + h.AssertNil(t, err) + h.AssertNil(t, img.Save()) + + h.AssertNil(t, idx.Add(manifestName)) + + h.AssertNil(t, idx.Save()) + }) + }) + }) +} diff --git a/remote/new.go b/remote/new.go index a392de57..00ee0e40 100644 --- a/remote/new.go +++ b/remote/new.go @@ -45,6 +45,7 @@ func NewImage(repoName string, keychain authn.Keychain, ops ...ImageOption) (*Im repoName: repoName, image: image, addEmptyLayerOnSave: imageOpts.addEmptyLayerOnSave, + withDigest: imageOpts.withDigest, withHistory: imageOpts.withHistory, registrySettings: imageOpts.registrySettings, } diff --git a/remote/new_index.go b/remote/new_index.go new file mode 100644 index 00000000..28815a18 --- /dev/null +++ b/remote/new_index.go @@ -0,0 +1,113 @@ +package remote + +import ( + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/name" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/empty" + "github.com/google/go-containerregistry/pkg/v1/mutate" + "github.com/google/go-containerregistry/pkg/v1/remote" + "github.com/google/go-containerregistry/pkg/v1/types" + + "github.com/buildpacks/imgutil" +) + +func NewIndex(repoName string, keychain authn.Keychain, ops ...ImageIndexOption) (*ImageIndex, error) { + ref, err := name.ParseReference(repoName, name.WeakValidation) + if err != nil { + return nil, err + } + + indexOpts := &indexOptions{} + for _, op := range ops { + if err := op(indexOpts); err != nil { + return nil, err + } + } + + // If WithManifest option is given, create an index using + // the provided v1.IndexManifest + if len(indexOpts.manifest.Manifests) != 0 { + index, err := emptyIndex(indexOpts.manifest.MediaType) + if err != nil { + return nil, err + } + + for _, manifest := range indexOpts.manifest.Manifests { + img, err := emptyImage(imgutil.Platform{ + Architecture: manifest.Platform.Architecture, + OS: manifest.Platform.OS, + OSVersion: manifest.Platform.OSVersion, + }) + if err != nil { + return nil, err + } + + index = mutate.AppendManifests(index, mutate.IndexAddendum{Add: img, Descriptor: manifest}) + } + + idx := &ImageIndex{ + keychain: keychain, + repoName: repoName, + index: index, + } + + return idx, nil + } + + // If index already exists in registry, use it as a base + desc, err := remote.Get(ref, remote.WithAuthFromKeychain(keychain)) + if err == nil { + index, err := desc.ImageIndex() + if err != nil { + return nil, err + } + + idx := &ImageIndex{ + keychain: keychain, + repoName: repoName, + index: index, + } + + return idx, nil + } + + mediaType := defaultMediaType() + if indexOpts.mediaTypes.IndexManifestType() != "" { + mediaType = indexOpts.mediaTypes + } + + index, err := emptyIndex(mediaType.IndexManifestType()) + if err != nil { + return nil, err + } + + ridx := &ImageIndex{ + keychain: keychain, + repoName: repoName, + index: index, + } + + return ridx, nil +} + +func emptyIndex(mediaType types.MediaType) (v1.ImageIndex, error) { + return mutate.IndexMediaType(empty.Index, mediaType), nil +} + +func defaultMediaType() imgutil.MediaTypes { + return imgutil.DockerTypes +} + +func NewIndexTest(repoName string, keychain authn.Keychain, ops ...ImageIndexOption) (*ImageIndexTest, error) { + ridx, err := NewIndex(repoName, keychain, ops...) + if err != nil { + return nil, err + } + + ridxt := &ImageIndexTest{ + ImageIndex: *ridx, + } + + return ridxt, nil +} diff --git a/remote/options.go b/remote/options.go index a1cd8294..00f4883b 100644 --- a/remote/options.go +++ b/remote/options.go @@ -17,6 +17,7 @@ type options struct { createdAt time.Time addEmptyLayerOnSave bool withHistory bool + withDigest bool registrySettings map[string]registrySetting mediaTypes imgutil.MediaTypes config *v1.Config @@ -32,6 +33,14 @@ func AddEmptyLayerOnSave() ImageOption { } } +// SaveWithDigest (remote only) +func SaveWithDigest() ImageOption { + return func(opts *options) error { + opts.withDigest = true + return nil + } +} + // FromBaseImage loads an existing image as the config and layers for the new image. // Ignored if image is not found. func FromBaseImage(imageName string) ImageOption { diff --git a/remote/remote.go b/remote/remote.go index 581593f7..28bbdd58 100644 --- a/remote/remote.go +++ b/remote/remote.go @@ -32,6 +32,7 @@ type Image struct { createdAt time.Time addEmptyLayerOnSave bool withHistory bool + withDigest bool registrySettings map[string]registrySetting requestedMediaTypes imgutil.MediaTypes } diff --git a/remote/save.go b/remote/save.go index e30de4df..0f5f9d55 100644 --- a/remote/save.go +++ b/remote/save.go @@ -87,6 +87,13 @@ func (i *Image) SaveAs(name string, additionalNames ...string) error { func (i *Image) doSave(imageName string) error { reg := getRegistry(i.repoName, i.registrySettings) + if i.withDigest { + id, err := i.Identifier() + if err != nil { + return err + } + imageName = id.String() + } ref, auth, err := referenceForRepoName(i.keychain, imageName, reg.insecure) if err != nil { return err