Skip to content

Commit 9f1e043

Browse files
committed
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
1 parent af5111c commit 9f1e043

19 files changed

Lines changed: 952 additions & 8 deletions

cmd/kosli/create.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ func newCreateCmd(out io.Writer) *cobra.Command {
2323
newCreatePolicyCmd(out),
2424
newCreateAttestationTypeCmd(out),
2525
newCreateApiKeyCmd(out),
26+
newCreateServiceAccountCmd(out),
2627
)
2728
return cmd
2829
}

cmd/kosli/createServiceAccount.go

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
package main
2+
3+
import (
4+
"io"
5+
"net/http"
6+
"net/url"
7+
8+
"github.com/kosli-dev/cli/internal/requests"
9+
"github.com/spf13/cobra"
10+
)
11+
12+
const createServiceAccountShortDesc = `Create a service account.`
13+
14+
const createServiceAccountLongDesc = createServiceAccountShortDesc + `
15+
16+
A service account is a non-human identity in your organization. API keys are
17+
created separately for it with ^kosli create api-key^.`
18+
19+
const createServiceAccountExample = `
20+
# create a service account:
21+
kosli create service-account yourServiceAccountName \
22+
--privilege member \
23+
--description "CI service account" \
24+
--api-token yourAPIToken \
25+
--org yourOrgName
26+
`
27+
28+
type createServiceAccountOptions struct {
29+
payload createServiceAccountPayload
30+
}
31+
32+
type createServiceAccountPayload struct {
33+
Name string `json:"name"`
34+
Description string `json:"description,omitempty"`
35+
Privilege string `json:"privilege"`
36+
}
37+
38+
func newCreateServiceAccountCmd(out io.Writer) *cobra.Command {
39+
o := new(createServiceAccountOptions)
40+
cmd := &cobra.Command{
41+
Use: "service-account SERVICE-ACCOUNT-NAME",
42+
Aliases: []string{"sa"},
43+
Short: createServiceAccountShortDesc,
44+
Long: createServiceAccountLongDesc,
45+
Example: createServiceAccountExample,
46+
Args: cobra.ExactArgs(1),
47+
PreRunE: func(cmd *cobra.Command, args []string) error {
48+
if err := RequireGlobalFlags(global, []string{"Org", "ApiToken"}); err != nil {
49+
return ErrorBeforePrintingUsage(cmd, err.Error())
50+
}
51+
return nil
52+
},
53+
RunE: func(cmd *cobra.Command, args []string) error {
54+
return o.run(args)
55+
},
56+
}
57+
58+
cmd.Flags().StringVarP(&o.payload.Description, "description", "d", "", serviceAccountDescriptionFlag)
59+
cmd.Flags().StringVar(&o.payload.Privilege, "privilege", "", serviceAccountPrivilegeFlag)
60+
addDryRunFlag(cmd)
61+
62+
err := RequireFlags(cmd, []string{"privilege"})
63+
if err != nil {
64+
logger.Error("failed to configure required flags: %v", err)
65+
}
66+
67+
return cmd
68+
}
69+
70+
func (o *createServiceAccountOptions) run(args []string) error {
71+
o.payload.Name = args[0]
72+
url, err := url.JoinPath(global.Host, "api/v2/service-accounts", global.Org)
73+
if err != nil {
74+
return err
75+
}
76+
77+
reqParams := &requests.RequestParams{
78+
Method: http.MethodPost,
79+
URL: url,
80+
Payload: o.payload,
81+
DryRun: global.DryRun,
82+
Token: global.ApiToken,
83+
}
84+
_, err = kosliClient.Do(reqParams)
85+
if err == nil && !global.DryRun {
86+
logger.Info("service account %s was created", o.payload.Name)
87+
}
88+
return err
89+
}

