diff --git a/cmd/oras/blob/fetch.go b/cmd/oras/blob/fetch.go index 22aa4d2f7..1c120afc7 100644 --- a/cmd/oras/blob/fetch.go +++ b/cmd/oras/blob/fetch.go @@ -24,18 +24,16 @@ import ( ocispec "github.com/opencontainers/image-spec/specs-go/v1" "github.com/spf13/cobra" "oras.land/oras-go/v2" - "oras.land/oras-go/v2/content/oci" "oras.land/oras/cmd/oras/internal/option" - "oras.land/oras/internal/cache" ) type fetchBlobOptions struct { + option.Cache option.Common option.Descriptor option.Pretty option.Remote - cacheRoot string outputPath string targetRef string } @@ -74,7 +72,6 @@ Example - Fetch blob from the insecure registry: return errors.New("`--output -` cannot be used with `--descriptor` at the same time") } - opts.cacheRoot = os.Getenv("ORAS_CACHE") return opts.ReadPassword() }, Aliases: []string{"get"}, @@ -101,13 +98,9 @@ func fetchBlob(opts fetchBlobOptions) (fetchErr error) { return fmt.Errorf("%s: blob reference must be of the form ", opts.targetRef) } - var src oras.ReadOnlyTarget = repo.Blobs() - if opts.cacheRoot != "" { - ociStore, err := oci.New(opts.cacheRoot) - if err != nil { - return err - } - src = cache.New(src, ociStore) + src, err := opts.CachedTarget(repo.Blobs()) + if err != nil { + return err } var desc ocispec.Descriptor diff --git a/cmd/oras/internal/option/cache.go b/cmd/oras/internal/option/cache.go new file mode 100644 index 000000000..0012a4253 --- /dev/null +++ b/cmd/oras/internal/option/cache.go @@ -0,0 +1,41 @@ +/* +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 option + +import ( + "os" + + "oras.land/oras-go/v2" + "oras.land/oras-go/v2/content/oci" + "oras.land/oras/internal/cache" +) + +type Cache struct { + Root string +} + +// CachedTarget gets the target storage with caching if cache root is specified. +func (opts *Cache) CachedTarget(src oras.ReadOnlyTarget) (oras.ReadOnlyTarget, error) { + opts.Root = os.Getenv("ORAS_CACHE") + if opts.Root != "" { + ociStore, err := oci.New(opts.Root) + if err != nil { + return nil, err + } + return cache.New(src, ociStore), nil + } + return src, nil +} diff --git a/cmd/oras/internal/option/cache_test.go b/cmd/oras/internal/option/cache_test.go new file mode 100644 index 000000000..6b8b6663a --- /dev/null +++ b/cmd/oras/internal/option/cache_test.go @@ -0,0 +1,63 @@ +/* +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 option + +import ( + "os" + "reflect" + "testing" + + "oras.land/oras-go/v2" + "oras.land/oras-go/v2/content/memory" + "oras.land/oras-go/v2/content/oci" + "oras.land/oras/internal/cache" +) + +var mockTarget oras.ReadOnlyTarget = memory.New() + +func TestCache_CachedTarget(t *testing.T) { + tempDir := t.TempDir() + os.Setenv("ORAS_CACHE", tempDir) + defer os.Unsetenv("ORAS_CACHE") + opts := Cache{} + + ociStore, err := oci.New(tempDir) + if err != nil { + t.Fatal("error calling oci.New(), error =", err) + } + want := cache.New(mockTarget, ociStore) + + got, err := opts.CachedTarget(mockTarget) + if err != nil { + t.Fatal("Cache.CachedTarget() error=", err) + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("Cache.CachedTarget() got %v, want %v", got, want) + } +} + +func TestCache_CachedTarget_emptyRoot(t *testing.T) { + os.Setenv("ORAS_CACHE", "") + opts := Cache{} + + got, err := opts.CachedTarget(mockTarget) + if err != nil { + t.Fatal("Cache.CachedTarget() error=", err) + } + if !reflect.DeepEqual(got, mockTarget) { + t.Fatalf("Cache.CachedTarget() got %v, want %v", got, mockTarget) + } +} diff --git a/cmd/oras/manifest/fetch.go b/cmd/oras/manifest/fetch.go index a50cbdcf5..8e52cca96 100644 --- a/cmd/oras/manifest/fetch.go +++ b/cmd/oras/manifest/fetch.go @@ -16,26 +16,28 @@ limitations under the License. package manifest import ( - "bytes" "encoding/json" - "fmt" + "errors" "os" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" "github.com/spf13/cobra" + "oras.land/oras-go/v2" oerrors "oras.land/oras/cmd/oras/internal/errors" "oras.land/oras/cmd/oras/internal/option" - "oras.land/oras/internal/cas" ) type fetchOptions struct { + option.Cache option.Common + option.Descriptor option.Remote option.Platform + option.Pretty - targetRef string - pretty bool - mediaTypes []string - fetchDescriptor bool + mediaTypes []string + outputPath string + targetRef string } func fetchCmd() *cobra.Command { @@ -44,6 +46,7 @@ func fetchCmd() *cobra.Command { Use: "fetch [flags] ", Short: "[Preview] Fetch manifest of the target artifact", Long: `[Preview] Fetch manifest of the target artifact + ** This command is in preview and under development. ** Example - Fetch raw manifest: @@ -63,6 +66,10 @@ Example - Fetch manifest with prettified json result: `, Args: cobra.ExactArgs(1), PreRunE: func(cmd *cobra.Command, args []string) error { + if opts.outputPath == "-" && opts.OutputDescriptor { + return errors.New("`--output -` cannot be used with `--descriptor` at the same time") + } + return opts.ReadPassword() }, Aliases: []string{"get"}, @@ -72,46 +79,71 @@ Example - Fetch manifest with prettified json result: }, } - cmd.Flags().BoolVarP(&opts.pretty, "pretty", "", false, "output prettified manifest") - cmd.Flags().BoolVarP(&opts.fetchDescriptor, "descriptor", "", false, "fetch a descriptor of the manifest") cmd.Flags().StringSliceVarP(&opts.mediaTypes, "media-type", "", nil, "accepted media types") + cmd.Flags().StringVarP(&opts.outputPath, "output", "o", "", "output file path") option.ApplyFlags(&opts, cmd.Flags()) return cmd } -func fetchManifest(opts fetchOptions) error { +func fetchManifest(opts fetchOptions) (fetchErr error) { ctx, _ := opts.SetLoggerLevel() - targetPlatform, err := opts.Parse() - if err != nil { - return err - } + repo, err := opts.NewRepository(opts.targetRef, opts.Common) if err != nil { return err } + if repo.Reference.Reference == "" { return oerrors.NewErrInvalidReference(repo.Reference) } repo.ManifestMediaTypes = opts.mediaTypes - // Fetch and output - var content []byte - if opts.fetchDescriptor { - content, err = cas.FetchDescriptor(ctx, repo, opts.targetRef, targetPlatform) - } else { - content, err = cas.FetchManifest(ctx, repo, opts.targetRef, targetPlatform) + targetPlatform, err := opts.Parse() + if err != nil { + return err } + + manifests, err := opts.CachedTarget(repo.Manifests()) if err != nil { return err } - if opts.pretty { - buf := bytes.NewBuffer(nil) - if err = json.Indent(buf, content, "", " "); err != nil { - return fmt.Errorf("failed to prettify: %w", err) + + var desc ocispec.Descriptor + if opts.OutputDescriptor && opts.outputPath == "" { + // fetch manifest descriptor only + desc, err = oras.Resolve(ctx, manifests, opts.targetRef, oras.DefaultResolveOptions) + if err != nil { + return err + } + } else { + // fetch manifest content + var content []byte + fetchOpts := oras.DefaultFetchBytesOptions + fetchOpts.TargetPlatform = targetPlatform + desc, content, err = oras.FetchBytes(ctx, manifests, opts.targetRef, fetchOpts) + if err != nil { + return err + } + + if opts.outputPath == "" || opts.outputPath == "-" { + // output manifest content + return opts.Output(os.Stdout, content) + } + + // save manifest content into the local file if the output path is provided + if err = os.WriteFile(opts.outputPath, content, 0666); err != nil { + return err + } + } + + // output manifest's descriptor if `--descriptor` is used + if opts.OutputDescriptor { + descBytes, err := json.Marshal(desc) + if err != nil { + return err } - buf.WriteByte('\n') - content = buf.Bytes() + return opts.Output(os.Stdout, descBytes) } - _, err = os.Stdout.Write(content) - return err + + return nil } diff --git a/cmd/oras/pull.go b/cmd/oras/pull.go index 3e1d779ce..910b14230 100644 --- a/cmd/oras/pull.go +++ b/cmd/oras/pull.go @@ -18,7 +18,6 @@ package main import ( "context" "fmt" - "os" "sync" ocispec "github.com/opencontainers/image-spec/specs-go/v1" @@ -26,20 +25,18 @@ import ( "oras.land/oras-go/v2" "oras.land/oras-go/v2/content" "oras.land/oras-go/v2/content/file" - "oras.land/oras-go/v2/content/oci" "oras.land/oras/cmd/oras/internal/display" "oras.land/oras/cmd/oras/internal/errors" "oras.land/oras/cmd/oras/internal/option" - "oras.land/oras/internal/cache" "oras.land/oras/internal/docker" ) type pullOptions struct { + option.Cache option.Common option.Remote targetRef string - cacheRoot string KeepOldFiles bool PathTraversal bool Output string @@ -68,7 +65,6 @@ Example - Pull files with local cache: `, Args: cobra.ExactArgs(1), PreRunE: func(cmd *cobra.Command, args []string) error { - opts.cacheRoot = os.Getenv("ORAS_CACHE") return opts.ReadPassword() }, RunE: func(cmd *cobra.Command, args []string) error { @@ -94,13 +90,9 @@ func runPull(opts pullOptions) error { if repo.Reference.Reference == "" { return errors.NewErrInvalidReference(repo.Reference) } - var src oras.ReadOnlyTarget = repo - if opts.cacheRoot != "" { - ociStore, err := oci.New(opts.cacheRoot) - if err != nil { - return err - } - src = cache.New(repo, ociStore) + src, err := opts.CachedTarget(repo) + if err != nil { + return err } // Copy Options diff --git a/internal/cas/fetch.go b/internal/cas/fetch.go deleted file mode 100644 index 2763157d3..000000000 --- a/internal/cas/fetch.go +++ /dev/null @@ -1,71 +0,0 @@ -/* -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 cas - -import ( - "context" - "encoding/json" - - ocispec "github.com/opencontainers/image-spec/specs-go/v1" - "oras.land/oras-go/v2" - "oras.land/oras-go/v2/content" - "oras.land/oras-go/v2/content/memory" - "oras.land/oras-go/v2/registry" - "oras.land/oras/internal/cache" -) - -// FetchDescriptor fetches a minimal descriptor of reference from target. -// If platform flag not empty, will fetch the specified platform. -func FetchDescriptor(ctx context.Context, target oras.ReadOnlyTarget, reference string, p *ocispec.Platform) ([]byte, error) { - desc, err := oras.Resolve(ctx, target, reference, oras.ResolveOptions{TargetPlatform: p}) - if err != nil { - return nil, err - } - return json.Marshal(ocispec.Descriptor{ - MediaType: desc.MediaType, - Digest: desc.Digest, - Size: desc.Size, - }) -} - -// FetchManifest fetches the manifest content of reference from target. -// If platform flag not empty, will fetch the specified platform. -func FetchManifest(ctx context.Context, target oras.ReadOnlyTarget, reference string, p *ocispec.Platform) ([]byte, error) { - // TODO: improve implementation once oras-go#102 is resolved - if p == nil { - if rf, ok := target.(registry.ReferenceFetcher); ok { - desc, rc, err := rf.FetchReference(ctx, reference) - if err != nil { - return nil, err - } - defer rc.Close() - return content.ReadAll(rc, desc) - } - } - target = cache.New(target, memory.New()) - desc, err := oras.Resolve(ctx, target, reference, oras.ResolveOptions{ - TargetPlatform: p, - }) - if err != nil { - return nil, err - } - rc, err := target.Fetch(ctx, desc) - if err != nil { - return nil, err - } - defer rc.Close() - return content.ReadAll(rc, desc) -} diff --git a/internal/cas/fetch_test.go b/internal/cas/fetch_test.go deleted file mode 100644 index aaa50b3e8..000000000 --- a/internal/cas/fetch_test.go +++ /dev/null @@ -1,145 +0,0 @@ -/* -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 cas_test - -import ( - "bytes" - "context" - "errors" - "testing" - - "github.com/opencontainers/go-digest" - ocispec "github.com/opencontainers/image-spec/specs-go/v1" - "oras.land/oras-go/v2/errdef" - "oras.land/oras/internal/cas" - "oras.land/oras/internal/mock" -) - -const ( - index = `{"manifests":[{"digest":"sha256:baf0239e48ff4c47ebac3ba02b5cf1506b69cd5a0c0d0c825a53ba65976fb942","mediaType":"application\/vnd.docker.distribution.manifest.v2+json","platform":{"architecture":"amd64","os":"linux"},"size":11},{"digest":"sha256:27cb13102d774dc36e0bc93f528db7e4f004a6e9636cb6926b1e389668535309","mediaType":"application\/vnd.docker.distribution.manifest.v2+json","platform":{"architecture":"arm","os":"linux","variant":"v5"},"size":12}]}` - amd64 = "linux/amd64" - armv5 = "linux/arm/v5" - armv7 = "linux/arm/v7" - - indexDesc = `{"mediaType":"application/vnd.oci.image.index.v1+json","digest":"sha256:bdcc003fa2d7882789773fe5fee506ef370dce5ce7988fd420587f144fc700db","size":452}` - armv5Desc = `{"mediaType":"application/vnd.docker.distribution.manifest.v2+json","digest":"sha256:27cb13102d774dc36e0bc93f528db7e4f004a6e9636cb6926b1e389668535309","size":12}` - amd64Desc = `{"mediaType":"application/vnd.docker.distribution.manifest.v2+json","digest":"sha256:baf0239e48ff4c47ebac3ba02b5cf1506b69cd5a0c0d0c825a53ba65976fb942","size":11}` - badType = "application/a.not.supported.manifest.v2+jso" - badDesc = `{"mediaType":"application/a.not.supported.manifest.v2+json","digest":"sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","size":0}` -) - -var repo = mock.New().WithFetch().WithFetchReference().WithResolve() - -func TestPlatform_FetchManifest_indexAndPlatform(t *testing.T) { - repo.Remount([]mock.Blob{ - {Content: index, MediaType: ocispec.MediaTypeImageIndex, Tag: ""}, - {Content: amd64, MediaType: ocispec.MediaTypeImageManifest, Tag: ""}, - {Content: armv5, MediaType: ocispec.MediaTypeImageManifest, Tag: ""}}) - - // Get index manifest - indexBytes := []byte(index) - got, err := cas.FetchManifest(context.Background(), repo, digest.FromBytes(indexBytes).String(), nil) - if err != nil || !bytes.Equal(got, indexBytes) { - t.Fatal(err) - } - - // Get manifest for specific platform - want := []byte(amd64) - got, err = cas.FetchManifest(context.Background(), repo, digest.FromBytes(indexBytes).String(), &ocispec.Platform{OS: "linux", Architecture: "amd64"}) - if err != nil || !bytes.Equal(got, want) { - t.Fatal(err) - } - - want = []byte(armv5) - got, err = cas.FetchManifest(context.Background(), repo, digest.FromBytes(indexBytes).String(), &ocispec.Platform{OS: "linux", Architecture: "arm", Variant: "v5"}) - if err != nil || !bytes.Equal(got, want) { - t.Fatal(err) - } -} - -func TestPlatform_FetchDescriptor_indexAndPlatform(t *testing.T) { - var indexTag = "multi-platform" - repo.Remount([]mock.Blob{ - {Content: index, MediaType: ocispec.MediaTypeImageIndex, Tag: indexTag}, - {Content: amd64, MediaType: ocispec.MediaTypeImageManifest, Tag: ""}, - {Content: armv5, MediaType: ocispec.MediaTypeImageManifest, Tag: ""}}) - - // Get index manifest - indexBytes := []byte(index) - got, err := cas.FetchDescriptor(context.Background(), repo, digest.FromBytes(indexBytes).String(), nil) - if err != nil || !bytes.Equal(got, []byte(indexDesc)) { - t.Fatal(err) - } - - // Get manifest for specific platform - want := []byte(amd64Desc) - got, err = cas.FetchDescriptor(context.Background(), repo, indexTag, &ocispec.Platform{OS: "linux", Architecture: "amd64"}) - if err != nil || !bytes.Equal(got, want) { - t.Fatal(err) - } - got, err = cas.FetchDescriptor(context.Background(), repo, indexTag, &ocispec.Platform{OS: "linux", Architecture: "arm", Variant: "v5"}) - if err != nil || !bytes.Equal(got, []byte(armv5Desc)) { - t.Fatal(err) - } -} - -func TestPlatform_FetchManifest_errNotMulti(t *testing.T) { - repo.Remount([]mock.Blob{{Content: "", MediaType: badType, Tag: badDesc}}) - - // Unknow media type - _, err := cas.FetchManifest(context.Background(), repo, digest.FromBytes([]byte("")).String(), &ocispec.Platform{OS: "linux", Architecture: "amd64"}) - if !errors.Is(err, errdef.ErrUnsupported) { - t.Fatalf("Expecting error: %v, got: %v", errdef.ErrUnsupported, err) - } -} -func TestPlatform_FetchManifest_errNoMatch(t *testing.T) { - // No matched platform found - repo.Remount([]mock.Blob{{Content: index, MediaType: ocispec.MediaTypeImageIndex, Tag: ""}}) - _, err := cas.FetchManifest( - context.Background(), - repo, - digest.FromBytes([]byte(index)).String(), - &ocispec.Platform{OS: "linux", Architecture: "arm", Variant: "v7"}) - if !errors.Is(err, errdef.ErrNotFound) { - t.Fatalf("Expecting error: %v, got: %v", errdef.ErrNotFound, err) - } -} - -func TestPlatform_FetchDescriptor_miscErr(t *testing.T) { - // Should throw err when repo is nil - repo.Remount(nil) - ret, err := cas.FetchDescriptor(context.Background(), repo, "invalid-RefERENCE", nil) - if err == nil { - t.Fatalf("Should fail oras.Resolve, unexpected return value: %v", ret) - } - -} - -func TestPlatform_FetchManifest_miscErr(t *testing.T) { - // Should throw err when repo is empty - repo.Remount(nil) - ret, err := cas.FetchManifest(context.Background(), repo, "mocked-reference", nil) - if err == nil { - t.Fatalf("Should fail oras.Resolve, unexpected return value: %v", ret) - } - // Should throw err when resolve succeeds but fetch reference fails - tmpRepo := mock.New().WithResolve() - tmpRepo.Remount([]mock.Blob{{Content: amd64, MediaType: ocispec.MediaTypeImageManifest, Tag: ""}}) - ret, err = cas.FetchManifest(context.Background(), tmpRepo, digest.FromBytes([]byte(amd64)).String(), nil) - if err == nil { - t.Fatalf("Should fail oras.Fetch, unexpected return value: %v", ret) - } -}