diff --git a/cmd/kosli/list.go b/cmd/kosli/list.go index f4c335036..5b223e1b6 100644 --- a/cmd/kosli/list.go +++ b/cmd/kosli/list.go @@ -24,16 +24,6 @@ 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 215d0683e..e6dfe22bd 100644 --- a/cmd/kosli/listTrails.go +++ b/cmd/kosli/listTrails.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "net/http" + "strings" "github.com/kosli-dev/cli/internal/output" "github.com/kosli-dev/cli/internal/requests" @@ -13,7 +14,7 @@ import ( const listTrailsShortDesc = `List Trails of an org.` -const listTrailsLongDesc = listTrailsShortDesc + `The list can be filtered by flow and artifact fingerprint. The results are paginated and ordered from latest to oldest.` +const listTrailsLongDesc = listTrailsShortDesc + `The list can be filtered by flow, flow tag and artifact fingerprint. The results are paginated and ordered from latest to oldest.` const listTrailsExample = ` # get a paginated list of trails for a flow: @@ -49,13 +50,30 @@ kosli list trails \ --fingerprint yourArtifactFingerprint \ --api-token yourAPIToken \ --org yourOrgName \ - --output json \ + --output json + + # get a paginated list of trails across all flows tagged with the provided key-value pair: +kosli list trails \ + --flow-tag team=backend \ + --api-token yourAPIToken \ + --org yourOrgName ` type listTrailsOptions struct { listOptions flowName string fingerprint string + flowTag string +} + +func (o *listTrailsOptions) validate(cmd *cobra.Command) error { + if err := o.listOptions.validate(cmd); err != nil { + return err + } + if o.flowTag != "" && !strings.Contains(o.flowTag, "=") { + return ErrorBeforePrintingUsage(cmd, "flag '--flow-tag' must be in the format of key=value") + } + return nil } type Trail struct { @@ -88,7 +106,7 @@ func newListTrailsCmd(out io.Writer) *cobra.Command { if err != nil { return ErrorBeforePrintingUsage(cmd, err.Error()) } - return o.validateForListTrails(cmd) + return o.validate(cmd) }, RunE: func(cmd *cobra.Command, args []string) error { return o.run(out) @@ -97,6 +115,7 @@ func newListTrailsCmd(out io.Writer) *cobra.Command { cmd.Flags().StringVarP(&o.flowName, "flow", "f", "", flowNameFlagOptional) cmd.Flags().StringVarP(&o.fingerprint, "fingerprint", "F", "", fingerprintInTrailsFlag) + cmd.Flags().StringVarP(&o.flowTag, "flow-tag", "t", "", flowTagFlag) addListFlags(cmd, &o.listOptions, 20) return cmd @@ -110,6 +129,9 @@ func (o *listTrailsOptions) run(out io.Writer) error { if o.fingerprint != "" { url += fmt.Sprintf("&fingerprint=%s", o.fingerprint) } + if o.flowTag != "" { + url += fmt.Sprintf("&flow_tag=%s", o.flowTag) + } reqParams := &requests.RequestParams{ Method: http.MethodGet, diff --git a/cmd/kosli/listTrails_test.go b/cmd/kosli/listTrails_test.go index 06c5ec079..a3a6b00d9 100644 --- a/cmd/kosli/listTrails_test.go +++ b/cmd/kosli/listTrails_test.go @@ -15,6 +15,8 @@ type ListTrailsCommandTestSuite struct { flowName string trailName string fingerprint string + flowTagKey string + flowTagValue string defaultKosliArguments string acmeOrgKosliArguments string } @@ -27,18 +29,23 @@ func (suite *ListTrailsCommandTestSuite) SetupTest() { } suite.flowName = "list-trails" + suite.flowTagKey = "team" + suite.flowTagValue = "backend" suite.trailName = "trail-name" suite.fingerprint = "7509e5bda0c762d2bac7f90d758b5b2263fa01ccbc542ab5e3df163be08e6ca9" + + // First flow (tagged), trail and artifact for the default org 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(suite.trailName, suite.flowName, "", suite.T()) CreateArtifactOnTrail(suite.flowName, suite.trailName, "artifact", suite.fingerprint, "artifact-name", suite.T()) + TagFlow(suite.flowName, suite.flowTagKey, suite.flowTagValue, suite.T()) + // Second flow for the acme org global.Org = "acme-org" global.ApiToken = "v3OWZiYWu9G2IMQStYg9BcPQUQ88lJNNnTJTNq8jfvmkR1C5wVpHSs7F00JcB5i6OGeUzrKt3CwRq7ndcN4TTfMeo8ASVJ5NdHpZT7DkfRfiFvm8s7GbsIHh2PtiQJYs2UoN13T8DblV5C4oKb6-yWH73h67OhotPlKfVKazR-c" CreateFlowWithTemplate(suite.flowName, "testdata/valid_template.yml", suite.T()) suite.acmeOrgKosliArguments = fmt.Sprintf(" --host %s --org %s --api-token %s", global.Host, global.Org, global.ApiToken) - } func (suite *ListTrailsCommandTestSuite) TestListTrailsCmd() { @@ -91,6 +98,22 @@ func (suite *ListTrailsCommandTestSuite) TestListTrailsCmd() { cmd: fmt.Sprintf(`list trails --fingerprint %s --output json %s`, suite.fingerprint, suite.defaultKosliArguments), goldenJson: []jsonCheck{{"data", "non-empty"}}, }, + { + name: "10 can list trails in a flow with the provided tag", + cmd: fmt.Sprintf(`list trails --flow-tag %s=%s --output json %s`, suite.flowTagKey, suite.flowTagValue, suite.defaultKosliArguments), + goldenJson: []jsonCheck{{"data", "non-empty"}}, + }, + { + name: "11 listing trails with a non-existing flow-tag returns no trails", + cmd: fmt.Sprintf(`list trails --flow-tag non=existing %s`, suite.defaultKosliArguments), + golden: "No trails were found.\n", + }, + { + wantError: true, + name: "12 the value of the flow-tag flag must be a key-value pair", + cmd: fmt.Sprintf(`list trails --flow-tag %s --output json %s`, "invalid-tag", suite.defaultKosliArguments), + golden: "Error: flag '--flow-tag' must be in the format of key=value\nUsage: kosli list trails [flags]\n", + }, } runTestCmd(suite.T(), tests) diff --git a/cmd/kosli/root.go b/cmd/kosli/root.go index 5b3c1d54f..b81054ba5 100644 --- a/cmd/kosli/root.go +++ b/cmd/kosli/root.go @@ -93,6 +93,7 @@ The ^.kosli_ignore^ will be treated as part of the artifact like any other file, flowNameFlag = "The Kosli flow name." flowNameFlagOptional = "[optional] The Kosli flow name." fingerprintInTrailsFlag = "[optional] The SHA256 fingerprint of the artifact to filter trails by." + flowTagFlag = "[optional] A key=value flow tag 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/testHelpers.go b/cmd/kosli/testHelpers.go index c1e25fd40..9b1f2ebd1 100644 --- a/cmd/kosli/testHelpers.go +++ b/cmd/kosli/testHelpers.go @@ -595,3 +595,15 @@ func GetAttestationId(flowName, trailName, attestationName string, t *testing.T) require.True(t, ok, "attestation_id field not found or not a string") return id } + +// TagFlow tags a flow with a key-value pair +func TagFlow(flowName, tagKey, tagValue string, t *testing.T) { + t.Helper() + o := &tagOptions{ + payload: TagResourcePayload{ + SetTags: map[string]string{tagKey: tagValue}, + }, + } + err := o.run([]string{"flow", flowName}) + require.NoError(t, err, "flow should be tagged without error") +}