cmd/kosli/delete.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ func newDeleteCmd(out io.Writer) *cobra.Command {
1919
// Add subcommands
2020
cmd.AddCommand(
2121
newDeleteApiKeyCmd(out),
22+
newDeleteServiceAccountCmd(out),
2223
)
2324

2425
return cmd

cmd/kosli/deleteServiceAccount.go

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
package main
2+
3+
import (
4+
"bufio"
5+
"fmt"
6+
"io"
7+
"net/http"
8+
"net/url"
9+
"strings"
10+
11+
"github.com/kosli-dev/cli/internal/requests"
12+
"github.com/spf13/cobra"
13+
)
14+
15+
const deleteServiceAccountShortDesc = `Delete one or more service accounts.`
16+
17+
const deleteServiceAccountLongDesc = deleteServiceAccountShortDesc + `
18+
19+
This permanently removes the service account(s) identified by SERVICE-ACCOUNT-NAME
20+
from the organization, along with their API keys. Deletion is immediate and
21+
cannot be undone. You are asked to confirm before deletion; use
22+
^--assume-yes^/^--yes^ to skip the confirmation prompt.`
23+
24+
const deleteServiceAccountExample = `
25+
# delete a service account (asks for confirmation):
26+
kosli delete service-account yourServiceAccountName \
27+
--api-token yourAPIToken \
28+
--org yourOrgName
29+
30+
# delete multiple service accounts at once:
31+
kosli delete service-account sa1 sa2 \
32+
--api-token yourAPIToken \
33+
--org yourOrgName
34+
35+
# delete a service account without confirmation:
36+
kosli delete service-account yourServiceAccountName \
37+
--assume-yes \
38+
--api-token yourAPIToken \
39+
--org yourOrgName
40+
`
41+
42+
type deleteServiceAccountOptions struct {
43+
assumeYes bool
44+
}
45+
46+
func newDeleteServiceAccountCmd(out io.Writer) *cobra.Command {
47+
o := new(deleteServiceAccountOptions)
48+
cmd := &cobra.Command{
49+
Use: "service-account SERVICE-ACCOUNT-NAME [SERVICE-ACCOUNT-NAME...]",
50+
Aliases: []string{"sa"},
51+
Short: deleteServiceAccountShortDesc,
52+
Long: deleteServiceAccountLongDesc,
53+
Example: deleteServiceAccountExample,
54+
Args: cobra.MinimumNArgs(1),
55+
PreRunE: func(cmd *cobra.Command, args []string) error {
56+
if err := RequireGlobalFlags(global, []string{"Org", "ApiToken"}); err != nil {
57+
return ErrorBeforePrintingUsage(cmd, err.Error())
58+
}
59+
return nil
60+
},
61+
RunE: func(cmd *cobra.Command, args []string) error {
62+
return o.run(cmd.InOrStdin(), args)
63+
},
64+
}
65+
66+
cmd.Flags().BoolVarP(&o.assumeYes, "assume-yes", "y", false, serviceAccountAssumeYesFlag)
67+
// keep --yes as a hidden alias for --assume-yes (bound to the same option)
68+
cmd.Flags().BoolVar(&o.assumeYes, "yes", false, serviceAccountAssumeYesFlag)
69+
if f := cmd.Flags().Lookup("yes"); f != nil {
70+
f.Hidden = true
71+
}
72+
addDryRunFlag(cmd)
73+
74+
return cmd
75+
}
76+
77+
func (o *deleteServiceAccountOptions) run(in io.Reader, args []string) error {
78+
if !o.assumeYes && !global.DryRun {
79+
confirmed, err := confirmServiceAccountDeletion(args, in)
80+
if err != nil {
81+
return err
82+
}
83+
if !confirmed {
84+
logger.Info("Deletion of service account(s) %s was cancelled.", strings.Join(styleServiceAccountNames(args), ", "))
85+
return nil
86+
}
87+
}
88+
89+
// deletion is destructive and one-way: on any failure mid-batch, make clear
90+
// which service accounts were already deleted before it.
91+
reportAlreadyDeleted := func(i int) {
92+
if i > 0 {
93+
logger.Info("Service accounts already deleted before this failure: %s", strings.Join(styleServiceAccountNames(args[:i]), ", "))
94+
}
95+
}
96+
97+
for i, name := range args {
98+
url, err := url.JoinPath(global.Host, "api/v2/service-accounts", global.Org, name)
99+
if err != nil {
100+
reportAlreadyDeleted(i)
101+
return err
102+
}
103+
104+
reqParams := &requests.RequestParams{
105+
Method: http.MethodDelete,
106+
URL: url,
107+
DryRun: global.DryRun,
108+
Token: global.ApiToken,
109+
}
110+
if _, err := kosliClient.Do(reqParams); err != nil {
111+
reportAlreadyDeleted(i)
112+
return fmt.Errorf("failed to delete service account: %w", err)
113+
}
114+
if !global.DryRun {
115+
logger.Info("service account %s was deleted!", style(logger.Out, name, ansiBold, ansiCyan))
116+
}
117+
}
118+
return nil
119+
}
120+
121+
// styleServiceAccountNames styles service account names for user-facing
122+
// messages printed via logger (bold cyan when styling is enabled).
123+
func styleServiceAccountNames(names []string) []string {
124+
styled := make([]string, len(names))
125+
for i, name := range names {
126+
styled[i] = style(logger.Out, name, ansiBold, ansiCyan)
127+
}
128+
return styled
129+
}
130+
131+
// confirmServiceAccountDeletion prompts the user to confirm deletion and
132+
// returns true only when the answer is an affirmative "y"/"yes"
133+
// (case-insensitive). The prompt has no trailing newline so the answer is
134+
// typed on the same line.
135+
func confirmServiceAccountDeletion(names []string, in io.Reader) (bool, error) {
136+
logger.Print("Are you sure you want to delete service account(s) %s? [y/N] ",
137+
strings.Join(styleServiceAccountNames(names), ", "))
138+
139+
answer, err := bufio.NewReader(in).ReadString('\n')
140+
if err != nil && err != io.EOF {
141+
return false, err
142+
}
143+
144+
answer = strings.ToLower(strings.TrimSpace(answer))
145+
return answer == "y" || answer == "yes", nil
146+
}

cmd/kosli/get.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ func newGetCmd(out io.Writer) *cobra.Command {
2828
newGetAttestationTypeCmd(out),
2929
newGetAttestationCmd(out),
3030
newGetRepoCmd(out),
31+
newGetServiceAccountCmd(out),
3132
)
3233
return cmd
3334
}

