diff --git a/cmd/kosli/getRegoPolicy.go b/cmd/kosli/getRegoPolicy.go index 259b024de..16ba8bd1f 100644 --- a/cmd/kosli/getRegoPolicy.go +++ b/cmd/kosli/getRegoPolicy.go @@ -2,9 +2,12 @@ package main import ( "encoding/json" + "errors" "fmt" "io" + "io/fs" "net/http" + "os" "github.com/kosli-dev/cli/internal/output" "github.com/kosli-dev/cli/internal/regopolicies" @@ -21,7 +24,11 @@ one an evaluation runs, so it can be older than the newest version. With ^--policy-version^, that version is shown with its status, its digest, who published it and when, and the reason when it is rejected or failed. The command -exits 0 whatever the status of the version.` +exits 0 whatever the status of the version. + +With ^--download^ and ^--policy-version^, the bundle of that version is written to the +given file as a gzipped tar, unchanged and whatever the status of the version, in place +of printing it. An existing file is not overwritten.` const getRegoPolicyExample = ` # get a rego policy and its current version: @@ -35,6 +42,13 @@ kosli get rego-policy pr-approved \ --api-token yourAPIToken \ --org yourOrgName +# download the bundle of version 3 of a rego policy to ./pr-approved-v3.tgz: +kosli get rego-policy pr-approved \ + --policy-version 3 \ + --download ./pr-approved-v3.tgz \ + --api-token yourAPIToken \ + --org yourOrgName + # get version 3 of a rego policy as JSON: kosli get rego-policy pr-approved \ --policy-version 3 \ @@ -44,8 +58,9 @@ kosli get rego-policy pr-approved \ ` type getRegoPolicyOptions struct { - output string - version int + output string + version int + download string } func newGetRegoPolicyCmd(out io.Writer) *cobra.Command { @@ -67,15 +82,22 @@ func newGetRegoPolicyCmd(out io.Writer) *cobra.Command { if cmd.Flags().Changed("policy-version") && o.version < 1 { return ErrorBeforePrintingUsage(cmd, "--policy-version must be a positive integer") } + if o.download != "" && o.version == 0 { + return ErrorBeforePrintingUsage(cmd, "--download needs --policy-version") + } return nil }, RunE: func(cmd *cobra.Command, args []string) error { + if o.download != "" { + return o.runDownload(out, args[0]) + } return o.run(out, args[0]) }, } cmd.Flags().IntVar(&o.version, "policy-version", 0, regoPolicyVersionFlag) cmd.Flags().StringVarP(&o.output, "output", "o", "table", outputFlag) + cmd.Flags().StringVar(&o.download, "download", "", regoPolicyDownloadFlag) return cmd } @@ -111,6 +133,46 @@ func (o *getRegoPolicyOptions) run(out io.Writer, identifier string) error { }) } +func (o *getRegoPolicyOptions) runDownload(out io.Writer, identifier string) error { + endpoint, err := regopolicies.BundleURL(global.Host, global.Org, identifier, o.version) + if err != nil { + return err + } + response, err := kosliClient.Do(&requests.RequestParams{ + Method: http.MethodGet, + URL: endpoint, + Token: global.ApiToken, + }) + if err != nil { + return err + } + if err := writeNewFile(o.download, []byte(response.Body)); err != nil { + return err + } + _, err = fmt.Fprintf(out, "Downloaded version %d of rego policy '%s' to %s\n", + o.version, identifier, o.download) + return err +} + +func writeNewFile(path string, content []byte) error { + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) + if errors.Is(err, fs.ErrExist) { + return fmt.Errorf("%s already exists; remove it or choose another file", path) + } + if err != nil { + return err + } + _, err = file.Write(content) + if closeErr := file.Close(); err == nil { + err = closeErr + } + if err != nil { + // A partial file would make a retry fail as "already exists". + _ = os.Remove(path) + } + return err +} + func printRegoPolicyAsTable(raw string, out io.Writer, page int) error { var policy map[string]any if err := json.Unmarshal([]byte(raw), &policy); err != nil { diff --git a/cmd/kosli/getRegoPolicy_test.go b/cmd/kosli/getRegoPolicy_test.go index 1af1f083e..c87ae3928 100644 --- a/cmd/kosli/getRegoPolicy_test.go +++ b/cmd/kosli/getRegoPolicy_test.go @@ -1,9 +1,14 @@ package main import ( + "archive/tar" + "bytes" + "compress/gzip" "fmt" "io" "net/http" + "os" + "path/filepath" "testing" "github.com/stretchr/testify/require" @@ -236,6 +241,92 @@ func (suite *GetRegoPolicyCommandTestSuite) TestItReturnsANotFoundInTheServersWo require.Contains(suite.T(), combined, "Rego policy 'pr-approved' has no version 9 in organization 'test-org'") } +// A gzipped tar, as the server sends it; the command must not look inside it. +func regoPolicyBundle(t *testing.T) string { + t.Helper() + var archive bytes.Buffer + zipped := gzip.NewWriter(&archive) + tw := tar.NewWriter(zipped) + body := "package policy\n" + require.NoError(t, tw.WriteHeader(&tar.Header{Name: "policy.rego", Mode: 0o644, Size: int64(len(body)), Typeflag: tar.TypeReg})) + _, err := tw.Write([]byte(body)) + require.NoError(t, err) + require.NoError(t, tw.Close()) + require.NoError(t, zipped.Close()) + return archive.String() +} + +func (suite *GetRegoPolicyCommandTestSuite) TestItDownloadsTheBundleOfAVersionToAFile() { + bundle := regoPolicyBundle(suite.T()) + server, fake := newFakeRegoPolicyRead(suite.T(), bundle) + file := filepath.Join(suite.T().TempDir(), "pr-approved-v3.tgz") + + _, combined, _, _, err := executeCommandC(suite.getCmd(server.URL, + "pr-approved --policy-version 3 --download "+file)) + + require.NoError(suite.T(), err) + require.Equal(suite.T(), "/api/v2/rego-policies/test-org/pr-approved/versions/3/bundle", fake.escapedPath) + require.Equal(suite.T(), + fmt.Sprintf("Downloaded version 3 of rego policy 'pr-approved' to %s\n", file), + combined) + written, err := os.ReadFile(file) + require.NoError(suite.T(), err) + require.Equal(suite.T(), bundle, string(written)) +} + +func (suite *GetRegoPolicyCommandTestSuite) TestItRefusesToOverwriteAnExistingFile() { + server, _ := newFakeRegoPolicyRead(suite.T(), regoPolicyBundle(suite.T())) + file := filepath.Join(suite.T().TempDir(), "pr-approved-v3.tgz") + require.NoError(suite.T(), os.WriteFile(file, []byte("mine\n"), 0o644)) + + _, combined, _, _, err := executeCommandC(suite.getCmd(server.URL, + "pr-approved --policy-version 3 --download "+file)) + + require.Error(suite.T(), err) + require.Contains(suite.T(), combined, file+" already exists; remove it or choose another file") + kept, err := os.ReadFile(file) + require.NoError(suite.T(), err) + require.Equal(suite.T(), "mine\n", string(kept)) +} + +func (suite *GetRegoPolicyCommandTestSuite) TestItRefusesADownloadWithoutAVersion() { + server, fake := newFakeRegoPolicyRead(suite.T(), regoPolicyWithCurrentVersion) + + _, combined, _, _, err := executeCommandC(suite.getCmd(server.URL, + "pr-approved --download "+filepath.Join(suite.T().TempDir(), "pr-approved.tgz"))) + + require.Error(suite.T(), err) + require.Contains(suite.T(), combined, "--download needs --policy-version") + require.Equal(suite.T(), 0, fake.calls) +} + +// An unset variable in --download "$OUT" must fail, not fall back to printing the version. +func (suite *GetRegoPolicyCommandTestSuite) TestItRefusesAnEmptyDownloadFile() { + server, fake := newFakeRegoPolicyRead(suite.T(), regoPolicyValid) + + _, combined, _, _, err := executeCommandC(suite.getCmd(server.URL, + `pr-approved --policy-version 1 --download ""`)) + + require.Error(suite.T(), err) + require.Contains(suite.T(), combined, "flag '--download' was given an empty value") + require.Equal(suite.T(), 0, fake.calls) +} + +func (suite *GetRegoPolicyCommandTestSuite) TestItWritesNothingWhenTheVersionIsNotFound() { + server, fake := newFakeRegoPolicyRead(suite.T(), + `{"message":"Rego policy 'pr-approved' has no version 9 in organization 'test-org'"}`) + fake.status = http.StatusNotFound + file := filepath.Join(suite.T().TempDir(), "pr-approved-v9.tgz") + + _, combined, _, _, err := executeCommandC(suite.getCmd(server.URL, + "pr-approved --policy-version 9 --download "+file)) + + require.Error(suite.T(), err) + require.Contains(suite.T(), combined, "Rego policy 'pr-approved' has no version 9 in organization 'test-org'") + require.Equal(suite.T(), 1, fake.calls) + require.NoFileExists(suite.T(), file) +} + func TestGetRegoPolicyCommandTestSuite(t *testing.T) { suite.Run(t, new(GetRegoPolicyCommandTestSuite)) } diff --git a/cmd/kosli/root.go b/cmd/kosli/root.go index 37895be14..c69458649 100644 --- a/cmd/kosli/root.go +++ b/cmd/kosli/root.go @@ -164,6 +164,7 @@ Paths the list already matches stay excluded whatever is later added there, so k regoPolicyDescriptionFlag = "[optional] The description of the rego policy. Without it the current description is kept." regoPolicyCommentFlag = "[optional] Why this version is published. It is kept with the version." regoPolicyVersionFlag = "[optional] The version of the rego policy to get. Without it, the policy and its current version are shown." + regoPolicyDownloadFlag = "[optional] The file to write the bundle of the version given by --policy-version to, as a gzipped tar. An existing file is not overwritten." regoPolicySearchFlag = "[optional] Only list rego policies whose name or identifier contains this substring (case-insensitive)." regoPolicySortFlag = "[optional] The field to sort rego policies by. Valid values are: [updated_at, identifier]. (defaults to updated_at)" regoPolicySortDirectionFlag = "[optional] The direction to sort rego policies in. Valid values are: [asc, desc]. (defaults to desc)" diff --git a/cmd/kosli/testdata/empty-flag-audit-coverage.json b/cmd/kosli/testdata/empty-flag-audit-coverage.json index 0a09464af..d2247dba3 100644 --- a/cmd/kosli/testdata/empty-flag-audit-coverage.json +++ b/cmd/kosli/testdata/empty-flag-audit-coverage.json @@ -679,6 +679,7 @@ "output": "string" }, "get rego-policy": { + "download": "string", "output": "string", "policy-version": "int" }, diff --git a/hack/empty-flag-audit/spec.json b/hack/empty-flag-audit/spec.json index 64f0bc553..bd3a1cf80 100644 --- a/hack/empty-flag-audit/spec.json +++ b/hack/empty-flag-audit/spec.json @@ -1562,10 +1562,12 @@ "error": "the server the audit runs against does not offer the rego policy endpoints", "needs": "server-side evaluation", "flags_to_test": [ + "download", "output", "policy-version" ], "flag_values": { + "download": "probe-bundle", "output": "json", "policy-version": "1" }, diff --git a/internal/regopolicies/client.go b/internal/regopolicies/client.go index 887ba695f..1d2bef1a1 100644 --- a/internal/regopolicies/client.go +++ b/internal/regopolicies/client.go @@ -157,6 +157,15 @@ func VersionURL(host, org, identifier string, version int) (string, error) { return url.JoinPath(endpoint, "versions", strconv.Itoa(version)) } +// BundleURL is the endpoint of the gzipped tar of one version's files. +func BundleURL(host, org, identifier string, version int) (string, error) { + endpoint, err := VersionURL(host, org, identifier, version) + if err != nil { + return "", err + } + return url.JoinPath(endpoint, "bundle") +} + // The server refuses unknown fields, so the wire shape is stated here exactly. type publishPayload struct { Name string `json:"name,omitempty"` diff --git a/internal/regopolicies/urls_test.go b/internal/regopolicies/urls_test.go index 5a2aa698a..261ce1521 100644 --- a/internal/regopolicies/urls_test.go +++ b/internal/regopolicies/urls_test.go @@ -34,3 +34,10 @@ func TestListURLNamesTheOrg(t *testing.T) { require.NoError(t, err) require.Equal(t, "https://app.kosli.com/api/v2/rego-policies/my%20org", endpoint) } + +func TestBundleURLNamesTheBundleOfTheVersion(t *testing.T) { + endpoint, err := BundleURL("https://app.kosli.com", "my-org", "pr-approved", 3) + + require.NoError(t, err) + require.Equal(t, "https://app.kosli.com/api/v2/rego-policies/my-org/pr-approved/versions/3/bundle", endpoint) +}