diff --git a/acceptance/reproducibility_test.go b/acceptance/reproducibility_test.go index 935f1c69..803ee2f9 100644 --- a/acceptance/reproducibility_test.go +++ b/acceptance/reproducibility_test.go @@ -55,10 +55,10 @@ func testReproducibility(t *testing.T, _ spec.G, it spec.S) { it.Before(func() { dockerClient = h.DockerCli(t) - daemonInfo, err := dockerClient.Info(context.TODO()) + daemonInfo, err := dockerClient.ServerVersion(context.TODO()) h.AssertNil(t, err) - daemonOS := daemonInfo.OSType + daemonOS := daemonInfo.Os runnableBaseImageName = h.RunnableBaseImage(daemonOS) h.PullIfMissing(t, dockerClient, runnableBaseImageName) diff --git a/cnb_image.go b/cnb_image.go index 01608d8a..702e2bc6 100644 --- a/cnb_image.go +++ b/cnb_image.go @@ -162,6 +162,25 @@ func (i *CNBImageCore) OSVersion() (string, error) { return configFile.OSVersion, nil } +func (i *CNBImageCore) OSFeatures() ([]string, error) { + configFile, err := getConfigFile(i.Image) + if err != nil { + return nil, err + } + return configFile.OSFeatures, nil +} + +func (i *CNBImageCore) Annotations() (map[string]string, error) { + manifest, err := getManifest(i.Image) + if err != nil { + return nil, err + } + if manifest.Annotations == nil { + return make(map[string]string), nil + } + return manifest.Annotations, nil +} + func (i *CNBImageCore) TopLayer() (string, error) { layers, err := i.Image.Layers() if err != nil { @@ -202,6 +221,12 @@ func (i *CNBImageCore) WorkingDir() (string, error) { } func (i *CNBImageCore) AnnotateRefName(refName string) error { + return i.SetAnnotations(map[string]string{ + "org.opencontainers.image.ref.name": refName, + }) +} + +func (i *CNBImageCore) SetAnnotations(annotations map[string]string) error { manifest, err := getManifest(i.Image) if err != nil { return err @@ -209,11 +234,13 @@ func (i *CNBImageCore) AnnotateRefName(refName string) error { if manifest.Annotations == nil { manifest.Annotations = make(map[string]string) } - manifest.Annotations["org.opencontainers.image.ref.name"] = refName + for k, v := range annotations { + manifest.Annotations[k] = v + } mutated := mutate.Annotations(i.Image, manifest.Annotations) image, ok := mutated.(v1.Image) if !ok { - return fmt.Errorf("failed to add annotation") + return fmt.Errorf("failed to add annotations") } i.Image = image return nil @@ -285,6 +312,12 @@ func (i *CNBImageCore) SetOS(osVal string) error { }) } +func (i *CNBImageCore) SetOSFeatures(osFeatures []string) error { + return i.MutateConfigFile(func(c *v1.ConfigFile) { + c.OSFeatures = osFeatures + }) +} + // TBD Deprecated: SetOSVersion func (i *CNBImageCore) SetOSVersion(osVersion string) error { return i.MutateConfigFile(func(c *v1.ConfigFile) { diff --git a/cnb_index.go b/cnb_index.go new file mode 100644 index 00000000..5045f2f7 --- /dev/null +++ b/cnb_index.go @@ -0,0 +1,394 @@ +package imgutil + +import ( + "fmt" + "os" + "path/filepath" + + "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/layout" + "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" +) + +var ( + ErrManifestUndefined = errors.New("encountered unexpected error while parsing image: manifest or index manifest is nil") + ErrUnknownMediaType = func(format types.MediaType) error { + return fmt.Errorf("unsupported media type encountered in image: '%s'", format) + } +) + +type CNBIndex struct { + // required + v1.ImageIndex // the working image index + // local options + XdgPath string + // push options + KeyChain authn.Keychain + RepoName string +} + +func (h *CNBIndex) getDescriptorFrom(digest name.Digest) (v1.Descriptor, error) { + indexManifest, err := getIndexManifest(h.ImageIndex) + if err != nil { + return v1.Descriptor{}, err + } + for _, current := range indexManifest.Manifests { + if current.Digest.String() == digest.Identifier() { + return current, nil + } + } + return v1.Descriptor{}, fmt.Errorf("failed to find image with digest %s in index", digest.Identifier()) +} + +// OS returns `OS` of an existing Image. +func (h *CNBIndex) OS(digest name.Digest) (os string, err error) { + desc, err := h.getDescriptorFrom(digest) + if err != nil { + return "", err + } + if desc.Platform != nil { + return desc.Platform.OS, nil + } + return "", nil +} + +// Architecture return the Architecture of an Image/Index based on given Digest. +// Returns an error if no Image/Index found with given Digest. +func (h *CNBIndex) Architecture(digest name.Digest) (arch string, err error) { + desc, err := h.getDescriptorFrom(digest) + if err != nil { + return "", err + } + if desc.Platform != nil { + return desc.Platform.Architecture, nil + } + return "", nil +} + +// Variant return the `Variant` of an Image. +// Returns an error if no Image/Index found with given Digest. +func (h *CNBIndex) Variant(digest name.Digest) (osVariant string, err error) { + desc, err := h.getDescriptorFrom(digest) + if err != nil { + return "", err + } + if desc.Platform != nil { + return desc.Platform.Variant, nil + } + return "", nil +} + +// OSVersion returns the `OSVersion` of an Image with given Digest. +// Returns an error if no Image/Index found with given Digest. +func (h *CNBIndex) OSVersion(digest name.Digest) (osVersion string, err error) { + desc, err := h.getDescriptorFrom(digest) + if err != nil { + return "", err + } + if desc.Platform != nil { + return desc.Platform.OSVersion, nil + } + return "", nil +} + +// OSFeatures returns the `OSFeatures` of an Image with given Digest. +// Returns an error if no Image/Index found with given Digest. +func (h *CNBIndex) OSFeatures(digest name.Digest) (osFeatures []string, err error) { + desc, err := h.getDescriptorFrom(digest) + if err != nil { + return nil, err + } + if desc.Platform != nil { + return desc.Platform.OSFeatures, nil + } + return []string{}, nil +} + +// Annotations return the `Annotations` of an Image with given Digest. +// Returns an error if no Image/Index found with given Digest. +// For Docker images and Indexes it returns an error. +func (h *CNBIndex) Annotations(digest name.Digest) (annotations map[string]string, err error) { + desc, err := h.getDescriptorFrom(digest) + if err != nil { + return nil, err + } + return desc.Annotations, nil +} + +// setters + +func (h *CNBIndex) SetAnnotations(digest name.Digest, annotations map[string]string) (err error) { + return h.replaceDescriptor(digest, func(descriptor v1.Descriptor) (v1.Descriptor, error) { + if len(descriptor.Annotations) == 0 { + descriptor.Annotations = make(map[string]string) + } + + for k, v := range annotations { + descriptor.Annotations[k] = v + } + return descriptor, nil + }) +} + +func (h *CNBIndex) SetArchitecture(digest name.Digest, arch string) (err error) { + return h.replaceDescriptor(digest, func(descriptor v1.Descriptor) (v1.Descriptor, error) { + descriptor.Platform.Architecture = arch + return descriptor, nil + }) +} + +func (h *CNBIndex) SetOS(digest name.Digest, os string) (err error) { + return h.replaceDescriptor(digest, func(descriptor v1.Descriptor) (v1.Descriptor, error) { + descriptor.Platform.OS = os + return descriptor, nil + }) +} + +func (h *CNBIndex) SetVariant(digest name.Digest, osVariant string) (err error) { + return h.replaceDescriptor(digest, func(descriptor v1.Descriptor) (v1.Descriptor, error) { + descriptor.Platform.Variant = osVariant + return descriptor, nil + }) +} + +func (h *CNBIndex) replaceDescriptor(digest name.Digest, withFun func(descriptor v1.Descriptor) (v1.Descriptor, error)) (err error) { + desc, err := h.getDescriptorFrom(digest) + if err != nil { + return err + } + mediaType := desc.MediaType + if desc.Platform == nil { + desc.Platform = &v1.Platform{} + } + desc, err = withFun(desc) + if err != nil { + return err + } + add := mutate.IndexAddendum{ + Add: h.ImageIndex, + Descriptor: desc, + } + h.ImageIndex = mutate.AppendManifests(mutate.RemoveManifests(h.ImageIndex, match.Digests(desc.Digest)), add) + + // Avoid overriding the original media-type + mediaTypeAfter, err := h.ImageIndex.MediaType() + if err != nil { + return err + } + if mediaTypeAfter != mediaType { + h.ImageIndex = mutate.IndexMediaType(h.ImageIndex, mediaType) + } + return nil +} + +func (h *CNBIndex) Image(hash v1.Hash) (v1.Image, error) { + index, err := h.IndexManifest() + if err != nil { + return nil, err + } + if !indexContains(index.Manifests, hash) { + return nil, fmt.Errorf("failed to find image with digest %s in index", hash.String()) + } + return h.ImageIndex.Image(hash) +} + +func indexContains(manifests []v1.Descriptor, hash v1.Hash) bool { + for _, m := range manifests { + if m.Digest.String() == hash.String() { + return true + } + } + return false +} + +// AddManifest adds an image to the index. +func (h *CNBIndex) AddManifest(image v1.Image) { + desc, _ := descriptor(image) + h.ImageIndex = mutate.AppendManifests(h.ImageIndex, mutate.IndexAddendum{ + Add: image, + Descriptor: desc, + }) +} + +// SaveDir will locally save the index. +func (h *CNBIndex) SaveDir() error { + layoutPath := filepath.Join(h.XdgPath, MakeFileSafeName(h.RepoName)) // FIXME: do we create an OCI-layout compatible directory structure? + var ( + path layout.Path + err error + ) + + if _, err = os.Stat(layoutPath); !os.IsNotExist(err) { + // We need to always init an empty index when saving + if err = os.RemoveAll(layoutPath); err != nil { + return err + } + } + + indexType, err := h.ImageIndex.MediaType() + if err != nil { + return err + } + if path, err = newEmptyLayoutPath(indexType, layoutPath); err != nil { + return err + } + + var errs SaveError + index, err := h.ImageIndex.IndexManifest() + if err != nil { + return err + } + for _, desc := range index.Manifests { + appendManifest(desc, path, &errs) + } + if len(errs.Errors) != 0 { + return errs + } + return nil +} + +func appendManifest(desc v1.Descriptor, path layout.Path, errs *SaveError) { + if err := path.RemoveDescriptors(match.Digests(desc.Digest)); err != nil { + errs.Errors = append(errs.Errors, SaveDiagnostic{ + Cause: err, + }) + } + if err := path.AppendDescriptor(desc); err != nil { + errs.Errors = append(errs.Errors, SaveDiagnostic{ + Cause: err, + }) + } +} + +func newEmptyLayoutPath(indexType types.MediaType, path string) (layout.Path, error) { + if indexType == types.OCIImageIndex { + return layout.Write(path, empty.Index) + } + return layout.Write(path, NewEmptyDockerIndex()) +} + +// Push Publishes ImageIndex to the registry assuming every image it referes exists in registry. +// +// It will only push the IndexManifest to registry. +func (h *CNBIndex) Push(ops ...IndexOption) error { + var pushOps = &IndexOptions{} + for _, op := range ops { + if err := op(pushOps); err != nil { + return err + } + } + + if pushOps.MediaType != "" { + if !pushOps.MediaType.IsIndex() { + return ErrUnknownMediaType(pushOps.MediaType) + } + existingType, err := h.ImageIndex.MediaType() + if err != nil { + return err + } + if pushOps.MediaType != existingType { + h.ImageIndex = mutate.IndexMediaType(h.ImageIndex, pushOps.MediaType) + } + } + + ref, err := name.ParseReference( + h.RepoName, + name.WeakValidation, + name.Insecure, + ) + if err != nil { + return err + } + + indexManifest, err := getIndexManifest(h.ImageIndex) + if err != nil { + return err + } + + var taggableIndex = NewTaggableIndex(indexManifest) + multiWriteTagables := map[name.Reference]remote.Taggable{ + ref: taggableIndex, + } + for _, tag := range pushOps.DestinationTags { + multiWriteTagables[ref.Context().Tag(tag)] = taggableIndex + } + + // Note: this will only push the index manifest, assuming that all the images it refers to exists in the registry + err = remote.MultiWrite( + multiWriteTagables, + remote.WithAuthFromKeychain(h.KeyChain), + remote.WithTransport(GetTransport(pushOps.Insecure)), + ) + if err != nil { + return err + } + + if pushOps.Purge { + return h.DeleteDir() + } + return h.SaveDir() +} + +// Inspect Displays IndexManifest. +func (h *CNBIndex) Inspect() (string, error) { + rawManifest, err := h.RawManifest() + if err != nil { + return "", err + } + return string(rawManifest), nil +} + +// RemoveManifest removes an image with a given digest from the index. +func (h *CNBIndex) RemoveManifest(digest name.Digest) (err error) { + hash, err := v1.NewHash(digest.Identifier()) + if err != nil { + return err + } + h.ImageIndex = mutate.RemoveManifests(h.ImageIndex, match.Digests(hash)) + _, err = h.ImageIndex.Digest() // force compute + return err +} + +// DeleteDir removes the index from the local filesystem if it exists. +func (h *CNBIndex) DeleteDir() error { + layoutPath := filepath.Join(h.XdgPath, MakeFileSafeName(h.RepoName)) + if _, err := os.Stat(layoutPath); err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + return os.RemoveAll(layoutPath) +} + +func getIndexManifest(ii v1.ImageIndex) (mfest *v1.IndexManifest, err error) { + mfest, err = ii.IndexManifest() + if mfest == nil { + return mfest, ErrManifestUndefined + } + return mfest, err +} + +// descriptor returns a v1.Descriptor filled with a v1.Platform created from reading +// the image config file. +func descriptor(image v1.Image) (v1.Descriptor, error) { + // Get the image configuration file + cfg, _ := GetConfigFile(image) + platform := v1.Platform{} + platform.Architecture = cfg.Architecture + platform.OS = cfg.OS + platform.OSVersion = cfg.OSVersion + platform.Variant = cfg.Variant + platform.OSFeatures = cfg.OSFeatures + return v1.Descriptor{ + Platform: &platform, + }, nil +} diff --git a/fakes/image.go b/fakes/image.go index a27704b6..90cfa49e 100644 --- a/fakes/image.go +++ b/fakes/image.go @@ -13,6 +13,7 @@ import ( registryName "github.com/google/go-containerregistry/pkg/name" v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/types" "github.com/pkg/errors" "github.com/buildpacks/imgutil" @@ -103,6 +104,18 @@ func (i *Image) Variant() (string, error) { return i.variant, nil } +func (i *Image) Features() ([]string, error) { + return nil, nil +} + +func (i *Image) OSFeatures() ([]string, error) { + return nil, nil +} + +func (i *Image) Annotations() (map[string]string, error) { + return nil, nil +} + func (i *Image) Rename(name string) { i.name = name } @@ -115,6 +128,14 @@ func (i *Image) Identifier() (imgutil.Identifier, error) { return i.identifier, nil } +func (i *Image) Digest() (v1.Hash, error) { + return v1.Hash{}, nil +} + +func (i *Image) MediaType() (types.MediaType, error) { + return types.MediaType(""), nil +} + func (i *Image) Kind() string { return "" } @@ -171,6 +192,18 @@ func (i *Image) SetVariant(a string) error { return nil } +func (i *Image) SetFeatures(_ []string) error { + return nil +} + +func (i *Image) SetOSFeatures(_ []string) error { + return nil +} + +func (i *Image) SetAnnotations(_ map[string]string) error { + return nil +} + func (i *Image) SetWorkingDir(dir string) error { i.workingDir = dir return nil diff --git a/image.go b/image.go index 4a43381c..16a9fc60 100644 --- a/image.go +++ b/image.go @@ -7,45 +7,73 @@ import ( "time" v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/types" ) type Image interface { + WithEditableManifest + WithEditableConfig + WithEditableLayers + // getters - Architecture() (string, error) - CreatedAt() (time.Time, error) - Entrypoint() ([]string, error) - Env(key string) (string, error) // Found reports if image exists in the image store with `Name()`. Found() bool - GetAnnotateRefName() (string, error) - // GetLayer retrieves layer by diff id. Returns a reader of the uncompressed contents of the layer. - GetLayer(diffID string) (io.ReadCloser, error) - History() ([]v1.History, error) Identifier() (Identifier, error) // Kind exposes the type of image that backs the imgutil.Image implementation. // It could be `local`, `remote`, or `layout`. Kind() string - Label(string) (string, error) - Labels() (map[string]string, error) - // ManifestSize returns the size of the manifest. If a manifest doesn't exist, it returns 0. - ManifestSize() (int64, error) Name() string - OS() (string, error) - OSVersion() (string, error) - // TopLayer returns the diff id for the top layer - TopLayer() (string, error) UnderlyingImage() v1.Image // Valid returns true if the image is well-formed (e.g. all manifest layers exist on the registry). Valid() bool + + // setters + + Delete() error + Rename(name string) + // Save saves the image as `Name()` and any additional names provided to this method. + Save(additionalNames ...string) error + // SaveAs ignores the image `Name()` method and saves the image according to name & additional names provided to this method + SaveAs(name string, additionalNames ...string) error + // SaveFile saves the image as a docker archive and provides the filesystem location + SaveFile() (string, error) +} + +type WithEditableManifest interface { + // getters + + Annotations() (map[string]string, error) + Digest() (v1.Hash, error) + GetAnnotateRefName() (string, error) + ManifestSize() (int64, error) + MediaType() (types.MediaType, error) + + // setters + + AnnotateRefName(refName string) error + SetAnnotations(map[string]string) error +} + +type WithEditableConfig interface { + // getters + + Architecture() (string, error) + CreatedAt() (time.Time, error) + Entrypoint() ([]string, error) + Env(key string) (string, error) + History() ([]v1.History, error) + Label(string) (string, error) + Labels() (map[string]string, error) + OS() (string, error) + OSFeatures() ([]string, error) + OSVersion() (string, error) + RemoveLabel(string) error Variant() (string, error) WorkingDir() (string, error) // setters - // AnnotateRefName set a value for the `org.opencontainers.image.ref.name` annotation - AnnotateRefName(refName string) error - Rename(name string) SetArchitecture(string) error SetCmd(...string) error SetEntrypoint(...string) error @@ -53,27 +81,29 @@ type Image interface { SetHistory([]v1.History) error SetLabel(string, string) error SetOS(string) error + SetOSFeatures([]string) error SetOSVersion(string) error SetVariant(string) error SetWorkingDir(string) error +} + +type WithEditableLayers interface { + // getters - // modifiers + // GetLayer retrieves layer by diff id. Returns a reader of the uncompressed contents of the layer. + GetLayer(diffID string) (io.ReadCloser, error) + // TopLayer returns the diff id for the top layer + TopLayer() (string, error) + + // setters AddLayer(path string) error AddLayerWithDiffID(path, diffID string) error AddLayerWithDiffIDAndHistory(path, diffID string, history v1.History) error AddOrReuseLayerWithHistory(path, diffID string, history v1.History) error - Delete() error Rebase(string, Image) error - RemoveLabel(string) error ReuseLayer(diffID string) error ReuseLayerWithHistory(diffID string, history v1.History) error - // Save saves the image as `Name()` and any additional names provided to this method. - Save(additionalNames ...string) error - // SaveAs ignores the image `Name()` method and saves the image according to name & additional names provided to this method - SaveAs(name string, additionalNames ...string) error - // SaveFile saves the image as a docker archive and provides the filesystem location - SaveFile() (string, error) } type Identifier fmt.Stringer diff --git a/index.go b/index.go new file mode 100644 index 00000000..532fc585 --- /dev/null +++ b/index.go @@ -0,0 +1,35 @@ +package imgutil + +import ( + "github.com/google/go-containerregistry/pkg/name" + v1 "github.com/google/go-containerregistry/pkg/v1" +) + +// ImageIndex an Interface with list of Methods required for creation and manipulation of v1.IndexManifest +type ImageIndex interface { + // getters + + Annotations(digest name.Digest) (annotations map[string]string, err error) + Architecture(digest name.Digest) (arch string, err error) + OS(digest name.Digest) (os string, err error) + OSFeatures(digest name.Digest) (osFeatures []string, err error) + OSVersion(digest name.Digest) (osVersion string, err error) + Variant(digest name.Digest) (osVariant string, err error) + + // setters + + SetAnnotations(digest name.Digest, annotations map[string]string) (err error) + SetArchitecture(digest name.Digest, arch string) (err error) + SetOS(digest name.Digest, os string) (err error) + SetVariant(digest name.Digest, osVariant string) (err error) + + // misc + + Inspect() (string, error) + AddManifest(image v1.Image) + RemoveManifest(digest name.Digest) error + + Push(ops ...IndexOption) error + SaveDir() error + DeleteDir() error +} diff --git a/layout/index.go b/layout/index.go new file mode 100644 index 00000000..f59f22b7 --- /dev/null +++ b/layout/index.go @@ -0,0 +1,44 @@ +package layout + +import ( + "fmt" + + v1 "github.com/google/go-containerregistry/pkg/v1" + + "github.com/buildpacks/imgutil" +) + +// NewIndex will return an OCI ImageIndex saved on disk using OCI media Types. It can be modified and saved to a registry +func NewIndex(repoName string, ops ...imgutil.IndexOption) (*imgutil.CNBIndex, error) { + options := &imgutil.IndexOptions{} + for _, op := range ops { + if err := op(options); err != nil { + return nil, err + } + } + + var err error + + if options.BaseIndex == nil && options.BaseIndexRepoName != "" { // options.BaseIndex supersedes options.BaseIndexRepoName + options.BaseIndex, err = newV1Index( + options.BaseIndexRepoName, + ) + if err != nil { + return nil, err + } + } + + return imgutil.NewCNBIndex(repoName, *options) +} + +// newV1Index creates a layout image index from the given path. +func newV1Index(path string) (v1.ImageIndex, error) { + if !imageExists(path) { + return nil, nil + } + layoutPath, err := FromPath(path) + if err != nil { + return nil, fmt.Errorf("failed to load layout from path: %w", err) + } + return layoutPath.ImageIndex() +} diff --git a/layout/index_test.go b/layout/index_test.go new file mode 100644 index 00000000..dda93ec2 --- /dev/null +++ b/layout/index_test.go @@ -0,0 +1,706 @@ +package layout_test + +import ( + "fmt" + "os" + "path" + "path/filepath" + "reflect" + "strings" + "testing" + + "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/random" + "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/layout" + imgutilRemote "github.com/buildpacks/imgutil/remote" + h "github.com/buildpacks/imgutil/testhelpers" +) + +func TestLayoutIndex(t *testing.T) { + dockerConfigDir, err := os.MkdirTemp("", "test.docker.config.dir") + h.AssertNil(t, err) + defer os.RemoveAll(dockerConfigDir) + + dockerRegistry = h.NewDockerRegistry(h.WithAuth(dockerConfigDir)) + dockerRegistry.Start(t) + defer dockerRegistry.Stop(t) + + os.Setenv("DOCKER_CONFIG", dockerConfigDir) + defer os.Unsetenv("DOCKER_CONFIG") + + spec.Run(t, "LayoutNewIndex", testNewIndex, spec.Parallel(), spec.Report(report.Terminal{})) + spec.Run(t, "LayoutIndex", testIndex, spec.Parallel(), spec.Report(report.Terminal{})) +} + +var ( + dockerRegistry *h.DockerRegistry + + // global directory and paths + testDataDir = filepath.Join("testdata", "layout") +) + +func testNewIndex(t *testing.T, when spec.G, it spec.S) { + var ( + idx imgutil.ImageIndex + tempDir string + repoName string + err error + ) + + it.Before(func() { + // creates the directory to save all the OCI images on disk + tempDir, err = os.MkdirTemp("", "image-indexes") + h.AssertNil(t, err) + + // global directory and paths + testDataDir = filepath.Join("testdata", "layout") + _ = idx + }) + + it.After(func() { + err := os.RemoveAll(tempDir) + h.AssertNil(t, err) + }) + + when("#NewIndex", func() { + it.Before(func() { + repoName = "some/index" + }) + + when("index doesn't exists on disk", func() { + it("creates empty image index", func() { + idx, err = layout.NewIndex( + repoName, + imgutil.WithXDGRuntimePath(tempDir), + ) + h.AssertNil(t, err) + }) + + it("ignores FromBaseIndex if it doesn't exist", func() { + idx, err = layout.NewIndex( + repoName, + imgutil.WithXDGRuntimePath(tempDir), + imgutil.FromBaseIndex("non-existent/index"), + ) + h.AssertNil(t, err) + }) + + it("creates empty image index with Docker media-types", func() { + idx, err = layout.NewIndex( + repoName, + imgutil.WithXDGRuntimePath(tempDir), + imgutil.WithMediaType(types.DockerManifestList), + ) + h.AssertNil(t, err) + }) + }) + }) +} + +func testIndex(t *testing.T, when spec.G, it spec.S) { + var ( + idx imgutil.ImageIndex + tmpDir string + localPath string + baseIndexPath string + err error + ) + + it.Before(func() { + // creates the directory to save all the OCI images on disk + tmpDir, err = os.MkdirTemp("", "layout-image-indexes") + h.AssertNil(t, err) + + // image index directory on disk + baseIndexPath = filepath.Join(testDataDir, "busybox-multi-platform") + // global directory and paths + testDataDir = filepath.Join("testdata", "layout") + }) + + it.After(func() { + err := os.RemoveAll(tmpDir) + h.AssertNil(t, err) + }) + + when("Getters", func() { + var ( + attribute string + attributes []string + annotations map[string]string + digest name.Digest + ) + when("index exists on disk", func() { + when("#FromBaseIndex", func() { + it.Before(func() { + idx, err = layout.NewIndex("busybox-multi-platform", imgutil.WithXDGRuntimePath(tmpDir), imgutil.FromBaseIndex(baseIndexPath)) + h.AssertNil(t, err) + localPath = filepath.Join(tmpDir, "busybox-multi-platform") + }) + + // See spec: https://github.com/opencontainers/image-spec/blob/main/image-index.md#image-index-property-descriptions + when("linux/amd64", func() { + it.Before(func() { + digest, err = name.NewDigest("busybox-multi-platform@sha256:f5b920213fc6498c0c5eaee7e04f8424202b565bb9e5e4de9e617719fb7bd873") + h.AssertNil(t, err) + }) + + it("existing platform attributes are readable", func() { + // #Architecture + attribute, err = idx.Architecture(digest) + h.AssertNil(t, err) + h.AssertEq(t, attribute, "amd64") + + // #OS + attribute, err = idx.OS(digest) + h.AssertNil(t, err) + h.AssertEq(t, attribute, "linux") + + // #Variant + attribute, err = idx.Variant(digest) + h.AssertNil(t, err) + h.AssertEq(t, attribute, "v1") + + // #OSVersion + attribute, err = idx.OSVersion(digest) + h.AssertNil(t, err) + h.AssertEq(t, attribute, "4.5.6") + + // #OSFeatures + attributes, err = idx.OSFeatures(digest) + h.AssertNil(t, err) + h.AssertContains(t, attributes, "os-feature-1", "os-feature-2") + }) + + it("existing annotations are readable", func() { + annotations, err = idx.Annotations(digest) + h.AssertNil(t, err) + h.AssertEq(t, annotations["com.docker.official-images.bashbrew.arch"], "amd64") + h.AssertEq(t, annotations["org.opencontainers.image.url"], "https://hub.docker.com/_/busybox") + h.AssertEq(t, annotations["org.opencontainers.image.revision"], "d0b7d566eb4f1fa9933984e6fc04ab11f08f4592") + }) + }) + + when("linux/arm64", func() { + it.Before(func() { + digest, err = name.NewDigest("busybox-multi-platform@sha256:e18f2c12bb4ea582045415243370a3d9cf3874265aa2867f21a35e630ebe45a7") + h.AssertNil(t, err) + }) + + it("existing platform attributes are readable", func() { + // #Architecture + attribute, err = idx.Architecture(digest) + h.AssertNil(t, err) + h.AssertEq(t, attribute, "arm") + + // #OS + attribute, err = idx.OS(digest) + h.AssertNil(t, err) + h.AssertEq(t, attribute, "linux") + + // #Variant + attribute, err = idx.Variant(digest) + h.AssertNil(t, err) + h.AssertEq(t, attribute, "v7") + + // #OSVersion + attribute, err = idx.OSVersion(digest) + h.AssertNil(t, err) + h.AssertEq(t, attribute, "1.2.3") + + // #OSFeatures + attributes, err = idx.OSFeatures(digest) + h.AssertNil(t, err) + h.AssertContains(t, attributes, "os-feature-3", "os-feature-4") + }) + + it("existing annotations are readable", func() { + annotations, err = idx.Annotations(digest) + h.AssertNil(t, err) + h.AssertEq(t, annotations["com.docker.official-images.bashbrew.arch"], "arm32v7") + h.AssertEq(t, annotations["org.opencontainers.image.url"], "https://hub.docker.com/_/busybox") + h.AssertEq(t, annotations["org.opencontainers.image.revision"], "185a3f7f21c307b15ef99b7088b228f004ff5f11") + }) + }) + + when("non-existent digest is provided", func() { + it.Before(func() { + // Just changed the last number of a valid digest + digest, err = name.NewDigest("busybox-multi-platform@sha256:f5b920213fc6498c0c5eaee7e04f8424202b565bb9e5e4de9e617719fb7bd872") + h.AssertNil(t, err) + }) + + it("error is returned", func() { + // #Architecture + attribute, err = idx.Architecture(digest) + h.AssertNotNil(t, err) + + // #OS + attribute, err = idx.OS(digest) + h.AssertNotNil(t, err) + + // #Variant + attribute, err = idx.Variant(digest) + h.AssertNotNil(t, err) + + // #OSVersion + attribute, err = idx.OSVersion(digest) + h.AssertNotNil(t, err) + + // #OSFeatures + attributes, err = idx.OSFeatures(digest) + h.AssertNotNil(t, err) + + // #Annotations + annotations, err = idx.Annotations(digest) + h.AssertNotNil(t, err) + }) + }) + }) + }) + }) + + when("#Setters", func() { + var ( + descriptor1 v1.Descriptor + digest1 name.Digest + ) + + when("index is created from scratch", func() { + it.Before(func() { + repoName := newRepoName() + idx = setupIndex(t, repoName, imgutil.WithXDGRuntimePath(tmpDir)) + localPath = filepath.Join(tmpDir, repoName) + }) + + when("digest is provided", func() { + it.Before(func() { + image1, err := random.Image(1024, 1) + h.AssertNil(t, err) + idx.AddManifest(image1) + + h.AssertNil(t, idx.SaveDir()) + + index := h.ReadIndexManifest(t, localPath) + h.AssertEq(t, len(index.Manifests), 1) + descriptor1 = index.Manifests[0] + + digest1, err = name.NewDigest(fmt.Sprintf("%s@%s", "random", descriptor1.Digest.String())) + h.AssertNil(t, err) + }) + + it("platform attributes are written on disk", func() { + h.AssertNil(t, idx.SetOS(digest1, "linux")) + h.AssertNil(t, idx.SetArchitecture(digest1, "arm")) + h.AssertNil(t, idx.SetVariant(digest1, "v6")) + h.AssertNil(t, idx.SaveDir()) + + index := h.ReadIndexManifest(t, localPath) + h.AssertEq(t, len(index.Manifests), 1) + h.AssertEq(t, index.Manifests[0].Digest.String(), descriptor1.Digest.String()) + h.AssertEq(t, index.Manifests[0].Platform.OS, "linux") + h.AssertEq(t, index.Manifests[0].Platform.Architecture, "arm") + h.AssertEq(t, index.Manifests[0].Platform.Variant, "v6") + }) + + it("annotations are written on disk", func() { + annotations := map[string]string{ + "some-key": "some-value", + } + h.AssertNil(t, idx.SetAnnotations(digest1, annotations)) + h.AssertNil(t, idx.SaveDir()) + + index := h.ReadIndexManifest(t, localPath) + h.AssertEq(t, len(index.Manifests), 1) + h.AssertEq(t, index.Manifests[0].Digest.String(), descriptor1.Digest.String()) + h.AssertEq(t, reflect.DeepEqual(index.Manifests[0].Annotations, annotations), true) + }) + }) + }) + + when("index exists on disk", func() { + when("#FromBaseIndex", func() { + when("digest is provided", func() { + when("attributes already exists", func() { + when("oci media-type is used", func() { + it.Before(func() { + idx = setupIndex(t, "busybox-multi-platform", imgutil.WithXDGRuntimePath(tmpDir), imgutil.FromBaseIndex(baseIndexPath)) + localPath = filepath.Join(tmpDir, "busybox-multi-platform") + digest1, err = name.NewDigest("busybox@sha256:e18f2c12bb4ea582045415243370a3d9cf3874265aa2867f21a35e630ebe45a7") + h.AssertNil(t, err) + }) + + it("platform attributes are updated on disk", func() { + h.AssertNil(t, idx.SetOS(digest1, "linux-2")) + h.AssertNil(t, idx.SetArchitecture(digest1, "arm-2")) + h.AssertNil(t, idx.SetVariant(digest1, "v6-2")) + h.AssertNil(t, idx.SaveDir()) + + index := h.ReadIndexManifest(t, localPath) + h.AssertEq(t, len(index.Manifests), 2) + h.AssertEq(t, index.Manifests[1].Digest.String(), "sha256:e18f2c12bb4ea582045415243370a3d9cf3874265aa2867f21a35e630ebe45a7") + h.AssertEq(t, index.Manifests[1].Platform.OS, "linux-2") + h.AssertEq(t, index.Manifests[1].Platform.Architecture, "arm-2") + h.AssertEq(t, index.Manifests[1].Platform.Variant, "v6-2") + }) + + it("new annotation are appended on disk", func() { + annotations := map[string]string{ + "some-key": "some-value", + } + h.AssertNil(t, idx.SetAnnotations(digest1, annotations)) + h.AssertNil(t, idx.SaveDir()) + + index := h.ReadIndexManifest(t, localPath) + h.AssertEq(t, len(index.Manifests), 2) + + // When updating a digest, it will be appended at the end + h.AssertEq(t, index.Manifests[1].Digest.String(), "sha256:e18f2c12bb4ea582045415243370a3d9cf3874265aa2867f21a35e630ebe45a7") + + // in testdata we have 7 annotations + 1 new + h.AssertEq(t, len(index.Manifests[1].Annotations), 8) + h.AssertEq(t, index.Manifests[1].Annotations["some-key"], "some-value") + }) + }) + + when("docker media-type is used", func() { + it.Before(func() { + baseIndexPath = filepath.Join(testDataDir, "index-with-docker-media-type") + idx = setupIndex(t, "some-docker-index", imgutil.WithXDGRuntimePath(tmpDir), imgutil.FromBaseIndex(baseIndexPath)) + localPath = filepath.Join(tmpDir, imgutil.MakeFileSafeName("some-docker-index")) + digest1, err = name.NewDigest("some-docker-manifest@sha256:a564fd8f0684d2e119b73db7fb89280a665ebb18e8c30f26d163b4c0da8a8090") + h.AssertNil(t, err) + }) + + it("new annotation are appended on disk and media-type is not override", func() { + annotations := map[string]string{ + "some-key": "some-value", + } + h.AssertNil(t, idx.SetAnnotations(digest1, annotations)) + h.AssertNil(t, idx.SaveDir()) + + index := h.ReadIndexManifest(t, localPath) + h.AssertEq(t, len(index.Manifests), 1) + h.AssertEq(t, index.MediaType, types.DockerManifestList) + + // When updating a digest, it will be appended at the end + h.AssertEq(t, index.Manifests[0].Digest.String(), "sha256:a564fd8f0684d2e119b73db7fb89280a665ebb18e8c30f26d163b4c0da8a8090") + + // in testdata we have 7 annotations + 1 new + h.AssertEq(t, len(index.Manifests[0].Annotations), 1) + h.AssertEq(t, index.Manifests[0].Annotations["some-key"], "some-value") + }) + }) + }) + }) + }) + }) + }) + + when("#Save", func() { + when("index exists on disk", func() { + when("#FromBaseIndex", func() { + it.Before(func() { + idx, err = layout.NewIndex("busybox-multi-platform", imgutil.WithXDGRuntimePath(tmpDir), imgutil.FromBaseIndex(baseIndexPath)) + h.AssertNil(t, err) + + localPath = filepath.Join(tmpDir, "busybox-multi-platform") + }) + + it("manifests from base image index are saved on disk", func() { + err = idx.SaveDir() + h.AssertNil(t, err) + + // assert linux/amd64 and linux/arm64 manifests were saved + index := h.ReadIndexManifest(t, localPath) + h.AssertEq(t, len(index.Manifests), 2) + h.AssertEq(t, index.Manifests[0].Digest.String(), "sha256:f5b920213fc6498c0c5eaee7e04f8424202b565bb9e5e4de9e617719fb7bd873") + h.AssertEq(t, index.Manifests[1].Digest.String(), "sha256:e18f2c12bb4ea582045415243370a3d9cf3874265aa2867f21a35e630ebe45a7") + }) + }) + + when("#FromBaseIndexInstance", func() { + it.Before(func() { + localIndex := h.ReadImageIndex(t, baseIndexPath) + + idx, err = layout.NewIndex("busybox-multi-platform", imgutil.WithXDGRuntimePath(tmpDir), imgutil.FromBaseIndexInstance(localIndex)) + h.AssertNil(t, err) + + localPath = filepath.Join(tmpDir, "busybox-multi-platform") + }) + + it("manifests from base image index instance are saved on disk", func() { + err = idx.SaveDir() + h.AssertNil(t, err) + + // assert linux/amd64 and linux/arm64 manifests were saved + index := h.ReadIndexManifest(t, localPath) + h.AssertEq(t, len(index.Manifests), 2) + h.AssertEq(t, index.Manifests[0].Digest.String(), "sha256:f5b920213fc6498c0c5eaee7e04f8424202b565bb9e5e4de9e617719fb7bd873") + h.AssertEq(t, index.Manifests[1].Digest.String(), "sha256:e18f2c12bb4ea582045415243370a3d9cf3874265aa2867f21a35e630ebe45a7") + }) + }) + }) + }) + + when("#Add", func() { + var ( + imagePath string + fullBaseImagePath string + ) + + it.Before(func() { + imagePath, err = os.MkdirTemp(tmpDir, "layout-test-image-index") + h.AssertNil(t, err) + + fullBaseImagePath = filepath.Join(testDataDir, "busybox") + }) + + when("index is created from scratch", func() { + it.Before(func() { + repoName := newRepoName() + idx = setupIndex(t, repoName, imgutil.WithXDGRuntimePath(tmpDir)) + localPath = filepath.Join(tmpDir, repoName) + }) + + when("manifest in OCI layout format is added", func() { + var editableImage v1.Image + it.Before(func() { + editableImage, err = layout.NewImage(imagePath, layout.FromBaseImagePath(fullBaseImagePath)) + h.AssertNil(t, err) + }) + + it("adds one manifest to the index", func() { + idx.AddManifest(editableImage) + h.AssertNil(t, idx.SaveDir()) + // manifest was added + index := h.ReadIndexManifest(t, localPath) + h.AssertEq(t, len(index.Manifests), 1) + }) + + it("add more than one manifest to the index", func() { + image1, err := random.Image(1024, 1) + h.AssertNil(t, err) + idx.AddManifest(image1) + + image2, err := random.Image(1024, 1) + h.AssertNil(t, err) + idx.AddManifest(image2) + + h.AssertNil(t, idx.SaveDir()) + + // manifest was added + index := h.ReadIndexManifest(t, localPath) + h.AssertEq(t, len(index.Manifests), 2) + }) + }) + }) + + when("index exists on disk", func() { + when("#FromBaseIndex", func() { + it.Before(func() { + idx = setupIndex(t, "busybox-multi-platform", imgutil.WithXDGRuntimePath(tmpDir), imgutil.FromBaseIndex(baseIndexPath)) + localPath = filepath.Join(tmpDir, "busybox-multi-platform") + }) + + when("manifest in OCI layout format is added", func() { + var editableImage v1.Image + it.Before(func() { + editableImage, err = layout.NewImage(imagePath, layout.FromBaseImagePath(fullBaseImagePath)) + h.AssertNil(t, err) + }) + + it("adds the manifest to the index", func() { + idx.AddManifest(editableImage) + h.AssertNil(t, idx.SaveDir()) + index := h.ReadIndexManifest(t, localPath) + // manifest was added + // initially it has 2 manifest + 1 new + h.AssertEq(t, len(index.Manifests), 3) + }) + }) + }) + }) + }) + + when("#Push", func() { + var repoName string + + // Index under test is created with this number of manifests on it + const expectedNumberOfManifests = 2 + + when("index is created from scratch", func() { + it.Before(func() { + repoName = newTestImageIndexName("push-index-test") + idx = setupIndex(t, repoName, imgutil.WithXDGRuntimePath(tmpDir), imgutil.WithKeychain(authn.DefaultKeychain)) + + // Note: It will only push IndexManifest, assuming all the images it refers exists in registry + // We need to push each individual image first + + // Manifest 1 + img1 := createRemoteImage(t, repoName, "busybox-amd64", "busybox@sha256:f5b920213fc6498c0c5eaee7e04f8424202b565bb9e5e4de9e617719fb7bd873") + idx.AddManifest(img1) + + // Manifest 2 + img2 := createRemoteImage(t, repoName, "busybox-arm64", "busybox@sha256:e18f2c12bb4ea582045415243370a3d9cf3874265aa2867f21a35e630ebe45a7") + idx.AddManifest(img2) + }) + + when("no options are provided", func() { + it("index is pushed to the registry", func() { + err = idx.Push() + h.AssertNil(t, err) + h.AssertRemoteImageIndex(t, repoName, types.OCIImageIndex, expectedNumberOfManifests) + }) + }) + + when("#WithMediaType", func() { + it("index is pushed to the registry using docker media types", func() { + // By default, OCI media types is used + err = idx.Push(imgutil.WithMediaType(types.DockerManifestList)) + h.AssertNil(t, err) + h.AssertRemoteImageIndex(t, repoName, types.DockerManifestList, expectedNumberOfManifests) + }) + + it("error when media-type doesn't refer to an index", func() { + err = idx.Push(imgutil.WithMediaType(types.DockerConfigJSON)) + h.AssertNotNil(t, err) + }) + }) + + when("#WithTags", func() { + it("index is pushed to the registry with the additional tag provided", func() { + // By default, OCI media types is used + err = idx.Push(imgutil.WithTags("some-cool-tag")) + h.AssertNil(t, err) + addionalRepoName := fmt.Sprintf("%s:%s", repoName, "some-cool-tag") + h.AssertRemoteImageIndex(t, addionalRepoName, types.OCIImageIndex, expectedNumberOfManifests) + }) + }) + + when("#WithPurge", func() { + it("index is pushed to the registry and remove from local storage", func() { + // By default, OCI media types is used + err = idx.Push(imgutil.WithPurge(true)) + h.AssertNil(t, err) + h.AssertRemoteImageIndex(t, repoName, types.OCIImageIndex, expectedNumberOfManifests) + h.AssertPathDoesNotExists(t, path.Join(tmpDir, imgutil.MakeFileSafeName(repoName))) + }) + }) + }) + }) + + when("#Delete", func() { + when("index exists on disk", func() { + when("#FromBaseIndex", func() { + it.Before(func() { + idx = setupIndex(t, "busybox-multi-platform", imgutil.WithXDGRuntimePath(tmpDir), imgutil.FromBaseIndex(baseIndexPath)) + localPath = filepath.Join(tmpDir, "busybox-multi-platform") + }) + + it("deletes the imange index from disk", func() { + // Verify the index exists + h.ReadIndexManifest(t, localPath) + + err = idx.DeleteDir() + h.AssertNil(t, err) + + _, err = os.Stat(localPath) + h.AssertNotNil(t, err) + h.AssertEq(t, true, os.IsNotExist(err)) + }) + }) + }) + }) + + when("#Remove", func() { + var digest name.Digest + when("index exists on disk", func() { + when("#FromBaseIndex", func() { + it.Before(func() { + idx = setupIndex(t, "busybox-multi-platform", imgutil.WithXDGRuntimePath(tmpDir), imgutil.FromBaseIndex(baseIndexPath), imgutil.WithKeychain(authn.DefaultKeychain)) + localPath = filepath.Join(tmpDir, "busybox-multi-platform") + digest, err = name.NewDigest("busybox@sha256:f5b920213fc6498c0c5eaee7e04f8424202b565bb9e5e4de9e617719fb7bd873") + h.AssertNil(t, err) + }) + + it("given manifest is removed", func() { + err = idx.RemoveManifest(digest) + h.AssertNil(t, err) + + // After removing any operation to get something about the digest must fail + _, err = idx.OS(digest) + h.AssertNotNil(t, err) + h.AssertError(t, err, "failed to find image with digest") + + // After saving, the index on disk must reflect the change + err = idx.SaveDir() + h.AssertNil(t, err) + + index := h.ReadIndexManifest(t, localPath) + h.AssertEq(t, len(index.Manifests), 1) + h.AssertEq(t, index.Manifests[0].Digest.String(), "sha256:e18f2c12bb4ea582045415243370a3d9cf3874265aa2867f21a35e630ebe45a7") + }) + }) + }) + }) + + when("#Inspect", func() { + var indexString string + when("index exists on disk", func() { + when("#FromBaseIndex", func() { + it.Before(func() { + idx = setupIndex(t, "busybox-multi-platform", imgutil.WithXDGRuntimePath(tmpDir), imgutil.FromBaseIndex(baseIndexPath)) + localPath = filepath.Join(tmpDir, "busybox-multi-platform") + }) + + it("returns an image index string representation", func() { + indexString, err = idx.Inspect() + h.AssertNil(t, err) + + idxFromString := parseIndex(t, indexString) + h.AssertEq(t, len(idxFromString.Manifests), 2) + }) + }) + }) + }) +} + +func createRemoteImage(t *testing.T, repoName, tag, baseImage string) *imgutilRemote.Image { + img1RepoName := fmt.Sprintf("%s:%s", repoName, tag) + img1, err := imgutilRemote.NewImage(img1RepoName, authn.DefaultKeychain, imgutilRemote.FromBaseImage(baseImage)) + h.AssertNil(t, err) + err = img1.Save() + h.AssertNil(t, err) + return img1 +} + +func setupIndex(t *testing.T, repoName string, ops ...imgutil.IndexOption) imgutil.ImageIndex { + idx, err := layout.NewIndex(repoName, ops...) + h.AssertNil(t, err) + + err = idx.SaveDir() + h.AssertNil(t, err) + return idx +} + +func newRepoName() string { + return "test-layout-index-" + h.RandString(10) +} + +func newTestImageIndexName(name string) string { + return dockerRegistry.RepoName(name + "-" + h.RandString(10)) +} + +func parseIndex(t *testing.T, index string) *v1.IndexManifest { + r := strings.NewReader(index) + idx, err := v1.ParseIndexManifest(r) + h.AssertNil(t, err) + return idx +} diff --git a/layout/layout.go b/layout/layout.go index b8100bb5..5dae6d02 100644 --- a/layout/layout.go +++ b/layout/layout.go @@ -10,6 +10,7 @@ import ( ) var _ imgutil.Image = (*Image)(nil) +var _ imgutil.ImageIndex = (*ImageIndex)(nil) type Image struct { *imgutil.CNBImageCore @@ -74,3 +75,7 @@ func (i *Image) Valid() bool { func (i *Image) Delete() error { return os.RemoveAll(i.repoPath) } + +type ImageIndex struct { + *imgutil.CNBIndex +} diff --git a/layout/layout_test.go b/layout/layout_test.go index d12dd986..1fa3457b 100644 --- a/layout/layout_test.go +++ b/layout/layout_test.go @@ -26,14 +26,13 @@ import ( // FIXME: relevant tests in this file should be moved into new_test.go and save_test.go to mirror the implementation func TestLayout(t *testing.T) { - spec.Run(t, "Image", testImage, spec.Sequential(), spec.Report(report.Terminal{})) + spec.Run(t, "LayoutImage", testImage, spec.Parallel(), spec.Report(report.Terminal{})) } func testImage(t *testing.T, when spec.G, it spec.S) { var ( - testImage v1.Image + remoteBaseImage v1.Image tmpDir string - testDataDir string imagePath string fullBaseImagePath string sparseBaseImagePath string @@ -42,10 +41,13 @@ func testImage(t *testing.T, when spec.G, it spec.S) { it.Before(func() { // creates a v1.Image from a remote repository - testImage = h.RemoteRunnableBaseImage(t) + remoteBaseImage = h.RemoteRunnableBaseImage(t) // creates the directory to save all the OCI images on disk - tmpDir, err = os.MkdirTemp("", "layout") + tmpDir, err = os.MkdirTemp("", "layout-test-files") + h.AssertNil(t, err) + + imagePath, err = os.MkdirTemp("", "layout-test-image") h.AssertNil(t, err) // global directory and paths @@ -57,17 +59,10 @@ func testImage(t *testing.T, when spec.G, it spec.S) { it.After(func() { // removes all images created os.RemoveAll(tmpDir) + os.RemoveAll(imagePath) }) when("#NewImage", func() { - it.Before(func() { - imagePath = filepath.Join(tmpDir, "new-image") - }) - - it.After(func() { - os.RemoveAll(imagePath) - }) - when("no base image or platform is given", func() { it("sets sensible defaults for all required fields", func() { // os, architecture, and rootfs are required per https://github.com/opencontainers/image-spec/blob/master/config.md @@ -151,13 +146,13 @@ func testImage(t *testing.T, when spec.G, it spec.S) { when("base image is provided", func() { it.Before(func() { var opts []remote.Option - testImage = h.RemoteImage(t, "arm64v8/busybox@sha256:50edf1d080946c6a76989d1c3b0e753b62f7d9b5f5e66e88bef23ebbd1e9709c", opts) + remoteBaseImage = h.RemoteImage(t, "arm64v8/busybox@sha256:50edf1d080946c6a76989d1c3b0e753b62f7d9b5f5e66e88bef23ebbd1e9709c", opts) }) it("sets the initial state from a linux/arm base image", func() { existingLayerSha := "sha256:5a0b973aa300cd2650869fd76d8546b361fcd6dfc77bd37b9d4f082cca9874e4" - img, err := layout.NewImage(imagePath, layout.FromBaseImageInstance(testImage), layout.WithMediaTypes(imgutil.OCITypes)) + img, err := layout.NewImage(imagePath, layout.FromBaseImageInstance(remoteBaseImage), layout.WithMediaTypes(imgutil.OCITypes)) h.AssertNil(t, err) h.AssertOCIMediaTypes(t, img) @@ -313,14 +308,12 @@ func testImage(t *testing.T, when spec.G, it spec.S) { it.Before(func() { // value from testdata/layout/my-previous-image config.RootFS.DiffIDs layerDiffID = "sha256:ebc931a4ab83b0c934f2436c975cca387bc1bcebd1a5ced12824ff7592f317ea" - imagePath = filepath.Join(tmpDir, "save-from-previous-image") previousImagePath = filepath.Join(testDataDir, "my-previous-image") }) when("previous image exists", func() { when("previous image is not sparse", func() { it.Before(func() { - imagePath = filepath.Join(tmpDir, "save-from-previous-image") previousImagePath = filepath.Join(testDataDir, "my-previous-image") }) @@ -335,7 +328,6 @@ func testImage(t *testing.T, when spec.G, it spec.S) { when("previous image is sparse", func() { it.Before(func() { - imagePath = filepath.Join(tmpDir, "save-from-previous-sparse-image") previousImagePath = filepath.Join(testDataDir, "my-previous-sparse-image") }) @@ -366,15 +358,10 @@ func testImage(t *testing.T, when spec.G, it spec.S) { var image *layout.Image it.Before(func() { - imagePath = filepath.Join(tmpDir, "working-dir-image") image, err = layout.NewImage(imagePath) h.AssertNil(t, err) }) - it.After(func() { - os.RemoveAll(imagePath) - }) - it("working dir is saved on disk in OCI layout format", func() { image.SetWorkingDir("/temp") @@ -401,15 +388,10 @@ func testImage(t *testing.T, when spec.G, it spec.S) { var image *layout.Image it.Before(func() { - imagePath = filepath.Join(tmpDir, "entry-point-image") image, err = layout.NewImage(imagePath) h.AssertNil(t, err) }) - it.After(func() { - os.RemoveAll(imagePath) - }) - it("entrypoint added is saved on disk in OCI layout format", func() { image.SetEntrypoint("bin/tool") @@ -438,15 +420,10 @@ func testImage(t *testing.T, when spec.G, it spec.S) { var image *layout.Image it.Before(func() { - imagePath = filepath.Join(tmpDir, "labels-image") image, err = layout.NewImage(imagePath) h.AssertNil(t, err) }) - it.After(func() { - os.RemoveAll(imagePath) - }) - it("label added is saved on disk in OCI layout format", func() { image.SetLabel("foo", "bar") @@ -491,15 +468,10 @@ func testImage(t *testing.T, when spec.G, it spec.S) { var image *layout.Image it.Before(func() { - imagePath = filepath.Join(tmpDir, "env-image") image, err = layout.NewImage(imagePath) h.AssertNil(t, err) }) - it.After(func() { - os.RemoveAll(imagePath) - }) - it("environment variable added is saved on disk in OCI layout format", func() { image.SetEnv("FOO_KEY", "bar") @@ -532,14 +504,6 @@ func testImage(t *testing.T, when spec.G, it spec.S) { }) when("#CreatedAt", func() { - it.Before(func() { - imagePath = filepath.Join(tmpDir, "new-created-at-image") - }) - - it.After(func() { - os.RemoveAll(imagePath) - }) - it("returns the containers created at time", func() { img, err := layout.NewImage(imagePath, layout.FromBaseImagePath(fullBaseImagePath)) h.AssertNil(t, err) @@ -554,14 +518,6 @@ func testImage(t *testing.T, when spec.G, it spec.S) { }) when("#SetLabel", func() { - it.Before(func() { - imagePath = filepath.Join(tmpDir, "new-set-label-image") - }) - - it.After(func() { - os.RemoveAll(imagePath) - }) - when("image exists", func() { it("sets label on img object", func() { img, err := layout.NewImage(imagePath) @@ -597,28 +553,21 @@ func testImage(t *testing.T, when spec.G, it spec.S) { }) when("#RemoveLabel", func() { - it.Before(func() { - imagePath = filepath.Join(tmpDir, "new-remove-label-image") - }) - - it.After(func() { - os.RemoveAll(imagePath) - }) - when("image exists", func() { - var baseImageNamePath = filepath.Join(tmpDir, "my-base-image") + var baseImageNamePath string it.Before(func() { - baseImage, err := layout.NewImage(baseImageNamePath, layout.FromBaseImageInstance(testImage)) + tmpBaseImageDir, err := os.MkdirTemp(tmpDir, "my-base-image") + h.AssertNil(t, err) + + baseImageNamePath = filepath.Join(tmpBaseImageDir, "my-base-image") + + baseImage, err := layout.NewImage(baseImageNamePath, layout.FromBaseImageInstance(remoteBaseImage)) h.AssertNil(t, err) h.AssertNil(t, baseImage.SetLabel("custom.label", "new-val")) h.AssertNil(t, baseImage.Save()) }) - it.After(func() { - os.RemoveAll(baseImageNamePath) - }) - it("removes label on img object", func() { img, err := layout.NewImage(imagePath, layout.FromBaseImagePath(baseImageNamePath)) h.AssertNil(t, err) @@ -654,17 +603,11 @@ func testImage(t *testing.T, when spec.G, it spec.S) { when("#SetCmd", func() { var image *layout.Image - it.Before(func() { - imagePath = filepath.Join(tmpDir, "set-cmd-image") image, err = layout.NewImage(imagePath) h.AssertNil(t, err) }) - it.After(func() { - os.RemoveAll(imagePath) - }) - it("CMD is added and saved on disk in OCI layout format", func() { image.SetCmd("echo", "Hello World") @@ -681,12 +624,6 @@ func testImage(t *testing.T, when spec.G, it spec.S) { }) when("#TopLayer", func() { - it.Before(func() { - imagePath = filepath.Join(tmpDir, "top-layer-from-base-image-path") - }) - it.After(func() { - os.RemoveAll(imagePath) - }) when("sparse image was saved on disk in OCI layout format", func() { it("Top layer DiffID from base image", func() { image, err := layout.NewImage(imagePath, layout.FromBaseImagePath(sparseBaseImagePath)) @@ -703,18 +640,10 @@ func testImage(t *testing.T, when spec.G, it spec.S) { }) when("#Save", func() { - it.After(func() { - os.RemoveAll(imagePath) - }) - when("#FromBaseImageInstance with full image", func() { - it.Before(func() { - imagePath = filepath.Join(tmpDir, "save-from-base-image") - }) - when("additional names are provided", func() { it("creates an image and save it to both path provided", func() { - image, err := layout.NewImage(imagePath, layout.FromBaseImageInstance(testImage)) + image, err := layout.NewImage(imagePath, layout.FromBaseImageInstance(remoteBaseImage)) h.AssertNil(t, err) anotherPath := filepath.Join(tmpDir, "another-save-from-base-image") @@ -736,7 +665,7 @@ func testImage(t *testing.T, when spec.G, it spec.S) { when("no additional names are provided", func() { it("creates an image with all the layers from the underlying image", func() { - image, err := layout.NewImage(imagePath, layout.FromBaseImageInstance(testImage)) + image, err := layout.NewImage(imagePath, layout.FromBaseImageInstance(remoteBaseImage)) h.AssertNil(t, err) // save on disk in OCI @@ -755,10 +684,6 @@ func testImage(t *testing.T, when spec.G, it spec.S) { }) when("#FromBaseImagePath", func() { - it.Before(func() { - imagePath = filepath.Join(tmpDir, "save-from-base-image-path") - }) - when("full image was saved on disk in OCI layout format", func() { when("a new layer was added", func() { it("image is saved on disk with all the layers", func() { @@ -844,17 +769,11 @@ func testImage(t *testing.T, when spec.G, it spec.S) { it.Before(func() { // value from testdata/layout/my-previous-image config.RootFS.DiffIDs prevImageLayerDiffID = "sha256:ebc931a4ab83b0c934f2436c975cca387bc1bcebd1a5ced12824ff7592f317ea" - imagePath = filepath.Join(tmpDir, "save-from-previous-image") previousImagePath = filepath.Join(testDataDir, "my-previous-image") }) - it.After(func() { - os.RemoveAll(imagePath) - }) - when("previous image is not sparse", func() { it.Before(func() { - imagePath = filepath.Join(tmpDir, "save-from-previous-image") previousImagePath = filepath.Join(testDataDir, "my-previous-image") }) @@ -1001,7 +920,6 @@ func testImage(t *testing.T, when spec.G, it spec.S) { when("previous image is sparse", func() { it.Before(func() { - imagePath = filepath.Join(tmpDir, "save-from-previous-sparse-image") previousImagePath = filepath.Join(testDataDir, "my-previous-sparse-image") }) @@ -1035,19 +953,14 @@ func testImage(t *testing.T, when spec.G, it spec.S) { var image *layout.Image it.Before(func() { - imagePath = filepath.Join(tmpDir, "found-image") image, err = layout.NewImage(imagePath) h.AssertNil(t, err) }) - it.After(func() { - os.RemoveAll(imagePath) - }) - when("image doesn't exist on disk", func() { it.Before(func() { - imagePath = filepath.Join(tmpDir, "non-exist-image") - image, err = layout.NewImage(imagePath) + localPath := filepath.Join(tmpDir, "non-exist-image") + image, err = layout.NewImage(localPath) h.AssertNil(t, err) }) @@ -1060,16 +973,11 @@ func testImage(t *testing.T, when spec.G, it spec.S) { when("image exists on disk", func() { it.Before(func() { - imagePath = filepath.Join(testDataDir, "my-previous-image") - image, err = layout.NewImage(imagePath) + localPath := filepath.Join(testDataDir, "my-previous-image") + image, err = layout.NewImage(localPath) h.AssertNil(t, err) }) - it.After(func() { - // We don't want to delete testdata/my-previous-image - imagePath = "" - }) - it("returns true", func() { h.AssertTrue(t, func() bool { return image.Found() @@ -1082,19 +990,14 @@ func testImage(t *testing.T, when spec.G, it spec.S) { var image *layout.Image it.Before(func() { - imagePath = filepath.Join(tmpDir, "found-image") image, err = layout.NewImage(imagePath) h.AssertNil(t, err) }) - it.After(func() { - os.RemoveAll(imagePath) - }) - when("image doesn't exist on disk", func() { it.Before(func() { - imagePath = filepath.Join(tmpDir, "non-exist-image") - image, err = layout.NewImage(imagePath) + localPath := filepath.Join(tmpDir, "non-exist-image") + image, err = layout.NewImage(localPath) h.AssertNil(t, err) }) @@ -1107,16 +1010,11 @@ func testImage(t *testing.T, when spec.G, it spec.S) { when("image exists on disk", func() { it.Before(func() { - imagePath = filepath.Join(testDataDir, "my-previous-image") - image, err = layout.NewImage(imagePath) + localPath := filepath.Join(testDataDir, "my-previous-image") + image, err = layout.NewImage(localPath) h.AssertNil(t, err) }) - it.After(func() { - // We don't want to delete testdata/my-previous-image - imagePath = "" - }) - it("returns true", func() { h.AssertTrue(t, func() bool { return image.Found() @@ -1129,7 +1027,6 @@ func testImage(t *testing.T, when spec.G, it spec.S) { var image *layout.Image it.Before(func() { - imagePath = filepath.Join(tmpDir, "delete-image") image, err = layout.NewImage(imagePath) h.AssertNil(t, err) @@ -1143,10 +1040,6 @@ func testImage(t *testing.T, when spec.G, it spec.S) { }) }) - it.After(func() { - os.RemoveAll(imagePath) - }) - it("images is deleted from disk", func() { err = image.Delete() h.AssertNil(t, err) @@ -1163,7 +1056,6 @@ func testImage(t *testing.T, when spec.G, it spec.S) { var image *layout.Image it.Before(func() { - imagePath = filepath.Join(tmpDir, "feature-image") image, err = layout.NewImage(imagePath) h.AssertNil(t, err) @@ -1174,21 +1066,31 @@ func testImage(t *testing.T, when spec.G, it spec.S) { } }) - it.After(func() { - os.RemoveAll(imagePath) - }) - it("Platform values are saved on disk in OCI layout format", func() { - image.SetArchitecture("amd64") - image.SetOS("linux") - image.SetOSVersion("1234") - - image.Save() - - _, configFile := h.ReadManifestAndConfigFile(t, imagePath) - h.AssertEq(t, configFile.OS, "linux") - h.AssertEq(t, configFile.Architecture, "amd64") - h.AssertEq(t, configFile.OSVersion, "1234") + var ( + os = "linux" + arch = "amd64" + variant = "some-variant" + osVersion = "1234" + osFeatures = []string{"some-osFeatures"} + annos = map[string]string{"some-key": "some-value"} + ) + h.AssertNil(t, image.SetOS(os)) + h.AssertNil(t, image.SetArchitecture(arch)) + h.AssertNil(t, image.SetVariant(variant)) + h.AssertNil(t, image.SetOSVersion(osVersion)) + h.AssertNil(t, image.SetOSFeatures(osFeatures)) + h.AssertNil(t, image.SetAnnotations(annos)) + + h.AssertNil(t, image.Save()) + + manifestFile, configFile := h.ReadManifestAndConfigFile(t, imagePath) + h.AssertEq(t, configFile.OS, os) + h.AssertEq(t, configFile.Architecture, arch) + h.AssertEq(t, configFile.Variant, variant) + h.AssertEq(t, configFile.OSVersion, osVersion) + h.AssertEq(t, configFile.OSFeatures, osFeatures) + h.AssertEq(t, manifestFile.Annotations, annos) }) it("Default Platform values are saved on disk in OCI layout format", func() { @@ -1198,20 +1100,13 @@ func testImage(t *testing.T, when spec.G, it spec.S) { image.Save() _, configFile := h.ReadManifestAndConfigFile(t, imagePath) - h.AssertEq(t, configFile.OS, "linux") - h.AssertEq(t, configFile.Architecture, "amd64") - h.AssertEq(t, configFile.OSVersion, "5678") + h.AssertEq(t, configFile.OS, platform.OS) + h.AssertEq(t, configFile.Architecture, platform.Architecture) + h.AssertEq(t, configFile.OSVersion, platform.OSVersion) }) }) when("#GetLayer", func() { - it.Before(func() { - imagePath = filepath.Join(tmpDir, "get-layer-from-base-image-path") - }) - it.After(func() { - os.RemoveAll(imagePath) - }) - when("sparse image was saved on disk in OCI layout format", func() { it("Get layer from sparse base image", func() { image, err := layout.NewImage(imagePath, layout.FromBaseImagePath(sparseBaseImagePath)) diff --git a/layout/sparse/sparse_test.go b/layout/sparse/sparse_test.go index 785140e5..9dcb3cd9 100644 --- a/layout/sparse/sparse_test.go +++ b/layout/sparse/sparse_test.go @@ -16,7 +16,7 @@ import ( ) func TestImage(t *testing.T) { - spec.Run(t, "LayoutSparseImage", testImage, spec.Report(report.Terminal{})) + spec.Run(t, "LayoutSparseImage", testImage, spec.Parallel(), spec.Report(report.Terminal{})) } func testImage(t *testing.T, when spec.G, it spec.S) { diff --git a/layout/testdata/layout/busybox-multi-platform/index.json b/layout/testdata/layout/busybox-multi-platform/index.json new file mode 100644 index 00000000..cf6ef919 --- /dev/null +++ b/layout/testdata/layout/busybox-multi-platform/index.json @@ -0,0 +1,56 @@ +{ + "manifests": [ + { + "annotations": { + "com.docker.official-images.bashbrew.arch": "amd64", + "org.opencontainers.image.base.name": "scratch", + "org.opencontainers.image.created": "2024-02-28T00:44:18Z", + "org.opencontainers.image.revision": "d0b7d566eb4f1fa9933984e6fc04ab11f08f4592", + "org.opencontainers.image.source": "https://github.com/docker-library/busybox.git", + "org.opencontainers.image.url": "https://hub.docker.com/_/busybox", + "org.opencontainers.image.version": "1.36.1-glibc" + }, + "digest": "sha256:f5b920213fc6498c0c5eaee7e04f8424202b565bb9e5e4de9e617719fb7bd873", + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "platform": { + "architecture": "amd64", + "os": "linux", + "os.version": "4.5.6", + "os.features": ["os-feature-1", "os-feature-2"], + "variant": "v1", + "features": ["feature-1", "feature-2"] + }, + "subject": { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": "sha256:8be429a5fbb2e71ae7958bfa558bc637cf3a61baf40a708cb8fff532b39e52d0", + "size": 100, + "urls": ["https://foo.bar"] + }, + "size": 610 + }, + { + "annotations": { + "com.docker.official-images.bashbrew.arch": "arm32v7", + "org.opencontainers.image.base.name": "scratch", + "org.opencontainers.image.created": "2024-02-28T00:44:18Z", + "org.opencontainers.image.revision": "185a3f7f21c307b15ef99b7088b228f004ff5f11", + "org.opencontainers.image.source": "https://github.com/docker-library/busybox.git", + "org.opencontainers.image.url": "https://hub.docker.com/_/busybox", + "org.opencontainers.image.version": "1.36.1-glibc" + }, + "digest": "sha256:e18f2c12bb4ea582045415243370a3d9cf3874265aa2867f21a35e630ebe45a7", + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "platform": { + "architecture": "arm", + "os": "linux", + "os.version": "1.2.3", + "os.features": ["os-feature-3", "os-feature-4"], + "variant": "v7", + "features": ["feature-3", "feature-4"] + }, + "size": 610 + } + ], + "mediaType": "application/vnd.oci.image.index.v1+json", + "schemaVersion": 2 +} diff --git a/layout/testdata/layout/busybox-multi-platform/oci-layout b/layout/testdata/layout/busybox-multi-platform/oci-layout new file mode 100644 index 00000000..1343d370 --- /dev/null +++ b/layout/testdata/layout/busybox-multi-platform/oci-layout @@ -0,0 +1 @@ +{"imageLayoutVersion":"1.0.0"} \ No newline at end of file diff --git a/layout/testdata/layout/index-with-docker-media-type/index.json b/layout/testdata/layout/index-with-docker-media-type/index.json new file mode 100755 index 00000000..ec24649a --- /dev/null +++ b/layout/testdata/layout/index-with-docker-media-type/index.json @@ -0,0 +1,15 @@ +{ + "schemaVersion": 2, + "mediaType": "application/vnd.docker.distribution.manifest.list.v2+json", + "manifests": [ + { + "mediaType": "application/vnd.docker.distribution.manifest.v2+json", + "size": 940, + "digest": "sha256:a564fd8f0684d2e119b73db7fb89280a665ebb18e8c30f26d163b4c0da8a8090", + "platform": { + "architecture": "", + "os": "linux" + } + } + ] +} diff --git a/layout/testdata/layout/index-with-docker-media-type/oci-layout b/layout/testdata/layout/index-with-docker-media-type/oci-layout new file mode 100755 index 00000000..224a8698 --- /dev/null +++ b/layout/testdata/layout/index-with-docker-media-type/oci-layout @@ -0,0 +1,3 @@ +{ + "imageLayoutVersion": "1.0.0" +} \ No newline at end of file diff --git a/new.go b/new.go index 5dfae9eb..0d91f9c8 100644 --- a/new.go +++ b/new.go @@ -291,3 +291,28 @@ func prepareNewWindowsImageIfNeeded(image *CNBImageCore) error { } return nil } + +func NewCNBIndex(repoName string, options IndexOptions) (*CNBIndex, error) { + if options.BaseIndex == nil { + switch options.MediaType { + case types.DockerManifestList: + options.BaseIndex = NewEmptyDockerIndex() + default: + options.BaseIndex = empty.Index + } + } + + index := &CNBIndex{ + RepoName: repoName, + ImageIndex: options.BaseIndex, + XdgPath: options.XdgPath, + KeyChain: options.Keychain, + } + return index, nil +} + +func NewTaggableIndex(manifest *v1.IndexManifest) *TaggableIndex { + return &TaggableIndex{ + IndexManifest: manifest, + } +} diff --git a/options.go b/options.go index e50e1eb4..99dd9aeb 100644 --- a/options.go +++ b/options.go @@ -1,9 +1,14 @@ package imgutil import ( + "crypto/tls" + "fmt" + "net/http" "time" + "github.com/google/go-containerregistry/pkg/authn" v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/types" ) type ImageOption func(*ImageOptions) @@ -93,3 +98,110 @@ func WithPreviousImage(name string) func(*ImageOptions) { o.PreviousImageRepoName = name } } + +type IndexOption func(options *IndexOptions) error + +type IndexOptions struct { + BaseIndexRepoName string + MediaType types.MediaType + LayoutIndexOptions + RemoteIndexOptions + IndexPushOptions + + // These options must be specified in each implementation's image index constructor + BaseIndex v1.ImageIndex +} + +type LayoutIndexOptions struct { + XdgPath string +} + +type RemoteIndexOptions struct { + Keychain authn.Keychain + Insecure bool +} + +// FromBaseIndex sets the name to use when loading the index. +// It used to either construct the path (if using layout) or the repo name (if using remote). +// If the index is not found, it does nothing. +func FromBaseIndex(name string) func(*IndexOptions) error { + return func(o *IndexOptions) error { + o.BaseIndexRepoName = name + return nil + } +} + +// FromBaseIndexInstance sets the provided image index as the working image index. +func FromBaseIndexInstance(index v1.ImageIndex) func(options *IndexOptions) error { + return func(o *IndexOptions) error { + o.BaseIndex = index + return nil + } +} + +// WithMediaType specifies the media type for the image index. +func WithMediaType(mediaType types.MediaType) func(options *IndexOptions) error { + return func(o *IndexOptions) error { + if !mediaType.IsIndex() { + return fmt.Errorf("unsupported media type encountered: '%s'", mediaType) + } + o.MediaType = mediaType + return nil + } +} + +// WithXDGRuntimePath Saves the Index to the '`xdgPath`/manifests' +func WithXDGRuntimePath(xdgPath string) func(options *IndexOptions) error { + return func(o *IndexOptions) error { + o.XdgPath = xdgPath + return nil + } +} + +// WithKeychain fetches Index from registry with keychain +func WithKeychain(keychain authn.Keychain) func(options *IndexOptions) error { + return func(o *IndexOptions) error { + o.Keychain = keychain + return nil + } +} + +// WithInsecure if true pulls and pushes the image to an insecure registry. +func WithInsecure() func(options *IndexOptions) error { + return func(o *IndexOptions) error { + o.Insecure = true + return nil + } +} + +type IndexPushOptions struct { + Purge bool + DestinationTags []string +} + +// WithPurge if true deletes the index from the local filesystem after pushing +func WithPurge(purge bool) func(options *IndexOptions) error { + return func(a *IndexOptions) error { + a.Purge = purge + return nil + } +} + +// WithTags sets the destination tags for the index when pushed +func WithTags(tags ...string) func(options *IndexOptions) error { + return func(a *IndexOptions) error { + a.DestinationTags = tags + return nil + } +} + +func GetTransport(insecure bool) http.RoundTripper { + if insecure { + return &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, // #nosec G402 + }, + } + } + return http.DefaultTransport +} diff --git a/remote/index.go b/remote/index.go new file mode 100644 index 00000000..64892750 --- /dev/null +++ b/remote/index.go @@ -0,0 +1,51 @@ +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/remote" + + "github.com/buildpacks/imgutil" +) + +// NewIndex returns a new ImageIndex from the registry that can be modified and saved to the local file system. +func NewIndex(repoName string, ops ...imgutil.IndexOption) (*imgutil.CNBIndex, error) { + options := &imgutil.IndexOptions{} + for _, op := range ops { + if err := op(options); err != nil { + return nil, err + } + } + + var err error + + if options.BaseIndex == nil && options.BaseIndexRepoName != "" { // options.BaseIndex supersedes options.BaseIndexRepoName + options.BaseIndex, err = newV1Index( + options.BaseIndexRepoName, + options.Keychain, + options.Insecure, + ) + if err != nil { + return nil, err + } + } + + return imgutil.NewCNBIndex(repoName, *options) +} + +func newV1Index(repoName string, keychain authn.Keychain, insecure bool) (v1.ImageIndex, error) { + ref, err := name.ParseReference(repoName, name.WeakValidation) + if err != nil { + return nil, err + } + desc, err := remote.Get( + ref, + remote.WithAuthFromKeychain(keychain), + remote.WithTransport(imgutil.GetTransport(insecure)), + ) + if err != nil { + return nil, err + } + return desc.ImageIndex() +} diff --git a/remote/index_test.go b/remote/index_test.go new file mode 100644 index 00000000..8341b534 --- /dev/null +++ b/remote/index_test.go @@ -0,0 +1,156 @@ +package remote_test + +import ( + "os" + "testing" + + "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/random" + remote2 "github.com/google/go-containerregistry/pkg/v1/remote" + "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 TestRemoteNewIndex(t *testing.T) { + dockerConfigDir, err := os.MkdirTemp("", "test.docker.config.remote.index.dir") + h.AssertNil(t, err) + defer os.RemoveAll(dockerConfigDir) + + dockerRegistry = h.NewDockerRegistry(h.WithAuth(dockerConfigDir)) + dockerRegistry.Start(t) + defer dockerRegistry.Stop(t) + os.Setenv("DOCKER_CONFIG", dockerConfigDir) + defer os.Unsetenv("DOCKER_CONFIG") + + spec.Run(t, "RemoteNewIndex", testNewIndex, spec.Parallel(), spec.Report(report.Terminal{})) +} + +const numberOfManifests = 2 + +func testNewIndex(t *testing.T, when spec.G, it spec.S) { + var ( + idx imgutil.ImageIndex + manifests []v1.Hash + remoteIndexRepoName string + xdgPath string + err error + ) + + it.Before(func() { + // creates the directory to save all the OCI images on disk + remoteIndexRepoName = newTestImageIndexName("random") + randomIndex := setUpRandomRemoteIndex(t, remoteIndexRepoName, 1, numberOfManifests) + manifests = h.DigestsFromImageIndex(t, randomIndex) + }) + + it.After(func() { + err = os.RemoveAll(xdgPath) + h.AssertNil(t, err) + }) + + when("#NewIndex", func() { + it("should have expected indexOptions", func() { + idx, err = remote.NewIndex( + "some-index", + imgutil.WithInsecure(), + imgutil.WithKeychain(authn.DefaultKeychain), + imgutil.WithXDGRuntimePath(xdgPath), + ) + h.AssertNil(t, err) + + imgIx, ok := idx.(*imgutil.CNBIndex) + h.AssertEq(t, ok, true) + h.AssertEq(t, imgIx.XdgPath, xdgPath) + h.AssertEq(t, imgIx.RepoName, "some-index") + }) + + it("should return an error when index with the given repoName doesn't exists", func() { + _, err = remote.NewIndex( + "my-index", + imgutil.WithKeychain(authn.DefaultKeychain), + imgutil.FromBaseIndex("some-none-existing-index"), + ) + h.AssertNotEq(t, err, nil) + }) + + it("should return ImageIndex with expected output", func() { + idx, err = remote.NewIndex( + "my-index", + imgutil.WithKeychain(authn.DefaultKeychain), + imgutil.FromBaseIndex(remoteIndexRepoName), + ) + h.AssertNil(t, err) + + imgIx, ok := idx.(*imgutil.CNBIndex) + h.AssertEq(t, ok, true) + + mfest, err := imgIx.IndexManifest() + h.AssertNil(t, err) + h.AssertNotNil(t, mfest) + h.AssertEq(t, len(mfest.Manifests), numberOfManifests) + }) + + it("should able to call #ImageIndex", func() { + idx, err = remote.NewIndex( + "my-index", + imgutil.WithKeychain(authn.DefaultKeychain), + imgutil.FromBaseIndex(remoteIndexRepoName), + ) + h.AssertNil(t, err) + + imgIx, ok := idx.(*imgutil.CNBIndex) + h.AssertEq(t, ok, true) + + // some none existing hash + hash1, err := v1.NewHash( + "sha256:b9d056b83bb6446fee29e89a7fcf10203c562c1f59586a6e2f39c903597bda34", + ) + h.AssertNil(t, err) + + _, err = imgIx.ImageIndex.ImageIndex(hash1) + // err is "no child with digest" + h.AssertNotEq(t, err.Error(), "empty index") + }) + + it("should able to call #Image", func() { + idx, err = remote.NewIndex( + "my-index", + imgutil.WithKeychain(authn.DefaultKeychain), + imgutil.FromBaseIndex(remoteIndexRepoName), + ) + h.AssertNil(t, err) + + imgIdx, ok := idx.(*imgutil.CNBIndex) + h.AssertEq(t, ok, true) + + // select one valid digest from the index + _, err = imgIdx.Image(manifests[0]) + h.AssertNil(t, err) + }) + }) +} + +func newTestImageIndexName(name string) string { + return dockerRegistry.RepoName(name + "-" + h.RandString(10)) +} + +// setUpRandomRemoteIndex creates a random image index with the provided (count) number of manifest +// each manifest will have the provided number of layers +func setUpRandomRemoteIndex(t *testing.T, repoName string, layers, count int64) v1.ImageIndex { + ref, err := name.ParseReference(repoName, name.WeakValidation) + h.AssertNil(t, err) + + randomIndex, err := random.Index(1024, layers, count) + h.AssertNil(t, err) + + err = remote2.WriteIndex(ref, randomIndex, remote2.WithAuthFromKeychain(authn.DefaultKeychain)) + h.AssertNil(t, err) + + return randomIndex +} diff --git a/remote/new.go b/remote/new.go index dc3dd017..92ece234 100644 --- a/remote/new.go +++ b/remote/new.go @@ -96,7 +96,7 @@ func processImageOption(repoName string, keychain authn.Keychain, withPlatform i image, err = remote.Image(ref, remote.WithAuth(auth), remote.WithPlatform(platform), - remote.WithTransport(getTransport(reg.Insecure)), + remote.WithTransport(imgutil.GetTransport(reg.Insecure)), ) if err != nil { if err == io.EOF && i != maxRetries { diff --git a/remote/remote.go b/remote/remote.go index dd03b5a9..53ccbad6 100644 --- a/remote/remote.go +++ b/remote/remote.go @@ -49,7 +49,7 @@ func (i *Image) found() (*v1.Descriptor, error) { if err != nil { return nil, err } - return remote.Head(ref, remote.WithAuth(auth), remote.WithTransport(getTransport(reg.Insecure))) + return remote.Head(ref, remote.WithAuth(auth), remote.WithTransport(imgutil.GetTransport(reg.Insecure))) } func (i *Image) Identifier() (imgutil.Identifier, error) { @@ -84,7 +84,7 @@ func (i *Image) valid() error { if err != nil { return err } - desc, err := remote.Get(ref, remote.WithAuth(auth), remote.WithTransport(getTransport(reg.Insecure))) + desc, err := remote.Get(ref, remote.WithAuth(auth), remote.WithTransport(imgutil.GetTransport(reg.Insecure))) if err != nil { return err } @@ -112,7 +112,7 @@ func (i *Image) Delete() error { if err != nil { return err } - return remote.Delete(ref, remote.WithAuth(auth), remote.WithTransport(getTransport(reg.Insecure))) + return remote.Delete(ref, remote.WithAuth(auth), remote.WithTransport(imgutil.GetTransport(reg.Insecure))) } // extras @@ -147,3 +147,9 @@ func (i *Image) CheckReadWriteAccess() (bool, error) { } return true, nil } + +var _ imgutil.ImageIndex = (*ImageIndex)(nil) + +type ImageIndex struct { + *imgutil.CNBIndex +} diff --git a/remote/remote_test.go b/remote/remote_test.go index d79b384c..3eeea3e3 100644 --- a/remote/remote_test.go +++ b/remote/remote_test.go @@ -1163,22 +1163,27 @@ func testImage(t *testing.T, when spec.G, it spec.S) { when("#SetOS #SetOSVersion #SetArchitecture", func() { it("sets the os/arch", func() { + var ( + os = "foobaros" + arch = "arm64" + osVersion = "1.2.3.4" + ) img, err := remote.NewImage(repoName, authn.DefaultKeychain) h.AssertNil(t, err) - err = img.SetOS("foobaros") + err = img.SetOS(os) h.AssertNil(t, err) - err = img.SetOSVersion("1.2.3.4") + err = img.SetOSVersion(osVersion) h.AssertNil(t, err) - err = img.SetArchitecture("arm64") + err = img.SetArchitecture(arch) h.AssertNil(t, err) h.AssertNil(t, img.Save()) configFile := h.FetchManifestImageConfigFile(t, repoName) - h.AssertEq(t, configFile.OS, "foobaros") - h.AssertEq(t, configFile.OSVersion, "1.2.3.4") - h.AssertEq(t, configFile.Architecture, "arm64") + h.AssertEq(t, configFile.OS, os) + h.AssertEq(t, configFile.OSVersion, osVersion) + h.AssertEq(t, configFile.Architecture, arch) }) }) diff --git a/remote/save.go b/remote/save.go index 2f63fa7b..c52b8daa 100644 --- a/remote/save.go +++ b/remote/save.go @@ -1,9 +1,7 @@ package remote import ( - "crypto/tls" "fmt" - "net/http" v1 "github.com/google/go-containerregistry/pkg/v1" "github.com/google/go-containerregistry/pkg/v1/remote" @@ -61,17 +59,6 @@ func (i *Image) doSave(imageName string) error { return remote.Write(ref, i.CNBImageCore, remote.WithAuth(auth), - remote.WithTransport(getTransport(reg.Insecure)), + remote.WithTransport(imgutil.GetTransport(reg.Insecure)), ) } - -func getTransport(insecure bool) http.RoundTripper { - if insecure { - return &http.Transport{ - TLSClientConfig: &tls.Config{ - InsecureSkipVerify: true, // #nosec G402 - }, - } - } - return http.DefaultTransport -} diff --git a/testhelpers/testhelpers.go b/testhelpers/testhelpers.go index 8d5655e6..179ec902 100644 --- a/testhelpers/testhelpers.go +++ b/testhelpers/testhelpers.go @@ -29,6 +29,7 @@ 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/layout" "github.com/google/go-containerregistry/pkg/v1/remote" "github.com/google/go-containerregistry/pkg/v1/types" "github.com/pkg/errors" @@ -116,6 +117,13 @@ func AssertNil(t *testing.T, actual interface{}) { } } +func AssertNotNil(t *testing.T, actual any) { + t.Helper() + if actual == nil { + t.Fatalf("Expected not nil: %s", actual) + } +} + func AssertBlobsLen(t *testing.T, path string, expected int) { t.Helper() fis, err := os.ReadDir(filepath.Join(path, "blobs", "sha256")) @@ -365,10 +373,26 @@ func FetchManifestImageConfigFile(t *testing.T, repoName string) *v1.ConfigFile configFile, err := gImg.ConfigFile() AssertNil(t, err) + AssertNotEq(t, configFile, nil) return configFile } +func FetchImageIndexDescriptor(t *testing.T, repoName string) v1.ImageIndex { + t.Helper() + + r, err := name.ParseReference(repoName, name.WeakValidation) + AssertNil(t, err) + + auth, err := authn.DefaultKeychain.Resolve(r.Context().Registry) + AssertNil(t, err) + + index, err := remote.Index(r, remote.WithTransport(http.DefaultTransport), remote.WithAuth(auth)) + AssertNil(t, err) + + return index +} + func FileDiffID(t *testing.T, path string) string { tarFile, err := os.Open(filepath.Clean(path)) AssertNil(t, err) @@ -491,7 +515,6 @@ func RemoteImage(t *testing.T, testImageName string, opts []remote.Option) v1.Im testImage, err := remote.Image(r, opts...) AssertNil(t, err) - return testImage } @@ -505,6 +528,14 @@ func AssertPathExists(t *testing.T, path string) { } } +func AssertPathDoesNotExists(t *testing.T, path string) { + t.Helper() + _, err := os.Stat(path) + if err == nil { + t.Errorf("Expected %q to not exists", path) + } +} + func AssertEqAnnotation(t *testing.T, manifest v1.Descriptor, key, value string) { t.Helper() AssertTrue(t, func() bool { @@ -549,7 +580,53 @@ func AssertDockerMediaTypes(t *testing.T, image v1.Image) { } } +func ReadImageIndex(t *testing.T, path string) v1.ImageIndex { + t.Helper() + + indexPath := filepath.Join(path, "index.json") + AssertPathExists(t, filepath.Join(path, "oci-layout")) + AssertPathExists(t, indexPath) + + layoutPath, err := layout.FromPath(path) + AssertNil(t, err) + + localIndex, err := layoutPath.ImageIndex() + AssertNil(t, err) + AssertNotNil(t, localIndex) + + return localIndex +} + +func DigestsFromImageIndex(t *testing.T, index v1.ImageIndex) []v1.Hash { + t.Helper() + + manifests, err := index.IndexManifest() + AssertNil(t, err) + + var hashes []v1.Hash + for _, manifest := range manifests.Manifests { + hashes = append(hashes, manifest.Digest) + } + return hashes +} + +func AssertRemoteImageIndex(t *testing.T, repoName string, mediaType types.MediaType, expectedNumberOfManifests int) { + t.Helper() + + remoteIndex := FetchImageIndexDescriptor(t, repoName) + AssertNotNil(t, remoteIndex) + remoteIndexMediaType, err := remoteIndex.MediaType() + AssertNil(t, err) + AssertEq(t, remoteIndexMediaType, mediaType) + remoteIndexManifest, err := remoteIndex.IndexManifest() + AssertNil(t, err) + AssertNotNil(t, remoteIndexManifest) + AssertEq(t, len(remoteIndexManifest.Manifests), expectedNumberOfManifests) +} + func ReadIndexManifest(t *testing.T, path string) *v1.IndexManifest { + t.Helper() + indexPath := filepath.Join(path, "index.json") AssertPathExists(t, filepath.Join(path, "oci-layout")) AssertPathExists(t, indexPath) @@ -565,6 +642,8 @@ func ReadIndexManifest(t *testing.T, path string) *v1.IndexManifest { } func ReadManifest(t *testing.T, digest v1.Hash, path string) *v1.Manifest { + t.Helper() + manifestPath := filepath.Join(path, "blobs", digest.Algorithm, digest.Hex) AssertPathExists(t, manifestPath) @@ -578,6 +657,7 @@ func ReadManifest(t *testing.T, digest v1.Hash, path string) *v1.Manifest { } func ReadConfigFile(t *testing.T, manifest *v1.Manifest, path string) *v1.ConfigFile { + t.Helper() digest := manifest.Config.Digest configPath := filepath.Join(path, "blobs", digest.Algorithm, digest.Hex) AssertPathExists(t, configPath) diff --git a/util.go b/util.go new file mode 100644 index 00000000..0dcde65f --- /dev/null +++ b/util.go @@ -0,0 +1,121 @@ +package imgutil + +import ( + "encoding/json" + "strings" + + 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/partial" + "github.com/google/go-containerregistry/pkg/v1/types" + "github.com/pkg/errors" +) + +func GetConfigFile(image v1.Image) (*v1.ConfigFile, error) { + configFile, err := image.ConfigFile() + if err != nil { + return nil, err + } + if configFile == nil { + return nil, errors.New("missing config file") + } + return configFile, nil +} + +func GetManifest(image v1.Image) (*v1.Manifest, error) { + manifest, err := image.Manifest() + if err != nil { + return nil, err + } + if manifest == nil { + return nil, errors.New("missing manifest") + } + return manifest, nil +} + +// TaggableIndex any ImageIndex with RawManifest method. +type TaggableIndex struct { + *v1.IndexManifest +} + +// RawManifest returns the bytes of IndexManifest. +func (t *TaggableIndex) RawManifest() ([]byte, error) { + return json.Marshal(t.IndexManifest) +} + +// Digest returns the Digest of the IndexManifest if present. +// Else generate a new Digest. +func (t *TaggableIndex) Digest() (v1.Hash, error) { + if t.IndexManifest.Subject != nil && t.IndexManifest.Subject.Digest != (v1.Hash{}) { + return t.IndexManifest.Subject.Digest, nil + } + + return partial.Digest(t) +} + +// MediaType returns the MediaType of the IndexManifest. +func (t *TaggableIndex) MediaType() (types.MediaType, error) { + return t.IndexManifest.MediaType, nil +} + +// Size returns the Size of IndexManifest if present. +// Calculate the Size of empty. +func (t *TaggableIndex) Size() (int64, error) { + if t.IndexManifest.Subject != nil && t.IndexManifest.Subject.Size != 0 { + return t.IndexManifest.Subject.Size, nil + } + + return partial.Size(t) +} + +type StringSet struct { + items map[string]bool +} + +func NewStringSet() *StringSet { + return &StringSet{items: make(map[string]bool)} +} + +func (s *StringSet) Add(str string) { + if s == nil { + s = &StringSet{items: make(map[string]bool)} + } + + s.items[str] = true +} + +func (s *StringSet) Remove(str string) { + if s == nil { + s = &StringSet{items: make(map[string]bool)} + } + + s.items[str] = false +} + +func (s *StringSet) StringSlice() (slice []string) { + if s == nil { + s = &StringSet{items: make(map[string]bool)} + } + + for i, ok := range s.items { + if ok { + slice = append(slice, i) + } + } + + return slice +} + +// MakeFileSafeName 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 NewEmptyDockerIndex() v1.ImageIndex { + idx := empty.Index + return mutate.IndexMediaType(idx, types.DockerManifestList) +} diff --git a/util_test.go b/util_test.go new file mode 100644 index 00000000..039bb1bf --- /dev/null +++ b/util_test.go @@ -0,0 +1,173 @@ +package imgutil_test + +import ( + "encoding/json" + "testing" + + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/types" + "github.com/sclevine/spec" + "github.com/sclevine/spec/report" + + "github.com/buildpacks/imgutil" + h "github.com/buildpacks/imgutil/testhelpers" +) + +func TestUtils(t *testing.T) { + spec.Run(t, "Utils", testUtils, spec.Parallel(), spec.Report(report.Terminal{})) +} + +type FakeIndentifier struct { + hash string +} + +func NewFakeIdentifier(hash string) FakeIndentifier { + return FakeIndentifier{ + hash: hash, + } +} + +func (f FakeIndentifier) String() string { + return f.hash +} + +func testUtils(t *testing.T, when spec.G, it spec.S) { + when("#TaggableIndex", func() { + var ( + taggableIndex *imgutil.TaggableIndex + amd64Hash, _ = v1.NewHash("sha256:b9d056b83bb6446fee29e89a7fcf10203c562c1f59586a6e2f39c903597bda34") + armv6Hash, _ = v1.NewHash("sha256:0bcc1b827b855c65eaf6e031e894e682b6170160b8a676e1df7527a19d51fb1a") + indexManifest = v1.IndexManifest{ + SchemaVersion: 2, + MediaType: types.OCIImageIndex, + Annotations: map[string]string{ + "test-key": "test-value", + }, + Manifests: []v1.Descriptor{ + { + MediaType: types.OCIManifestSchema1, + Size: 832, + Digest: amd64Hash, + Platform: &v1.Platform{ + OS: "linux", + Architecture: "amd64", + }, + }, + { + MediaType: types.OCIManifestSchema1, + Size: 926, + Digest: armv6Hash, + Platform: &v1.Platform{ + OS: "linux", + Architecture: "arm", + OSVersion: "v6", + }, + }, + }, + } + ) + it.Before(func() { + taggableIndex = imgutil.NewTaggableIndex(&indexManifest) + }) + it("should return RawManifest in expected format", func() { + mfestBytes, err := taggableIndex.RawManifest() + h.AssertNil(t, err) + + expectedMfestBytes, err := json.Marshal(indexManifest) + h.AssertNil(t, err) + + h.AssertEq(t, mfestBytes, expectedMfestBytes) + }) + it("should return expected digest", func() { + digest, err := taggableIndex.Digest() + h.AssertNil(t, err) + h.AssertEq(t, digest.String(), "sha256:2375c0dfd06dd51b313fd97df5ecf3b175380e895287dd9eb2240b13eb0b5703") + }) + it("should return expected size", func() { + size, err := taggableIndex.Size() + h.AssertNil(t, err) + h.AssertEq(t, size, int64(547)) + }) + it("should return expected media type", func() { + format, err := taggableIndex.MediaType() + h.AssertNil(t, err) + h.AssertEq(t, format, indexManifest.MediaType) + }) + }) + + when("#StringSet", func() { + when("#NewStringSet", func() { + it("should return not nil StringSet instance", func() { + stringSet := imgutil.NewStringSet() + h.AssertNotNil(t, stringSet) + h.AssertEq(t, stringSet.StringSlice(), []string(nil)) + }) + }) + + when("#Add", func() { + var ( + stringSet *imgutil.StringSet + ) + it.Before(func() { + stringSet = imgutil.NewStringSet() + }) + it("should add items", func() { + item := "item1" + stringSet.Add(item) + + h.AssertEq(t, stringSet.StringSlice(), []string{item}) + }) + it("should return added items", func() { + items := []string{"item1", "item2", "item3"} + for _, item := range items { + stringSet.Add(item) + } + h.AssertEq(t, len(stringSet.StringSlice()), 3) + h.AssertContains(t, stringSet.StringSlice(), items...) + }) + it("should not support duplicates", func() { + stringSet := imgutil.NewStringSet() + item1 := "item1" + item2 := "item2" + items := []string{item1, item2, item1} + for _, item := range items { + stringSet.Add(item) + } + h.AssertEq(t, len(stringSet.StringSlice()), 2) + h.AssertContains(t, stringSet.StringSlice(), []string{item1, item2}...) + }) + }) + + when("#Remove", func() { + var ( + stringSet *imgutil.StringSet + item string + ) + it.Before(func() { + stringSet = imgutil.NewStringSet() + item = "item1" + stringSet.Add(item) + h.AssertEq(t, stringSet.StringSlice(), []string{item}) + }) + it("should remove item", func() { + stringSet.Remove(item) + h.AssertEq(t, stringSet.StringSlice(), []string(nil)) + }) + }) + }) + + when("#NewEmptyDockerIndex", func() { + it("should return an empty docker index", func() { + idx := imgutil.NewEmptyDockerIndex() + h.AssertNotNil(t, idx) + + digest, err := idx.Digest() + h.AssertNil(t, err) + h.AssertNotEq(t, digest, v1.Hash{}) + + format, err := idx.MediaType() + h.AssertNil(t, err) + h.AssertEq(t, format, types.DockerManifestList) + }) + }) +}