cmd/kosli/getServiceAccount.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package main
2+
3+
import (
4+
"io"
5+
"net/http"
6+
"net/url"
7+
8+
"github.com/kosli-dev/cli/internal/output"
9+
"github.com/kosli-dev/cli/internal/requests"
10+
"github.com/spf13/cobra"
11+
)
12+
13+
const getServiceAccountShortDesc = `Get a service account's metadata.`
14+
15+
const getServiceAccountExample = `
16+
# get the metadata of a service account:
17+
kosli get service-account yourServiceAccountName \
18+
--api-token yourAPIToken \
19+
--org yourOrgName
20+
`
21+
22+
type getServiceAccountOptions struct {
23+
output string
24+
}
25+
26+
func newGetServiceAccountCmd(out io.Writer) *cobra.Command {
27+
o := new(getServiceAccountOptions)
28+
cmd := &cobra.Command{
29+
Use: "service-account SERVICE-ACCOUNT-NAME",
30+
Aliases: []string{"sa"},
31+
Short: getServiceAccountShortDesc,
32+
Long: getServiceAccountShortDesc,
33+
Example: getServiceAccountExample,
34+
Args: cobra.ExactArgs(1),
35+
PreRunE: func(cmd *cobra.Command, args []string) error {
36+
if err := RequireGlobalFlags(global, []string{"Org", "ApiToken"}); err != nil {
37+
return ErrorBeforePrintingUsage(cmd, err.Error())
38+
}
39+
return nil
40+
},
41+
RunE: func(cmd *cobra.Command, args []string) error {
42+
return o.run(out, args)
43+
},
44+
}
45+
46+
cmd.Flags().StringVarP(&o.output, "output", "o", "table", outputFlag)
47+
48+
return cmd
49+
}
50+
51+
func (o *getServiceAccountOptions) run(out io.Writer, args []string) error {
52+
url, err := url.JoinPath(global.Host, "api/v2/service-accounts", global.Org, args[0])
53+
if err != nil {
54+
return err
55+
}
56+
57+
reqParams := &requests.RequestParams{
58+
Method: http.MethodGet,
59+
URL: url,
60+
Token: global.ApiToken,
61+
}
62+
response, err := kosliClient.Do(reqParams)
63+
if err != nil {
64+
return err
65+
}
66+
67+
return output.FormattedPrint(response.Body, o.output, out, 0,
68+
map[string]output.FormatOutputFunc{
69+
"table": printServiceAccountAsTable,
70+
"json": output.PrintJson,
71+
})
72+
}

cmd/kosli/list.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ func newListCmd(out io.Writer) *cobra.Command {
4444
newListAttestationTypesCmd(out),
4545
newListReposCmd(out),
4646
newListApiKeysCmd(out),
47+
newListServiceAccountsCmd(out),
4748
)
4849

4950
return cmd

0 commit comments

Comments
 (0)