add download cmd
Signed-off-by: Meredith Lancaster <malancas@github.com>
This commit is contained in:
parent
b1fbfdd228
commit
cbd57deb11
5 changed files with 449 additions and 1 deletions
|
|
@ -1,6 +1,7 @@
|
|||
package attestation
|
||||
|
||||
import (
|
||||
"github.com/cli/cli/v2/pkg/cmd/attestation/download"
|
||||
"github.com/cli/cli/v2/pkg/cmd/attestation/verify"
|
||||
"github.com/cli/cli/v2/pkg/cmdutil"
|
||||
|
||||
|
|
@ -11,7 +12,7 @@ import (
|
|||
func NewCmdAttestation(f *cmdutil.Factory) *cobra.Command {
|
||||
root := &cobra.Command{
|
||||
Use: "attestation [subcommand]",
|
||||
Short: "Work with attestations.",
|
||||
Short: "Work with attestations",
|
||||
Aliases: []string{"at"},
|
||||
Long: heredoc.Docf(`
|
||||
Work with attestations that represent trusted metadata about artifacts and images.
|
||||
|
|
@ -25,6 +26,7 @@ func NewCmdAttestation(f *cmdutil.Factory) *cobra.Command {
|
|||
`, "`"),
|
||||
}
|
||||
|
||||
root.AddCommand(download.NewDownloadCmd(f))
|
||||
root.AddCommand(verify.NewVerifyCmd(f))
|
||||
|
||||
return root
|
||||
|
|
|
|||
165
pkg/cmd/attestation/download/download.go
Normal file
165
pkg/cmd/attestation/download/download.go
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
package download
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/cli/cli/v2/pkg/cmdutil"
|
||||
"github.com/cli/cli/v2/pkg/cmd/attestation/artifact"
|
||||
"github.com/cli/cli/v2/pkg/cmd/attestation/api"
|
||||
|
||||
"github.com/MakeNowJust/heredoc"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func NewDownloadCmd(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &Options{}
|
||||
downloadCmd := &cobra.Command{
|
||||
Use: "download [<file path> | oci://<OCI image URI>]",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Short: "Download trusted metadata about a binary artifact for offline use",
|
||||
Long: heredoc.Docf(`
|
||||
Download trusted metadata about a binary artifact for offline use.
|
||||
|
||||
The command accepts either:
|
||||
* a relative path to a local artifact
|
||||
* a container image URI (e.g. oci://<my-OCI-URI>)
|
||||
|
||||
Note that you must already be authenticated with a container registry
|
||||
if you provide an OCI image URI as the artifact.
|
||||
|
||||
The command also requires you provide either the %[1]s--owner%[1]s or %[1]s--repo%[1]s flag.
|
||||
The value of the %[1]s--owner%[1]s flag should be the name of the GitHub organization
|
||||
that the artifact is associated with.
|
||||
The value of the %[1]s--repo%[1]s flag should be the name of the GitHub repository
|
||||
that the artifact is associated with.
|
||||
|
||||
Metadata is written to a file in the current directory named after the artifact's digest.
|
||||
For example, if the artifact's digest is "sha256:1234", the metadata will be
|
||||
written to "sha256:1234.jsonl".
|
||||
`, "`"),
|
||||
Example: heredoc.Doc(`
|
||||
# Download trusted metadata for a local artifact associated with a GitHub organization
|
||||
$ gh attestation download <my-artifact> -o <GitHub organization>
|
||||
|
||||
# Download trusted metadata for a local artifact associated with a GitHub repository
|
||||
$ gh attestation download <my-artifact> -R <GitHub repo>
|
||||
|
||||
# Download trusted metadata for an OCI image associated with a GitHub organization
|
||||
$ gh attestation download oci://<my-OCI-image> -o <GitHub organization>
|
||||
`),
|
||||
// PreRunE is used to validate flags before the command is run
|
||||
// If an error is returned, its message will be printed to the terminal
|
||||
// along with information about how use the command
|
||||
PreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
opts.APIClient = api.NewLiveClient()
|
||||
|
||||
// Create a logger for use throughout the download command
|
||||
opts.ConfigureLogger()
|
||||
|
||||
// Configure the live OCI client
|
||||
opts.ConfigureOCIClient()
|
||||
|
||||
// set the artifact path
|
||||
opts.ArtifactPath = args[0]
|
||||
|
||||
// check that the provided flags are valid
|
||||
if err := opts.AreFlagsValid(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
// Use Run instead of RunE because if an error is returned by RunVerify
|
||||
// when RunE is used, the command usage will be printed
|
||||
// We only want to print the error, not usage
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if err := RunDownload(opts); err != nil {
|
||||
opts.Logger.Println(opts.Logger.IO.Out, opts.Logger.ColorScheme.Redf("Failed to download the artifact's trusted metadata: %s", err.Error()))
|
||||
os.Exit(1)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
downloadCmd.Flags().StringVarP(&opts.Owner, "owner", "o", "", "a GitHub organization to scope attestation lookup by")
|
||||
downloadCmd.MarkFlagRequired("owner") //nolint:errcheck
|
||||
downloadCmd.Flags().StringVarP(&opts.DigestAlgorithm, "digest-alg", "d", "sha256", "The algorithm used to compute a digest of the artifact (sha256 or sha512)")
|
||||
downloadCmd.Flags().IntVarP(&opts.Limit, "limit", "L", api.DefaultLimit, "Maximum number of attestations to fetch")
|
||||
downloadCmd.Flags().BoolVarP(&opts.Verbose, "verbose", "v", false, "If set to true, the CLI will output verbose information.")
|
||||
|
||||
return downloadCmd
|
||||
}
|
||||
|
||||
|
||||
func RunDownload(opts *Options) error {
|
||||
artifact, err := artifact.NewDigestedArtifact(opts.OCIClient, opts.ArtifactPath, opts.DigestAlgorithm)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to digest artifact: %w", err)
|
||||
}
|
||||
|
||||
opts.Logger.VerbosePrintf("Downloading trusted metadata for artifact %s\n\n", opts.ArtifactPath)
|
||||
attestations, err := opts.APIClient.GetByOwnerAndDigest(opts.Owner, artifact.DigestWithAlg(), opts.Limit)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch attestations: %w", err)
|
||||
}
|
||||
|
||||
if attestations == nil {
|
||||
fmt.Fprintf(opts.Logger.IO.Out, "No attestations found for %s\n", opts.ArtifactPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
filePath := createJSONLinesFilePath(artifact.DigestWithAlg(), opts.OutputPath)
|
||||
fmt.Fprintf(opts.Logger.IO.Out, "Writing attestations to file %s.\nAny previous content will be overwritten\n\n", filePath)
|
||||
|
||||
metadataFilePath, err := createMetadataFile(attestations, filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write attestation: %w", err)
|
||||
}
|
||||
|
||||
fmt.Fprint(opts.Logger.IO.Out,
|
||||
opts.Logger.ColorScheme.Greenf(
|
||||
"The trusted metadata is now available at %s\n", metadataFilePath,
|
||||
),
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func createJSONLinesFilePath(artifact, outputPath string) string {
|
||||
path := fmt.Sprintf("%s.jsonl", artifact)
|
||||
if outputPath != "" {
|
||||
return fmt.Sprintf("%s/%s", outputPath, path)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func createMetadataFile(attestationsResp []*api.Attestation, filePath string) (string, error) {
|
||||
f, err := os.Create(filePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create trusted metadata file: %w", err)
|
||||
}
|
||||
|
||||
for _, resp := range attestationsResp {
|
||||
bundle := resp.Bundle
|
||||
attBytes, err := json.Marshal(bundle)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshall attestation to JSON: %w", err)
|
||||
}
|
||||
|
||||
withNewline := fmt.Sprintf("%s\n", attBytes)
|
||||
_, err = f.Write([]byte(withNewline))
|
||||
if err != nil {
|
||||
if err = f.Close(); err != nil {
|
||||
return "", fmt.Errorf("failed to close file while handling write error: %w", err)
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("failed to write trusted metadata: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err = f.Close(); err != nil {
|
||||
return "", fmt.Errorf("failed ot close file after writing metadata: %w", err)
|
||||
}
|
||||
|
||||
return filePath, nil
|
||||
}
|
||||
173
pkg/cmd/attestation/download/download_test.go
Normal file
173
pkg/cmd/attestation/download/download_test.go
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
package download
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/cli/cli/v2/pkg/cmd/attestation/api"
|
||||
"github.com/cli/cli/v2/pkg/cmd/attestation/artifact"
|
||||
"github.com/cli/cli/v2/pkg/cmd/attestation/artifact/oci"
|
||||
"github.com/cli/cli/v2/pkg/cmd/attestation/logger"
|
||||
"github.com/cli/cli/v2/pkg/cmd/attestation/test"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRunDownload(t *testing.T) {
|
||||
res := test.SuppressAndRestoreOutput()
|
||||
defer res()
|
||||
|
||||
tempDir, err := os.MkdirTemp("", "gh-attestation-test")
|
||||
require.NoError(t, err)
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
baseOpts := Options{
|
||||
ArtifactPath: "../test/data/sigstore-js-2.1.0.tgz",
|
||||
APIClient: api.NewTestClient(),
|
||||
OCIClient: oci.NewMockClient(),
|
||||
DigestAlgorithm: "sha512",
|
||||
OutputPath: tempDir,
|
||||
Limit: 30,
|
||||
Logger: logger.NewDefaultLogger(),
|
||||
}
|
||||
|
||||
t.Run("fetch and store attestations successfully", func(t *testing.T) {
|
||||
err = RunDownload(&baseOpts)
|
||||
assert.NoError(t, err)
|
||||
|
||||
artifact, err := artifact.NewDigestedArtifact(baseOpts.OCIClient, baseOpts.ArtifactPath, baseOpts.DigestAlgorithm)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.FileExists(t, fmt.Sprintf("%s/%s.jsonl", tempDir, artifact.DigestWithAlg()))
|
||||
|
||||
actualLineCount, err := countLines(fmt.Sprintf("%s/%s.jsonl", tempDir, artifact.DigestWithAlg()))
|
||||
require.NoError(t, err)
|
||||
|
||||
expectedLineCount := 2
|
||||
assert.Equal(t, expectedLineCount, actualLineCount)
|
||||
|
||||
err = os.Remove(fmt.Sprintf("%s/%s.jsonl", tempDir, artifact.DigestWithAlg()))
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("download OCI image attestations successfully", func(t *testing.T) {
|
||||
opts := baseOpts
|
||||
opts.ArtifactPath = "oci://ghcr.io/github/test"
|
||||
|
||||
err = RunDownload(&opts)
|
||||
assert.NoError(t, err)
|
||||
|
||||
artifact, err := artifact.NewDigestedArtifact(opts.OCIClient, opts.ArtifactPath, opts.DigestAlgorithm)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.FileExists(t, fmt.Sprintf("%s/%s.jsonl", tempDir, artifact.DigestWithAlg()))
|
||||
|
||||
actualLineCount, err := countLines(fmt.Sprintf("%s/%s.jsonl", tempDir, artifact.DigestWithAlg()))
|
||||
require.NoError(t, err)
|
||||
|
||||
expectedLineCount := 2
|
||||
assert.Equal(t, expectedLineCount, actualLineCount)
|
||||
|
||||
err = os.Remove(fmt.Sprintf("%s/%s.jsonl", tempDir, artifact.DigestWithAlg()))
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("cannot find artifact", func(t *testing.T) {
|
||||
opts := baseOpts
|
||||
opts.ArtifactPath = "../test/data/not-real.zip"
|
||||
|
||||
err := RunDownload(&opts)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("no attestations found", func(t *testing.T) {
|
||||
opts := baseOpts
|
||||
opts.APIClient = api.MockClient{
|
||||
OnGetByOwnerAndDigest: func(repo, digest string, limit int) ([]*api.Attestation, error) {
|
||||
return nil, nil
|
||||
},
|
||||
}
|
||||
|
||||
err := RunDownload(&opts)
|
||||
assert.NoError(t, err)
|
||||
|
||||
artifact, err := artifact.NewDigestedArtifact(opts.OCIClient, opts.ArtifactPath, opts.DigestAlgorithm)
|
||||
require.NoError(t, err)
|
||||
assert.NoFileExists(t, artifact.DigestWithAlg())
|
||||
})
|
||||
|
||||
t.Run("cannot download OCI artifact", func(t *testing.T) {
|
||||
opts := baseOpts
|
||||
opts.ArtifactPath = "oci://ghcr.io/github/test"
|
||||
opts.OCIClient = oci.NewReferenceFailClient()
|
||||
|
||||
err := RunDownload(&opts)
|
||||
assert.Error(t, err)
|
||||
assert.ErrorContains(t, err, "failed to digest artifact")
|
||||
})
|
||||
}
|
||||
|
||||
func TestCreateJSONLinesFilePath(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "gh-attestation-test")
|
||||
require.NoError(t, err)
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
t.Run("with output path", func(t *testing.T) {
|
||||
artifact, err := artifact.NewDigestedArtifact(oci.NewMockClient(), "../test/data/sigstore-js-2.1.0.tgz", "sha512")
|
||||
require.NoError(t, err)
|
||||
path := createJSONLinesFilePath(artifact.DigestWithAlg(), tempDir)
|
||||
|
||||
expectedPath := fmt.Sprintf("%s/%s.jsonl", tempDir, artifact.DigestWithAlg())
|
||||
assert.Equal(t, expectedPath, path)
|
||||
})
|
||||
|
||||
t.Run("with nested output path", func(t *testing.T) {
|
||||
artifact, err := artifact.NewDigestedArtifact(oci.NewMockClient(), "../test/data/sigstore-js-2.1.0.tgz", "sha512")
|
||||
require.NoError(t, err)
|
||||
|
||||
nestedPath := fmt.Sprintf("%s/subdir", tempDir)
|
||||
path := createJSONLinesFilePath(artifact.DigestWithAlg(), nestedPath)
|
||||
|
||||
expectedPath := fmt.Sprintf("%s/subdir/%s.jsonl", tempDir, artifact.DigestWithAlg())
|
||||
assert.Equal(t, expectedPath, path)
|
||||
})
|
||||
|
||||
t.Run("with output path with beginning slash", func(t *testing.T) {
|
||||
artifact, err := artifact.NewDigestedArtifact(oci.NewMockClient(), "../test/data/sigstore-js-2.1.0.tgz", "sha512")
|
||||
require.NoError(t, err)
|
||||
|
||||
nestedPath := fmt.Sprintf("/%s/subdir", tempDir)
|
||||
path := createJSONLinesFilePath(artifact.DigestWithAlg(), nestedPath)
|
||||
|
||||
expectedPath := fmt.Sprintf("/%s/subdir/%s.jsonl", tempDir, artifact.DigestWithAlg())
|
||||
assert.Equal(t, expectedPath, path)
|
||||
})
|
||||
|
||||
t.Run("without output path", func(t *testing.T) {
|
||||
artifact, err := artifact.NewDigestedArtifact(oci.NewMockClient(), "../test/data/sigstore-js-2.1.0.tgz", "sha512")
|
||||
require.NoError(t, err)
|
||||
path := createJSONLinesFilePath(artifact.DigestWithAlg(), "")
|
||||
|
||||
expectedPath := fmt.Sprintf("%s.jsonl", artifact.DigestWithAlg())
|
||||
assert.Equal(t, expectedPath, path)
|
||||
})
|
||||
}
|
||||
|
||||
func countLines(path string) (int, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
counter := 0
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
counter += 1
|
||||
}
|
||||
|
||||
return counter, nil
|
||||
}
|
||||
55
pkg/cmd/attestation/download/options.go
Normal file
55
pkg/cmd/attestation/download/options.go
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
package download
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/cli/cli/v2/pkg/cmd/attestation/api"
|
||||
"github.com/cli/cli/v2/pkg/cmd/attestation/artifact/digest"
|
||||
"github.com/cli/cli/v2/pkg/cmd/attestation/artifact/oci"
|
||||
"github.com/cli/cli/v2/pkg/cmd/attestation/logger"
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
ArtifactPath string
|
||||
DigestAlgorithm string
|
||||
APIClient api.Client
|
||||
Logger *logger.Logger
|
||||
Limit int
|
||||
OCIClient oci.Client
|
||||
OutputPath string
|
||||
Owner string
|
||||
Verbose bool
|
||||
}
|
||||
|
||||
// ConfigureLogger configures a logger using configuration provided
|
||||
// through the options
|
||||
func (opts *Options) ConfigureLogger() {
|
||||
opts.Logger = logger.NewLogger(false, opts.Verbose)
|
||||
}
|
||||
|
||||
// ConfigureOCIClient configures an OCI client
|
||||
func (opts *Options) ConfigureOCIClient() {
|
||||
opts.OCIClient = oci.NewLiveClient()
|
||||
}
|
||||
|
||||
func (opts *Options) AreFlagsValid() error {
|
||||
if opts.Owner == "" {
|
||||
return fmt.Errorf("owner must be provided")
|
||||
}
|
||||
|
||||
// DigestAlgorithm must not be empty
|
||||
if opts.DigestAlgorithm == "" {
|
||||
return fmt.Errorf("digest-alg cannot be empty")
|
||||
}
|
||||
|
||||
if !digest.IsValidDigestAlgorithm(opts.DigestAlgorithm) {
|
||||
return fmt.Errorf("invalid digest algorithm '%s' provided in digest-alg", opts.DigestAlgorithm)
|
||||
}
|
||||
|
||||
// Check that limit is between 1 and 1000
|
||||
if opts.Limit < 1 || opts.Limit > 1000 {
|
||||
return fmt.Errorf("limit %d not allowed, must be between 1 and 1000", opts.Limit)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
53
pkg/cmd/attestation/download/options_test.go
Normal file
53
pkg/cmd/attestation/download/options_test.go
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
package download
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestAreFlagsValid(t *testing.T) {
|
||||
t.Run("missing Owner", func(t *testing.T) {
|
||||
opts := Options{
|
||||
DigestAlgorithm: "sha512",
|
||||
}
|
||||
|
||||
err := opts.AreFlagsValid()
|
||||
assert.Error(t, err)
|
||||
assert.ErrorContains(t, err, "owner must be provided")
|
||||
})
|
||||
|
||||
t.Run("missing DigestAlgorithm", func(t *testing.T) {
|
||||
opts := Options{
|
||||
Owner: "github",
|
||||
}
|
||||
|
||||
err := opts.AreFlagsValid()
|
||||
assert.Error(t, err)
|
||||
assert.ErrorContains(t, err, "digest-alg cannot be empty")
|
||||
})
|
||||
|
||||
t.Run("Limit is too low", func(t *testing.T) {
|
||||
opts := Options{
|
||||
DigestAlgorithm: "sha512",
|
||||
Limit: 0,
|
||||
Owner: "github",
|
||||
}
|
||||
|
||||
err := opts.AreFlagsValid()
|
||||
assert.Error(t, err)
|
||||
assert.ErrorContains(t, err, "limit 0 not allowed, must be between 1 and 1000")
|
||||
})
|
||||
|
||||
t.Run("Limit is too high", func(t *testing.T) {
|
||||
opts := Options{
|
||||
DigestAlgorithm: "sha512",
|
||||
Limit: 1001,
|
||||
Owner: "github",
|
||||
}
|
||||
|
||||
err := opts.AreFlagsValid()
|
||||
assert.Error(t, err)
|
||||
assert.ErrorContains(t, err, "limit 1001 not allowed, must be between 1 and 1000")
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue