diff --git a/cmd/kosli/listTrails.go b/cmd/kosli/listTrails.go index 7860c5359..215d0683e 100644 --- a/cmd/kosli/listTrails.go +++ b/cmd/kosli/listTrails.go @@ -11,28 +11,25 @@ import ( "github.com/spf13/cobra" ) -const listTrailsShortDesc = `List Trails for a Flow in an org.` +const listTrailsShortDesc = `List Trails of 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 listTrailsLongDesc = listTrailsShortDesc + `The list can be filtered by flow and artifact fingerprint. The results are paginated and ordered from latest to oldest.` const listTrailsExample = ` -# list all trails for a flow: +# get a paginated list of trails for a flow: kosli list trails \ --flow yourFlowName \ --api-token yourAPIToken \ --org yourOrgName -#list the most recent 30 trails for a flow: +# 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: +# show the second page of trails for a flow: kosli list trails \ --flow yourFlowName \ --page-limit 30 \ @@ -40,17 +37,25 @@ kosli list trails \ --api-token yourAPIToken \ --org yourOrgName -# list all trails for a flow (in JSON): +# get a paginated list of trails for a flow (in JSON): kosli list trails \ --flow yourFlowName \ --api-token yourAPIToken \ --org yourOrgName \ --output json + +# get a paginated list of trails across all flows that contain an artifact with the provided fingerprint (in JSON): +kosli list trails \ + --fingerprint yourArtifactFingerprint \ + --api-token yourAPIToken \ + --org yourOrgName \ + --output json \ ` type listTrailsOptions struct { listOptions - flowName string + flowName string + fingerprint string } type Trail struct { @@ -90,20 +95,21 @@ func newListTrailsCmd(out io.Writer) *cobra.Command { }, } - cmd.Flags().StringVarP(&o.flowName, "flow", "f", "", flowNameFlag) - // 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 { - logger.Error("failed to configure required flags: %v", err) - } + cmd.Flags().StringVarP(&o.flowName, "flow", "f", "", flowNameFlagOptional) + cmd.Flags().StringVarP(&o.fingerprint, "fingerprint", "F", "", fingerprintInTrailsFlag) + addListFlags(cmd, &o.listOptions, 20) return cmd } func (o *listTrailsOptions) run(out io.Writer) error { - url := fmt.Sprintf("%s/api/v2/trails/%s/%s?per_page=%d&page=%d", global.Host, global.Org, o.flowName, o.pageLimit, o.pageNumber) + url := fmt.Sprintf("%s/api/v2/trails/%s?per_page=%d&page=%d", global.Host, global.Org, o.pageLimit, o.pageNumber) + if o.flowName != "" { + url += fmt.Sprintf("&flow=%s", o.flowName) + } + if o.fingerprint != "" { + url += fmt.Sprintf("&fingerprint=%s", o.fingerprint) + } reqParams := &requests.RequestParams{ Method: http.MethodGet, @@ -124,19 +130,11 @@ func (o *listTrailsOptions) run(out io.Writer) error { func printTrailsListAsTable(raw string, out io.Writer, page int) error { 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) + err := json.Unmarshal([]byte(raw), response) if err != nil { - err = json.Unmarshal([]byte(raw), &response) - if err != nil { - return err - } - trails = response.Data + return err } + trails := response.Data if len(trails) == 0 { msg := "No trails were found" @@ -153,11 +151,9 @@ func printTrailsListAsTable(raw string, out io.Writer, page int) error { 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) - } + 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) diff --git a/cmd/kosli/listTrails_test.go b/cmd/kosli/listTrails_test.go index 1ae06f7d0..06c5ec079 100644 --- a/cmd/kosli/listTrails_test.go +++ b/cmd/kosli/listTrails_test.go @@ -13,6 +13,8 @@ import ( type ListTrailsCommandTestSuite struct { suite.Suite flowName string + trailName string + fingerprint string defaultKosliArguments string acmeOrgKosliArguments string } @@ -20,66 +22,75 @@ type ListTrailsCommandTestSuite struct { func (suite *ListTrailsCommandTestSuite) SetupTest() { global = &GlobalOpts{ ApiToken: "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6ImNkNzg4OTg5In0.e8i_lA_QrEhFncb05Xw6E_tkCHU9QfcY4OLTVUCHffY", - Org: "docs-cmd-test-user", + Org: `docs-cmd-test-user`, Host: "http://localhost:8001", } suite.flowName = "list-trails" - suite.defaultKosliArguments = fmt.Sprintf(" --flow %s --host %s --org %s --api-token %s", suite.flowName, global.Host, global.Org, global.ApiToken) + suite.trailName = "trail-name" + suite.fingerprint = "7509e5bda0c762d2bac7f90d758b5b2263fa01ccbc542ab5e3df163be08e6ca9" + suite.defaultKosliArguments = fmt.Sprintf(" --host %s --org %s --api-token %s", global.Host, global.Org, global.ApiToken) CreateFlowWithTemplate(suite.flowName, "testdata/valid_template.yml", suite.T()) - BeginTrail("trail-name", suite.flowName, "", suite.T()) + BeginTrail(suite.trailName, suite.flowName, "", suite.T()) + CreateArtifactOnTrail(suite.flowName, suite.trailName, "artifact", suite.fingerprint, "artifact-name", suite.T()) global.Org = "acme-org" global.ApiToken = "v3OWZiYWu9G2IMQStYg9BcPQUQ88lJNNnTJTNq8jfvmkR1C5wVpHSs7F00JcB5i6OGeUzrKt3CwRq7ndcN4TTfMeo8ASVJ5NdHpZT7DkfRfiFvm8s7GbsIHh2PtiQJYs2UoN13T8DblV5C4oKb6-yWH73h67OhotPlKfVKazR-c" CreateFlowWithTemplate(suite.flowName, "testdata/valid_template.yml", suite.T()) - suite.acmeOrgKosliArguments = fmt.Sprintf(" --flow %s --host %s --org %s --api-token %s", suite.flowName, global.Host, global.Org, global.ApiToken) + suite.acmeOrgKosliArguments = fmt.Sprintf(" --host %s --org %s --api-token %s", global.Host, global.Org, global.ApiToken) + } func (suite *ListTrailsCommandTestSuite) TestListTrailsCmd() { tests := []cmdTestCase{ { - name: "listing trails works when there are trails", - cmd: fmt.Sprintf(`list trails %s`, suite.defaultKosliArguments), - golden: "", + name: "1 listing trails works when there are trails", + cmd: fmt.Sprintf(`list trails --flow %s %s`, suite.flowName, suite.defaultKosliArguments), + goldenFile: "output/list/list-trails.txt", }, { - name: "listing trails works when there are no trails", - cmd: fmt.Sprintf(`list trails %s`, suite.acmeOrgKosliArguments), + name: "2 listing trails works when there are no trails", + cmd: fmt.Sprintf(`list trails --flow %s %s`, suite.flowName, suite.acmeOrgKosliArguments), golden: "No trails were found.\n", }, { - name: "listing trails with --output json works when there are trails", - cmd: fmt.Sprintf(`list trails --output json %s`, suite.defaultKosliArguments), - goldenJson: []jsonCheck{{"", "non-empty"}}, + name: "3 listing trails with --output json works when there are trails", + cmd: fmt.Sprintf(`list trails --flow %s --output json %s`, suite.flowName, suite.defaultKosliArguments), + goldenJson: []jsonCheck{{"data", "non-empty"}}, }, { - name: "listing trails with --output json works when there are no trails", - cmd: fmt.Sprintf(`list trails --output json %s`, suite.acmeOrgKosliArguments), - goldenJson: []jsonCheck{{"", "[]"}}, + name: "4 listing trails with --output json works when there are no trails", + cmd: fmt.Sprintf(`list trails --flow %s --output json %s`, suite.flowName, suite.acmeOrgKosliArguments), + goldenJson: []jsonCheck{{"data", "[]"}}, }, { wantError: true, - name: "providing an argument causes an error", + name: "5 providing an argument causes an error", 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), + name: "6 negative page limit causes an error", + cmd: fmt.Sprintf(`list trails --flow %s --page-limit -1 %s`, suite.flowName, 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), + name: "7 negative page number causes an error", + cmd: fmt.Sprintf(`list trails --flow %s --page -1 %s`, suite.flowName, 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), + name: "8 can list trails with pagination", + cmd: fmt.Sprintf(`list trails --flow %s --page-limit 15 --page 2 %s`, suite.flowName, suite.defaultKosliArguments), golden: "", }, + { + name: "9 can list trails that contain an artifact with the provided fingerprint", + cmd: fmt.Sprintf(`list trails --fingerprint %s --output json %s`, suite.fingerprint, suite.defaultKosliArguments), + goldenJson: []jsonCheck{{"data", "non-empty"}}, + }, } runTestCmd(suite.T(), tests) diff --git a/cmd/kosli/root.go b/cmd/kosli/root.go index 7bdaf9908..5b3c1d54f 100644 --- a/cmd/kosli/root.go +++ b/cmd/kosli/root.go @@ -91,6 +91,8 @@ The ^.kosli_ignore^ will be treated as part of the artifact like any other file, debugFlag = "[optional] Print debug logs to stdout. A boolean flag https://docs.kosli.com/faq/#boolean-flags (default false)" artifactTypeFlag = "The type of the artifact to calculate its SHA256 fingerprint. One of: [oci, docker, file, dir]. Only required if you want Kosli to calculate the fingerprint for you (i.e. when you don't specify '--fingerprint' on commands that allow it)." flowNameFlag = "The Kosli flow name." + flowNameFlagOptional = "[optional] The Kosli flow name." + fingerprintInTrailsFlag = "[optional] The SHA256 fingerprint of the artifact to filter trails by." trailNameFlag = "The Kosli trail name." trailNameFlagOptional = "[optional] The Kosli trail name." templateArtifactName = "The name of the artifact in the yml template file." diff --git a/cmd/kosli/testdata/output/list/list-trails.txt b/cmd/kosli/testdata/output/list/list-trails.txt new file mode 100644 index 000000000..b5527f3cf --- /dev/null +++ b/cmd/kosli/testdata/output/list/list-trails.txt @@ -0,0 +1,4 @@ +NAME DESCRIPTION COMPLIANCE +trail-name test trail INCOMPLETE + +Showing page 1 of 1, total 1 items