diff --git a/cmd/kosli/flags.go b/cmd/kosli/flags.go index b4010033b..e8996e1c3 100644 --- a/cmd/kosli/flags.go +++ b/cmd/kosli/flags.go @@ -105,10 +105,17 @@ func addEvidenceFlags(cmd *cobra.Command, payload *TypedEvidencePayload, ci stri cmd.Flags().StringVar(&payload.EvidenceURL, "evidence-url", "", evidenceURLFlag) } -func addListFlags(cmd *cobra.Command, o *listOptions) { +func addListFlags(cmd *cobra.Command, o *listOptions, customPageLimit ...int) { cmd.Flags().StringVarP(&o.output, "output", "o", "table", outputFlag) cmd.Flags().IntVar(&o.pageNumber, "page", 1, pageNumberFlag) - cmd.Flags().IntVarP(&o.pageLimit, "page-limit", "n", 15, pageLimitFlag) + + // Use customPageLimit if provided, otherwise default to 15 + pageLimit := 15 + if len(customPageLimit) > 0 { + pageLimit = customPageLimit[0] + } + + cmd.Flags().IntVarP(&o.pageLimit, "page-limit", "n", pageLimit, pageLimitFlag) } func addAttestationFlags(cmd *cobra.Command, o *CommonAttestationOptions, payload *CommonAttestationPayload, ci string) { diff --git a/cmd/kosli/list.go b/cmd/kosli/list.go index 177368b56..a66cda71a 100644 --- a/cmd/kosli/list.go +++ b/cmd/kosli/list.go @@ -24,6 +24,16 @@ func (o *listOptions) validate(cmd *cobra.Command) error { return nil } +func (o *listOptions) validateForListTrails(cmd *cobra.Command) error { + if o.pageNumber <= 0 { + return ErrorBeforePrintingUsage(cmd, "page number must be a positive integer") + } + if o.pageLimit < 0 { + return ErrorBeforePrintingUsage(cmd, "page limit must be a positive integer") + } + return nil +} + func newListCmd(out io.Writer) *cobra.Command { cmd := &cobra.Command{ Use: "list", diff --git a/cmd/kosli/listTrails.go b/cmd/kosli/listTrails.go index 201338dcc..7860c5359 100644 --- a/cmd/kosli/listTrails.go +++ b/cmd/kosli/listTrails.go @@ -11,26 +11,79 @@ import ( "github.com/spf13/cobra" ) -const listTrailsDesc = `List Trails for a Flow in an org.` +const listTrailsShortDesc = `List Trails for a Flow in an org.` + +const listTrailsLongDesc = listTrailsShortDesc + `The results are ordered from latest to oldest. +If the ^page-limit^ flag is provided, the results will be paginated, otherwise all results will be +returned. +If ^page-limit^ is set to 0, all results will be returned.` + +const listTrailsExample = ` +# list all trails for a flow: +kosli list trails \ + --flow yourFlowName \ + --api-token yourAPIToken \ + --org yourOrgName + +#list the most recent 30 trails for a flow: +kosli list trails \ + --flow yourFlowName \ + --page-limit 30 \ + --api-token yourAPIToken \ + --org yourOrgName + +#show the second page of trails for a flow: +kosli list trails \ + --flow yourFlowName \ + --page-limit 30 \ + --page 2 \ + --api-token yourAPIToken \ + --org yourOrgName + +# list all trails for a flow (in JSON): +kosli list trails \ + --flow yourFlowName \ + --api-token yourAPIToken \ + --org yourOrgName \ + --output json +` type listTrailsOptions struct { + listOptions flowName string - output string +} + +type Trail struct { + Name string `json:"name"` + Description string `json:"description"` + ComplianceState string `json:"compliance_state"` +} + +type Pagination struct { + Page float64 `json:"page"` + PageCount float64 `json:"page_count"` + Total float64 `json:"total"` +} + +type listTrailsResponse struct { + Data []Trail `json:"data"` + Pagination Pagination `json:"pagination"` } func newListTrailsCmd(out io.Writer) *cobra.Command { o := new(listTrailsOptions) cmd := &cobra.Command{ - Use: "trails", - Short: listTrailsDesc, - Long: listTrailsDesc, - Args: cobra.NoArgs, + Use: "trails", + Short: listTrailsShortDesc, + Long: listTrailsLongDesc, + Example: listTrailsExample, + Args: cobra.NoArgs, PreRunE: func(cmd *cobra.Command, args []string) error { err := RequireGlobalFlags(global, []string{"Org", "ApiToken"}) if err != nil { return ErrorBeforePrintingUsage(cmd, err.Error()) } - return nil + return o.validateForListTrails(cmd) }, RunE: func(cmd *cobra.Command, args []string) error { return o.run(out) @@ -38,7 +91,8 @@ func newListTrailsCmd(out io.Writer) *cobra.Command { } cmd.Flags().StringVarP(&o.flowName, "flow", "f", "", flowNameFlag) - cmd.Flags().StringVarP(&o.output, "output", "o", "table", outputFlag) + // We set the defauly page limit to 0 so that all results are returned if the flag is not provided + addListFlags(cmd, &o.listOptions, 0) err := RequireFlags(cmd, []string{"flow"}) if err != nil { @@ -49,7 +103,7 @@ func newListTrailsCmd(out io.Writer) *cobra.Command { } func (o *listTrailsOptions) run(out io.Writer) error { - url := fmt.Sprintf("%s/api/v2/trails/%s/%s", global.Host, global.Org, o.flowName) + url := fmt.Sprintf("%s/api/v2/trails/%s/%s?per_page=%d&page=%d", global.Host, global.Org, o.flowName, o.pageLimit, o.pageNumber) reqParams := &requests.RequestParams{ Method: http.MethodGet, @@ -61,7 +115,7 @@ func (o *listTrailsOptions) run(out io.Writer) error { return err } - return output.FormattedPrint(response.Body, o.output, out, 0, + return output.FormattedPrint(response.Body, o.output, out, o.pageNumber, map[string]output.FormatOutputFunc{ "table": printTrailsListAsTable, "json": output.PrintJson, @@ -69,23 +123,42 @@ func (o *listTrailsOptions) run(out io.Writer) error { } func printTrailsListAsTable(raw string, out io.Writer, page int) error { - var trails []map[string]interface{} + response := &listTrailsResponse{} + trails := []Trail{} + + // If using pagination, the response will have the format {data: [], pagination: {}} + // and therefore will not unmarshal into an array of Trail structs; instead, we need + // to unmarshal into a listTrailsResponse struct and extract the data field. err := json.Unmarshal([]byte(raw), &trails) if err != nil { - return err + err = json.Unmarshal([]byte(raw), &response) + if err != nil { + return err + } + trails = response.Data } if len(trails) == 0 { - logger.Info("No trails were found.") + msg := "No trails were found" + if page != 1 { + msg = fmt.Sprintf("%s at page number %d", msg, page) + } + logger.Info(msg + ".") return nil } header := []string{"NAME", "DESCRIPTION", "COMPLIANCE"} rows := []string{} for _, trail := range trails { - row := fmt.Sprintf("%s\t%s\t%s", trail["name"], trail["description"], trail["compliance_state"]) + row := fmt.Sprintf("%s\t%s\t%s", trail.Name, trail.Description, trail.ComplianceState) rows = append(rows, row) } + if len(response.Data) > 0 { + pagination := response.Pagination + paginationInfo := fmt.Sprintf("\nShowing page %.0f of %.0f, total %.0f items", pagination.Page, pagination.PageCount, pagination.Total) + rows = append(rows, paginationInfo) + } + tabFormattedPrint(out, header, rows) return nil diff --git a/cmd/kosli/listTrails_test.go b/cmd/kosli/listTrails_test.go index 5c25e2e25..1d9ea0bc0 100644 --- a/cmd/kosli/listTrails_test.go +++ b/cmd/kosli/listTrails_test.go @@ -63,6 +63,23 @@ func (suite *ListTrailsCommandTestSuite) TestListTrailsCmd() { cmd: fmt.Sprintf(`list trails xxx %s`, suite.defaultKosliArguments), golden: "Error: unknown command \"xxx\" for \"kosli list trails\"\n", }, + { + wantError: true, + name: "negative page limit causes an error", + cmd: fmt.Sprintf(`list trails --page-limit -1 %s`, suite.defaultKosliArguments), + golden: "Error: flag '--page-limit' has value '-1' which is illegal\n", + }, + { + wantError: true, + name: "negative page number causes an error", + cmd: fmt.Sprintf(`list trails --page -1 %s`, suite.defaultKosliArguments), + golden: "Error: flag '--page' has value '-1' which is illegal\n", + }, + { + name: "can list trails with pagination", + cmd: fmt.Sprintf(`list trails --page-limit 15 --page 2 %s`, suite.defaultKosliArguments), + golden: "", + }, } runTestCmd(suite.Suite.T(), tests)