diff --git a/cmd/oras/manifest/cmd.go b/cmd/oras/manifest/cmd.go index 0720585a2..347106326 100644 --- a/cmd/oras/manifest/cmd.go +++ b/cmd/oras/manifest/cmd.go @@ -26,9 +26,10 @@ func Cmd() *cobra.Command { } cmd.AddCommand( - pushCmd(), - fetchCmd(), deleteCmd(), + fetchCmd(), + fetchConfigCmd(), + pushCmd(), ) return cmd } diff --git a/cmd/oras/manifest/fetch.go b/cmd/oras/manifest/fetch.go index 8e52cca96..57965dd12 100644 --- a/cmd/oras/manifest/fetch.go +++ b/cmd/oras/manifest/fetch.go @@ -111,7 +111,9 @@ func fetchManifest(opts fetchOptions) (fetchErr error) { var desc ocispec.Descriptor if opts.OutputDescriptor && opts.outputPath == "" { // fetch manifest descriptor only - desc, err = oras.Resolve(ctx, manifests, opts.targetRef, oras.DefaultResolveOptions) + fetchOpts := oras.DefaultResolveOptions + fetchOpts.TargetPlatform = targetPlatform + desc, err = oras.Resolve(ctx, manifests, opts.targetRef, fetchOpts) if err != nil { return err } diff --git a/cmd/oras/manifest/fetch_config.go b/cmd/oras/manifest/fetch_config.go new file mode 100644 index 000000000..623715c07 --- /dev/null +++ b/cmd/oras/manifest/fetch_config.go @@ -0,0 +1,169 @@ +/* +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 manifest + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/spf13/cobra" + "oras.land/oras-go/v2" + "oras.land/oras-go/v2/content" + oerrors "oras.land/oras/cmd/oras/internal/errors" + "oras.land/oras/cmd/oras/internal/option" + "oras.land/oras/internal/descriptor" +) + +type fetchConfigOptions struct { + option.Cache + option.Common + option.Descriptor + option.Platform + option.Pretty + option.Remote + + outputPath string + targetRef string +} + +func fetchConfigCmd() *cobra.Command { + var opts fetchConfigOptions + cmd := &cobra.Command{ + Use: "fetch-config [flag] name<:tag|@digest>", + Short: "[Preview] Fetch the config of a manifest from a remote registry", + Long: `[Preview] Fetch the config of a manifest from a remote registry + +** This command is in preview and under development. ** + +Example - Fetch the config: + oras manifest fetch-config localhost:5000/hello:latest + +Example - Fetch the config of certain platform: + oras manifest fetch-config --platform 'linux/arm/v5' localhost:5000/hello:latest + +Example - Fetch and print the prettified config: + oras manifest fetch-config --pretty localhost:5000/hello:latest + +Example - Fetch the config and save it to a local file: + oras manifest fetch-config --output config.json localhost:5000/hello:latest + +Example - Fetch the descriptor of the config: + oras manifest fetch-config --descriptor localhost:5000/hello:latest + +Example - Fetch and print the prettified descriptor of the config: + oras manifest fetch-config --descriptor --pretty localhost:5000/hello:latest +`, + 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() + }, + RunE: func(cmd *cobra.Command, args []string) error { + opts.targetRef = args[0] + return fetchConfig(opts) + }, + } + + cmd.Flags().StringVarP(&opts.outputPath, "output", "o", "", "output file path") + option.ApplyFlags(&opts, cmd.Flags()) + return cmd +} + +func fetchConfig(opts fetchConfigOptions) (fetchErr error) { + ctx, _ := opts.SetLoggerLevel() + + repo, err := opts.NewRepository(opts.targetRef, opts.Common) + if err != nil { + return err + } + + if repo.Reference.Reference == "" { + return oerrors.NewErrInvalidReference(repo.Reference) + } + + targetPlatform, err := opts.Parse() + if err != nil { + return err + } + + src, err := opts.CachedTarget(repo) + if err != nil { + return err + } + + // fetch config descriptor + configDesc, err := fetchConfigDesc(ctx, src, opts.targetRef, targetPlatform) + if err != nil { + return err + } + + if !opts.OutputDescriptor || opts.outputPath != "" { + // fetch config content + contentBytes, err := content.FetchAll(ctx, src, configDesc) + if err != nil { + return err + } + + if opts.outputPath == "" || opts.outputPath == "-" { + // output config content + return opts.Output(os.Stdout, contentBytes) + } + + // save config into the local file if the output path is provided + if err = os.WriteFile(opts.outputPath, contentBytes, 0666); err != nil { + return err + } + } + + if opts.OutputDescriptor { + // output config's descriptor + descBytes, err := json.Marshal(configDesc) + if err != nil { + return err + } + return opts.Output(os.Stdout, descBytes) + } + + return nil +} + +func fetchConfigDesc(ctx context.Context, src oras.ReadOnlyTarget, reference string, targetPlatform *ocispec.Platform) (ocispec.Descriptor, error) { + // fetch manifest descriptor and content + fetchOpts := oras.DefaultFetchBytesOptions + fetchOpts.TargetPlatform = targetPlatform + manifestDesc, manifestContent, err := oras.FetchBytes(ctx, src, reference, fetchOpts) + if err != nil { + return ocispec.Descriptor{}, err + } + + if !descriptor.IsImageManifest(manifestDesc) { + return ocispec.Descriptor{}, fmt.Errorf("%q is not an image manifest and does not have a config", manifestDesc.Digest) + } + + // unmarshal manifest content to extract config descriptor + var manifest ocispec.Manifest + if err := json.Unmarshal(manifestContent, &manifest); err != nil { + return ocispec.Descriptor{}, err + } + return manifest.Config, nil +} diff --git a/cmd/oras/pull.go b/cmd/oras/pull.go index 910b14230..50bbc8d7b 100644 --- a/cmd/oras/pull.go +++ b/cmd/oras/pull.go @@ -28,7 +28,7 @@ import ( "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/docker" + "oras.land/oras/internal/descriptor" ) type pullOptions struct { @@ -113,7 +113,7 @@ func runPull(opts pullOptions) error { // 2) MediaType not specified and current node is config. // Note: For a manifest, the 0th indexed element is always a // manifest config. - if (s.MediaType == configMediaType || (configMediaType == "" && i == 0 && isManifestMediaType(desc.MediaType))) && configPath != "" { + if (s.MediaType == configMediaType || (configMediaType == "" && i == 0 && descriptor.IsImageManifest(desc))) && configPath != "" { // Add annotation for manifest config if s.Annotations == nil { s.Annotations = make(map[string]string) @@ -189,10 +189,6 @@ func runPull(opts pullOptions) error { return nil } -func isManifestMediaType(mediaType string) bool { - return mediaType == docker.MediaTypeManifest || mediaType == ocispec.MediaTypeImageManifest -} - // generateContentKey generates a unique key for each content descriptor, using // its digest and name if applicable. func generateContentKey(desc ocispec.Descriptor) string { diff --git a/internal/descriptor/descriptor.go b/internal/descriptor/descriptor.go new file mode 100644 index 000000000..6e9aaa674 --- /dev/null +++ b/internal/descriptor/descriptor.go @@ -0,0 +1,27 @@ +/* +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 descriptor + +import ( + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + + "oras.land/oras/internal/docker" +) + +// IsImageManifest checks whether a manifest is an image manifest. +func IsImageManifest(desc ocispec.Descriptor) bool { + return desc.MediaType == docker.MediaTypeManifest || desc.MediaType == ocispec.MediaTypeImageManifest +} diff --git a/internal/descriptor/descriptor_test.go b/internal/descriptor/descriptor_test.go new file mode 100644 index 000000000..82b54c2b8 --- /dev/null +++ b/internal/descriptor/descriptor_test.go @@ -0,0 +1,48 @@ +/* +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 descriptor_test + +import ( + "reflect" + "testing" + + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "oras.land/oras/internal/descriptor" +) + +func TestIsImageManifest(t *testing.T) { + imageDesc := ocispec.Descriptor{ + MediaType: "application/vnd.oci.image.manifest.v1+json", + Digest: "sha256:2e0e0fe1fb3edbcdddad941c90d2b51e25a6bcd593e82545441a216de7bfa834", + Size: 474, + } + + got := descriptor.IsImageManifest(imageDesc) + if !reflect.DeepEqual(got, true) { + t.Fatalf("IsImageManifest() got %v, want %v", got, true) + } + + artifactDesc := ocispec.Descriptor{ + MediaType: "application/vnd.cncf.oras.artifact.manifest.v1+json", + Digest: "sha256:772fbebcda7e6937de01295bae28360afd463c2d5f1f7aca59a3ef267608bc66", + Size: 568, + } + + got = descriptor.IsImageManifest(artifactDesc) + if !reflect.DeepEqual(got, false) { + t.Fatalf("IsImageManifest() got %v, want %v", got, false) + } +}