From 04f4629688fc14009069c0a15c2971e61bd48f64 Mon Sep 17 00:00:00 2001 From: Peter Beckham Date: Wed, 1 Jul 2026 16:53:35 +0100 Subject: [PATCH 1/2] feat(controls): add 'kosli update control' command Adds the missing "update" of CRUD for controls, wrapping the released PUT /api/v2/controls/{org}/{identifier} endpoint (body ControlPutInput). - --name/-n, --description/-d, and repeatable --link (name=url) - only flags the user set are sent, so omitted fields are left untouched (matches the server's model_fields_set merge); --link replaces all links - RequireAtLeastOneOfFlags gives an actionable error when no updatable flag is provided; not-found and validation errors surface verbatim - beta-gated (betaCLIAnnotation), consistent with the other control commands Closes #6096 Co-Authored-By: Claude Opus 4.8 --- cmd/kosli/root.go | 2 + cmd/kosli/update.go | 1 + cmd/kosli/updateControl.go | 101 ++++++++++++++++++++++++++++++++ cmd/kosli/updateControl_test.go | 72 +++++++++++++++++++++++ 4 files changed, 176 insertions(+) create mode 100644 cmd/kosli/updateControl.go create mode 100644 cmd/kosli/updateControl_test.go diff --git a/cmd/kosli/root.go b/cmd/kosli/root.go index 51d09f23e..2d278e7b1 100644 --- a/cmd/kosli/root.go +++ b/cmd/kosli/root.go @@ -299,7 +299,9 @@ The ^.kosli_ignore^ will be treated as part of the artifact like any other file, 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." + updateControlNameFlag = "[optional] The new control name." controlDescriptionFlag = "[optional] The control description." + controlLinkFlag = "[optional] A link for the control, given as 'name=url'. Can be repeated. Replaces all existing links." 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." diff --git a/cmd/kosli/update.go b/cmd/kosli/update.go index d64759e52..5fb74cbae 100644 --- a/cmd/kosli/update.go +++ b/cmd/kosli/update.go @@ -20,6 +20,7 @@ func newUpdateCmd(out io.Writer) *cobra.Command { cmd.AddCommand( newUpdateServiceAccountCmd(out), newUpdateDefaultOrgCmd(out), + newUpdateControlCmd(out), ) return cmd diff --git a/cmd/kosli/updateControl.go b/cmd/kosli/updateControl.go new file mode 100644 index 000000000..0cb03e477 --- /dev/null +++ b/cmd/kosli/updateControl.go @@ -0,0 +1,101 @@ +package main + +import ( + "io" + "net/http" + "net/url" + + "github.com/kosli-dev/cli/internal/requests" + "github.com/spf13/cobra" +) + +const updateControlShortDesc = `Update a Kosli control.` + +const updateControlLongDesc = updateControlShortDesc + ` + +Only the flags you provide are changed; omitted fields are left untouched. +Providing ^--link^ replaces all of the control's existing links.` + +const updateControlExample = ` +# update a control's name: +kosli update control yourControlIdentifier \ + --name "New control name" \ + --api-token yourAPIToken \ + --org yourOrgName + +# update a control's description and links: +kosli update control yourControlIdentifier \ + --description "what this control checks" \ + --link runbook=https://example.com/runbook \ + --api-token yourAPIToken \ + --org yourOrgName +` + +type updateControlOptions struct { + name string + description string + links map[string]string +} + +func newUpdateControlCmd(out io.Writer) *cobra.Command { + o := new(updateControlOptions) + cmd := &cobra.Command{ + Use: "control CONTROL-IDENTIFIER", + Short: updateControlShortDesc, + Long: updateControlLongDesc, + Example: updateControlExample, + Args: cobra.ExactArgs(1), + Annotations: map[string]string{betaCLIAnnotation: ""}, + PreRunE: func(cmd *cobra.Command, args []string) error { + if err := RequireGlobalFlags(global, []string{"Org", "ApiToken"}); err != nil { + return ErrorBeforePrintingUsage(cmd, err.Error()) + } + return RequireAtLeastOneOfFlags(cmd, []string{"name", "description", "link"}) + }, + RunE: func(cmd *cobra.Command, args []string) error { + return o.run(cmd, args) + }, + } + + cmd.Flags().StringVarP(&o.name, "name", "n", "", updateControlNameFlag) + cmd.Flags().StringVarP(&o.description, "description", "d", "", controlDescriptionFlag) + cmd.Flags().StringToStringVar(&o.links, "link", map[string]string{}, controlLinkFlag) + + addDryRunFlag(cmd) + + return cmd +} + +func (o *updateControlOptions) run(cmd *cobra.Command, args []string) error { + url, err := url.JoinPath(global.Host, "api/v2/controls", global.Org, args[0]) + if err != nil { + return err + } + + // Only send the fields the user explicitly set, so unset flags leave the + // corresponding values unchanged (the server treats an omitted field as + // "no change"). + payload := map[string]interface{}{} + if cmd.Flags().Changed("name") { + payload["name"] = o.name + } + if cmd.Flags().Changed("description") { + payload["description"] = o.description + } + if cmd.Flags().Changed("link") { + payload["links"] = o.links + } + + reqParams := &requests.RequestParams{ + Method: http.MethodPut, + URL: url, + Payload: payload, + DryRun: global.DryRun, + Token: global.ApiToken, + } + _, err = kosliClient.Do(reqParams) + if err == nil && !global.DryRun { + logger.Info("control %s was updated", args[0]) + } + return err +} diff --git a/cmd/kosli/updateControl_test.go b/cmd/kosli/updateControl_test.go new file mode 100644 index 000000000..ce5e8ad96 --- /dev/null +++ b/cmd/kosli/updateControl_test.go @@ -0,0 +1,72 @@ +package main + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/suite" +) + +type UpdateControlCommandTestSuite struct { + suite.Suite + defaultKosliArguments string +} + +func (suite *UpdateControlCommandTestSuite) 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, "update-me", "Update me", suite.T()) +} + +func (suite *UpdateControlCommandTestSuite) TestUpdateControlCmd() { + tests := []cmdTestCase{ + { + wantError: true, + name: "fails when no identifier argument is provided", + cmd: "update control --name 'New name'" + suite.defaultKosliArguments, + golden: "Error: accepts 1 arg(s), received 0\n", + }, + { + wantError: true, + name: "fails when no updatable flags are provided", + cmd: "update control update-me" + suite.defaultKosliArguments, + golden: "Error: at least one of --name, --description, --link is required\n", + }, + { + name: "updating a control's name works", + cmd: "update control update-me --name 'Updated name'" + suite.defaultKosliArguments, + golden: "control update-me was updated\n", + }, + { + name: "updating a control's description works", + cmd: "update control update-me --description 'checks something new'" + suite.defaultKosliArguments, + golden: "control update-me was updated\n", + }, + { + name: "updating a control's links works", + cmd: "update control update-me --link runbook=https://example.com/runbook" + suite.defaultKosliArguments, + golden: "control update-me was updated\n", + }, + { + name: "updating multiple fields in one call works", + cmd: "update control update-me --name 'Another name' --description 'and a description'" + suite.defaultKosliArguments, + golden: "control update-me was updated\n", + }, + { + wantError: true, + name: "updating a non-existing control gives a clear error", + cmd: "update control no-such-control --name 'New name'" + suite.defaultKosliArguments, + goldenRegex: "^Error: Control 'no-such-control' does not exist in org", + }, + } + + runTestCmd(suite.T(), tests) +} + +func TestUpdateControlCommandTestSuite(t *testing.T) { + suite.Run(t, new(UpdateControlCommandTestSuite)) +} From 9173a5cfec3f1bdf5442d5294a18daa02f888b18 Mon Sep 17 00:00:00 2001 From: Peter Beckham Date: Wed, 1 Jul 2026 18:23:04 +0100 Subject: [PATCH 2/2] test(controls): assert update control sends only changed fields Adds a dry-run case pinning the command's core behaviour: a name-only update must send exactly {"name": ...} in the PUT body, with no stray empty description or links. Guards the model_fields_set merge against regressions the success-message assertions couldn't catch. Co-Authored-By: Claude Opus 4.8 --- cmd/kosli/updateControl_test.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/cmd/kosli/updateControl_test.go b/cmd/kosli/updateControl_test.go index ce5e8ad96..a1c23159e 100644 --- a/cmd/kosli/updateControl_test.go +++ b/cmd/kosli/updateControl_test.go @@ -56,6 +56,14 @@ func (suite *UpdateControlCommandTestSuite) TestUpdateControlCmd() { cmd: "update control update-me --name 'Another name' --description 'and a description'" + suite.defaultKosliArguments, golden: "control update-me was updated\n", }, + { + // Guards the core behaviour: only the flags the user set land in the + // PUT body. A name-only update must send exactly {"name": ...} — no + // stray empty description or links. + name: "sends only the fields that were set (dry-run)", + cmd: "update control update-me --name 'Only name' --dry-run" + suite.defaultKosliArguments, + goldenRegex: `(?s)controls/docs-cmd-test-user/update-me.*\{\s*"name": "Only name"\s*\}`, + }, { wantError: true, name: "updating a non-existing control gives a clear error",