diff --git a/cmd/oras/discover.go b/cmd/oras/discover.go new file mode 100644 index 000000000..ba05f2cbb --- /dev/null +++ b/cmd/oras/discover.go @@ -0,0 +1,209 @@ +/* +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 main + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strings" + + "oras.land/oras-go/v2/registry/remote" + "oras.land/oras/cmd/oras/internal/option" + + "github.com/need-being/go-tree" + digest "github.com/opencontainers/go-digest" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + artifactspec "github.com/oras-project/artifacts-spec/specs-go/v1" + "github.com/spf13/cobra" +) + +type discoverOptions struct { + option.Common + option.Remote + + targetRef string + artifactType string + outputType string +} + +func discoverCmd() *cobra.Command { + var opts discoverOptions + cmd := &cobra.Command{ + Use: "discover [options] ", + Short: "[Preview] Discover referrers of a manifest in the remote registry", + Long: `[Preview] Discover referrers of a manifest in the remote registry + +** This command is in preview and under development. ** + +Example - Discover direct referrers of manifest 'hello:latest' in registry 'localhost:5000': + oras discover localhost:5000/hello + +Example - Discover all the referrers of manifest 'hello:latest' in registry 'localhost:5000' in a tree view: + oras discover localhost:5000/hello -o tree + +Example - Discover referrers with type 'test-artifact' of manifest 'hello:latest' in registry 'localhost:5000': + oras discover --artifact test-artifact localhost:5000/hello +`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + opts.targetRef = args[0] + return runDiscover(opts) + }, + } + + cmd.Flags().StringVarP(&opts.artifactType, "artifact-type", "", "", "artifact type") + cmd.Flags().StringVarP(&opts.outputType, "output", "o", "table", "format in which to display referrers (table, json, or tree). tree format will also show indirect referrers") + option.ApplyFlags(&opts, cmd.Flags()) + return cmd +} + +func runDiscover(opts discoverOptions) error { + ctx, _ := opts.SetLoggerLevel() + repo, err := opts.NewRepository(opts.targetRef, opts.Common) + if err != nil { + return err + } + + // discover artifacts + ref := repo.Reference.ReferenceOrDefault() + if ref != repo.Reference.Reference { + fmt.Println("Using default tag:", ref) + repo.Reference.Reference = ref + } + desc, err := repo.Resolve(ctx, ref) + if err != nil { + return err + } + + if opts.outputType == "tree" { + root := tree.New(repo.Reference.String()) + err = fetchAllReferrers(ctx, repo, desc, opts.artifactType, root) + if err != nil { + return err + } + return tree.Print(root) + } + + refs, err := fetchReferrers(ctx, repo, desc, opts.artifactType) + if err != nil { + return err + } + if opts.outputType == "json" { + return printDiscoveredReferrersJSON(desc, refs) + } + + fmt.Println("Discovered", len(refs), "artifacts referencing", repo.Reference) + fmt.Println("Digest:", desc.Digest) + if len(refs) > 0 { + fmt.Println() + printDiscoveredReferrersTable(refs, opts.Verbose) + } + return nil +} + +func fetchReferrers(ctx context.Context, repo *remote.Repository, desc ocispec.Descriptor, artifactType string) ([]artifactspec.Descriptor, error) { + var results []artifactspec.Descriptor + err := repo.Referrers(ctx, desc, artifactType, func(referrers []artifactspec.Descriptor) error { + results = append(results, referrers...) + return nil + }) + if err != nil { + return nil, err + } + return results, nil +} + +func fetchAllReferrers(ctx context.Context, repo *remote.Repository, desc ocispec.Descriptor, artifactType string, node *tree.Node) error { + results, err := fetchReferrers(ctx, repo, desc, artifactType) + if err != nil { + return err + } + + for _, r := range results { + // Find all indirect referrers + referrerNode := node.AddPath(r.ArtifactType, r.Digest) + err := fetchAllReferrers( + ctx, repo, + ocispec.Descriptor{ + Digest: r.Digest, + Size: r.Size, + MediaType: r.MediaType, + }, + artifactType, referrerNode) + if err != nil { + return err + } + } + return nil +} + +func printDiscoveredReferrersTable(refs []artifactspec.Descriptor, verbose bool) { + typeNameTitle := "Artifact Type" + typeNameLength := len(typeNameTitle) + for _, ref := range refs { + if length := len(ref.ArtifactType); length > typeNameLength { + typeNameLength = length + } + } + + print := func(key string, value interface{}) { + fmt.Println(key, strings.Repeat(" ", typeNameLength-len(key)+1), value) + } + + print(typeNameTitle, "Digest") + for _, ref := range refs { + print(ref.ArtifactType, ref.Digest) + if verbose { + printJSON(ref) + } + } +} + +// printDiscoveredReferrersJSON prints referrer list in JSON equivalent to the +// API result: https://github.com/oras-project/artifacts-spec/blob/v1.0.0-rc.1/manifest-referrers-api.md#artifact-referrers-api-results +func printDiscoveredReferrersJSON(desc ocispec.Descriptor, refs []artifactspec.Descriptor) error { + type referrerDesc struct { + Digest digest.Digest `json:"digest"` + MediaType string `json:"mediaType"` + Artifact string `json:"artifactType"` + Size int64 `json:"size"` + } + output := struct { + Referrers []referrerDesc `json:"referrers"` + }{ + Referrers: make([]referrerDesc, len(refs)), + } + + for i, ref := range refs { + output.Referrers[i] = referrerDesc{ + Digest: ref.Digest, + Artifact: ref.ArtifactType, + Size: ref.Size, + MediaType: ref.MediaType, + } + } + + return printJSON(output) +} + +func printJSON(object interface{}) error { + encoder := json.NewEncoder(os.Stdout) + encoder.SetEscapeHTML(false) + encoder.SetIndent("", " ") + return encoder.Encode(object) +} diff --git a/cmd/oras/main.go b/cmd/oras/main.go index 927a101c1..1579f4a35 100644 --- a/cmd/oras/main.go +++ b/cmd/oras/main.go @@ -11,7 +11,7 @@ func main() { Use: "oras [command]", SilenceUsage: true, } - cmd.AddCommand(pullCmd(), pushCmd(), loginCmd(), logoutCmd(), versionCmd()) + cmd.AddCommand(pullCmd(), pushCmd(), loginCmd(), logoutCmd(), versionCmd(), discoverCmd()) if err := cmd.Execute(); err != nil { os.Exit(1) } diff --git a/go.mod b/go.mod index 72631ccd4..5b9013905 100644 --- a/go.mod +++ b/go.mod @@ -5,12 +5,14 @@ go 1.18 require ( github.com/docker/cli v20.10.17+incompatible github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6 + github.com/need-being/go-tree v0.1.0 github.com/opencontainers/go-digest v1.0.0 github.com/opencontainers/image-spec v1.0.3-0.20211202183452-c5a74bcca799 + github.com/oras-project/artifacts-spec v1.0.0-rc.1 github.com/sirupsen/logrus v1.8.1 github.com/spf13/cobra v1.5.0 github.com/spf13/pflag v1.0.5 - oras.land/oras-go/v2 v2.0.0-alpha + oras.land/oras-go/v2 v2.0.0-20220629060154-3f83f0c01a01 ) require ( @@ -18,7 +20,6 @@ require ( github.com/docker/docker v20.10.17+incompatible // indirect github.com/docker/docker-credential-helpers v0.6.4 // indirect github.com/inconshreveable/mousetrap v1.0.0 // indirect - github.com/oras-project/artifacts-spec v1.0.0-draft.1.1 // indirect github.com/pkg/errors v0.9.1 // indirect golang.org/x/sync v0.0.0-20210220032951-036812b2e83c // indirect golang.org/x/sys v0.0.0-20210616094352-59db8d763f22 // indirect diff --git a/go.sum b/go.sum index ffa699109..828de050f 100644 --- a/go.sum +++ b/go.sum @@ -20,12 +20,14 @@ github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NH github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6 h1:dcztxKSvZ4Id8iPpHERQBbIJfabdt4wUm5qy3wOL2Zc= github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6/go.mod h1:E2VnQOmVuvZB6UYnnDB0qG5Nq/1tD9acaOpo6xmt0Kw= +github.com/need-being/go-tree v0.1.0 h1:blQrtD006cFm97UDeMUfixwPc9o06A6c+uLaUskdNNw= +github.com/need-being/go-tree v0.1.0/go.mod h1:UOHUchuOm+lxM+EtvQ9h/IO88hK/ke7FHai4oGhhEoI= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.0.3-0.20211202183452-c5a74bcca799 h1:rc3tiVYb5z54aKaDfakKn0dDjIyPpTtszkjuMzyt7ec= github.com/opencontainers/image-spec v1.0.3-0.20211202183452-c5a74bcca799/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= -github.com/oras-project/artifacts-spec v1.0.0-draft.1.1 h1:2YMUDyDH0glYA4gNG/zEg9HNVzgGX8kr/NBLR9AQkLQ= -github.com/oras-project/artifacts-spec v1.0.0-draft.1.1/go.mod h1:Xch2aLzSwtkhbFFN6LUzTfLtukYvMMdXJ4oZ8O7BOdc= +github.com/oras-project/artifacts-spec v1.0.0-rc.1 h1:bCHf9mPbrgiNwQFyVzBX79BYZVAl0OUrmvICZOCOwts= +github.com/oras-project/artifacts-spec v1.0.0-rc.1/go.mod h1:Xch2aLzSwtkhbFFN6LUzTfLtukYvMMdXJ4oZ8O7BOdc= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -62,5 +64,5 @@ gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gotest.tools/v3 v3.0.2 h1:kG1BFyqVHuQoVQiR1bWGnfz/fmHvvuiSPIV7rvl360E= gotest.tools/v3 v3.0.2/go.mod h1:3SzNCllyD9/Y+b5r9JIKQ474KzkZyqLqEfYqMsX94Bk= -oras.land/oras-go/v2 v2.0.0-alpha h1:Uwso3p1KMTmm7YheWBkGNjf8xqXZ2AYxMfxu1DoQiH0= -oras.land/oras-go/v2 v2.0.0-alpha/go.mod h1:0IQiLwHUJuMs0+QYGavaeQWw5FD4ABD/RP5YamXT/sc= +oras.land/oras-go/v2 v2.0.0-20220629060154-3f83f0c01a01 h1:jUTJqQr+wPBDESDdRuv9Vm4xRztjhJgzYrdvRtt9HVQ= +oras.land/oras-go/v2 v2.0.0-20220629060154-3f83f0c01a01/go.mod h1:0IQiLwHUJuMs0+QYGavaeQWw5FD4ABD/RP5YamXT/sc=