From be3356a328532e366bff90be50c4e60c5d7cce1a Mon Sep 17 00:00:00 2001 From: Peter Beckham Date: Tue, 30 Jun 2026 12:24:06 +0100 Subject: [PATCH 01/13] feat(controls): add 'kosli create control' command Add `kosli create control --name [--description]` which POSTs to /api/v2/controls/{org}, the first slice of CLI parity for the controls API (#5742). Also fix 409 handling in the shared HTTP client: 409 is retried by default (transient lock conflicts), but control creation uses 409 for a duplicate identifier, a permanent error. Add a per-request RequestParams.DisableConflictRetry (threaded via request context) so control-create surfaces the duplicate error immediately instead of retrying and reporting "giving up after N attempts". Lock-conflict retries are preserved for all other requests. Co-Authored-By: Claude Opus 4.8 --- cmd/kosli/create.go | 1 + cmd/kosli/createControl.go | 93 ++++++++++++++++++++++++++++++ cmd/kosli/createControl_test.go | 65 +++++++++++++++++++++ cmd/kosli/root.go | 2 + internal/requests/requests.go | 19 +++++- internal/requests/requests_test.go | 14 +++++ 6 files changed, 193 insertions(+), 1 deletion(-) create mode 100644 cmd/kosli/createControl.go create mode 100644 cmd/kosli/createControl_test.go diff --git a/cmd/kosli/create.go b/cmd/kosli/create.go index 7e4ca5f30..5db4fa714 100644 --- a/cmd/kosli/create.go +++ b/cmd/kosli/create.go @@ -22,6 +22,7 @@ func newCreateCmd(out io.Writer) *cobra.Command { newCreateFlowCmd(out), newCreatePolicyCmd(out), newCreateAttestationTypeCmd(out), + newCreateControlCmd(out), newCreateApiKeyCmd(out), newCreateServiceAccountCmd(out), ) diff --git a/cmd/kosli/createControl.go b/cmd/kosli/createControl.go new file mode 100644 index 000000000..42bbb134e --- /dev/null +++ b/cmd/kosli/createControl.go @@ -0,0 +1,93 @@ +package main + +import ( + "io" + "net/http" + "net/url" + + "github.com/kosli-dev/cli/internal/requests" + "github.com/spf13/cobra" +) + +const createControlShortDesc = `Create a Kosli control.` + +const createControlLongDesc = createControlShortDesc + ` + +^CONTROL-IDENTIFIER^ must start with a letter or number, and only contain letters, numbers, ^.^, ^-^, ^_^, and ^~^. +` + +const createControlExample = ` +# create a Kosli control: +kosli create control yourControlIdentifier \ + --name "Your control name" \ + --description "what this control checks" \ + --api-token yourAPIToken \ + --org yourOrgName +` + +type createControlOptions struct { + payload ControlPayload +} + +type ControlPayload struct { + Identifier string `json:"identifier"` + Name string `json:"name"` + Description string `json:"description,omitempty"` +} + +func newCreateControlCmd(out io.Writer) *cobra.Command { + o := new(createControlOptions) + cmd := &cobra.Command{ + Use: "control CONTROL-IDENTIFIER", + Short: createControlShortDesc, + Long: createControlLongDesc, + Example: createControlExample, + Args: cobra.ExactArgs(1), + PreRunE: func(cmd *cobra.Command, args []string) error { + err := RequireGlobalFlags(global, []string{"Org", "ApiToken"}) + if err != nil { + return ErrorBeforePrintingUsage(cmd, err.Error()) + } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + return o.run(args) + }, + } + + cmd.Flags().StringVarP(&o.payload.Name, "name", "n", "", controlNameFlag) + cmd.Flags().StringVarP(&o.payload.Description, "description", "d", "", controlDescriptionFlag) + + addDryRunFlag(cmd) + + err := RequireFlags(cmd, []string{"name"}) + if err != nil { + logger.Error("failed to configure required flags: %v", err) + } + + return cmd +} + +func (o *createControlOptions) run(args []string) error { + o.payload.Identifier = args[0] + url, err := url.JoinPath(global.Host, "api/v2/controls", global.Org) + if err != nil { + return err + } + + reqParams := &requests.RequestParams{ + Method: http.MethodPost, + URL: url, + Payload: o.payload, + DryRun: global.DryRun, + Token: global.ApiToken, + // A 409 here means the identifier already exists — a permanent error, + // so surface it immediately rather than retrying. + DisableConflictRetry: true, + } + _, err = kosliClient.Do(reqParams) + if err == nil && !global.DryRun { + logger.Info("control %s was created", o.payload.Identifier) + } + return err +} diff --git a/cmd/kosli/createControl_test.go b/cmd/kosli/createControl_test.go new file mode 100644 index 000000000..27423576e --- /dev/null +++ b/cmd/kosli/createControl_test.go @@ -0,0 +1,65 @@ +package main + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/suite" +) + +type CreateControlTestSuite struct { + suite.Suite + defaultKosliArguments string +} + +func (suite *CreateControlTestSuite) SetupTest() { + global = &GlobalOpts{ + ApiToken: "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6ImNkNzg4OTg5In0.e8i_lA_QrEhFncb05Xw6E_tkCHU9QfcY4OLTVUCHffY", + Org: "docs-cmd-test-user", + Host: "http://localhost:8001", + } + suite.defaultKosliArguments = fmt.Sprintf(" --host %s --org %s --api-token %s", global.Host, global.Org, global.ApiToken) +} + +func (suite *CreateControlTestSuite) TestCreateControlCmd() { + tests := []cmdTestCase{ + { + wantError: true, + name: "fails when no identifier argument is provided", + cmd: "create control --name 'My Control'" + suite.defaultKosliArguments, + golden: "Error: accepts 1 arg(s), received 0\n", + }, + { + wantError: true, + name: "fails when --name is missing", + cmd: "create control my-control" + suite.defaultKosliArguments, + golden: "Error: required flag(s) \"name\" not set\n", + }, + { + wantError: false, + name: "creates a control with identifier and --name", + cmd: "create control my-control --name 'My Control'" + suite.defaultKosliArguments, + golden: "control my-control was created\n", + }, + { + wantError: false, + name: "creates a control with a --description", + cmd: "create control my-control-2 --name 'My Second Control' --description 'checks something'" + suite.defaultKosliArguments, + golden: "control my-control-2 was created\n", + }, + { + // Relies on "my-control" created by the earlier test case (cases run + // sequentially against the same server within the suite). + wantError: true, + name: "fails with a clear error when the identifier already exists", + cmd: "create control my-control --name 'My Control'" + suite.defaultKosliArguments, + goldenRegex: "^Error: A control with identifier 'my-control' already exists in organization", + }, + } + + runTestCmd(suite.T(), tests) +} + +func TestCreateControlTestSuite(t *testing.T) { + suite.Run(t, new(CreateControlTestSuite)) +} diff --git a/cmd/kosli/root.go b/cmd/kosli/root.go index 23ded268b..922d36de8 100644 --- a/cmd/kosli/root.go +++ b/cmd/kosli/root.go @@ -298,6 +298,8 @@ The ^.kosli_ignore^ will be treated as part of the artifact like any other file, attestationTypeDescriptionFlag = "[optional] The attestation type description." attestationTypeSchemaFlag = "[optional] Path to the attestation type schema in JSON Schema format." attestationTypeJqFlag = "[optional] The attestation type evaluation JQ rules." + controlNameFlag = "[required] The control name." + controlDescriptionFlag = "[optional] The control description." envNameFlag = "The Kosli environment name to assert the artifact against." pathsWatchFlag = "[optional] Watch the filesystem for changes and report snapshots of artifacts running in specific filesystem paths to Kosli." getAttestationFingerprintFlag = "[conditional] The fingerprint of the artifact for the attestation. Cannot be used together with --trail or --attestation-id." diff --git a/internal/requests/requests.go b/internal/requests/requests.go index 9ab5a8287..06b027d61 100644 --- a/internal/requests/requests.go +++ b/internal/requests/requests.go @@ -97,8 +97,17 @@ type RequestParams struct { Password string Token string DryRun bool + // DisableConflictRetry stops the client retrying on HTTP 409 for this + // request. By default 409 is retried (it signals a transient lock + // conflict), but some endpoints use 409 for a permanent client error + // (e.g. a duplicate identifier) that should be surfaced immediately. + DisableConflictRetry bool } +type contextKey string + +const disableConflictRetryKey contextKey = "disableConflictRetry" + func (p *RequestParams) newHTTPRequest() (*http.Request, map[string]any, error) { if len(p.AdditionalHeaders) == 0 { p.AdditionalHeaders = make(map[string]string) @@ -132,6 +141,10 @@ func (p *RequestParams) newHTTPRequest() (*http.Request, map[string]any, error) return nil, nil, fmt.Errorf("failed to create %s request to %s : %v", p.Method, p.URL, err) } + if p.DisableConflictRetry { + req = req.WithContext(context.WithValue(req.Context(), disableConflictRetryKey, true)) + } + req.Header.Set("Content-Type", "application/json; charset=utf-8") req.Header.Set("User-Agent", "Kosli/"+version.GetVersion()) @@ -340,8 +353,12 @@ func customCheckRetry(ctx context.Context, resp *http.Response, err error) (bool if shouldRetry { return true, nil } - // The server gives 409 if we have a lock conflict. + // The server gives 409 if we have a lock conflict, so retry — unless the + // request opted out (some endpoints use 409 for a permanent client error). if resp != nil && resp.StatusCode == 409 { + if disable, ok := ctx.Value(disableConflictRetryKey).(bool); ok && disable { + return false, nil + } return true, nil } return false, nil diff --git a/internal/requests/requests_test.go b/internal/requests/requests_test.go index 02e0df501..32eb436b2 100644 --- a/internal/requests/requests_test.go +++ b/internal/requests/requests_test.go @@ -56,6 +56,10 @@ func (suite *RequestsTestSuite) SetupSuite() { Get("/locked/"). Reply(409). BodyString("resource temporarily locked") + suite.fakeService.NewHandler(). + Get("/conflict/"). + Reply(409). + BodyString(`{"message": "already exists"}`) suite.fakeService.NewHandler(). Get("/fail/"). Reply(500). @@ -307,6 +311,16 @@ func (suite *RequestsTestSuite) TestDo() { wantError: true, expectedErrorMsg: fmt.Sprintf("Get \"%s\": GET %s giving up after 2 attempt(s)", suite.fakeService.ResolveURL("/locked/"), suite.fakeService.ResolveURL("/locked/")), }, + { + name: "409 is not retried and is surfaced when DisableConflictRetry is set", + params: &RequestParams{ + Method: http.MethodGet, + URL: suite.fakeService.ResolveURL("/conflict/"), + DisableConflictRetry: true, + }, + wantError: true, + expectedErrorMsg: "already exists", + }, { name: "GET request to 500 endpoint", params: &RequestParams{ From 17d6073e543fc15fe0d2675ec8919b7cfca7835e Mon Sep 17 00:00:00 2001 From: Peter Beckham Date: Tue, 30 Jun 2026 13:32:42 +0100 Subject: [PATCH 02/13] feat(controls): add 'kosli list controls' command Add `kosli list controls` which GETs /api/v2/controls/{org} and renders the HAL list response as a table or JSON (--output). Supports pagination via --page and --page-limit, with an empty-state message and a page footer (#5742). Tests cover the populated/empty cases (table and JSON), page-limit capping, the echoed pagination params, a page beyond the data, and the positive-integer validation for --page and --page-limit. Co-Authored-By: Claude Opus 4.8 --- cmd/kosli/list.go | 1 + cmd/kosli/listControls.go | 134 +++++++++++++++++++++++++++++++++ cmd/kosli/listControls_test.go | 93 +++++++++++++++++++++++ 3 files changed, 228 insertions(+) create mode 100644 cmd/kosli/listControls.go create mode 100644 cmd/kosli/listControls_test.go diff --git a/cmd/kosli/list.go b/cmd/kosli/list.go index 94259a931..992efd13c 100644 --- a/cmd/kosli/list.go +++ b/cmd/kosli/list.go @@ -42,6 +42,7 @@ func newListCmd(out io.Writer) *cobra.Command { newListTrailsCmd(out), newListPoliciesCmd(out), newListAttestationTypesCmd(out), + newListControlsCmd(out), newListReposCmd(out), newListApiKeysCmd(out), newListServiceAccountsCmd(out), diff --git a/cmd/kosli/listControls.go b/cmd/kosli/listControls.go new file mode 100644 index 000000000..f11f26c46 --- /dev/null +++ b/cmd/kosli/listControls.go @@ -0,0 +1,134 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + + "github.com/kosli-dev/cli/internal/output" + "github.com/kosli-dev/cli/internal/requests" + "github.com/spf13/cobra" +) + +const listControlsShortDesc = `List controls for an org.` + +const listControlsLongDesc = listControlsShortDesc + ` +The results are paginated; use --page and --page-limit to navigate the pages.` + +const listControlsExample = ` +# list the first page of controls for an org: +kosli list controls \ + --api-token yourAPIToken \ + --org yourOrgName + +# list the second page of controls (10 per page) in JSON: +kosli list controls \ + --page 2 \ + --page-limit 10 \ + --output json \ + --api-token yourAPIToken \ + --org yourOrgName +` + +type listControlsOptions struct { + listOptions +} + +func newListControlsCmd(out io.Writer) *cobra.Command { + o := new(listControlsOptions) + cmd := &cobra.Command{ + Use: "controls", + Short: listControlsShortDesc, + Long: listControlsLongDesc, + Example: listControlsExample, + 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 o.validate(cmd) + }, + RunE: func(cmd *cobra.Command, args []string) error { + return o.run(out) + }, + } + + addListFlags(cmd, &o.listOptions) + + return cmd +} + +func (o *listControlsOptions) run(out io.Writer) error { + base, err := url.JoinPath(global.Host, "api/v2/controls", global.Org) + if err != nil { + return err + } + + params := url.Values{} + params.Set("page", strconv.Itoa(o.pageNumber)) + params.Set("per_page", strconv.Itoa(o.pageLimit)) + reqURL := base + "?" + params.Encode() + + reqParams := &requests.RequestParams{ + Method: http.MethodGet, + URL: reqURL, + Token: global.ApiToken, + } + response, err := kosliClient.Do(reqParams) + if err != nil { + return err + } + + return output.FormattedPrint(response.Body, o.output, out, o.pageNumber, + map[string]output.FormatOutputFunc{ + "table": printControlsListAsTable, + "json": output.PrintJson, + }) +} + +type listControlsResponse struct { + Controls []map[string]interface{} `json:"controls"` + Page int `json:"page"` + TotalPages int `json:"total_pages"` + TotalCount int `json:"total_count"` +} + +func printControlsListAsTable(raw string, out io.Writer, page int) error { + response := &listControlsResponse{} + if err := json.Unmarshal([]byte(raw), response); err != nil { + return err + } + + if len(response.Controls) == 0 { + msg := "No controls were found" + if page != 1 { + msg = fmt.Sprintf("%s at page number %d", msg, page) + } + logger.Info(msg + ".") + return nil + } + + header := []string{"IDENTIFIER", "NAME", "DESCRIPTION", "CREATED AT"} + rows := []string{} + for _, control := range response.Controls { + description := control["description"] + if description == nil { + description = "" + } + createdAt := "" + if control["created_at"] != nil { + createdAt, _ = formattedTimestamp(control["created_at"], true) + } + rows = append(rows, fmt.Sprintf("%s\t%s\t%s\t%s", control["identifier"], control["name"], description, createdAt)) + } + + rows = append(rows, fmt.Sprintf("\nShowing page %d of %d, total %d controls", response.Page, response.TotalPages, response.TotalCount)) + + tabFormattedPrint(out, header, rows) + + return nil +} diff --git a/cmd/kosli/listControls_test.go b/cmd/kosli/listControls_test.go new file mode 100644 index 000000000..fea71625c --- /dev/null +++ b/cmd/kosli/listControls_test.go @@ -0,0 +1,93 @@ +package main + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/suite" +) + +type ListControlsCommandTestSuite struct { + suite.Suite + defaultKosliArguments string + acmeOrgKosliArguments string +} + +func (suite *ListControlsCommandTestSuite) SetupTest() { + global = &GlobalOpts{ + ApiToken: "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6ImNkNzg4OTg5In0.e8i_lA_QrEhFncb05Xw6E_tkCHU9QfcY4OLTVUCHffY", + Org: "docs-cmd-test-user", + Host: "http://localhost:8001", + } + suite.defaultKosliArguments = fmt.Sprintf(" --host %s --org %s --api-token %s", global.Host, global.Org, global.ApiToken) + CreateControl(global.Org, "list-control-1", "First control", suite.T()) + CreateControl(global.Org, "list-control-2", "Second control", suite.T()) + + global.Org = "acme-org" + global.ApiToken = "v3OWZiYWu9G2IMQStYg9BcPQUQ88lJNNnTJTNq8jfvmkR1C5wVpHSs7F00JcB5i6OGeUzrKt3CwRq7ndcN4TTfMeo8ASVJ5NdHpZT7DkfRfiFvm8s7GbsIHh2PtiQJYs2UoN13T8DblV5C4oKb6-yWH73h67OhotPlKfVKazR-c" + suite.acmeOrgKosliArguments = fmt.Sprintf(" --host %s --org %s --api-token %s", global.Host, global.Org, global.ApiToken) +} + +func (suite *ListControlsCommandTestSuite) TestListControlsCmd() { + tests := []cmdTestCase{ + { + name: "listing controls works when some exist", + cmd: "list controls" + suite.defaultKosliArguments, + golden: "", + }, + { + name: "listing controls works when there are none", + cmd: "list controls" + suite.acmeOrgKosliArguments, + golden: "No controls were found.\n", + }, + { + name: "listing controls with --output json works when some exist", + cmd: "list controls --output json" + suite.defaultKosliArguments, + goldenJson: []jsonCheck{{"controls", "non-empty"}}, + }, + { + name: "listing controls with --output json works when there are none", + cmd: "list controls --output json" + suite.acmeOrgKosliArguments, + goldenJson: []jsonCheck{{"controls", "[]"}}, + }, + { + wantError: true, + name: "providing an argument causes an error", + cmd: "list controls xxx" + suite.defaultKosliArguments, + golden: "Error: unknown command \"xxx\" for \"kosli list controls\"\n", + }, + { + name: "--page-limit caps the page size and the response echoes the pagination params", + cmd: "list controls --page-limit 1 --output json" + suite.defaultKosliArguments, + goldenJson: []jsonCheck{{"controls", "length:1"}, {"page", float64(1)}, {"per_page", float64(1)}}, + }, + { + name: "a page beyond the data returns an empty controls list (json)", + cmd: "list controls --page 999 --output json" + suite.defaultKosliArguments, + goldenJson: []jsonCheck{{"controls", "[]"}}, + }, + { + name: "a page beyond the data reports the empty page (table)", + cmd: "list controls --page 999" + suite.defaultKosliArguments, + golden: "No controls were found at page number 999.\n", + }, + { + wantError: true, + name: "--page must be a positive integer", + cmd: "list controls --page 0" + suite.defaultKosliArguments, + goldenRegex: "^Error: page number must be a positive integer", + }, + { + wantError: true, + name: "--page-limit must be a positive integer", + cmd: "list controls --page-limit 0" + suite.defaultKosliArguments, + goldenRegex: "^Error: page limit must be a positive integer", + }, + } + + runTestCmd(suite.T(), tests) +} + +func TestListControlsCommandTestSuite(t *testing.T) { + suite.Run(t, new(ListControlsCommandTestSuite)) +} From cde0922634984c0f2642a3cc4acbcda7ae46b6af Mon Sep 17 00:00:00 2001 From: Peter Beckham Date: Tue, 30 Jun 2026 14:14:08 +0100 Subject: [PATCH 03/13] feat(controls): add --search, --tag and --archived filters to 'list controls' Wire the list controls command to the API's filtering query params: --search (name/identifier substring), --tag (repeatable, 'key' or 'key:value') and --archived (list archived instead of active) (#5742). Add ArchiveControl and TagControl test helpers and cover each filter: search by identifier, tag-only matches, archived excluded by default, and --archived returning archived controls. Co-Authored-By: Claude Opus 4.8 --- cmd/kosli/listControls.go | 15 +++++++++++++++ cmd/kosli/listControls_test.go | 27 +++++++++++++++++++++++++++ cmd/kosli/root.go | 3 +++ cmd/kosli/testHelpers.go | 29 +++++++++++++++++++++++++++++ 4 files changed, 74 insertions(+) diff --git a/cmd/kosli/listControls.go b/cmd/kosli/listControls.go index f11f26c46..91ff7e0ef 100644 --- a/cmd/kosli/listControls.go +++ b/cmd/kosli/listControls.go @@ -35,6 +35,9 @@ kosli list controls \ type listControlsOptions struct { listOptions + search string + tags []string + archived bool } func newListControlsCmd(out io.Writer) *cobra.Command { @@ -58,6 +61,9 @@ func newListControlsCmd(out io.Writer) *cobra.Command { } addListFlags(cmd, &o.listOptions) + cmd.Flags().StringVar(&o.search, "search", "", controlSearchFlag) + cmd.Flags().StringArrayVar(&o.tags, "tag", []string{}, controlTagFlag) + cmd.Flags().BoolVar(&o.archived, "archived", false, controlArchivedFlag) return cmd } @@ -71,6 +77,15 @@ func (o *listControlsOptions) run(out io.Writer) error { params := url.Values{} params.Set("page", strconv.Itoa(o.pageNumber)) params.Set("per_page", strconv.Itoa(o.pageLimit)) + if o.search != "" { + params.Set("search", o.search) + } + for _, tag := range o.tags { + params.Add("tag", tag) + } + if o.archived { + params.Set("archived", "true") + } reqURL := base + "?" + params.Encode() reqParams := &requests.RequestParams{ diff --git a/cmd/kosli/listControls_test.go b/cmd/kosli/listControls_test.go index fea71625c..347e803b0 100644 --- a/cmd/kosli/listControls_test.go +++ b/cmd/kosli/listControls_test.go @@ -23,6 +23,13 @@ func (suite *ListControlsCommandTestSuite) SetupTest() { CreateControl(global.Org, "list-control-1", "First control", suite.T()) CreateControl(global.Org, "list-control-2", "Second control", suite.T()) + // fixtures for filtering: a tagged control and an archived control with + // unique identifiers/tags so assertions are robust to shared server state. + CreateControl(global.Org, "tagged-control", "Tagged control", suite.T()) + TagControl(global.Org, "tagged-control", map[string]string{"slice2b": "tagtest"}, suite.T()) + CreateControl(global.Org, "archived-control", "Archived control", suite.T()) + ArchiveControl(global.Org, "archived-control", suite.T()) + global.Org = "acme-org" global.ApiToken = "v3OWZiYWu9G2IMQStYg9BcPQUQ88lJNNnTJTNq8jfvmkR1C5wVpHSs7F00JcB5i6OGeUzrKt3CwRq7ndcN4TTfMeo8ASVJ5NdHpZT7DkfRfiFvm8s7GbsIHh2PtiQJYs2UoN13T8DblV5C4oKb6-yWH73h67OhotPlKfVKazR-c" suite.acmeOrgKosliArguments = fmt.Sprintf(" --host %s --org %s --api-token %s", global.Host, global.Org, global.ApiToken) @@ -83,6 +90,26 @@ func (suite *ListControlsCommandTestSuite) TestListControlsCmd() { cmd: "list controls --page-limit 0" + suite.defaultKosliArguments, goldenRegex: "^Error: page limit must be a positive integer", }, + { + name: "--search matches by identifier", + cmd: "list controls --search list-control-1 --output json" + suite.defaultKosliArguments, + goldenJson: []jsonCheck{{"controls", "length:1"}, {"controls.[0].identifier", "list-control-1"}}, + }, + { + name: "--tag returns only controls carrying that tag", + cmd: "list controls --tag slice2b:tagtest --output json" + suite.defaultKosliArguments, + goldenJson: []jsonCheck{{"controls", "length:1"}, {"controls.[0].identifier", "tagged-control"}}, + }, + { + name: "archived controls are excluded by default", + cmd: "list controls --search archived-control --output json" + suite.defaultKosliArguments, + goldenJson: []jsonCheck{{"controls", "[]"}}, + }, + { + name: "--archived returns archived controls", + cmd: "list controls --archived --search archived-control --output json" + suite.defaultKosliArguments, + goldenJson: []jsonCheck{{"controls", "length:1"}, {"controls.[0].identifier", "archived-control"}}, + }, } runTestCmd(suite.T(), tests) diff --git a/cmd/kosli/root.go b/cmd/kosli/root.go index 922d36de8..15dba2212 100644 --- a/cmd/kosli/root.go +++ b/cmd/kosli/root.go @@ -300,6 +300,9 @@ The ^.kosli_ignore^ will be treated as part of the artifact like any other file, attestationTypeJqFlag = "[optional] The attestation type evaluation JQ rules." controlNameFlag = "[required] The control name." controlDescriptionFlag = "[optional] The control description." + controlSearchFlag = "[optional] Only list controls whose name or identifier contains this substring (case-insensitive)." + controlTagFlag = "[optional] Filter by tag, given as 'key' or 'key:value'. Can be repeated." + controlArchivedFlag = "[optional] List archived controls instead of active ones." envNameFlag = "The Kosli environment name to assert the artifact against." pathsWatchFlag = "[optional] Watch the filesystem for changes and report snapshots of artifacts running in specific filesystem paths to Kosli." getAttestationFingerprintFlag = "[conditional] The fingerprint of the artifact for the attestation. Cannot be used together with --trail or --attestation-id." diff --git a/cmd/kosli/testHelpers.go b/cmd/kosli/testHelpers.go index 271446b19..c342a6f09 100644 --- a/cmd/kosli/testHelpers.go +++ b/cmd/kosli/testHelpers.go @@ -544,6 +544,35 @@ func CreateControl(org, identifier, name string, t *testing.T) { require.NoError(t, err, "control should be created without error") } +func ArchiveControl(org, identifier string, t *testing.T) { + t.Helper() + u, err := url.JoinPath(global.Host, "api/v2/controls", org, identifier, "archive") + require.NoError(t, err, "control archive URL should be constructed without error") + + reqParams := &requests.RequestParams{ + Method: http.MethodPost, + URL: u, + Token: global.ApiToken, + } + _, err = kosliClient.Do(reqParams) + require.NoError(t, err, "control should be archived without error") +} + +func TagControl(org, identifier string, tags map[string]string, t *testing.T) { + t.Helper() + u, err := url.JoinPath(global.Host, "api/v2/tags", org, "control", identifier) + require.NoError(t, err, "control tag URL should be constructed without error") + + reqParams := &requests.RequestParams{ + Method: http.MethodPatch, + URL: u, + Payload: TagResourcePayload{SetTags: tags}, + Token: global.ApiToken, + } + _, err = kosliClient.Do(reqParams) + require.NoError(t, err, "control should be tagged without error") +} + // CreatePolicy creates a policy on the server func CreatePolicy(org, policyName string, t *testing.T) { t.Helper() From 9acc75e0b760c21091a52246cd0bdd0be2187918 Mon Sep 17 00:00:00 2001 From: Peter Beckham Date: Tue, 30 Jun 2026 14:20:43 +0100 Subject: [PATCH 04/13] feat(controls): allow 'kosli tag' to tag controls Add "control"/"controls" to the tag command's accepted resource types (the server already supports tagging controls via /api/v2/tags/{org}/control/{id}) and document it with an example. Tests cover adding, removing and the plural resource-type alias on a control (#5742). Co-Authored-By: Claude Opus 4.8 --- cmd/kosli/tag.go | 8 +++++++- cmd/kosli/tag_test.go | 18 ++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/cmd/kosli/tag.go b/cmd/kosli/tag.go index b2e307ccf..efde61b00 100644 --- a/cmd/kosli/tag.go +++ b/cmd/kosli/tag.go @@ -56,6 +56,12 @@ kosli tag env yourEnvironmentName \ --unset key1=value1 \ --api-token yourApiToken \ --org yourOrgName + +# tag a control +kosli tag control yourControlIdentifier \ + --set key1=value1 \ + --api-token yourApiToken \ + --org yourOrgName ` func newTagCmd(out io.Writer) *cobra.Command { @@ -143,7 +149,7 @@ func (o *tagOptions) run(args []string) error { } func validateResourceType(resourceType string) error { - options := []string{"flow", "flows", "env", "environment", "environments"} + options := []string{"flow", "flows", "env", "environment", "environments", "control", "controls"} match := false for _, opt := range options { if resourceType == opt { diff --git a/cmd/kosli/tag_test.go b/cmd/kosli/tag_test.go index 9e3962236..7ea93fc39 100644 --- a/cmd/kosli/tag_test.go +++ b/cmd/kosli/tag_test.go @@ -16,12 +16,14 @@ type TagTestSuite struct { flowName string envName string envType string + controlID string } func (suite *TagTestSuite) SetupTest() { suite.flowName = "tag-flow" suite.envName = "tag-env" suite.envType = "K8S" + suite.controlID = "tag-control" global = &GlobalOpts{ ApiToken: "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6ImNkNzg4OTg5In0.e8i_lA_QrEhFncb05Xw6E_tkCHU9QfcY4OLTVUCHffY", Org: "docs-cmd-test-user", @@ -31,6 +33,7 @@ func (suite *TagTestSuite) SetupTest() { CreateFlow(suite.flowName, suite.T()) CreateEnv(global.Org, suite.envName, suite.envType, suite.T()) + CreateControl(global.Org, suite.controlID, "Tag control", suite.T()) } func (suite *TagTestSuite) TestTagCmd() { @@ -65,6 +68,21 @@ func (suite *TagTestSuite) TestTagCmd() { cmd: fmt.Sprintf("tag flow %s --set foo=bar %s", suite.flowName, suite.defaultKosliArguments), golden: "Tag(s) [foo] added for flow 'tag-flow'\n", }, + { + name: "can tag a control", + cmd: fmt.Sprintf("tag control %s --set foo=bar %s", suite.controlID, suite.defaultKosliArguments), + golden: "Tag(s) [foo] added for control 'tag-control'\n", + }, + { + name: "can remove a tag from a control", + cmd: fmt.Sprintf("tag control %s --unset foo %s", suite.controlID, suite.defaultKosliArguments), + golden: "Tag(s) [foo] removed for control 'tag-control'\n", + }, + { + name: "can tag a control using the plural resource type", + cmd: fmt.Sprintf("tag controls %s --set key=value %s", suite.controlID, suite.defaultKosliArguments), + golden: "Tag(s) [key] added for controls 'tag-control'\n", + }, } runTestCmd(suite.T(), tests) } From 6fef86296a491932af17b0ed3bdca5807aebacd4 Mon Sep 17 00:00:00 2001 From: Peter Beckham Date: Tue, 30 Jun 2026 16:03:06 +0100 Subject: [PATCH 05/13] feat(controls): add 'kosli get control' command Add `kosli get control ` which GETs /api/v2/controls/{org}/{identifier} and renders the control as a table (identifier, name, description, version, archived, created by/at, tags, links, referencing policies) or JSON via --output (#5742). Tests cover argument validation, fetching an existing control as table and JSON, and the clear error for a non-existing control. Co-Authored-By: Claude Opus 4.8 --- cmd/kosli/get.go | 1 + cmd/kosli/getControl.go | 131 +++++++++++++++++++++++++++++++++++ cmd/kosli/getControl_test.go | 56 +++++++++++++++ 3 files changed, 188 insertions(+) create mode 100644 cmd/kosli/getControl.go create mode 100644 cmd/kosli/getControl_test.go diff --git a/cmd/kosli/get.go b/cmd/kosli/get.go index a330c9c99..1c60ed89f 100644 --- a/cmd/kosli/get.go +++ b/cmd/kosli/get.go @@ -26,6 +26,7 @@ func newGetCmd(out io.Writer) *cobra.Command { newGetTrailCmd(out), newGetPolicyCmd(out), newGetAttestationTypeCmd(out), + newGetControlCmd(out), newGetAttestationCmd(out), newGetRepoCmd(out), newGetServiceAccountCmd(out), diff --git a/cmd/kosli/getControl.go b/cmd/kosli/getControl.go new file mode 100644 index 000000000..977bb3bc8 --- /dev/null +++ b/cmd/kosli/getControl.go @@ -0,0 +1,131 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/kosli-dev/cli/internal/output" + "github.com/kosli-dev/cli/internal/requests" + "github.com/spf13/cobra" +) + +const getControlShortDesc = `Get a Kosli control.` + +const getControlExample = ` +# get a control: +kosli get control yourControlIdentifier \ + --api-token yourAPIToken \ + --org yourOrgName +` + +type getControlOptions struct { + output string +} + +func newGetControlCmd(out io.Writer) *cobra.Command { + o := new(getControlOptions) + cmd := &cobra.Command{ + Use: "control CONTROL-IDENTIFIER", + Short: getControlShortDesc, + Long: getControlShortDesc, + Example: getControlExample, + Args: cobra.ExactArgs(1), + PreRunE: func(cmd *cobra.Command, args []string) error { + err := RequireGlobalFlags(global, []string{"Org", "ApiToken"}) + if err != nil { + return ErrorBeforePrintingUsage(cmd, err.Error()) + } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + return o.run(out, args) + }, + } + + cmd.Flags().StringVarP(&o.output, "output", "o", "table", outputFlag) + + return cmd +} + +func (o *getControlOptions) run(out io.Writer, args []string) error { + url, err := url.JoinPath(global.Host, "api/v2/controls", global.Org, args[0]) + if err != nil { + return err + } + + reqParams := &requests.RequestParams{ + Method: http.MethodGet, + URL: url, + Token: global.ApiToken, + } + response, err := kosliClient.Do(reqParams) + if err != nil { + return err + } + + return output.FormattedPrint(response.Body, o.output, out, 0, + map[string]output.FormatOutputFunc{ + "table": printControlAsTable, + "json": output.PrintJson, + }) +} + +func printControlAsTable(raw string, out io.Writer, page int) error { + var control map[string]interface{} + if err := json.Unmarshal([]byte(raw), &control); err != nil { + return err + } + + rows := []string{} + rows = append(rows, fmt.Sprintf("Identifier:\t%s", control["identifier"])) + rows = append(rows, fmt.Sprintf("Name:\t%s", control["name"])) + if description, ok := control["description"]; ok && description != nil { + rows = append(rows, fmt.Sprintf("Description:\t%s", description)) + } + if version, ok := control["version"]; ok { + rows = append(rows, fmt.Sprintf("Version:\t%.0f", version)) + } + if archived, ok := control["archived"]; ok { + rows = append(rows, fmt.Sprintf("Archived:\t%t", archived)) + } + if createdBy, ok := control["created_by"]; ok { + rows = append(rows, fmt.Sprintf("Created by:\t%s", createdBy)) + } + if createdAt, ok := control["created_at"]; ok && createdAt != nil { + createdAtFormatted, err := formattedTimestamp(createdAt, false) + if err != nil { + return err + } + rows = append(rows, fmt.Sprintf("Created at:\t%s", createdAtFormatted)) + } + + if tags, ok := control["tags"].(map[string]interface{}); ok && len(tags) > 0 { + tagPairs := []string{} + for key, value := range tags { + tagPairs = append(tagPairs, fmt.Sprintf("%s=%s", key, value)) + } + rows = append(rows, fmt.Sprintf("Tags:\t%s", strings.Join(tagPairs, ", "))) + } + + if links, ok := control["links"].(map[string]interface{}); ok && len(links) > 0 { + rows = append(rows, "Links:\t") + for name, link := range links { + rows = append(rows, fmt.Sprintf("\t%s:\t%s", name, link)) + } + } + + if policies, ok := control["policies_referencing"].([]interface{}); ok && len(policies) > 0 { + policyNames := make([]string, 0, len(policies)) + for _, p := range policies { + policyNames = append(policyNames, fmt.Sprintf("%s", p)) + } + rows = append(rows, fmt.Sprintf("Policies referencing:\t%s", strings.Join(policyNames, ", "))) + } + + tabFormattedPrint(out, []string{}, rows) + return nil +} diff --git a/cmd/kosli/getControl_test.go b/cmd/kosli/getControl_test.go new file mode 100644 index 000000000..1e148189e --- /dev/null +++ b/cmd/kosli/getControl_test.go @@ -0,0 +1,56 @@ +package main + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/suite" +) + +type GetControlCommandTestSuite struct { + suite.Suite + defaultKosliArguments string +} + +func (suite *GetControlCommandTestSuite) SetupTest() { + global = &GlobalOpts{ + ApiToken: "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6ImNkNzg4OTg5In0.e8i_lA_QrEhFncb05Xw6E_tkCHU9QfcY4OLTVUCHffY", + Org: "docs-cmd-test-user", + Host: "http://localhost:8001", + } + suite.defaultKosliArguments = fmt.Sprintf(" --host %s --org %s --api-token %s", global.Host, global.Org, global.ApiToken) + CreateControl(global.Org, "get-control", "Gettable control", suite.T()) +} + +func (suite *GetControlCommandTestSuite) TestGetControlCmd() { + tests := []cmdTestCase{ + { + wantError: true, + name: "fails when no identifier argument is provided", + cmd: "get control" + suite.defaultKosliArguments, + golden: "Error: accepts 1 arg(s), received 0\n", + }, + { + name: "getting an existing control works", + cmd: "get control get-control" + suite.defaultKosliArguments, + golden: "", + }, + { + name: "getting an existing control as json works", + cmd: "get control get-control --output json" + suite.defaultKosliArguments, + goldenJson: []jsonCheck{{"identifier", "get-control"}, {"name", "Gettable control"}}, + }, + { + wantError: true, + name: "getting a non-existing control gives a clear error", + cmd: "get control no-such-control" + suite.defaultKosliArguments, + goldenRegex: "^Error: Control 'no-such-control' does not exist in org", + }, + } + + runTestCmd(suite.T(), tests) +} + +func TestGetControlCommandTestSuite(t *testing.T) { + suite.Run(t, new(GetControlCommandTestSuite)) +} From 606cfd0284eee6ede5594138d11328d4ec3cc9d6 Mon Sep 17 00:00:00 2001 From: Peter Beckham Date: Tue, 30 Jun 2026 16:12:33 +0100 Subject: [PATCH 06/13] feat(controls): add 'kosli archive control' and 'kosli unarchive control' Add `kosli archive control ` (POST .../archive) and a new `unarchive` command group with `kosli unarchive control ` (POST .../unarchive) (#5742). Tests cover argument validation, the success messages, the resulting archived/active state (verified via get control), and the clear error when the control does not exist. Co-Authored-By: Claude Opus 4.8 --- cmd/kosli/archive.go | 1 + cmd/kosli/archiveControl.go | 61 ++++++++++++++++++++++++++++++ cmd/kosli/archiveControl_test.go | 56 +++++++++++++++++++++++++++ cmd/kosli/root.go | 1 + cmd/kosli/unarchive.go | 23 +++++++++++ cmd/kosli/unarchiveControl.go | 60 +++++++++++++++++++++++++++++ cmd/kosli/unarchiveControl_test.go | 51 +++++++++++++++++++++++++ 7 files changed, 253 insertions(+) create mode 100644 cmd/kosli/archiveControl.go create mode 100644 cmd/kosli/archiveControl_test.go create mode 100644 cmd/kosli/unarchive.go create mode 100644 cmd/kosli/unarchiveControl.go create mode 100644 cmd/kosli/unarchiveControl_test.go diff --git a/cmd/kosli/archive.go b/cmd/kosli/archive.go index a6c347b28..ba00b49d0 100644 --- a/cmd/kosli/archive.go +++ b/cmd/kosli/archive.go @@ -20,6 +20,7 @@ func newArchiveCmd(out io.Writer) *cobra.Command { newArchiveFlowCmd(out), newArchiveEnvironmentCmd(out), newArchiveAttestationTypeCmd(out), + newArchiveControlCmd(out), ) return cmd } diff --git a/cmd/kosli/archiveControl.go b/cmd/kosli/archiveControl.go new file mode 100644 index 000000000..d10b260c1 --- /dev/null +++ b/cmd/kosli/archiveControl.go @@ -0,0 +1,61 @@ +package main + +import ( + "io" + "net/http" + "net/url" + + "github.com/kosli-dev/cli/internal/requests" + "github.com/spf13/cobra" +) + +const archiveControlShortDesc = `Archive a Kosli control.` + +const archiveControlLongDesc = archiveControlShortDesc + ` +An archived control is no longer active. It remains visible via ^kosli get control^ and +via ^kosli list controls --archived^, and can be restored with ^kosli unarchive control^. +` + +const archiveControlExample = ` +# archive a Kosli control: +kosli archive control yourControlIdentifier \ + --api-token yourAPIToken \ + --org yourOrgName +` + +func newArchiveControlCmd(out io.Writer) *cobra.Command { + cmd := &cobra.Command{ + Use: "control CONTROL-IDENTIFIER", + Short: archiveControlShortDesc, + Long: archiveControlLongDesc, + Example: archiveControlExample, + Args: cobra.ExactArgs(1), + PreRunE: func(cmd *cobra.Command, args []string) error { + err := RequireGlobalFlags(global, []string{"Org", "ApiToken"}) + if err != nil { + return ErrorBeforePrintingUsage(cmd, err.Error()) + } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + url, err := url.JoinPath(global.Host, "api/v2/controls", global.Org, args[0], "archive") + if err != nil { + return err + } + + reqParams := &requests.RequestParams{ + Method: http.MethodPost, + URL: url, + DryRun: global.DryRun, + Token: global.ApiToken, + } + _, err = kosliClient.Do(reqParams) + if err == nil && !global.DryRun { + logger.Info("control %s was archived", args[0]) + } + return err + }, + } + addDryRunFlag(cmd) + return cmd +} diff --git a/cmd/kosli/archiveControl_test.go b/cmd/kosli/archiveControl_test.go new file mode 100644 index 000000000..dcd1153d0 --- /dev/null +++ b/cmd/kosli/archiveControl_test.go @@ -0,0 +1,56 @@ +package main + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/suite" +) + +type ArchiveControlCommandTestSuite struct { + suite.Suite + defaultKosliArguments string +} + +func (suite *ArchiveControlCommandTestSuite) SetupTest() { + global = &GlobalOpts{ + ApiToken: "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6ImNkNzg4OTg5In0.e8i_lA_QrEhFncb05Xw6E_tkCHU9QfcY4OLTVUCHffY", + Org: "docs-cmd-test-user", + Host: "http://localhost:8001", + } + suite.defaultKosliArguments = fmt.Sprintf(" --host %s --org %s --api-token %s", global.Host, global.Org, global.ApiToken) + CreateControl(global.Org, "archive-me", "Archive me", suite.T()) +} + +func (suite *ArchiveControlCommandTestSuite) TestArchiveControlCmd() { + tests := []cmdTestCase{ + { + wantError: true, + name: "fails when no identifier argument is provided", + cmd: "archive control" + suite.defaultKosliArguments, + golden: "Error: accepts 1 arg(s), received 0\n", + }, + { + name: "archiving an existing control works", + cmd: "archive control archive-me" + suite.defaultKosliArguments, + golden: "control archive-me was archived\n", + }, + { + name: "the control is archived afterwards", + cmd: "get control archive-me --output json" + suite.defaultKosliArguments, + goldenJson: []jsonCheck{{"archived", true}}, + }, + { + wantError: true, + name: "archiving a non-existing control gives a clear error", + cmd: "archive control no-such-control" + suite.defaultKosliArguments, + goldenRegex: "^Error: Control 'no-such-control' does not exist in org", + }, + } + + runTestCmd(suite.T(), tests) +} + +func TestArchiveControlCommandTestSuite(t *testing.T) { + suite.Run(t, new(ArchiveControlCommandTestSuite)) +} diff --git a/cmd/kosli/root.go b/cmd/kosli/root.go index 15dba2212..51d09f23e 100644 --- a/cmd/kosli/root.go +++ b/cmd/kosli/root.go @@ -440,6 +440,7 @@ func newRootCmd(out, errOut io.Writer, args []string) (*cobra.Command, error) { newRenameCmd(out), newJoinCmd(out), newArchiveCmd(out), + newUnarchiveCmd(out), newSnapshotCmd(out), newRequestCmd(out), newLogCmd(out), diff --git a/cmd/kosli/unarchive.go b/cmd/kosli/unarchive.go new file mode 100644 index 000000000..0acf49aaf --- /dev/null +++ b/cmd/kosli/unarchive.go @@ -0,0 +1,23 @@ +package main + +import ( + "io" + + "github.com/spf13/cobra" +) + +const unarchiveDesc = `All Kosli unarchive commands.` + +func newUnarchiveCmd(out io.Writer) *cobra.Command { + cmd := &cobra.Command{ + Use: "unarchive", + Short: unarchiveDesc, + Long: unarchiveDesc, + } + + // Add subcommands + cmd.AddCommand( + newUnarchiveControlCmd(out), + ) + return cmd +} diff --git a/cmd/kosli/unarchiveControl.go b/cmd/kosli/unarchiveControl.go new file mode 100644 index 000000000..1b0a63e3b --- /dev/null +++ b/cmd/kosli/unarchiveControl.go @@ -0,0 +1,60 @@ +package main + +import ( + "io" + "net/http" + "net/url" + + "github.com/kosli-dev/cli/internal/requests" + "github.com/spf13/cobra" +) + +const unarchiveControlShortDesc = `Unarchive a Kosli control.` + +const unarchiveControlLongDesc = unarchiveControlShortDesc + ` +Restores a previously archived control to the active state. +` + +const unarchiveControlExample = ` +# unarchive a Kosli control: +kosli unarchive control yourControlIdentifier \ + --api-token yourAPIToken \ + --org yourOrgName +` + +func newUnarchiveControlCmd(out io.Writer) *cobra.Command { + cmd := &cobra.Command{ + Use: "control CONTROL-IDENTIFIER", + Short: unarchiveControlShortDesc, + Long: unarchiveControlLongDesc, + Example: unarchiveControlExample, + Args: cobra.ExactArgs(1), + PreRunE: func(cmd *cobra.Command, args []string) error { + err := RequireGlobalFlags(global, []string{"Org", "ApiToken"}) + if err != nil { + return ErrorBeforePrintingUsage(cmd, err.Error()) + } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + url, err := url.JoinPath(global.Host, "api/v2/controls", global.Org, args[0], "unarchive") + if err != nil { + return err + } + + reqParams := &requests.RequestParams{ + Method: http.MethodPost, + URL: url, + DryRun: global.DryRun, + Token: global.ApiToken, + } + _, err = kosliClient.Do(reqParams) + if err == nil && !global.DryRun { + logger.Info("control %s was unarchived", args[0]) + } + return err + }, + } + addDryRunFlag(cmd) + return cmd +} diff --git a/cmd/kosli/unarchiveControl_test.go b/cmd/kosli/unarchiveControl_test.go new file mode 100644 index 000000000..cfff1c299 --- /dev/null +++ b/cmd/kosli/unarchiveControl_test.go @@ -0,0 +1,51 @@ +package main + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/suite" +) + +type UnarchiveControlCommandTestSuite struct { + suite.Suite + defaultKosliArguments string +} + +func (suite *UnarchiveControlCommandTestSuite) SetupTest() { + global = &GlobalOpts{ + ApiToken: "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6ImNkNzg4OTg5In0.e8i_lA_QrEhFncb05Xw6E_tkCHU9QfcY4OLTVUCHffY", + Org: "docs-cmd-test-user", + Host: "http://localhost:8001", + } + suite.defaultKosliArguments = fmt.Sprintf(" --host %s --org %s --api-token %s", global.Host, global.Org, global.ApiToken) + CreateControl(global.Org, "unarchive-me", "Unarchive me", suite.T()) + ArchiveControl(global.Org, "unarchive-me", suite.T()) +} + +func (suite *UnarchiveControlCommandTestSuite) TestUnarchiveControlCmd() { + tests := []cmdTestCase{ + { + wantError: true, + name: "fails when no identifier argument is provided", + cmd: "unarchive control" + suite.defaultKosliArguments, + golden: "Error: accepts 1 arg(s), received 0\n", + }, + { + name: "unarchiving an archived control works", + cmd: "unarchive control unarchive-me" + suite.defaultKosliArguments, + golden: "control unarchive-me was unarchived\n", + }, + { + name: "the control is active afterwards", + cmd: "get control unarchive-me --output json" + suite.defaultKosliArguments, + goldenJson: []jsonCheck{{"archived", false}}, + }, + } + + runTestCmd(suite.T(), tests) +} + +func TestUnarchiveControlCommandTestSuite(t *testing.T) { + suite.Run(t, new(UnarchiveControlCommandTestSuite)) +} From b0c2b042c5973f6604078d95bf4e6cb1a506123e Mon Sep 17 00:00:00 2001 From: Peter Beckham Date: Tue, 30 Jun 2026 16:34:17 +0100 Subject: [PATCH 07/13] test(controls): add error-case coverage for invalid id, tag and unarchive Cover the remaining unhappy paths for control commands (#5742): - create control with an invalid identifier surfaces the server's validation error verbatim - tagging a non-existing control gives a clear "does not exist" error - unarchiving a non-existing control gives a clear "does not exist" error Co-Authored-By: Claude Opus 4.8 --- cmd/kosli/createControl_test.go | 6 ++++++ cmd/kosli/tag_test.go | 6 ++++++ cmd/kosli/unarchiveControl_test.go | 6 ++++++ 3 files changed, 18 insertions(+) diff --git a/cmd/kosli/createControl_test.go b/cmd/kosli/createControl_test.go index 27423576e..2c218bb1e 100644 --- a/cmd/kosli/createControl_test.go +++ b/cmd/kosli/createControl_test.go @@ -55,6 +55,12 @@ func (suite *CreateControlTestSuite) TestCreateControlCmd() { cmd: "create control my-control --name 'My Control'" + suite.defaultKosliArguments, goldenRegex: "^Error: A control with identifier 'my-control' already exists in organization", }, + { + wantError: true, + name: "fails when the identifier contains invalid characters", + cmd: "create control 'bad identifier' --name 'My Control'" + suite.defaultKosliArguments, + goldenRegex: "^Error: Input payload validation failed:.*Control identifier 'bad identifier' contains invalid characters", + }, } runTestCmd(suite.T(), tests) diff --git a/cmd/kosli/tag_test.go b/cmd/kosli/tag_test.go index 7ea93fc39..b9add627a 100644 --- a/cmd/kosli/tag_test.go +++ b/cmd/kosli/tag_test.go @@ -83,6 +83,12 @@ func (suite *TagTestSuite) TestTagCmd() { cmd: fmt.Sprintf("tag controls %s --set key=value %s", suite.controlID, suite.defaultKosliArguments), golden: "Tag(s) [key] added for controls 'tag-control'\n", }, + { + wantError: true, + name: "tagging a non-existing control gives a clear error", + cmd: fmt.Sprintf("tag control no-such-control --set foo=bar %s", suite.defaultKosliArguments), + goldenRegex: "^Error: \"Control 'no-such-control' does not exist in organization", + }, } runTestCmd(suite.T(), tests) } diff --git a/cmd/kosli/unarchiveControl_test.go b/cmd/kosli/unarchiveControl_test.go index cfff1c299..1a3e96331 100644 --- a/cmd/kosli/unarchiveControl_test.go +++ b/cmd/kosli/unarchiveControl_test.go @@ -41,6 +41,12 @@ func (suite *UnarchiveControlCommandTestSuite) TestUnarchiveControlCmd() { cmd: "get control unarchive-me --output json" + suite.defaultKosliArguments, goldenJson: []jsonCheck{{"archived", false}}, }, + { + wantError: true, + name: "unarchiving a non-existing control gives a clear error", + cmd: "unarchive control no-such-control" + suite.defaultKosliArguments, + goldenRegex: "^Error: Control 'no-such-control' does not exist in org", + }, } runTestCmd(suite.T(), tests) From 16cf990529e5b27c1fdb88955b02da8b148e9352 Mon Sep 17 00:00:00 2001 From: Peter Beckham Date: Tue, 30 Jun 2026 17:03:32 +0100 Subject: [PATCH 08/13] test(controls): add registry-driven OpenAPI contract test Guard against drift between the CLI's hand-written request payload structs and the Kosli API by fetching the live OpenAPI schema from the test server and asserting, per registered case, that every json field the CLI sends exists in the schema component and every required schema property is sent by the CLI. Optional schema properties the CLI does not expose are logged, not failed. Seeded with create control (ControlPostInput) and create environment (CreateEnvironmentPutInput) to prove the registry generalises beyond controls; adding an entity is a one-line registry change (#5742). Co-Authored-By: Claude Opus 4.8 --- cmd/kosli/openapiContract_test.go | 138 ++++++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 cmd/kosli/openapiContract_test.go diff --git a/cmd/kosli/openapiContract_test.go b/cmd/kosli/openapiContract_test.go new file mode 100644 index 000000000..74c9c0c7d --- /dev/null +++ b/cmd/kosli/openapiContract_test.go @@ -0,0 +1,138 @@ +package main + +import ( + "encoding/json" + "io" + "net/http" + "reflect" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +// OpenAPIContractTestSuite guards against drift between the CLI's hand-written +// request payload structs and the Kosli API's OpenAPI schema. It fetches the +// live schema from the test server (the same image the integration tests run +// against) and, for each registered case, asserts that: +// +// - every json field the CLI struct sends exists as a property in the schema +// component (so the CLI never sends a field the server would reject — the +// control/env inputs are declared extra="forbid"), and +// - every property the schema marks required is present in the CLI struct (so +// the CLI can always satisfy the server's required fields). +// +// Optional schema properties the CLI does not (yet) expose are allowed and only +// logged — that is a deliberate coverage gap, not drift. +// +// To extend coverage, add one line to the registry in TestPayloadsMatchSchema. +type OpenAPIContractTestSuite struct { + suite.Suite + schema openAPISchema +} + +type openAPISchema struct { + Components struct { + Schemas map[string]componentSchema `json:"schemas"` + } `json:"components"` +} + +type componentSchema struct { + Properties map[string]json.RawMessage `json:"properties"` + Required []string `json:"required"` +} + +// driftCase maps a CLI payload struct to the OpenAPI component it must match. +type driftCase struct { + name string + payload interface{} + component string + // ignore lists json field names to skip on both sides, for deliberate + // CLI/API divergences (none needed yet). + ignore []string +} + +func (suite *OpenAPIContractTestSuite) SetupSuite() { + resp, err := http.Get("http://localhost:8001/api/v2/openapi.json") + require.NoError(suite.T(), err, "should fetch the OpenAPI schema from the test server") + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + require.NoError(suite.T(), err) + require.NoError(suite.T(), json.Unmarshal(body, &suite.schema), "OpenAPI schema should be valid JSON") + require.NotEmpty(suite.T(), suite.schema.Components.Schemas, "OpenAPI schema should contain components") +} + +func (suite *OpenAPIContractTestSuite) TestPayloadsMatchSchema() { + registry := []driftCase{ + {name: "create control", payload: ControlPayload{}, component: "ControlPostInput"}, + {name: "create environment", payload: CreateEnvironmentPayload{}, component: "CreateEnvironmentPutInput"}, + } + + for _, c := range registry { + suite.Run(c.name, func() { + t := suite.T() + component, ok := suite.schema.Components.Schemas[c.component] + require.True(t, ok, "OpenAPI component %q not found — it may have been renamed or removed", c.component) + + structFields := jsonFieldNames(c.payload) + + // 1. Every field the CLI sends must exist in the schema. + for _, field := range structFields { + if contains(c.ignore, field) { + continue + } + _, exists := component.Properties[field] + require.True(t, exists, + "CLI struct %T sends field %q which is not a property of OpenAPI component %q — schema drift", + c.payload, field, c.component) + } + + // 2. Every required schema property must be covered by the CLI struct. + for _, req := range component.Required { + if contains(c.ignore, req) { + continue + } + require.Contains(t, structFields, req, + "OpenAPI component %q requires %q but CLI struct %T does not send it — schema drift", + c.component, req, c.payload) + } + + // Surface (without failing) optional schema properties the CLI does + // not expose yet, so the coverage gap is visible. + for prop := range component.Properties { + if !contains(structFields, prop) && !contains(c.ignore, prop) { + t.Logf("note: OpenAPI component %q has property %q not exposed by CLI struct %T", c.component, prop, c.payload) + } + } + }) + } +} + +// jsonFieldNames returns the wire names from a struct's json tags. +func jsonFieldNames(v interface{}) []string { + t := reflect.TypeOf(v) + names := []string{} + for i := 0; i < t.NumField(); i++ { + tag := t.Field(i).Tag.Get("json") + name := strings.Split(tag, ",")[0] + if name == "" || name == "-" { + continue + } + names = append(names, name) + } + return names +} + +func contains(list []string, s string) bool { + for _, item := range list { + if item == s { + return true + } + } + return false +} + +func TestOpenAPIContractTestSuite(t *testing.T) { + suite.Run(t, new(OpenAPIContractTestSuite)) +} From 32e3b63f242d1b0ecbfcc1b3eef5f64ee1601673 Mon Sep 17 00:00:00 2001 From: Peter Beckham Date: Wed, 1 Jul 2026 11:29:00 +0100 Subject: [PATCH 09/13] chore(controls): check resp.Body.Close in openapi contract test Satisfy the errcheck linter by handling the deferred Body.Close return value. Co-Authored-By: Claude Opus 4.8 --- cmd/kosli/openapiContract_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/kosli/openapiContract_test.go b/cmd/kosli/openapiContract_test.go index 74c9c0c7d..639c51ad1 100644 --- a/cmd/kosli/openapiContract_test.go +++ b/cmd/kosli/openapiContract_test.go @@ -56,7 +56,7 @@ type driftCase struct { func (suite *OpenAPIContractTestSuite) SetupSuite() { resp, err := http.Get("http://localhost:8001/api/v2/openapi.json") require.NoError(suite.T(), err, "should fetch the OpenAPI schema from the test server") - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() body, err := io.ReadAll(resp.Body) require.NoError(suite.T(), err) require.NoError(suite.T(), json.Unmarshal(body, &suite.schema), "OpenAPI schema should be valid JSON") From 685cb26fa89046d2ad5071cfd93328bfe3488a25 Mon Sep 17 00:00:00 2001 From: Peter Beckham Date: Wed, 1 Jul 2026 11:55:39 +0100 Subject: [PATCH 10/13] fix(controls): stable tag/link order and nil-safe fields in get control table Address PR review on the get control table printer: - sort tags and links keys before printing so output is deterministic (matching tag.go), instead of relying on random map iteration order - guard version, archived and created_by with the same != nil check used for description and created_at, so an explicit null never renders as a Go error verb Co-Authored-By: Claude Opus 4.8 --- cmd/kosli/getControl.go | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/cmd/kosli/getControl.go b/cmd/kosli/getControl.go index 977bb3bc8..49cbb8874 100644 --- a/cmd/kosli/getControl.go +++ b/cmd/kosli/getControl.go @@ -6,6 +6,7 @@ import ( "io" "net/http" "net/url" + "sort" "strings" "github.com/kosli-dev/cli/internal/output" @@ -86,13 +87,13 @@ func printControlAsTable(raw string, out io.Writer, page int) error { if description, ok := control["description"]; ok && description != nil { rows = append(rows, fmt.Sprintf("Description:\t%s", description)) } - if version, ok := control["version"]; ok { + if version, ok := control["version"]; ok && version != nil { rows = append(rows, fmt.Sprintf("Version:\t%.0f", version)) } - if archived, ok := control["archived"]; ok { + if archived, ok := control["archived"]; ok && archived != nil { rows = append(rows, fmt.Sprintf("Archived:\t%t", archived)) } - if createdBy, ok := control["created_by"]; ok { + if createdBy, ok := control["created_by"]; ok && createdBy != nil { rows = append(rows, fmt.Sprintf("Created by:\t%s", createdBy)) } if createdAt, ok := control["created_at"]; ok && createdAt != nil { @@ -104,17 +105,27 @@ func printControlAsTable(raw string, out io.Writer, page int) error { } if tags, ok := control["tags"].(map[string]interface{}); ok && len(tags) > 0 { - tagPairs := []string{} - for key, value := range tags { - tagPairs = append(tagPairs, fmt.Sprintf("%s=%s", key, value)) + tagKeys := make([]string, 0, len(tags)) + for key := range tags { + tagKeys = append(tagKeys, key) + } + sort.Strings(tagKeys) + tagPairs := make([]string, 0, len(tags)) + for _, key := range tagKeys { + tagPairs = append(tagPairs, fmt.Sprintf("%s=%s", key, tags[key])) } rows = append(rows, fmt.Sprintf("Tags:\t%s", strings.Join(tagPairs, ", "))) } if links, ok := control["links"].(map[string]interface{}); ok && len(links) > 0 { rows = append(rows, "Links:\t") - for name, link := range links { - rows = append(rows, fmt.Sprintf("\t%s:\t%s", name, link)) + linkNames := make([]string, 0, len(links)) + for name := range links { + linkNames = append(linkNames, name) + } + sort.Strings(linkNames) + for _, name := range linkNames { + rows = append(rows, fmt.Sprintf("\t%s:\t%s", name, links[name])) } } From 7cf5055aa510a1ff949a198922869fa3fa13291d Mon Sep 17 00:00:00 2001 From: Peter Beckham Date: Wed, 1 Jul 2026 12:54:05 +0100 Subject: [PATCH 11/13] feat(controls): mark control commands as beta The controls feature is gated server-side behind the is-controls-enabled flag and published as beta in the API schema, so mark the create, list, get, archive and unarchive control commands beta (visible, with the beta banner in --help), consistent with other beta CLI commands. Add a lifecycle test asserting all five stay beta while flag-gated (#5742). Co-Authored-By: Claude Opus 4.8 --- cmd/kosli/archiveControl.go | 11 ++++++----- cmd/kosli/createControl.go | 11 ++++++----- cmd/kosli/getControl.go | 11 ++++++----- cmd/kosli/lifecycle_test.go | 16 ++++++++++++++++ cmd/kosli/listControls.go | 11 ++++++----- cmd/kosli/unarchiveControl.go | 11 ++++++----- 6 files changed, 46 insertions(+), 25 deletions(-) diff --git a/cmd/kosli/archiveControl.go b/cmd/kosli/archiveControl.go index d10b260c1..037e8b84e 100644 --- a/cmd/kosli/archiveControl.go +++ b/cmd/kosli/archiveControl.go @@ -25,11 +25,12 @@ kosli archive control yourControlIdentifier \ func newArchiveControlCmd(out io.Writer) *cobra.Command { cmd := &cobra.Command{ - Use: "control CONTROL-IDENTIFIER", - Short: archiveControlShortDesc, - Long: archiveControlLongDesc, - Example: archiveControlExample, - Args: cobra.ExactArgs(1), + Use: "control CONTROL-IDENTIFIER", + Short: archiveControlShortDesc, + Long: archiveControlLongDesc, + Example: archiveControlExample, + Args: cobra.ExactArgs(1), + Annotations: map[string]string{betaCLIAnnotation: ""}, PreRunE: func(cmd *cobra.Command, args []string) error { err := RequireGlobalFlags(global, []string{"Org", "ApiToken"}) if err != nil { diff --git a/cmd/kosli/createControl.go b/cmd/kosli/createControl.go index 42bbb134e..ca8322844 100644 --- a/cmd/kosli/createControl.go +++ b/cmd/kosli/createControl.go @@ -38,11 +38,12 @@ type ControlPayload struct { func newCreateControlCmd(out io.Writer) *cobra.Command { o := new(createControlOptions) cmd := &cobra.Command{ - Use: "control CONTROL-IDENTIFIER", - Short: createControlShortDesc, - Long: createControlLongDesc, - Example: createControlExample, - Args: cobra.ExactArgs(1), + Use: "control CONTROL-IDENTIFIER", + Short: createControlShortDesc, + Long: createControlLongDesc, + Example: createControlExample, + Args: cobra.ExactArgs(1), + Annotations: map[string]string{betaCLIAnnotation: ""}, PreRunE: func(cmd *cobra.Command, args []string) error { err := RequireGlobalFlags(global, []string{"Org", "ApiToken"}) if err != nil { diff --git a/cmd/kosli/getControl.go b/cmd/kosli/getControl.go index 49cbb8874..aa3e2e23a 100644 --- a/cmd/kosli/getControl.go +++ b/cmd/kosli/getControl.go @@ -30,11 +30,12 @@ type getControlOptions struct { func newGetControlCmd(out io.Writer) *cobra.Command { o := new(getControlOptions) cmd := &cobra.Command{ - Use: "control CONTROL-IDENTIFIER", - Short: getControlShortDesc, - Long: getControlShortDesc, - Example: getControlExample, - Args: cobra.ExactArgs(1), + Use: "control CONTROL-IDENTIFIER", + Short: getControlShortDesc, + Long: getControlShortDesc, + Example: getControlExample, + Args: cobra.ExactArgs(1), + Annotations: map[string]string{betaCLIAnnotation: ""}, PreRunE: func(cmd *cobra.Command, args []string) error { err := RequireGlobalFlags(global, []string{"Org", "ApiToken"}) if err != nil { diff --git a/cmd/kosli/lifecycle_test.go b/cmd/kosli/lifecycle_test.go index 3bc6e20b0..5cd76d4b5 100644 --- a/cmd/kosli/lifecycle_test.go +++ b/cmd/kosli/lifecycle_test.go @@ -39,6 +39,22 @@ func TestLifecycleAttestDecisionIsBetaAndDocHidden(t *testing.T) { } } +func TestLifecycleControlCommandsAreBeta(t *testing.T) { + global = &GlobalOpts{} + cmds := map[string]*cobra.Command{ + "create control": newCreateControlCmd(io.Discard), + "list controls": newListControlsCmd(io.Discard), + "get control": newGetControlCmd(io.Discard), + "archive control": newArchiveControlCmd(io.Discard), + "unarchive control": newUnarchiveControlCmd(io.Discard), + } + for name, cmd := range cmds { + if !isBeta(cmd) { + t.Errorf("expected %q to be marked beta while controls is behind a feature flag", name) + } + } +} + func TestDeprecationHint(t *testing.T) { generic := &cobra.Command{Use: "x", Deprecated: deprecatedCommandMsg} if got := deprecationHint(generic); got != "" { diff --git a/cmd/kosli/listControls.go b/cmd/kosli/listControls.go index 91ff7e0ef..d4dde8586 100644 --- a/cmd/kosli/listControls.go +++ b/cmd/kosli/listControls.go @@ -43,11 +43,12 @@ type listControlsOptions struct { func newListControlsCmd(out io.Writer) *cobra.Command { o := new(listControlsOptions) cmd := &cobra.Command{ - Use: "controls", - Short: listControlsShortDesc, - Long: listControlsLongDesc, - Example: listControlsExample, - Args: cobra.NoArgs, + Use: "controls", + Short: listControlsShortDesc, + Long: listControlsLongDesc, + Example: listControlsExample, + Args: cobra.NoArgs, + Annotations: map[string]string{betaCLIAnnotation: ""}, PreRunE: func(cmd *cobra.Command, args []string) error { err := RequireGlobalFlags(global, []string{"Org", "ApiToken"}) if err != nil { diff --git a/cmd/kosli/unarchiveControl.go b/cmd/kosli/unarchiveControl.go index 1b0a63e3b..75ee96538 100644 --- a/cmd/kosli/unarchiveControl.go +++ b/cmd/kosli/unarchiveControl.go @@ -24,11 +24,12 @@ kosli unarchive control yourControlIdentifier \ func newUnarchiveControlCmd(out io.Writer) *cobra.Command { cmd := &cobra.Command{ - Use: "control CONTROL-IDENTIFIER", - Short: unarchiveControlShortDesc, - Long: unarchiveControlLongDesc, - Example: unarchiveControlExample, - Args: cobra.ExactArgs(1), + Use: "control CONTROL-IDENTIFIER", + Short: unarchiveControlShortDesc, + Long: unarchiveControlLongDesc, + Example: unarchiveControlExample, + Args: cobra.ExactArgs(1), + Annotations: map[string]string{betaCLIAnnotation: ""}, PreRunE: func(cmd *cobra.Command, args []string) error { err := RequireGlobalFlags(global, []string{"Org", "ApiToken"}) if err != nil { From 7079fcb7c0a2a206512f167a499547c9f8403a03 Mon Sep 17 00:00:00 2001 From: Peter Beckham Date: Wed, 1 Jul 2026 13:24:09 +0100 Subject: [PATCH 12/13] feat(attest decision): make command visible while keeping it beta Drop Hidden and the docHidden annotation from attest decision so it appears in --help and the generated docs, keeping the betaCLIAnnotation so it still shows the beta banner. Update the lifecycle test to assert the command is beta and visible. Co-Authored-By: Claude Opus 4.8 --- cmd/kosli/attestDecision.go | 4 +--- cmd/kosli/lifecycle_test.go | 14 +++++++------- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/cmd/kosli/attestDecision.go b/cmd/kosli/attestDecision.go index ae20612ae..5e6c8f469 100644 --- a/cmd/kosli/attestDecision.go +++ b/cmd/kosli/attestDecision.go @@ -7,7 +7,6 @@ import ( "net/url" "os" - "github.com/kosli-dev/cli/internal/docgen" "github.com/kosli-dev/cli/internal/requests" "github.com/spf13/cobra" ) @@ -97,8 +96,7 @@ func newAttestDecisionCmd(out io.Writer) *cobra.Command { Short: attestDecisionShortDesc, Long: attestDecisionLongDesc, Example: attestDecisionExample, - Hidden: true, - Annotations: map[string]string{docgen.DocHiddenAnnotation: "", betaCLIAnnotation: ""}, + Annotations: map[string]string{betaCLIAnnotation: ""}, PreRunE: func(cmd *cobra.Command, args []string) error { err := CustomMaximumNArgs(1, args) if err != nil { diff --git a/cmd/kosli/lifecycle_test.go b/cmd/kosli/lifecycle_test.go index 5cd76d4b5..ebf1ec0c7 100644 --- a/cmd/kosli/lifecycle_test.go +++ b/cmd/kosli/lifecycle_test.go @@ -22,20 +22,20 @@ func TestLifecycleEvaluateIsBeta(t *testing.T) { } } -func TestLifecycleAttestDecisionIsBetaAndDocHidden(t *testing.T) { +func TestLifecycleAttestDecisionIsBetaAndVisible(t *testing.T) { global = &GlobalOpts{} cmd := newAttestDecisionCmd(io.Discard) - if !cmd.Hidden { - t.Error("expected attest decision to stay Hidden") + if cmd.Hidden { + t.Error("expected attest decision to be visible (not Hidden)") } if !isBeta(cmd) { t.Error("expected attest decision to be beta") } - if _, ok := cmd.Annotations[docgen.DocHiddenAnnotation]; !ok { - t.Error("expected attest decision to carry the docHidden annotation") + if _, ok := cmd.Annotations[docgen.DocHiddenAnnotation]; ok { + t.Error("expected attest decision to no longer carry the docHidden annotation") } - if !isDocHidden(cmd) { - t.Error("expected isDocHidden to be true for attest decision") + if isDocHidden(cmd) { + t.Error("expected isDocHidden to be false for attest decision") } } From c6a6a5d05709b16e2b8d4204924b0bd5ffab0fd4 Mon Sep 17 00:00:00 2001 From: Peter Beckham Date: Wed, 1 Jul 2026 15:07:29 +0100 Subject: [PATCH 13/13] test(controls): address review feedback on list/archive commands - drop the "control is archived/active afterwards" cases from the archive/unarchive tests: verifying persisted state is the server's responsibility, and the existing archiveFlow/archiveEnvironment tests assert only the success message and error cases - add --search, --tag and --archived examples to list controls help - move listControlsResponse up with the other type declarations Co-Authored-By: Claude Opus 4.8 --- cmd/kosli/archiveControl_test.go | 5 ----- cmd/kosli/listControls.go | 32 +++++++++++++++++++++++------- cmd/kosli/unarchiveControl_test.go | 5 ----- 3 files changed, 25 insertions(+), 17 deletions(-) diff --git a/cmd/kosli/archiveControl_test.go b/cmd/kosli/archiveControl_test.go index dcd1153d0..874f52a8f 100644 --- a/cmd/kosli/archiveControl_test.go +++ b/cmd/kosli/archiveControl_test.go @@ -35,11 +35,6 @@ func (suite *ArchiveControlCommandTestSuite) TestArchiveControlCmd() { cmd: "archive control archive-me" + suite.defaultKosliArguments, golden: "control archive-me was archived\n", }, - { - name: "the control is archived afterwards", - cmd: "get control archive-me --output json" + suite.defaultKosliArguments, - goldenJson: []jsonCheck{{"archived", true}}, - }, { wantError: true, name: "archiving a non-existing control gives a clear error", diff --git a/cmd/kosli/listControls.go b/cmd/kosli/listControls.go index d4dde8586..6a1f2bc5c 100644 --- a/cmd/kosli/listControls.go +++ b/cmd/kosli/listControls.go @@ -31,6 +31,24 @@ kosli list controls \ --output json \ --api-token yourAPIToken \ --org yourOrgName + +# list controls whose name or identifier contains "sdlc": +kosli list controls \ + --search sdlc \ + --api-token yourAPIToken \ + --org yourOrgName + +# list controls tagged framework=finos-sdlc (--tag can be repeated): +kosli list controls \ + --tag framework:finos-sdlc \ + --api-token yourAPIToken \ + --org yourOrgName + +# list archived controls instead of active ones: +kosli list controls \ + --archived \ + --api-token yourAPIToken \ + --org yourOrgName ` type listControlsOptions struct { @@ -40,6 +58,13 @@ type listControlsOptions struct { archived bool } +type listControlsResponse struct { + Controls []map[string]interface{} `json:"controls"` + Page int `json:"page"` + TotalPages int `json:"total_pages"` + TotalCount int `json:"total_count"` +} + func newListControlsCmd(out io.Writer) *cobra.Command { o := new(listControlsOptions) cmd := &cobra.Command{ @@ -106,13 +131,6 @@ func (o *listControlsOptions) run(out io.Writer) error { }) } -type listControlsResponse struct { - Controls []map[string]interface{} `json:"controls"` - Page int `json:"page"` - TotalPages int `json:"total_pages"` - TotalCount int `json:"total_count"` -} - func printControlsListAsTable(raw string, out io.Writer, page int) error { response := &listControlsResponse{} if err := json.Unmarshal([]byte(raw), response); err != nil { diff --git a/cmd/kosli/unarchiveControl_test.go b/cmd/kosli/unarchiveControl_test.go index 1a3e96331..bc718a5f1 100644 --- a/cmd/kosli/unarchiveControl_test.go +++ b/cmd/kosli/unarchiveControl_test.go @@ -36,11 +36,6 @@ func (suite *UnarchiveControlCommandTestSuite) TestUnarchiveControlCmd() { cmd: "unarchive control unarchive-me" + suite.defaultKosliArguments, golden: "control unarchive-me was unarchived\n", }, - { - name: "the control is active afterwards", - cmd: "get control unarchive-me --output json" + suite.defaultKosliArguments, - goldenJson: []jsonCheck{{"archived", false}}, - }, { wantError: true, name: "unarchiving a non-existing control gives a clear error",