From 9f1e043039ad51e054fe60e85a1ecd1c3b2d238c Mon Sep 17 00:00:00 2001 From: Marko Bevc Date: Thu, 25 Jun 2026 15:55:22 +0100 Subject: [PATCH 1/3] feat(cli): add Service Sccount commands Add CLI commands wrapping the new service account management endpoints (kosli-dev/server#5951): create, list, get, update and delete service accounts. Previously the CLI could only manage a service account's API keys, not the accounts themselves. - create/list/get/delete added under the existing verbs - update added under a new `update` parent command (registered in root) - shared models and table printers in `serviceAccount.go`, mirroring `apiKey.go` - httpfake-backed tests plus response fixtures under testdata/service-account --- cmd/kosli/create.go | 1 + cmd/kosli/createServiceAccount.go | 89 +++++ cmd/kosli/delete.go | 1 + cmd/kosli/deleteServiceAccount.go | 146 ++++++++ cmd/kosli/get.go | 1 + cmd/kosli/getServiceAccount.go | 72 ++++ cmd/kosli/list.go | 1 + cmd/kosli/listServiceAccounts.go | 72 ++++ cmd/kosli/root.go | 4 + cmd/kosli/serviceAccount.go | 86 +++++ cmd/kosli/serviceAccount_test.go | 330 ++++++++++++++++++ cmd/kosli/testdata/service-account/README.md | 34 +- .../created_service_account.json | 1 + .../service-account/delete_success.json | 1 + .../listed_service_accounts.json | 1 + .../service-account/service_account.json | 1 + .../updated_service_account.json | 1 + cmd/kosli/update.go | 25 ++ cmd/kosli/updateServiceAccount.go | 93 +++++ 19 files changed, 952 insertions(+), 8 deletions(-) create mode 100644 cmd/kosli/createServiceAccount.go create mode 100644 cmd/kosli/deleteServiceAccount.go create mode 100644 cmd/kosli/getServiceAccount.go create mode 100644 cmd/kosli/listServiceAccounts.go create mode 100644 cmd/kosli/serviceAccount.go create mode 100644 cmd/kosli/serviceAccount_test.go create mode 100644 cmd/kosli/testdata/service-account/created_service_account.json create mode 100644 cmd/kosli/testdata/service-account/delete_success.json create mode 100644 cmd/kosli/testdata/service-account/listed_service_accounts.json create mode 100644 cmd/kosli/testdata/service-account/service_account.json create mode 100644 cmd/kosli/testdata/service-account/updated_service_account.json create mode 100644 cmd/kosli/update.go create mode 100644 cmd/kosli/updateServiceAccount.go diff --git a/cmd/kosli/create.go b/cmd/kosli/create.go index 6ba942615..7e4ca5f30 100644 --- a/cmd/kosli/create.go +++ b/cmd/kosli/create.go @@ -23,6 +23,7 @@ func newCreateCmd(out io.Writer) *cobra.Command { newCreatePolicyCmd(out), newCreateAttestationTypeCmd(out), newCreateApiKeyCmd(out), + newCreateServiceAccountCmd(out), ) return cmd } diff --git a/cmd/kosli/createServiceAccount.go b/cmd/kosli/createServiceAccount.go new file mode 100644 index 000000000..58f735b0b --- /dev/null +++ b/cmd/kosli/createServiceAccount.go @@ -0,0 +1,89 @@ +package main + +import ( + "io" + "net/http" + "net/url" + + "github.com/kosli-dev/cli/internal/requests" + "github.com/spf13/cobra" +) + +const createServiceAccountShortDesc = `Create a service account.` + +const createServiceAccountLongDesc = createServiceAccountShortDesc + ` + +A service account is a non-human identity in your organization. API keys are +created separately for it with ^kosli create api-key^.` + +const createServiceAccountExample = ` +# create a service account: +kosli create service-account yourServiceAccountName \ + --privilege member \ + --description "CI service account" \ + --api-token yourAPIToken \ + --org yourOrgName +` + +type createServiceAccountOptions struct { + payload createServiceAccountPayload +} + +type createServiceAccountPayload struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Privilege string `json:"privilege"` +} + +func newCreateServiceAccountCmd(out io.Writer) *cobra.Command { + o := new(createServiceAccountOptions) + cmd := &cobra.Command{ + Use: "service-account SERVICE-ACCOUNT-NAME", + Aliases: []string{"sa"}, + Short: createServiceAccountShortDesc, + Long: createServiceAccountLongDesc, + Example: createServiceAccountExample, + Args: cobra.ExactArgs(1), + PreRunE: func(cmd *cobra.Command, args []string) error { + if err := RequireGlobalFlags(global, []string{"Org", "ApiToken"}); 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.Description, "description", "d", "", serviceAccountDescriptionFlag) + cmd.Flags().StringVar(&o.payload.Privilege, "privilege", "", serviceAccountPrivilegeFlag) + addDryRunFlag(cmd) + + err := RequireFlags(cmd, []string{"privilege"}) + if err != nil { + logger.Error("failed to configure required flags: %v", err) + } + + return cmd +} + +func (o *createServiceAccountOptions) run(args []string) error { + o.payload.Name = args[0] + url, err := url.JoinPath(global.Host, "api/v2/service-accounts", global.Org) + if err != nil { + return err + } + + reqParams := &requests.RequestParams{ + Method: http.MethodPost, + URL: url, + Payload: o.payload, + DryRun: global.DryRun, + Token: global.ApiToken, + } + _, err = kosliClient.Do(reqParams) + if err == nil && !global.DryRun { + logger.Info("service account %s was created", o.payload.Name) + } + return err +} diff --git a/cmd/kosli/delete.go b/cmd/kosli/delete.go index d66cd23d4..8eda3e5d7 100644 --- a/cmd/kosli/delete.go +++ b/cmd/kosli/delete.go @@ -19,6 +19,7 @@ func newDeleteCmd(out io.Writer) *cobra.Command { // Add subcommands cmd.AddCommand( newDeleteApiKeyCmd(out), + newDeleteServiceAccountCmd(out), ) return cmd diff --git a/cmd/kosli/deleteServiceAccount.go b/cmd/kosli/deleteServiceAccount.go new file mode 100644 index 000000000..e397d56e6 --- /dev/null +++ b/cmd/kosli/deleteServiceAccount.go @@ -0,0 +1,146 @@ +package main + +import ( + "bufio" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/kosli-dev/cli/internal/requests" + "github.com/spf13/cobra" +) + +const deleteServiceAccountShortDesc = `Delete one or more service accounts.` + +const deleteServiceAccountLongDesc = deleteServiceAccountShortDesc + ` + +This permanently removes the service account(s) identified by SERVICE-ACCOUNT-NAME +from the organization, along with their API keys. Deletion is immediate and +cannot be undone. You are asked to confirm before deletion; use +^--assume-yes^/^--yes^ to skip the confirmation prompt.` + +const deleteServiceAccountExample = ` +# delete a service account (asks for confirmation): +kosli delete service-account yourServiceAccountName \ + --api-token yourAPIToken \ + --org yourOrgName + +# delete multiple service accounts at once: +kosli delete service-account sa1 sa2 \ + --api-token yourAPIToken \ + --org yourOrgName + +# delete a service account without confirmation: +kosli delete service-account yourServiceAccountName \ + --assume-yes \ + --api-token yourAPIToken \ + --org yourOrgName +` + +type deleteServiceAccountOptions struct { + assumeYes bool +} + +func newDeleteServiceAccountCmd(out io.Writer) *cobra.Command { + o := new(deleteServiceAccountOptions) + cmd := &cobra.Command{ + Use: "service-account SERVICE-ACCOUNT-NAME [SERVICE-ACCOUNT-NAME...]", + Aliases: []string{"sa"}, + Short: deleteServiceAccountShortDesc, + Long: deleteServiceAccountLongDesc, + Example: deleteServiceAccountExample, + Args: cobra.MinimumNArgs(1), + PreRunE: func(cmd *cobra.Command, args []string) error { + if err := RequireGlobalFlags(global, []string{"Org", "ApiToken"}); err != nil { + return ErrorBeforePrintingUsage(cmd, err.Error()) + } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + return o.run(cmd.InOrStdin(), args) + }, + } + + cmd.Flags().BoolVarP(&o.assumeYes, "assume-yes", "y", false, serviceAccountAssumeYesFlag) + // keep --yes as a hidden alias for --assume-yes (bound to the same option) + cmd.Flags().BoolVar(&o.assumeYes, "yes", false, serviceAccountAssumeYesFlag) + if f := cmd.Flags().Lookup("yes"); f != nil { + f.Hidden = true + } + addDryRunFlag(cmd) + + return cmd +} + +func (o *deleteServiceAccountOptions) run(in io.Reader, args []string) error { + if !o.assumeYes && !global.DryRun { + confirmed, err := confirmServiceAccountDeletion(args, in) + if err != nil { + return err + } + if !confirmed { + logger.Info("Deletion of service account(s) %s was cancelled.", strings.Join(styleServiceAccountNames(args), ", ")) + return nil + } + } + + // deletion is destructive and one-way: on any failure mid-batch, make clear + // which service accounts were already deleted before it. + reportAlreadyDeleted := func(i int) { + if i > 0 { + logger.Info("Service accounts already deleted before this failure: %s", strings.Join(styleServiceAccountNames(args[:i]), ", ")) + } + } + + for i, name := range args { + url, err := url.JoinPath(global.Host, "api/v2/service-accounts", global.Org, name) + if err != nil { + reportAlreadyDeleted(i) + return err + } + + reqParams := &requests.RequestParams{ + Method: http.MethodDelete, + URL: url, + DryRun: global.DryRun, + Token: global.ApiToken, + } + if _, err := kosliClient.Do(reqParams); err != nil { + reportAlreadyDeleted(i) + return fmt.Errorf("failed to delete service account: %w", err) + } + if !global.DryRun { + logger.Info("service account %s was deleted!", style(logger.Out, name, ansiBold, ansiCyan)) + } + } + return nil +} + +// styleServiceAccountNames styles service account names for user-facing +// messages printed via logger (bold cyan when styling is enabled). +func styleServiceAccountNames(names []string) []string { + styled := make([]string, len(names)) + for i, name := range names { + styled[i] = style(logger.Out, name, ansiBold, ansiCyan) + } + return styled +} + +// confirmServiceAccountDeletion prompts the user to confirm deletion and +// returns true only when the answer is an affirmative "y"/"yes" +// (case-insensitive). The prompt has no trailing newline so the answer is +// typed on the same line. +func confirmServiceAccountDeletion(names []string, in io.Reader) (bool, error) { + logger.Print("Are you sure you want to delete service account(s) %s? [y/N] ", + strings.Join(styleServiceAccountNames(names), ", ")) + + answer, err := bufio.NewReader(in).ReadString('\n') + if err != nil && err != io.EOF { + return false, err + } + + answer = strings.ToLower(strings.TrimSpace(answer)) + return answer == "y" || answer == "yes", nil +} diff --git a/cmd/kosli/get.go b/cmd/kosli/get.go index c3f4db0e8..cb31a4ce1 100644 --- a/cmd/kosli/get.go +++ b/cmd/kosli/get.go @@ -28,6 +28,7 @@ func newGetCmd(out io.Writer) *cobra.Command { newGetAttestationTypeCmd(out), newGetAttestationCmd(out), newGetRepoCmd(out), + newGetServiceAccountCmd(out), ) return cmd } diff --git a/cmd/kosli/getServiceAccount.go b/cmd/kosli/getServiceAccount.go new file mode 100644 index 000000000..db7781fc0 --- /dev/null +++ b/cmd/kosli/getServiceAccount.go @@ -0,0 +1,72 @@ +package main + +import ( + "io" + "net/http" + "net/url" + + "github.com/kosli-dev/cli/internal/output" + "github.com/kosli-dev/cli/internal/requests" + "github.com/spf13/cobra" +) + +const getServiceAccountShortDesc = `Get a service account's metadata.` + +const getServiceAccountExample = ` +# get the metadata of a service account: +kosli get service-account yourServiceAccountName \ + --api-token yourAPIToken \ + --org yourOrgName +` + +type getServiceAccountOptions struct { + output string +} + +func newGetServiceAccountCmd(out io.Writer) *cobra.Command { + o := new(getServiceAccountOptions) + cmd := &cobra.Command{ + Use: "service-account SERVICE-ACCOUNT-NAME", + Aliases: []string{"sa"}, + Short: getServiceAccountShortDesc, + Long: getServiceAccountShortDesc, + Example: getServiceAccountExample, + Args: cobra.ExactArgs(1), + PreRunE: func(cmd *cobra.Command, args []string) error { + if err := RequireGlobalFlags(global, []string{"Org", "ApiToken"}); 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 *getServiceAccountOptions) run(out io.Writer, args []string) error { + url, err := url.JoinPath(global.Host, "api/v2/service-accounts", 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": printServiceAccountAsTable, + "json": output.PrintJson, + }) +} diff --git a/cmd/kosli/list.go b/cmd/kosli/list.go index 577f47d7c..94259a931 100644 --- a/cmd/kosli/list.go +++ b/cmd/kosli/list.go @@ -44,6 +44,7 @@ func newListCmd(out io.Writer) *cobra.Command { newListAttestationTypesCmd(out), newListReposCmd(out), newListApiKeysCmd(out), + newListServiceAccountsCmd(out), ) return cmd diff --git a/cmd/kosli/listServiceAccounts.go b/cmd/kosli/listServiceAccounts.go new file mode 100644 index 000000000..a07368cc5 --- /dev/null +++ b/cmd/kosli/listServiceAccounts.go @@ -0,0 +1,72 @@ +package main + +import ( + "io" + "net/http" + "net/url" + + "github.com/kosli-dev/cli/internal/output" + "github.com/kosli-dev/cli/internal/requests" + "github.com/spf13/cobra" +) + +const listServiceAccountsShortDesc = `List service accounts in an organization.` + +const listServiceAccountsExample = ` +# list the service accounts in an organization: +kosli list service-accounts \ + --api-token yourAPIToken \ + --org yourOrgName +` + +type listServiceAccountsOptions struct { + output string +} + +func newListServiceAccountsCmd(out io.Writer) *cobra.Command { + o := new(listServiceAccountsOptions) + cmd := &cobra.Command{ + Use: "service-accounts", + Aliases: []string{"sa", "sas", "service-account"}, + Short: listServiceAccountsShortDesc, + Long: listServiceAccountsShortDesc, + Example: listServiceAccountsExample, + Args: cobra.NoArgs, + PreRunE: func(cmd *cobra.Command, args []string) error { + if err := RequireGlobalFlags(global, []string{"Org", "ApiToken"}); 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 *listServiceAccountsOptions) run(out io.Writer, args []string) error { + url, err := url.JoinPath(global.Host, "api/v2/service-accounts", global.Org) + 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": printServiceAccountsListAsTable, + "json": output.PrintJson, + }) +} diff --git a/cmd/kosli/root.go b/cmd/kosli/root.go index 9b4ec0625..0584437e8 100644 --- a/cmd/kosli/root.go +++ b/cmd/kosli/root.go @@ -120,6 +120,9 @@ The ^.kosli_ignore^ will be treated as part of the artifact like any other file, searchByNameFlag = "[optional] Only list flows whose name contains this substring. The Kosli API supports alphanumeric characters and '-'." ignoreCaseFlag = "[optional] Perform case-insensitive matching for --name. By default matching is case sensitive." serviceAccountNameFlag = "The name of the service account whose API keys are managed." + serviceAccountDescriptionFlag = "[optional] A description for the service account." + serviceAccountPrivilegeFlag = "The privilege granted to the service account." + serviceAccountAssumeYesFlag = "[optional] Skip the confirmation prompt and delete the service account without asking. (alias: --yes)" apiKeyDescriptionFlag = "A description for the API key." apiKeyExpiresAtFlag = "[optional] When the API key expires. Accepts an epoch timestamp or a date like '2026-06-04', '2026-06-04 15:04:05', or an RFC3339 timestamp. Defaults to no expiry." apiKeyGracePeriodHoursFlag = "[optional] How many hours the old API key remains valid after rotation, to allow time to update dependent systems. Defaults to the server-side value when not set." @@ -439,6 +442,7 @@ func newRootCmd(out, errOut io.Writer, args []string) (*cobra.Command, error) { newEvaluateCmd(out), newDeleteCmd(out), newRotateCmd(out), + newUpdateCmd(out), ) cobra.AddTemplateFunc("isBeta", isBeta) diff --git a/cmd/kosli/serviceAccount.go b/cmd/kosli/serviceAccount.go new file mode 100644 index 000000000..02aa78384 --- /dev/null +++ b/cmd/kosli/serviceAccount.go @@ -0,0 +1,86 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" +) + +// serviceAccount models a single service account as returned by the +// create/get/update endpoints (ServiceAccountResponse) and, as a subset, by +// the list endpoint (ServiceAccountListItemResponse). Timestamps are epoch +// seconds (float64), matching the rest of the v2 API (see apiKey.go). +// +// NOTE: the exact field set is mirrored from the server's ServiceAccount* +// response models. If the API adds/renames fields, update this struct and the +// fixtures under testdata/service-account. +type serviceAccount struct { + Name string `json:"name"` + Description string `json:"description"` + Privilege string `json:"privilege"` + CreatedAt float64 `json:"created_at"` +} + +// printServiceAccountAsTable renders a single service account (the create/get/ +// update response) as a key:value table. +func printServiceAccountAsTable(raw string, out io.Writer, page int) error { + var sa serviceAccount + if err := json.Unmarshal([]byte(raw), &sa); err != nil { + return err + } + + rows, err := serviceAccountTableRows(sa) + if err != nil { + return err + } + tabFormattedPrint(out, []string{}, rows) + return nil +} + +// printServiceAccountsListAsTable renders the list response as a table. +func printServiceAccountsListAsTable(raw string, out io.Writer, page int) error { + var accounts []serviceAccount + if err := json.Unmarshal([]byte(raw), &accounts); err != nil { + return err + } + + if len(accounts) == 0 { + logger.Info("No service accounts were found.") + return nil + } + + header := []string{"NAME", "DESCRIPTION", "PRIVILEGE", "CREATED"} + rows := []string{} + for _, sa := range accounts { + createdAt, err := optionalTimestamp(sa.CreatedAt) + if err != nil { + return err + } + description := sa.Description + if description == "" { + description = "N/A" + } + rows = append(rows, fmt.Sprintf("%s\t%s\t%s\t%s", sa.Name, description, sa.Privilege, createdAt)) + } + tabFormattedPrint(out, header, rows) + return nil +} + +// serviceAccountTableRows builds the key:value rows describing one service account. +func serviceAccountTableRows(sa serviceAccount) ([]string, error) { + createdAt, err := optionalTimestamp(sa.CreatedAt) + if err != nil { + return nil, err + } + description := sa.Description + if description == "" { + description = "N/A" + } + + rows := []string{} + rows = append(rows, fmt.Sprintf("Name:\t%s", sa.Name)) + rows = append(rows, fmt.Sprintf("Description:\t%s", description)) + rows = append(rows, fmt.Sprintf("Privilege:\t%s", sa.Privilege)) + rows = append(rows, fmt.Sprintf("Created At:\t%s", createdAt)) + return rows, nil +} diff --git a/cmd/kosli/serviceAccount_test.go b/cmd/kosli/serviceAccount_test.go new file mode 100644 index 000000000..60deb358d --- /dev/null +++ b/cmd/kosli/serviceAccount_test.go @@ -0,0 +1,330 @@ +package main + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/maxcnunes/httpfake" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +// serviceAccountFixture reads a service-account response fixture from +// testdata/service-account. The fixtures hold the canonical API response +// bodies so the response contract lives in one place (see the README there). +func serviceAccountFixture(t *testing.T, name string) string { + t.Helper() + body, err := os.ReadFile(filepath.Join("testdata", "service-account", name)) + require.NoError(t, err, "failed to read fixture %s", name) + return string(body) +} + +func TestPrintServiceAccountAsTable(t *testing.T) { + // Timestamps come back as floating-point epoch seconds. + raw := `{"name":"ci-bot","description":"CI service account","privilege":"member","created_at":1780584129.6878593}` + + var buf bytes.Buffer + err := printServiceAccountAsTable(raw, &buf, 0) + require.NoError(t, err) + + out := buf.String() + for _, want := range []string{"Name:", "ci-bot", "Description:", "CI service account", "Privilege:", "member", "Created At:"} { + require.Contains(t, out, want) + } +} + +func TestPrintServiceAccountAsTableEmptyDescription(t *testing.T) { + raw := `{"name":"ci-bot","description":"","privilege":"member","created_at":1780584129.5}` + + var buf bytes.Buffer + err := printServiceAccountAsTable(raw, &buf, 0) + require.NoError(t, err) + require.Regexp(t, `Description:\s+N/A`, buf.String()) +} + +func TestPrintServiceAccountsListAsTable(t *testing.T) { + raw := `[{"name":"ci-bot","description":"first","privilege":"member","created_at":1780584129.5},` + + `{"name":"deployer","description":"","privilege":"admin","created_at":1780584130.5}]` + + var buf bytes.Buffer + err := printServiceAccountsListAsTable(raw, &buf, 0) + require.NoError(t, err) + + out := buf.String() + for _, want := range []string{"NAME", "DESCRIPTION", "PRIVILEGE", "CREATED", "ci-bot", "first", "deployer", "admin"} { + require.Contains(t, out, want) + } + // the deployer's empty description must render as N/A, not blank + require.Contains(t, out, "N/A") +} + +// Define the suite, and absorb the built-in basic suite functionality from testify. +type ServiceAccountCommandTestSuite struct { + suite.Suite + defaultKosliArguments string +} + +func (suite *ServiceAccountCommandTestSuite) 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 *ServiceAccountCommandTestSuite) TestCreateServiceAccountCmd() { + tests := []cmdTestCase{ + { + wantError: false, + name: "create builds the right url and payload (dry-run)", + cmd: "create service-account ci-bot --privilege member --description 'CI bot' --dry-run" + suite.defaultKosliArguments, + goldenRegex: `(?s)service-accounts/docs-cmd-test-user.*"name": "ci-bot".*"privilege": "member"`, + }, + { + wantError: false, + name: "the service-account alias (sa) works", + cmd: "create sa ci-bot --privilege member --dry-run" + suite.defaultKosliArguments, + goldenRegex: `service-accounts/docs-cmd-test-user`, + }, + { + wantError: true, + name: "create fails when NAME argument is missing", + cmd: "create service-account --privilege member" + suite.defaultKosliArguments, + golden: "Error: accepts 1 arg(s), received 0\n", + }, + { + wantError: true, + name: "create fails when --privilege is missing", + cmd: "create service-account ci-bot" + suite.defaultKosliArguments, + golden: "Error: required flag(s) \"privilege\" not set\n", + }, + } + + runTestCmd(suite.T(), tests) +} + +func (suite *ServiceAccountCommandTestSuite) TestListServiceAccountsCmd() { + tests := []cmdTestCase{ + { + wantError: true, + name: "list does not accept positional args", + cmd: "list service-accounts extra-arg" + suite.defaultKosliArguments, + golden: "Error: unknown command \"extra-arg\" for \"kosli list service-accounts\"\n", + }, + } + + runTestCmd(suite.T(), tests) +} + +func (suite *ServiceAccountCommandTestSuite) TestUpdateServiceAccountCmd() { + tests := []cmdTestCase{ + { + wantError: false, + name: "update sends only the changed description (dry-run)", + cmd: "update service-account ci-bot --description 'new desc' --dry-run" + suite.defaultKosliArguments, + goldenRegex: `(?s)service-accounts/docs-cmd-test-user/ci-bot.*"description": "new desc"`, + }, + { + wantError: false, + name: "update sends only the changed privilege (dry-run)", + cmd: "update service-account ci-bot --privilege admin --dry-run" + suite.defaultKosliArguments, + goldenRegex: `(?s)"privilege": "admin"`, + }, + { + wantError: true, + name: "update fails when neither --description nor --privilege is set", + cmd: "update service-account ci-bot" + suite.defaultKosliArguments, + goldenRegex: `at least one of --description, --privilege is required`, + }, + { + wantError: true, + name: "update fails when NAME argument is missing", + cmd: "update service-account --privilege admin" + suite.defaultKosliArguments, + golden: "Error: accepts 1 arg(s), received 0\n", + }, + } + + runTestCmd(suite.T(), tests) +} + +func (suite *ServiceAccountCommandTestSuite) TestDeleteServiceAccountCmd() { + tests := []cmdTestCase{ + { + wantError: false, + name: "delete without confirmation (empty stdin) is cancelled and makes no call", + cmd: "delete service-account ci-bot" + suite.defaultKosliArguments, + golden: "Are you sure you want to delete service account(s) ci-bot? [y/N] Deletion of service account(s) ci-bot was cancelled.\n", + }, + { + wantError: false, + name: "delete with --assume-yes and --dry-run builds the right url", + cmd: "delete service-account ci-bot --assume-yes --dry-run" + suite.defaultKosliArguments, + goldenRegex: `service-accounts/docs-cmd-test-user/ci-bot`, + }, + { + wantError: false, + name: "delete accepts multiple names (dry-run)", + cmd: "delete service-account sa1 sa2 --assume-yes --dry-run" + suite.defaultKosliArguments, + goldenRegex: `(?s)service-accounts/docs-cmd-test-user/sa1.*service-accounts/docs-cmd-test-user/sa2`, + }, + { + wantError: false, + name: "the sa alias, -y shorthand and hidden --yes work", + cmd: "delete sa ci-bot -y --dry-run" + suite.defaultKosliArguments, + goldenRegex: `service-accounts/docs-cmd-test-user/ci-bot`, + }, + { + wantError: true, + name: "delete fails when NAME argument is missing", + cmd: "delete service-account" + suite.defaultKosliArguments, + golden: "Error: requires at least 1 arg(s), only received 0\n", + }, + } + + runTestCmd(suite.T(), tests) +} + +// TestServiceAccountSuccessOutput stubs successful (2xx) API responses to verify +// that create/list/get/update render the server's response on the happy path. +func (suite *ServiceAccountCommandTestSuite) TestServiceAccountSuccessOutput() { + fake := httpfake.New() + defer fake.Close() + fake.NewHandler(). + Post("/api/v2/service-accounts/docs-cmd-test-user"). + Reply(201). + BodyString(serviceAccountFixture(suite.T(), "created_service_account.json")) + fake.NewHandler(). + Get("/api/v2/service-accounts/docs-cmd-test-user"). + Reply(200). + BodyString(serviceAccountFixture(suite.T(), "listed_service_accounts.json")) + fake.NewHandler(). + Get("/api/v2/service-accounts/docs-cmd-test-user/ci-bot"). + Reply(200). + BodyString(serviceAccountFixture(suite.T(), "service_account.json")) + fake.NewHandler(). + Patch("/api/v2/service-accounts/docs-cmd-test-user/ci-bot"). + Reply(200). + BodyString(serviceAccountFixture(suite.T(), "updated_service_account.json")) + + args := fmt.Sprintf(" --host %s --org %s --api-token %s", fake.Server.URL, global.Org, global.ApiToken) + tests := []cmdTestCase{ + { + wantError: false, + name: "create reports the created service account", + cmd: "create service-account ci-bot --privilege member" + args, + goldenRegex: `service account ci-bot was created`, + }, + { + wantError: false, + name: "list prints the returned service accounts", + cmd: "list service-accounts --output json" + args, + goldenRegex: `(?s)ci-bot.*deployer`, + }, + { + wantError: false, + name: "get prints the service account", + cmd: "get service-account ci-bot --output json" + args, + goldenRegex: `ci-bot`, + }, + { + wantError: false, + name: "update reports the updated service account", + cmd: "update service-account ci-bot --privilege admin" + args, + goldenRegex: `service account ci-bot was updated`, + }, + } + + runTestCmd(suite.T(), tests) +} + +// TestServiceAccountDeleteSuccess stubs a successful delete and verifies the +// CLI reports it. +func (suite *ServiceAccountCommandTestSuite) TestServiceAccountDeleteSuccess() { + fake := httpfake.New() + defer fake.Close() + fake.NewHandler(). + Delete("/api/v2/service-accounts/docs-cmd-test-user/ci-bot"). + Reply(200). + BodyString(serviceAccountFixture(suite.T(), "delete_success.json")) + + args := fmt.Sprintf(" --host %s --org %s --api-token %s", fake.Server.URL, global.Org, global.ApiToken) + tests := []cmdTestCase{ + { + wantError: false, + name: "delete reports the deleted service account", + cmd: "delete service-account ci-bot --assume-yes" + args, + goldenRegex: `service account ci-bot was deleted!`, + }, + } + + runTestCmd(suite.T(), tests) +} + +// TestServiceAccountDeletePartialFailure verifies that when one name in a +// multi-name delete fails, the names already deleted are reported before the +// error is surfaced. +func (suite *ServiceAccountCommandTestSuite) TestServiceAccountDeletePartialFailure() { + fake := httpfake.New() + defer fake.Close() + fake.NewHandler(). + Delete("/api/v2/service-accounts/docs-cmd-test-user/sa1"). + Reply(200). + BodyString(serviceAccountFixture(suite.T(), "delete_success.json")) + fake.NewHandler(). + Delete("/api/v2/service-accounts/docs-cmd-test-user/sa2"). + Reply(404). + BodyString(serviceAccountFixture(suite.T(), "error_service_account_not_found.json")) + + args := fmt.Sprintf(" --host %s --org %s --api-token %s", fake.Server.URL, global.Org, global.ApiToken) + tests := []cmdTestCase{ + { + wantError: true, + name: "delete reports deleted accounts before a later one fails", + cmd: "delete service-account sa1 sa2 --assume-yes" + args, + goldenRegex: `(?s)service account sa1 was deleted!.*already deleted before this failure: sa1.*failed to delete service account: Service account not found`, + }, + } + + runTestCmd(suite.T(), tests) +} + +// TestServiceAccountApiErrorsAreSurfaced stubs 4xx responses to verify that the +// commands surface the server's error message instead of succeeding. +func (suite *ServiceAccountCommandTestSuite) TestServiceAccountApiErrorsAreSurfaced() { + fake := httpfake.New() + defer fake.Close() + fake.NewHandler(). + Get("/api/v2/service-accounts/docs-cmd-test-user/missing-sa"). + Reply(404). + BodyString(serviceAccountFixture(suite.T(), "error_service_account_not_found.json")) + fake.NewHandler(). + Get("/api/v2/service-accounts/docs-cmd-test-user"). + Reply(403). + BodyString(serviceAccountFixture(suite.T(), "error_forbidden.json")) + + args := fmt.Sprintf(" --host %s --org %s --api-token %s", fake.Server.URL, global.Org, global.ApiToken) + tests := []cmdTestCase{ + { + wantError: true, + name: "get surfaces a 404 from the API as an error", + cmd: "get service-account missing-sa" + args, + goldenRegex: `Error: Service account not found`, + }, + { + wantError: true, + name: "list surfaces a 403 from the API as an error", + cmd: "list service-accounts" + args, + goldenRegex: `Error: You don't have permission to access this resource`, + }, + } + + runTestCmd(suite.T(), tests) +} + +func TestServiceAccountCommandTestSuite(t *testing.T) { + suite.Run(t, new(ServiceAccountCommandTestSuite)) +} diff --git a/cmd/kosli/testdata/service-account/README.md b/cmd/kosli/testdata/service-account/README.md index b1d13b514..01283d45e 100644 --- a/cmd/kosli/testdata/service-account/README.md +++ b/cmd/kosli/testdata/service-account/README.md @@ -1,16 +1,34 @@ -# Service Account API-key response fixtures +# Service Account response fixtures These JSON files are the canonical example response bodies returned by the -Service Account `api-keys` endpoints. The command tests in -`cmd/kosli/apiKey_test.go` stub the API (`httpfake`) with these fixtures -instead of inline strings, so the response contract lives in one place. +Service Account endpoints (the accounts themselves and their `api-keys`). The +command tests in `cmd/kosli/apiKey_test.go` and `cmd/kosli/serviceAccount_test.go` +stub the API (`httpfake`) with these fixtures instead of inline strings, so the +response contract lives in one place. + +### Service account `api-keys` endpoints + +| Fixture | Endpoint / response | +|---------|---------------------| +| `created_api_key.json` | `POST .../{name}/api-keys` → `201` (create) | +| `rotated_api_key.json` | `POST .../{name}/api-keys/{key_id}/rotate` → `201` (rotate; includes `grace_period_expires_at`) | +| `listed_api_keys.json` | `GET .../{name}/api-keys` → `200` (list) | +| `revoke_success.json` | `DELETE .../{name}/api-keys/{key_id}` → `200` (bare string) | + +### Service account management endpoints + +| Fixture | Endpoint / response | +|---------|---------------------| +| `created_service_account.json` | `POST /service-accounts/{org}` → `201` (create) | +| `listed_service_accounts.json` | `GET /service-accounts/{org}` → `200` (list) | +| `service_account.json` | `GET /service-accounts/{org}/{name}` → `200` (get) | +| `updated_service_account.json` | `PATCH /service-accounts/{org}/{name}` → `200` (update) | +| `delete_success.json` | `DELETE /service-accounts/{org}/{name}` → `200` (bare `"OK"`) | + +### Shared | Fixture | Endpoint / response | |---------|---------------------| -| `created_api_key.json` | `POST .../api-keys` → `201` (create) | -| `rotated_api_key.json` | `POST .../api-keys/{key_id}/rotate` → `201` (rotate; includes `grace_period_expires_at`) | -| `listed_api_keys.json` | `GET .../api-keys` → `200` (list) | -| `revoke_success.json` | `DELETE .../api-keys/{key_id}` → `200` (bare string) | | `error_*.json` | error envelope `{ "message": string }` (`403`/`404`) | > **Note:** these fixtures only exercise CLI logic (flag parsing, output diff --git a/cmd/kosli/testdata/service-account/created_service_account.json b/cmd/kosli/testdata/service-account/created_service_account.json new file mode 100644 index 000000000..ccdabfba9 --- /dev/null +++ b/cmd/kosli/testdata/service-account/created_service_account.json @@ -0,0 +1 @@ +{"name":"ci-bot","description":"CI service account","privilege":"member","created_at":1780584129.5} diff --git a/cmd/kosli/testdata/service-account/delete_success.json b/cmd/kosli/testdata/service-account/delete_success.json new file mode 100644 index 000000000..45021e710 --- /dev/null +++ b/cmd/kosli/testdata/service-account/delete_success.json @@ -0,0 +1 @@ +"OK" \ No newline at end of file diff --git a/cmd/kosli/testdata/service-account/listed_service_accounts.json b/cmd/kosli/testdata/service-account/listed_service_accounts.json new file mode 100644 index 000000000..7c6407583 --- /dev/null +++ b/cmd/kosli/testdata/service-account/listed_service_accounts.json @@ -0,0 +1 @@ +[{"name":"ci-bot","description":"CI service account","privilege":"member","created_at":1780584129.5},{"name":"deployer","description":"","privilege":"admin","created_at":1780584130.5}] diff --git a/cmd/kosli/testdata/service-account/service_account.json b/cmd/kosli/testdata/service-account/service_account.json new file mode 100644 index 000000000..ccdabfba9 --- /dev/null +++ b/cmd/kosli/testdata/service-account/service_account.json @@ -0,0 +1 @@ +{"name":"ci-bot","description":"CI service account","privilege":"member","created_at":1780584129.5} diff --git a/cmd/kosli/testdata/service-account/updated_service_account.json b/cmd/kosli/testdata/service-account/updated_service_account.json new file mode 100644 index 000000000..96d342178 --- /dev/null +++ b/cmd/kosli/testdata/service-account/updated_service_account.json @@ -0,0 +1 @@ +{"name":"ci-bot","description":"updated description","privilege":"admin","created_at":1780584129.5} diff --git a/cmd/kosli/update.go b/cmd/kosli/update.go new file mode 100644 index 000000000..253515dfe --- /dev/null +++ b/cmd/kosli/update.go @@ -0,0 +1,25 @@ +package main + +import ( + "io" + + "github.com/spf13/cobra" +) + +const updateDesc = `All Kosli update commands.` + +func newUpdateCmd(out io.Writer) *cobra.Command { + cmd := &cobra.Command{ + Use: "update", + Aliases: []string{"u", "up"}, + Short: updateDesc, + Long: updateDesc, + } + + // Add subcommands + cmd.AddCommand( + newUpdateServiceAccountCmd(out), + ) + + return cmd +} diff --git a/cmd/kosli/updateServiceAccount.go b/cmd/kosli/updateServiceAccount.go new file mode 100644 index 000000000..b3b941a83 --- /dev/null +++ b/cmd/kosli/updateServiceAccount.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 updateServiceAccountShortDesc = `Update a service account.` + +const updateServiceAccountLongDesc = updateServiceAccountShortDesc + ` + +Only the flags you provide are changed; omitted fields are left untouched.` + +const updateServiceAccountExample = ` +# update a service account's description: +kosli update service-account yourServiceAccountName \ + --description "new description" \ + --api-token yourAPIToken \ + --org yourOrgName + +# update a service account's privilege: +kosli update service-account yourServiceAccountName \ + --privilege member \ + --api-token yourAPIToken \ + --org yourOrgName +` + +type updateServiceAccountOptions struct { + description string + privilege string +} + +func newUpdateServiceAccountCmd(out io.Writer) *cobra.Command { + o := new(updateServiceAccountOptions) + cmd := &cobra.Command{ + Use: "service-account SERVICE-ACCOUNT-NAME", + Aliases: []string{"sa"}, + Short: updateServiceAccountShortDesc, + Long: updateServiceAccountLongDesc, + Example: updateServiceAccountExample, + Args: cobra.ExactArgs(1), + 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{"description", "privilege"}) + }, + RunE: func(cmd *cobra.Command, args []string) error { + return o.run(cmd, args) + }, + } + + cmd.Flags().StringVarP(&o.description, "description", "d", "", serviceAccountDescriptionFlag) + cmd.Flags().StringVar(&o.privilege, "privilege", "", serviceAccountPrivilegeFlag) + addDryRunFlag(cmd) + + return cmd +} + +func (o *updateServiceAccountOptions) run(cmd *cobra.Command, args []string) error { + url, err := url.JoinPath(global.Host, "api/v2/service-accounts", 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("description") { + payload["description"] = o.description + } + if cmd.Flags().Changed("privilege") { + payload["privilege"] = o.privilege + } + + reqParams := &requests.RequestParams{ + Method: http.MethodPatch, + URL: url, + Payload: payload, + DryRun: global.DryRun, + Token: global.ApiToken, + } + _, err = kosliClient.Do(reqParams) + if err == nil && !global.DryRun { + logger.Info("service account %s was updated", args[0]) + } + return err +} From 37f70fbaed8ef192479791fc4010b6071cc1979b Mon Sep 17 00:00:00 2001 From: Marko Bevc Date: Thu, 25 Jun 2026 17:07:12 +0100 Subject: [PATCH 2/3] chore: add possible roles for SAs and expand fixtures covering all roles --- cmd/kosli/root.go | 7 ++++- cmd/kosli/serviceAccount_test.go | 31 ++++++++++++++++--- cmd/kosli/testdata/service-account/README.md | 2 +- .../listed_service_accounts.json | 2 +- 4 files changed, 34 insertions(+), 8 deletions(-) diff --git a/cmd/kosli/root.go b/cmd/kosli/root.go index 0584437e8..23ded268b 100644 --- a/cmd/kosli/root.go +++ b/cmd/kosli/root.go @@ -94,6 +94,11 @@ The ^.kosli_ignore^ will be treated as part of the artifact like any other file, // the server is the authority on which types are actually accepted validEnvTypesList = "K8S, ECS, S3, lambda, server, docker, azure-apps, cloud-run, logical" + // single source of truth for the service account privilege list shown in + // flag help texts; the server is the authority on which privileges are + // actually accepted + validServiceAccountPrivilegesList = "admin, member, snapshotter, reader" + // flags apiTokenFlag = "The Kosli API token." artifactName = "[optional] Artifact display name, if different from file, image or directory name." @@ -121,7 +126,7 @@ The ^.kosli_ignore^ will be treated as part of the artifact like any other file, ignoreCaseFlag = "[optional] Perform case-insensitive matching for --name. By default matching is case sensitive." serviceAccountNameFlag = "The name of the service account whose API keys are managed." serviceAccountDescriptionFlag = "[optional] A description for the service account." - serviceAccountPrivilegeFlag = "The privilege granted to the service account." + serviceAccountPrivilegeFlag = "The privilege granted to the service account. One of: [" + validServiceAccountPrivilegesList + "]." serviceAccountAssumeYesFlag = "[optional] Skip the confirmation prompt and delete the service account without asking. (alias: --yes)" apiKeyDescriptionFlag = "A description for the API key." apiKeyExpiresAtFlag = "[optional] When the API key expires. Accepts an epoch timestamp or a date like '2026-06-04', '2026-06-04 15:04:05', or an RFC3339 timestamp. Defaults to no expiry." diff --git a/cmd/kosli/serviceAccount_test.go b/cmd/kosli/serviceAccount_test.go index 60deb358d..6e9d09aa5 100644 --- a/cmd/kosli/serviceAccount_test.go +++ b/cmd/kosli/serviceAccount_test.go @@ -47,14 +47,23 @@ func TestPrintServiceAccountAsTableEmptyDescription(t *testing.T) { func TestPrintServiceAccountsListAsTable(t *testing.T) { raw := `[{"name":"ci-bot","description":"first","privilege":"member","created_at":1780584129.5},` + - `{"name":"deployer","description":"","privilege":"admin","created_at":1780584130.5}]` + `{"name":"deployer","description":"","privilege":"admin","created_at":1780584130.5},` + + `{"name":"snapshot-bot","description":"snaps","privilege":"snapshotter","created_at":1780584131.5},` + + `{"name":"auditor","description":"ro","privilege":"reader","created_at":1780584132.5}]` var buf bytes.Buffer err := printServiceAccountsListAsTable(raw, &buf, 0) require.NoError(t, err) out := buf.String() - for _, want := range []string{"NAME", "DESCRIPTION", "PRIVILEGE", "CREATED", "ci-bot", "first", "deployer", "admin"} { + // every privilege must render in the PRIVILEGE column + for _, want := range []string{ + "NAME", "DESCRIPTION", "PRIVILEGE", "CREATED", + "ci-bot", "first", "member", + "deployer", "admin", + "snapshot-bot", "snapshotter", + "auditor", "reader", + } { require.Contains(t, out, want) } // the deployer's empty description must render as N/A, not blank @@ -90,6 +99,18 @@ func (suite *ServiceAccountCommandTestSuite) TestCreateServiceAccountCmd() { cmd: "create sa ci-bot --privilege member --dry-run" + suite.defaultKosliArguments, goldenRegex: `service-accounts/docs-cmd-test-user`, }, + { + wantError: false, + name: "the snapshotter privilege passes through to the payload (dry-run)", + cmd: "create service-account snapshot-bot --privilege snapshotter --dry-run" + suite.defaultKosliArguments, + goldenRegex: `(?s)service-accounts/docs-cmd-test-user.*"privilege": "snapshotter"`, + }, + { + wantError: false, + name: "the reader privilege passes through to the payload (dry-run)", + cmd: "create service-account auditor --privilege reader --dry-run" + suite.defaultKosliArguments, + goldenRegex: `(?s)service-accounts/docs-cmd-test-user.*"privilege": "reader"`, + }, { wantError: true, name: "create fails when NAME argument is missing", @@ -220,9 +241,9 @@ func (suite *ServiceAccountCommandTestSuite) TestServiceAccountSuccessOutput() { }, { wantError: false, - name: "list prints the returned service accounts", - cmd: "list service-accounts --output json" + args, - goldenRegex: `(?s)ci-bot.*deployer`, + name: "list prints the returned service accounts across all privileges", + cmd: "list service-accounts" + args, + goldenRegex: `(?s)ci-bot.*member.*deployer.*admin.*snapshot-bot.*snapshotter.*auditor.*reader`, }, { wantError: false, diff --git a/cmd/kosli/testdata/service-account/README.md b/cmd/kosli/testdata/service-account/README.md index 01283d45e..333d978f1 100644 --- a/cmd/kosli/testdata/service-account/README.md +++ b/cmd/kosli/testdata/service-account/README.md @@ -20,7 +20,7 @@ response contract lives in one place. | Fixture | Endpoint / response | |---------|---------------------| | `created_service_account.json` | `POST /service-accounts/{org}` → `201` (create) | -| `listed_service_accounts.json` | `GET /service-accounts/{org}` → `200` (list) | +| `listed_service_accounts.json` | `GET /service-accounts/{org}` → `200` (list; one account per privilege: member, admin, snapshotter, reader) | | `service_account.json` | `GET /service-accounts/{org}/{name}` → `200` (get) | | `updated_service_account.json` | `PATCH /service-accounts/{org}/{name}` → `200` (update) | | `delete_success.json` | `DELETE /service-accounts/{org}/{name}` → `200` (bare `"OK"`) | diff --git a/cmd/kosli/testdata/service-account/listed_service_accounts.json b/cmd/kosli/testdata/service-account/listed_service_accounts.json index 7c6407583..65087fdb5 100644 --- a/cmd/kosli/testdata/service-account/listed_service_accounts.json +++ b/cmd/kosli/testdata/service-account/listed_service_accounts.json @@ -1 +1 @@ -[{"name":"ci-bot","description":"CI service account","privilege":"member","created_at":1780584129.5},{"name":"deployer","description":"","privilege":"admin","created_at":1780584130.5}] +[{"name":"ci-bot","description":"CI service account","privilege":"member","created_at":1780584129.5},{"name":"deployer","description":"","privilege":"admin","created_at":1780584130.5},{"name":"snapshot-bot","description":"reports env snapshots","privilege":"snapshotter","created_at":1780584131.5},{"name":"auditor","description":"read-only access","privilege":"reader","created_at":1780584132.5}] From 217fc4b046cf66132713732922c48964d511e3d1 Mon Sep 17 00:00:00 2001 From: Marko Bevc Date: Thu, 25 Jun 2026 17:13:33 +0100 Subject: [PATCH 3/3] chore: add log descriptions for SA list/get --- cmd/kosli/getServiceAccount.go | 8 +++++++- cmd/kosli/listServiceAccounts.go | 8 +++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/cmd/kosli/getServiceAccount.go b/cmd/kosli/getServiceAccount.go index db7781fc0..04a1fc7ef 100644 --- a/cmd/kosli/getServiceAccount.go +++ b/cmd/kosli/getServiceAccount.go @@ -12,6 +12,12 @@ import ( const getServiceAccountShortDesc = `Get a service account's metadata.` +const getServiceAccountLongDesc = getServiceAccountShortDesc + ` + +The metadata includes the name, description, privilege, and creation time. The +secret values of the account's API keys are never returned. Use ^--output json^ +to get the raw response for scripting.` + const getServiceAccountExample = ` # get the metadata of a service account: kosli get service-account yourServiceAccountName \ @@ -29,7 +35,7 @@ func newGetServiceAccountCmd(out io.Writer) *cobra.Command { Use: "service-account SERVICE-ACCOUNT-NAME", Aliases: []string{"sa"}, Short: getServiceAccountShortDesc, - Long: getServiceAccountShortDesc, + Long: getServiceAccountLongDesc, Example: getServiceAccountExample, Args: cobra.ExactArgs(1), PreRunE: func(cmd *cobra.Command, args []string) error { diff --git a/cmd/kosli/listServiceAccounts.go b/cmd/kosli/listServiceAccounts.go index a07368cc5..f47295d27 100644 --- a/cmd/kosli/listServiceAccounts.go +++ b/cmd/kosli/listServiceAccounts.go @@ -12,6 +12,12 @@ import ( const listServiceAccountsShortDesc = `List service accounts in an organization.` +const listServiceAccountsLongDesc = listServiceAccountsShortDesc + ` + +Each entry shows the name, description, privilege, and creation time. The secret +values of any API keys are never listed. Use ^--output json^ to get the raw +response for scripting.` + const listServiceAccountsExample = ` # list the service accounts in an organization: kosli list service-accounts \ @@ -29,7 +35,7 @@ func newListServiceAccountsCmd(out io.Writer) *cobra.Command { Use: "service-accounts", Aliases: []string{"sa", "sas", "service-account"}, Short: listServiceAccountsShortDesc, - Long: listServiceAccountsShortDesc, + Long: listServiceAccountsLongDesc, Example: listServiceAccountsExample, Args: cobra.NoArgs, PreRunE: func(cmd *cobra.Command, args []string) error {