From 071f0605bd29303a876f01370b13538a8d77751a Mon Sep 17 00:00:00 2001 From: Xiaoxuan Wang <103478229+wangxiaoxuan273@users.noreply.github.com> Date: Fri, 13 Sep 2024 15:50:38 +0800 Subject: [PATCH] feat: update existing index (index update) (#1476) Signed-off-by: Xiaoxuan Wang --- cmd/oras/internal/display/status/utils.go | 12 +- cmd/oras/root/manifest/index/cmd.go | 1 + cmd/oras/root/manifest/index/create.go | 22 +- cmd/oras/root/manifest/index/update.go | 238 +++++++++++++++++++ cmd/oras/root/manifest/index/update_test.go | 123 ++++++++++ internal/descriptor/descriptor.go | 5 + test/e2e/suite/command/manifest_index.go | 245 ++++++++++++++++++++ 7 files changed, 633 insertions(+), 13 deletions(-) create mode 100644 cmd/oras/root/manifest/index/update.go create mode 100644 cmd/oras/root/manifest/index/update_test.go diff --git a/cmd/oras/internal/display/status/utils.go b/cmd/oras/internal/display/status/utils.go index 860156067..79662a72f 100644 --- a/cmd/oras/internal/display/status/utils.go +++ b/cmd/oras/internal/display/status/utils.go @@ -50,10 +50,14 @@ const ( // Prompts for index events. const ( - IndexPromptFetching = "Fetching" - IndexPromptFetched = "Fetched " - IndexPromptPacked = "Packed " - IndexPromptPushed = "Pushed " + IndexPromptFetching = "Fetching " + IndexPromptFetched = "Fetched " + IndexPromptAdded = "Added " + IndexPromptMerged = "Merged " + IndexPromptRemoved = "Removed " + IndexPromptPacked = "Packed " + IndexPromptPushed = "Pushed " + IndexPromptUpdated = "Updated " ) // DeduplicatedFilter filters out deduplicated descriptors. diff --git a/cmd/oras/root/manifest/index/cmd.go b/cmd/oras/root/manifest/index/cmd.go index 54391c123..3633faa03 100644 --- a/cmd/oras/root/manifest/index/cmd.go +++ b/cmd/oras/root/manifest/index/cmd.go @@ -25,6 +25,7 @@ func Cmd() *cobra.Command { cmd.AddCommand( createCmd(), + updateCmd(), ) return cmd } diff --git a/cmd/oras/root/manifest/index/create.go b/cmd/oras/root/manifest/index/create.go index 66bdecb6e..1d6164d5d 100644 --- a/cmd/oras/root/manifest/index/create.go +++ b/cmd/oras/root/manifest/index/create.go @@ -36,6 +36,7 @@ import ( oerrors "oras.land/oras/cmd/oras/internal/errors" "oras.land/oras/cmd/oras/internal/option" "oras.land/oras/cmd/oras/internal/output" + "oras.land/oras/internal/contentutil" "oras.land/oras/internal/descriptor" "oras.land/oras/internal/listener" ) @@ -59,25 +60,25 @@ func createCmd() *cobra.Command { Short: "[Experimental] Create and push an index from provided manifests", Long: `[Experimental] Create and push an index from provided manifests. All manifests should be in the same repository -Example - create an index from source manifests tagged 'linux-amd64' and 'linux-arm64', and push without tagging: +Example - Create an index from source manifests tagged 'linux-amd64' and 'linux-arm64', and push without tagging: oras manifest index create localhost:5000/hello linux-amd64 linux-arm64 -Example - create an index from source manifests tagged 'linux-amd64' and 'linux-arm64', and push with the tag 'v1': +Example - Create an index from source manifests tagged 'linux-amd64' and 'linux-arm64', and push with the tag 'v1': oras manifest index create localhost:5000/hello:v1 linux-amd64 linux-arm64 -Example - create an index from source manifests using both tags and digests, and push with tag 'v1': +Example - Create an index from source manifests using both tags and digests, and push with tag 'v1': oras manifest index create localhost:5000/hello:v1 linux-amd64 sha256:99e4703fbf30916f549cd6bfa9cdbab614b5392fbe64fdee971359a77073cdf9 -Example - create an index and push it with multiple tags: +Example - Create an index and push it with multiple tags: oras manifest index create localhost:5000/hello:tag1,tag2,tag3 linux-amd64 linux-arm64 sha256:99e4703fbf30916f549cd6bfa9cdbab614b5392fbe64fdee971359a77073cdf9 -Example - create an index and push to an OCI image layout folder 'layout-dir' and tag with 'v1': +Example - Create an index and push to an OCI image layout folder 'layout-dir' and tag with 'v1': oras manifest index create layout-dir:v1 linux-amd64 sha256:99e4703fbf30916f549cd6bfa9cdbab614b5392fbe64fdee971359a77073cdf9 -Example - create an index and save it locally to index.json, auto push will be disabled: +Example - Create an index and save it locally to index.json, auto push will be disabled: oras manifest index create --output index.json localhost:5000/hello linux-amd64 linux-arm64 -Example - create an index and output the index to stdout, auto push will be disabled: +Example - Create an index and output the index to stdout, auto push will be disabled: oras manifest index create localhost:5000/hello linux-arm64 --output - --pretty `, Args: oerrors.CheckArgs(argument.AtLeast(1), "the destination index to create."), @@ -115,7 +116,10 @@ func createIndex(cmd *cobra.Command, opts createOptions) error { MediaType: ocispec.MediaTypeImageIndex, Manifests: manifests, } - indexBytes, _ := json.Marshal(index) + indexBytes, err := json.Marshal(index) + if err != nil { + return err + } desc := content.NewDescriptorFromBytes(ocispec.MediaTypeImageIndex, indexBytes) opts.Println(status.IndexPromptPacked, descriptor.ShortDigest(desc), ocispec.MediaTypeImageIndex) @@ -182,7 +186,7 @@ func getPlatform(ctx context.Context, target oras.ReadOnlyTarget, manifestBytes func pushIndex(ctx context.Context, target oras.Target, desc ocispec.Descriptor, content []byte, ref string, extraRefs []string, path string, printer *output.Printer) error { // push the index var err error - if ref == "" { + if ref == "" || contentutil.IsDigest(ref) { err = target.Push(ctx, desc, bytes.NewReader(content)) } else { _, err = oras.TagBytes(ctx, target, desc.MediaType, content, ref) diff --git a/cmd/oras/root/manifest/index/update.go b/cmd/oras/root/manifest/index/update.go new file mode 100644 index 000000000..d63b20d49 --- /dev/null +++ b/cmd/oras/root/manifest/index/update.go @@ -0,0 +1,238 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package index + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/opencontainers/go-digest" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/spf13/cobra" + "github.com/spf13/pflag" + "oras.land/oras-go/v2" + "oras.land/oras-go/v2/content" + "oras.land/oras/cmd/oras/internal/argument" + "oras.land/oras/cmd/oras/internal/command" + "oras.land/oras/cmd/oras/internal/display/status" + oerrors "oras.land/oras/cmd/oras/internal/errors" + "oras.land/oras/cmd/oras/internal/option" + "oras.land/oras/cmd/oras/internal/output" + "oras.land/oras/internal/contentutil" + "oras.land/oras/internal/descriptor" +) + +type updateOptions struct { + option.Common + option.Target + + addArguments []string + mergeArguments []string + removeArguments []string + tags []string +} + +func updateCmd() *cobra.Command { + var opts updateOptions + cmd := &cobra.Command{ + Use: "update {:|@} [{--add|--merge|--remove} {|}] [...]", + Short: "[Experimental] Update and push an image index", + Long: `[Experimental] Update and push an image index. All manifests should be in the same repository + +Example - Remove a manifest and add two manifests from an index tagged 'v1'. The tag will point to the updated index: + oras manifest index update localhost:5000/hello:v1 --add linux-amd64 --add linux-arm64 --remove sha256:99e4703fbf30916f549cd6bfa9cdbab614b5392fbe64fdee971359a77073cdf9 + +Example - Create a new index by updating an existing index specified by its digest: + oras manifest index update localhost:5000/hello@sha256:99e4703fbf30916f549cd6bfa9cdbab614b5392fbe64fdee971359a77073cdf9 --add linux-amd64 --remove sha256:fd6ed2f36b5465244d5dc86cb4e7df0ab8a9d24adc57825099f522fe009a22bb + +Example - Merge manifests from the index 'v2-windows' to the index 'v2': + oras manifest index update localhost:5000/hello:v2 --merge v2-windows + +Example - Update an index and tag the updated index as 'v2.1.0' and 'v2': + oras manifest index update localhost:5000/hello@sha256:99e4703fbf30916f549cd6bfa9cdbab614b5392fbe64fdee971359a77073cdf9 --add linux-amd64 --tag "v2.1.0" --tag "v2" + `, + Args: oerrors.CheckArgs(argument.Exactly(1), "the target index to update"), + PreRunE: func(cmd *cobra.Command, args []string) error { + opts.RawReference = args[0] + for _, manifestRef := range opts.removeArguments { + if !contentutil.IsDigest(manifestRef) { + return fmt.Errorf("remove: %s is not a digest", manifestRef) + } + } + return option.Parse(cmd, &opts) + }, + RunE: func(cmd *cobra.Command, args []string) error { + return updateIndex(cmd, opts) + }, + } + option.ApplyFlags(&opts, cmd.Flags()) + cmd.Flags().StringArrayVarP(&opts.addArguments, "add", "", nil, "manifests to add to the index") + cmd.Flags().StringArrayVarP(&opts.mergeArguments, "merge", "", nil, "indexes to be merged into the index") + cmd.Flags().StringArrayVarP(&opts.removeArguments, "remove", "", nil, "manifests to remove from the index, must be digests") + cmd.Flags().StringArrayVarP(&opts.tags, "tag", "", nil, "extra tags for the updated index") + return oerrors.Command(cmd, &opts.Target) +} + +func updateIndex(cmd *cobra.Command, opts updateOptions) error { + // if no update flag is used, do nothing + if !updateFlagsUsed(cmd.Flags()) { + opts.Println("Nothing to update as no change is requested") + return nil + } + ctx, logger := command.GetLogger(cmd, &opts.Common) + target, err := opts.NewTarget(opts.Common, logger) + if err != nil { + return err + } + if err := opts.EnsureReferenceNotEmpty(cmd, true); err != nil { + return err + } + index, err := fetchIndex(ctx, target, opts) + if err != nil { + return err + } + manifests, err := removeManifests(ctx, index.Manifests, target, opts) + if err != nil { + return err + } + manifests, err = addManifests(ctx, manifests, target, opts) + if err != nil { + return err + } + manifests, err = mergeIndexes(ctx, manifests, target, opts) + if err != nil { + return err + } + + index.Manifests = manifests + indexBytes, err := json.Marshal(index) + if err != nil { + return err + } + desc := content.NewDescriptorFromBytes(index.MediaType, indexBytes) + + printUpdateStatus(status.IndexPromptUpdated, string(desc.Digest), "", opts.Printer) + path := getPushPath(opts.RawReference, opts.Type, opts.Reference, opts.Path) + return pushIndex(ctx, target, desc, indexBytes, opts.Reference, opts.tags, path, opts.Printer) +} + +func fetchIndex(ctx context.Context, target oras.ReadOnlyTarget, opts updateOptions) (ocispec.Index, error) { + printUpdateStatus(status.IndexPromptFetching, opts.Reference, "", opts.Printer) + desc, content, err := oras.FetchBytes(ctx, target, opts.Reference, oras.DefaultFetchBytesOptions) + if err != nil { + return ocispec.Index{}, fmt.Errorf("could not find the index %s: %w", opts.Reference, err) + } + if !descriptor.IsIndex(desc) { + return ocispec.Index{}, fmt.Errorf("%s is not an index", opts.Reference) + } + printUpdateStatus(status.IndexPromptFetched, opts.Reference, string(desc.Digest), opts.Printer) + var index ocispec.Index + if err := json.Unmarshal(content, &index); err != nil { + return ocispec.Index{}, err + } + return index, nil +} + +func addManifests(ctx context.Context, manifests []ocispec.Descriptor, target oras.ReadOnlyTarget, opts updateOptions) ([]ocispec.Descriptor, error) { + for _, manifestRef := range opts.addArguments { + printUpdateStatus(status.IndexPromptFetching, manifestRef, "", opts.Printer) + desc, content, err := oras.FetchBytes(ctx, target, manifestRef, oras.DefaultFetchBytesOptions) + if err != nil { + return nil, fmt.Errorf("could not find the manifest %s: %w", manifestRef, err) + } + if !descriptor.IsManifest(desc) { + return nil, fmt.Errorf("%s is not a manifest", manifestRef) + } + printUpdateStatus(status.IndexPromptFetched, manifestRef, string(desc.Digest), opts.Printer) + if descriptor.IsImageManifest(desc) { + desc.Platform, err = getPlatform(ctx, target, content) + if err != nil { + return nil, err + } + } + manifests = append(manifests, desc) + printUpdateStatus(status.IndexPromptAdded, manifestRef, string(desc.Digest), opts.Printer) + } + return manifests, nil +} + +func mergeIndexes(ctx context.Context, manifests []ocispec.Descriptor, target oras.ReadOnlyTarget, opts updateOptions) ([]ocispec.Descriptor, error) { + for _, indexRef := range opts.mergeArguments { + printUpdateStatus(status.IndexPromptFetching, indexRef, "", opts.Printer) + desc, content, err := oras.FetchBytes(ctx, target, indexRef, oras.DefaultFetchBytesOptions) + if err != nil { + return nil, fmt.Errorf("could not find the index %s: %w", indexRef, err) + } + if !descriptor.IsIndex(desc) { + return nil, fmt.Errorf("%s is not an index", indexRef) + } + printUpdateStatus(status.IndexPromptFetched, indexRef, string(desc.Digest), opts.Printer) + var index ocispec.Index + if err := json.Unmarshal(content, &index); err != nil { + return nil, err + } + manifests = append(manifests, index.Manifests...) + printUpdateStatus(status.IndexPromptMerged, indexRef, string(desc.Digest), opts.Printer) + } + return manifests, nil +} + +func removeManifests(ctx context.Context, manifests []ocispec.Descriptor, target oras.ReadOnlyTarget, opts updateOptions) ([]ocispec.Descriptor, error) { + // create a set of digests to speed up the remove + digestToRemove := make(map[digest.Digest]bool) + for _, manifestRef := range opts.removeArguments { + digestToRemove[digest.Digest(manifestRef)] = false + } + return doRemoveManifests(manifests, digestToRemove, opts.Printer, opts.Reference) +} + +func doRemoveManifests(originalManifests []ocispec.Descriptor, digestToRemove map[digest.Digest]bool, printer *output.Printer, indexRef string) ([]ocispec.Descriptor, error) { + manifests := []ocispec.Descriptor{} + for _, m := range originalManifests { + if _, exists := digestToRemove[m.Digest]; exists { + digestToRemove[m.Digest] = true + } else { + manifests = append(manifests, m) + } + } + for digest, removed := range digestToRemove { + if !removed { + return nil, fmt.Errorf("%s does not exist in the index %s", digest, indexRef) + } + printUpdateStatus(status.IndexPromptRemoved, string(digest), "", printer) + } + return manifests, nil +} + +func updateFlagsUsed(flags *pflag.FlagSet) bool { + return flags.Changed("add") || flags.Changed("remove") || flags.Changed("merge") +} + +func printUpdateStatus(verb string, reference string, resolvedDigest string, printer *output.Printer) { + if resolvedDigest == "" || contentutil.IsDigest(reference) { + printer.Println(verb, reference) + } else { + printer.Println(verb, resolvedDigest, reference) + } +} + +func getPushPath(rawReference string, targetType string, reference string, path string) string { + if contentutil.IsDigest(reference) { + return fmt.Sprintf("[%s] %s", targetType, path) + } + return fmt.Sprintf("[%s] %s", targetType, rawReference) +} diff --git a/cmd/oras/root/manifest/index/update_test.go b/cmd/oras/root/manifest/index/update_test.go new file mode 100644 index 000000000..ed919727e --- /dev/null +++ b/cmd/oras/root/manifest/index/update_test.go @@ -0,0 +1,123 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package index + +import ( + "os" + "reflect" + "testing" + + "github.com/opencontainers/go-digest" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "oras.land/oras/cmd/oras/internal/output" +) + +var ( + A = ocispec.Descriptor{ + MediaType: ocispec.MediaTypeImageManifest, + Size: 16, + Digest: "sha256:58efe73e78fe043ca31b89007a025c594ce12aa7e6da27d21c7b14b50112e255", + } + B = ocispec.Descriptor{ + MediaType: ocispec.MediaTypeImageManifest, + Size: 18, + Digest: "sha256:9d16f5505246424aed7116cb21216704ba8c919997d0f1f37e154c11d509e1d2", + } + C = ocispec.Descriptor{ + MediaType: ocispec.MediaTypeImageManifest, + Size: 19, + Digest: "sha256:fd6ed2f36b5465244d5dc86cb4e7df0ab8a9d24adc57825099f522fe009a22bb", + } +) + +func Test_doRemoveManifests(t *testing.T) { + tests := []struct { + name string + manifests []ocispec.Descriptor + digestSet map[digest.Digest]bool + printer *output.Printer + indexRef string + want []ocispec.Descriptor + wantErr bool + }{ + { + name: "remove one matched item", + manifests: []ocispec.Descriptor{A, B, C}, + digestSet: map[digest.Digest]bool{B.Digest: false}, + printer: output.NewPrinter(os.Stdout, os.Stderr, false), + indexRef: "test01", + want: []ocispec.Descriptor{A, C}, + wantErr: false, + }, + { + name: "remove all matched items", + manifests: []ocispec.Descriptor{A, B, A, C, A, A, A}, + digestSet: map[digest.Digest]bool{A.Digest: false}, + printer: output.NewPrinter(os.Stdout, os.Stderr, false), + indexRef: "test02", + want: []ocispec.Descriptor{B, C}, + wantErr: false, + }, + { + name: "remove correctly when there is only one item", + manifests: []ocispec.Descriptor{A}, + digestSet: map[digest.Digest]bool{A.Digest: false}, + printer: output.NewPrinter(os.Stdout, os.Stderr, false), + indexRef: "test03", + want: []ocispec.Descriptor{}, + wantErr: false, + }, + { + name: "remove multiple distinct manifests", + manifests: []ocispec.Descriptor{A, B, C}, + digestSet: map[digest.Digest]bool{A.Digest: false, C.Digest: false}, + printer: output.NewPrinter(os.Stdout, os.Stderr, false), + indexRef: "test04", + want: []ocispec.Descriptor{B}, + wantErr: false, + }, + { + name: "remove multiple duplicate manifests", + manifests: []ocispec.Descriptor{A, B, C, C, B, A, B}, + digestSet: map[digest.Digest]bool{A.Digest: false, C.Digest: false}, + printer: output.NewPrinter(os.Stdout, os.Stderr, false), + indexRef: "test04", + want: []ocispec.Descriptor{B, B, B}, + wantErr: false, + }, + { + name: "return error when deleting a nonexistent item", + manifests: []ocispec.Descriptor{A, C}, + digestSet: map[digest.Digest]bool{B.Digest: false}, + printer: output.NewPrinter(os.Stdout, os.Stderr, false), + indexRef: "test04", + want: nil, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := doRemoveManifests(tt.manifests, tt.digestSet, tt.printer, tt.indexRef) + if (err != nil) != tt.wantErr { + t.Errorf("removeManifestsFromIndex() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("removeManifestsFromIndex() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/descriptor/descriptor.go b/internal/descriptor/descriptor.go index f884edd99..41f7321b7 100644 --- a/internal/descriptor/descriptor.go +++ b/internal/descriptor/descriptor.go @@ -40,6 +40,11 @@ func IsImageManifest(desc ocispec.Descriptor) bool { return desc.MediaType == docker.MediaTypeManifest || desc.MediaType == ocispec.MediaTypeImageManifest } +// IsIndex checks if a descriptor describes an image index or Docker manifest list. +func IsIndex(desc ocispec.Descriptor) bool { + return desc.MediaType == ocispec.MediaTypeImageIndex || desc.MediaType == docker.MediaTypeManifestList +} + // ShortDigest converts the digest of the descriptor to a short form for displaying. func ShortDigest(desc ocispec.Descriptor) (digestString string) { digestString = desc.Digest.String() diff --git a/test/e2e/suite/command/manifest_index.go b/test/e2e/suite/command/manifest_index.go index fe6d35139..95eee3165 100644 --- a/test/e2e/suite/command/manifest_index.go +++ b/test/e2e/suite/command/manifest_index.go @@ -36,6 +36,12 @@ var _ = Describe("ORAS beginners:", func() { }) }) }) + + When("running `manifest index update`", func() { + It("should show help doc with --tag flag", func() { + ORAS("manifest", "index", "update", "--help").MatchKeyWords("--tag", "tags for the updated index").Exec() + }) + }) }) func indexTestRepo(subcommand string, text string) string { @@ -154,6 +160,147 @@ var _ = Describe("1.1 registry users:", func() { MatchErrKeyWords("Error", "could not find", "does-not-exist").Exec() }) }) + + When("running `manifest index update`", func() { + It("should update by specifying the index tag", func() { + testRepo := indexTestRepo("update", "by-index-tag") + CopyZOTRepo(ImageRepo, testRepo) + // create an index for testing purpose + ORAS("manifest", "index", "create", RegistryRef(ZOTHost, testRepo, "latest"), + string(multi_arch.LinuxAMD64.Digest), string(multi_arch.LinuxARM64.Digest)).Exec() + // add a manifest to the index + ORAS("manifest", "index", "update", RegistryRef(ZOTHost, testRepo, "latest"), + "--add", string(multi_arch.LinuxARMV7.Digest)). + MatchKeyWords("sha256:84887718c9e61daa0f1996aad3ae2eb10db15dcbdab394e4b2dfee7967c55f2c").Exec() + // verify + content := ORAS("manifest", "fetch", RegistryRef(ZOTHost, testRepo, "latest")).Exec().Out.Contents() + expectedManifests := []ocispec.Descriptor{multi_arch.LinuxAMD64, multi_arch.LinuxARM64, multi_arch.LinuxARMV7} + ValidateIndex(content, expectedManifests) + }) + + It("should update by specifying the index digest", func() { + testRepo := indexTestRepo("update", "by-index-digest") + CopyZOTRepo(ImageRepo, testRepo) + // create an index for testing purpose + ORAS("manifest", "index", "create", RegistryRef(ZOTHost, testRepo, ""), + string(multi_arch.LinuxAMD64.Digest), string(multi_arch.LinuxARM64.Digest)).Exec() + // add a manifest to the index + ORAS("manifest", "index", "update", RegistryRef(ZOTHost, testRepo, "sha256:cce9590b1193d8bcb70467e2381dc81e77869be4801c09abe9bc274b6a1d2001"), + "--add", string(multi_arch.LinuxARMV7.Digest)). + MatchKeyWords("sha256:84887718c9e61daa0f1996aad3ae2eb10db15dcbdab394e4b2dfee7967c55f2c").Exec() + // verify + content := ORAS("manifest", "fetch", RegistryRef(ZOTHost, testRepo, "sha256:84887718c9e61daa0f1996aad3ae2eb10db15dcbdab394e4b2dfee7967c55f2c")). + Exec().Out.Contents() + expectedManifests := []ocispec.Descriptor{multi_arch.LinuxAMD64, multi_arch.LinuxARM64, multi_arch.LinuxARMV7} + ValidateIndex(content, expectedManifests) + }) + + It("should update by add, merge and remove flags", func() { + testRepo := indexTestRepo("update", "all-flags") + CopyZOTRepo(ImageRepo, testRepo) + // create indexes for testing purpose + ORAS("manifest", "index", "create", RegistryRef(ZOTHost, testRepo, "index01"), + string(multi_arch.LinuxAMD64.Digest)).Exec() + ORAS("manifest", "index", "create", RegistryRef(ZOTHost, testRepo, "index02"), + string(multi_arch.LinuxARM64.Digest)).Exec() + // update index with add, merge and remove flags + ORAS("manifest", "index", "update", RegistryRef(ZOTHost, testRepo, "index01"), + "--add", string(multi_arch.LinuxARMV7.Digest), "--merge", "index02", + "--remove", string(multi_arch.LinuxAMD64.Digest)).Exec() + // verify + content := ORAS("manifest", "fetch", RegistryRef(ZOTHost, testRepo, "index01")).Exec().Out.Contents() + expectedManifests := []ocispec.Descriptor{multi_arch.LinuxARMV7, multi_arch.LinuxARM64} + ValidateIndex(content, expectedManifests) + }) + + It("should update and tag the updated index by --tag flag", func() { + testRepo := indexTestRepo("update", "tag-updated-index") + CopyZOTRepo(ImageRepo, testRepo) + // create an index for testing purpose + ORAS("manifest", "index", "create", RegistryRef(ZOTHost, testRepo, ""), + string(multi_arch.LinuxAMD64.Digest), string(multi_arch.LinuxARM64.Digest)).Exec() + // add a manifest to the index + ORAS("manifest", "index", "update", RegistryRef(ZOTHost, testRepo, "sha256:cce9590b1193d8bcb70467e2381dc81e77869be4801c09abe9bc274b6a1d2001"), + "--add", string(multi_arch.LinuxARMV7.Digest), "--tag", "updated"). + MatchKeyWords("sha256:84887718c9e61daa0f1996aad3ae2eb10db15dcbdab394e4b2dfee7967c55f2c").Exec() + // verify + content := ORAS("manifest", "fetch", RegistryRef(ZOTHost, testRepo, "updated")). + Exec().Out.Contents() + expectedManifests := []ocispec.Descriptor{multi_arch.LinuxAMD64, multi_arch.LinuxARM64, multi_arch.LinuxARMV7} + ValidateIndex(content, expectedManifests) + }) + + It("should tell user nothing to update if no update flags are used", func() { + testRepo := indexTestRepo("update", "no-flags") + CopyZOTRepo(ImageRepo, testRepo) + ORAS("manifest", "index", "update", RegistryRef(ZOTHost, testRepo, "nothing-to-update")). + MatchKeyWords("nothing to update").Exec() + }) + + It("should fail if empty reference is given", func() { + testRepo := indexTestRepo("update", "empty-reference") + CopyZOTRepo(ImageRepo, testRepo) + ORAS("manifest", "index", "update", RegistryRef(ZOTHost, testRepo, ""), + "--add", string(multi_arch.LinuxARMV7.Digest)).ExpectFailure(). + MatchErrKeyWords("Error:", "no tag or digest specified").Exec() + }) + + It("should fail if a wrong reference is given as the index to update", func() { + testRepo := indexTestRepo("update", "wrong-index-ref") + CopyZOTRepo(ImageRepo, testRepo) + ORAS("manifest", "index", "update", RegistryRef(ZOTHost, testRepo, "does-not-exist"), + "--add", string(multi_arch.LinuxARMV7.Digest)).ExpectFailure(). + MatchErrKeyWords("Error", "could not find", "does-not-exist").Exec() + }) + + It("should fail if a wrong reference is given as the manifest to add", func() { + testRepo := indexTestRepo("update", "wrong-add-ref") + CopyZOTRepo(ImageRepo, testRepo) + // create an index for testing purpose + ORAS("manifest", "index", "create", RegistryRef(ZOTHost, testRepo, "add-wrong-tag"), + string(multi_arch.LinuxAMD64.Digest), string(multi_arch.LinuxARM64.Digest)).Exec() + // add a manifest to the index + ORAS("manifest", "index", "update", RegistryRef(ZOTHost, testRepo, "add-wrong-tag"), + "--add", "does-not-exist").ExpectFailure(). + MatchErrKeyWords("Error", "could not find", "does-not-exist").Exec() + }) + + It("should fail if a wrong reference is given as the index to merge", func() { + testRepo := indexTestRepo("update", "wrong-merge-ref") + CopyZOTRepo(ImageRepo, testRepo) + // create an index for testing purpose + ORAS("manifest", "index", "create", RegistryRef(ZOTHost, testRepo, "merge-wrong-tag"), + string(multi_arch.LinuxAMD64.Digest), string(multi_arch.LinuxARM64.Digest)).Exec() + // add a manifest to the index + ORAS("manifest", "index", "update", RegistryRef(ZOTHost, testRepo, "merge-wrong-tag"), + "--merge", "does-not-exist").ExpectFailure(). + MatchErrKeyWords("Error", "could not find", "does-not-exist").Exec() + }) + + It("should fail if a non-digest reference is given as the manifest to remove", func() { + testRepo := indexTestRepo("update", "remove-by-tag") + CopyZOTRepo(ImageRepo, testRepo) + // create an index for testing purpose + ORAS("manifest", "index", "create", RegistryRef(ZOTHost, testRepo, "remove-by-tag"), + string(multi_arch.LinuxAMD64.Digest), string(multi_arch.LinuxARM64.Digest)).Exec() + // add a manifest to the index + ORAS("manifest", "index", "update", RegistryRef(ZOTHost, testRepo, "remove-by-tag"), + "--remove", "latest").ExpectFailure(). + MatchErrKeyWords("Error", "latest", "is not a digest").Exec() + }) + + It("should fail if delete a manifest that does not exist in the index", func() { + testRepo := indexTestRepo("update", "wrong-remove-ref-index") + CopyZOTRepo(ImageRepo, testRepo) + // create an index for testing purpose + ORAS("manifest", "index", "create", RegistryRef(ZOTHost, testRepo, "remove-not-exist"), + string(multi_arch.LinuxAMD64.Digest)).Exec() + // add a manifest to the index + ORAS("manifest", "index", "update", RegistryRef(ZOTHost, testRepo, "remove-not-exist"), + "--remove", string(multi_arch.LinuxARM64.Digest)).ExpectFailure(). + MatchErrKeyWords("Error", "does not exist").Exec() + }) + }) }) var _ = Describe("OCI image layout users:", func() { @@ -256,4 +403,102 @@ var _ = Describe("OCI image layout users:", func() { MatchErrKeyWords("is not a manifest").Exec() }) }) + + When("running `manifest index update`", func() { + It("should update by specifying the index tag", func() { + root := PrepareTempOCI(ImageRepo) + indexRef := LayoutRef(root, "latest") + // create an index for testing purpose + ORAS("manifest", "index", "create", Flags.Layout, indexRef, + string(multi_arch.LinuxAMD64.Digest), string(multi_arch.LinuxARM64.Digest)).Exec() + // add a manifest to the index + ORAS("manifest", "index", "update", Flags.Layout, indexRef, + "--add", string(multi_arch.LinuxARMV7.Digest)). + MatchKeyWords("sha256:84887718c9e61daa0f1996aad3ae2eb10db15dcbdab394e4b2dfee7967c55f2c").Exec() + // verify + content := ORAS("manifest", "fetch", Flags.Layout, indexRef).Exec().Out.Contents() + expectedManifests := []ocispec.Descriptor{multi_arch.LinuxAMD64, multi_arch.LinuxARM64, multi_arch.LinuxARMV7} + ValidateIndex(content, expectedManifests) + }) + + It("should update by specifying the index digest", func() { + root := PrepareTempOCI(ImageRepo) + // create an index for testing purpose + ORAS("manifest", "index", "create", Flags.Layout, LayoutRef(root, ""), + string(multi_arch.LinuxAMD64.Digest), string(multi_arch.LinuxARM64.Digest)).Exec() + // add a manifest to the index + ORAS("manifest", "index", "update", Flags.Layout, LayoutRef(root, "sha256:cce9590b1193d8bcb70467e2381dc81e77869be4801c09abe9bc274b6a1d2001"), + "--add", string(multi_arch.LinuxARMV7.Digest)). + MatchKeyWords("sha256:84887718c9e61daa0f1996aad3ae2eb10db15dcbdab394e4b2dfee7967c55f2c").Exec() + // verify + content := ORAS("manifest", "fetch", Flags.Layout, LayoutRef(root, "sha256:84887718c9e61daa0f1996aad3ae2eb10db15dcbdab394e4b2dfee7967c55f2c")). + Exec().Out.Contents() + expectedManifests := []ocispec.Descriptor{multi_arch.LinuxAMD64, multi_arch.LinuxARM64, multi_arch.LinuxARMV7} + ValidateIndex(content, expectedManifests) + }) + + It("should update by add, merge and remove flags", func() { + root := PrepareTempOCI(ImageRepo) + // create indexes for testing purpose + ORAS("manifest", "index", "create", Flags.Layout, LayoutRef(root, "index01"), + string(multi_arch.LinuxAMD64.Digest)).Exec() + ORAS("manifest", "index", "create", Flags.Layout, LayoutRef(root, "index02"), + string(multi_arch.LinuxARM64.Digest)).Exec() + // update index with add, merge and remove flags + ORAS("manifest", "index", "update", Flags.Layout, LayoutRef(root, "index01"), + "--add", string(multi_arch.LinuxARMV7.Digest), "--merge", "index02", + "--remove", string(multi_arch.LinuxAMD64.Digest)).Exec() + // verify + content := ORAS("manifest", "fetch", Flags.Layout, LayoutRef(root, "index01")).Exec().Out.Contents() + expectedManifests := []ocispec.Descriptor{multi_arch.LinuxARMV7, multi_arch.LinuxARM64} + ValidateIndex(content, expectedManifests) + }) + + It("should tell user nothing to update if no update flags are used", func() { + root := PrepareTempOCI(ImageRepo) + indexRef := LayoutRef(root, "latest") + ORAS("manifest", "index", "update", Flags.Layout, indexRef). + MatchKeyWords("nothing to update").Exec() + }) + + It("should fail if empty reference is given", func() { + root := PrepareTempOCI(ImageRepo) + indexRef := LayoutRef(root, "") + ORAS("manifest", "index", "update", Flags.Layout, indexRef, + "--add", string(multi_arch.LinuxARMV7.Digest)).ExpectFailure(). + MatchErrKeyWords("Error:", "no tag or digest specified").Exec() + }) + + It("should fail if a non-index reference is given as the index to update", func() { + root := PrepareTempOCI(ImageRepo) + indexRef := LayoutRef(root, "linux-amd64") + ORAS("manifest", "index", "update", Flags.Layout, indexRef, + "--add", string(multi_arch.LinuxARMV7.Digest)).ExpectFailure(). + MatchErrKeyWords("Error", "is not an index").Exec() + }) + + It("should fail if a non-manifest reference is given as the manifest to add", func() { + root := PrepareTempOCI(ImageRepo) + indexRef := LayoutRef(root, "latest") + // create an index for testing purpose + ORAS("manifest", "index", "create", Flags.Layout, indexRef, + string(multi_arch.LinuxAMD64.Digest), string(multi_arch.LinuxARM64.Digest)).Exec() + // add a manifest to the index + ORAS("manifest", "index", "update", Flags.Layout, indexRef, + "--add", "sha256:02c15a8d1735c65bb8ca86c716615d3c0d8beb87dc68ed88bb49192f90b184e2").ExpectFailure(). + MatchErrKeyWords("Error", "is not a manifest").Exec() + }) + + It("should fail if a wrong reference is given as the index to merge", func() { + root := PrepareTempOCI(ImageRepo) + indexRef := LayoutRef(root, "latest") + // create an index for testing purpose + ORAS("manifest", "index", "create", Flags.Layout, indexRef, + string(multi_arch.LinuxAMD64.Digest), string(multi_arch.LinuxARM64.Digest)).Exec() + // add a manifest to the index + ORAS("manifest", "index", "update", Flags.Layout, indexRef, + "--merge", "linux-amd64").ExpectFailure(). + MatchErrKeyWords("Error", "is not an index").Exec() + }) + }) })