Skip to content

Commit f973c78

Browse files
committed
Add server service-account commands
1 parent b9be7a6 commit f973c78

File tree

8 files changed

+1118
-0
lines changed

8 files changed

+1118
-0
lines changed

internal/cmd/beta/server/server.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@ import (
88
"github.com/stackitcloud/stackit-cli/internal/cmd/beta/server/describe"
99
"github.com/stackitcloud/stackit-cli/internal/cmd/beta/server/list"
1010
publicip "github.com/stackitcloud/stackit-cli/internal/cmd/beta/server/public-ip"
11+
serviceaccount "github.com/stackitcloud/stackit-cli/internal/cmd/beta/server/service-account"
1112
"github.com/stackitcloud/stackit-cli/internal/cmd/beta/server/update"
1213
"github.com/stackitcloud/stackit-cli/internal/cmd/beta/server/volume"
14+
1315
"github.com/stackitcloud/stackit-cli/internal/pkg/args"
1416
"github.com/stackitcloud/stackit-cli/internal/pkg/print"
1517
"github.com/stackitcloud/stackit-cli/internal/pkg/utils"
@@ -37,6 +39,7 @@ func addSubcommands(cmd *cobra.Command, p *print.Printer) {
3739
cmd.AddCommand(describe.NewCmd(p))
3840
cmd.AddCommand(list.NewCmd(p))
3941
cmd.AddCommand(publicip.NewCmd(p))
42+
cmd.AddCommand(serviceaccount.NewCmd(p))
4043
cmd.AddCommand(update.NewCmd(p))
4144
cmd.AddCommand(volume.NewCmd(p))
4245
}
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
package attach
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
8+
"github.com/stackitcloud/stackit-cli/internal/pkg/args"
9+
"github.com/stackitcloud/stackit-cli/internal/pkg/errors"
10+
"github.com/stackitcloud/stackit-cli/internal/pkg/examples"
11+
"github.com/stackitcloud/stackit-cli/internal/pkg/flags"
12+
"github.com/stackitcloud/stackit-cli/internal/pkg/globalflags"
13+
"github.com/stackitcloud/stackit-cli/internal/pkg/print"
14+
"github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client"
15+
iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils"
16+
17+
"github.com/goccy/go-yaml"
18+
"github.com/spf13/cobra"
19+
"github.com/stackitcloud/stackit-sdk-go/services/iaas"
20+
)
21+
22+
const (
23+
serviceAccMailArg = "SERVICE_ACCOUNT_MAIL"
24+
25+
serverIdFlag = "server-id"
26+
)
27+
28+
type inputModel struct {
29+
*globalflags.GlobalFlagModel
30+
ServerId *string
31+
ServiceAccMail string
32+
}
33+
34+
func NewCmd(p *print.Printer) *cobra.Command {
35+
cmd := &cobra.Command{
36+
Use: "attach",
37+
Short: "Attach a service account to a server",
38+
Long: "Attach a service account to a server",
39+
Args: args.SingleArg(serviceAccMailArg, nil),
40+
Example: examples.Build(
41+
examples.NewExample(
42+
`Attach a service account with mail "[email protected]" to a server with ID "yyy"`,
43+
"$ stackit beta server service-account attach [email protected] --server-id yyy",
44+
),
45+
),
46+
RunE: func(cmd *cobra.Command, args []string) error {
47+
ctx := context.Background()
48+
model, err := parseInput(p, cmd, args)
49+
if err != nil {
50+
return err
51+
}
52+
53+
// Configure API client
54+
apiClient, err := client.ConfigureClient(p)
55+
if err != nil {
56+
return err
57+
}
58+
serverLabel, err := iaasUtils.GetServerName(ctx, apiClient, model.ProjectId, *model.ServerId)
59+
if err != nil {
60+
p.Debug(print.ErrorLevel, "get server name: %v", err)
61+
serverLabel = *model.ServerId
62+
}
63+
64+
if !model.AssumeYes {
65+
prompt := fmt.Sprintf("Are you sure you want to attach service account %q to server %q?", model.ServiceAccMail, serverLabel)
66+
err = p.PromptForConfirmation(prompt)
67+
if err != nil {
68+
return err
69+
}
70+
}
71+
72+
// Call API
73+
req := buildRequest(ctx, model, apiClient)
74+
resp, err := req.Execute()
75+
if err != nil {
76+
return fmt.Errorf("attach service account to server: %w", err)
77+
}
78+
79+
return outputResult(p, model.OutputFormat, model.ServiceAccMail, serverLabel, resp)
80+
},
81+
}
82+
configureFlags(cmd)
83+
return cmd
84+
}
85+
86+
func configureFlags(cmd *cobra.Command) {
87+
cmd.Flags().VarP(flags.UUIDFlag(), serverIdFlag, "s", "Server ID")
88+
89+
err := flags.MarkFlagsRequired(cmd, serverIdFlag)
90+
cobra.CheckErr(err)
91+
}
92+
93+
func parseInput(p *print.Printer, cmd *cobra.Command, args []string) (*inputModel, error) {
94+
serviceAccMail := args[0]
95+
globalFlags := globalflags.Parse(p, cmd)
96+
if globalFlags.ProjectId == "" {
97+
return nil, &errors.ProjectIdError{}
98+
}
99+
100+
model := inputModel{
101+
GlobalFlagModel: globalFlags,
102+
ServerId: flags.FlagToStringPointer(p, cmd, serverIdFlag),
103+
ServiceAccMail: serviceAccMail,
104+
}
105+
106+
if p.IsVerbosityDebug() {
107+
modelStr, err := print.BuildDebugStrFromInputModel(model)
108+
if err != nil {
109+
p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err)
110+
} else {
111+
p.Debug(print.DebugLevel, "parsed input values: %s", modelStr)
112+
}
113+
}
114+
115+
return &model, nil
116+
}
117+
118+
func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiAddServiceAccountToServerRequest {
119+
req := apiClient.AddServiceAccountToServer(ctx, model.ProjectId, *model.ServerId, model.ServiceAccMail)
120+
return req
121+
}
122+
123+
func outputResult(p *print.Printer, outputFormat string, serviceAccMail string, serverLabel string, serviceAccounts *iaas.ServiceAccountMailListResponse) error {
124+
switch outputFormat {
125+
case print.JSONOutputFormat:
126+
details, err := json.MarshalIndent(serviceAccounts, "", " ")
127+
if err != nil {
128+
return fmt.Errorf("marshal service account: %w", err)
129+
}
130+
p.Outputln(string(details))
131+
132+
return nil
133+
case print.YAMLOutputFormat:
134+
details, err := yaml.MarshalWithOptions(serviceAccounts, yaml.IndentSequence(true))
135+
if err != nil {
136+
return fmt.Errorf("marshal service account: %w", err)
137+
}
138+
p.Outputln(string(details))
139+
140+
return nil
141+
default:
142+
p.Outputf("Attached service account %q to server %q\n", serviceAccMail, serverLabel)
143+
return nil
144+
}
145+
}
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
package attach
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/stackitcloud/stackit-cli/internal/pkg/globalflags"
8+
"github.com/stackitcloud/stackit-cli/internal/pkg/print"
9+
"github.com/stackitcloud/stackit-cli/internal/pkg/utils"
10+
"github.com/stackitcloud/stackit-sdk-go/services/iaas"
11+
12+
"github.com/google/go-cmp/cmp"
13+
"github.com/google/go-cmp/cmp/cmpopts"
14+
"github.com/google/uuid"
15+
)
16+
17+
var projectIdFlag = globalflags.ProjectIdFlag
18+
19+
type testCtxKey struct{}
20+
21+
var testCtx = context.WithValue(context.Background(), &testCtxKey{}, "test")
22+
var testClient = &iaas.APIClient{}
23+
var testProjectId = uuid.NewString()
24+
var testServerId = uuid.NewString()
25+
var testServiceAccount = "[email protected]"
26+
27+
func fixtureArgValues(mods ...func(argValues []string)) []string {
28+
argValues := []string{
29+
testServiceAccount,
30+
}
31+
for _, mod := range mods {
32+
mod(argValues)
33+
}
34+
return argValues
35+
}
36+
37+
func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string {
38+
flagValues := map[string]string{
39+
projectIdFlag: testProjectId,
40+
serverIdFlag: testServerId,
41+
}
42+
for _, mod := range mods {
43+
mod(flagValues)
44+
}
45+
return flagValues
46+
}
47+
48+
func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
49+
model := &inputModel{
50+
GlobalFlagModel: &globalflags.GlobalFlagModel{
51+
Verbosity: globalflags.VerbosityDefault,
52+
ProjectId: testProjectId,
53+
},
54+
ServerId: utils.Ptr(testServerId),
55+
ServiceAccMail: testServiceAccount,
56+
}
57+
for _, mod := range mods {
58+
mod(model)
59+
}
60+
return model
61+
}
62+
63+
func fixtureRequest(mods ...func(request *iaas.ApiAddServiceAccountToServerRequest)) iaas.ApiAddServiceAccountToServerRequest {
64+
request := testClient.AddServiceAccountToServer(testCtx, testProjectId, testServerId, testServiceAccount)
65+
for _, mod := range mods {
66+
mod(&request)
67+
}
68+
return request
69+
}
70+
71+
func TestParseInput(t *testing.T) {
72+
tests := []struct {
73+
description string
74+
argValues []string
75+
flagValues map[string]string
76+
isValid bool
77+
expectedModel *inputModel
78+
}{
79+
{
80+
description: "base",
81+
argValues: fixtureArgValues(),
82+
flagValues: fixtureFlagValues(),
83+
isValid: true,
84+
expectedModel: fixtureInputModel(),
85+
},
86+
{
87+
description: "no values",
88+
argValues: fixtureArgValues(),
89+
flagValues: map[string]string{},
90+
isValid: false,
91+
},
92+
{
93+
description: "project id missing",
94+
argValues: fixtureArgValues(),
95+
flagValues: fixtureFlagValues(func(flagValues map[string]string) {
96+
delete(flagValues, projectIdFlag)
97+
}),
98+
isValid: false,
99+
},
100+
{
101+
description: "project id invalid 1",
102+
argValues: fixtureArgValues(),
103+
flagValues: fixtureFlagValues(func(flagValues map[string]string) {
104+
flagValues[projectIdFlag] = ""
105+
}),
106+
isValid: false,
107+
},
108+
{
109+
description: "project id invalid 2",
110+
argValues: fixtureArgValues(),
111+
flagValues: fixtureFlagValues(func(flagValues map[string]string) {
112+
flagValues[projectIdFlag] = "invalid-uuid"
113+
}),
114+
isValid: false,
115+
},
116+
{
117+
description: "server id missing",
118+
argValues: fixtureArgValues(),
119+
flagValues: fixtureFlagValues(func(flagValues map[string]string) {
120+
delete(flagValues, serverIdFlag)
121+
}),
122+
},
123+
{
124+
description: "server id invalid 1",
125+
argValues: fixtureArgValues(),
126+
flagValues: fixtureFlagValues(func(flagValues map[string]string) {
127+
flagValues[serverIdFlag] = ""
128+
}),
129+
isValid: false,
130+
},
131+
{
132+
description: "server id invalid 2",
133+
argValues: fixtureArgValues(),
134+
flagValues: fixtureFlagValues(func(flagValues map[string]string) {
135+
flagValues[serverIdFlag] = "invalid-uuid"
136+
}),
137+
isValid: false,
138+
},
139+
{
140+
description: "service account argument missing",
141+
argValues: []string{},
142+
isValid: false,
143+
},
144+
}
145+
146+
for _, tt := range tests {
147+
t.Run(tt.description, func(t *testing.T) {
148+
p := print.NewPrinter()
149+
cmd := NewCmd(p)
150+
err := globalflags.Configure(cmd.Flags())
151+
if err != nil {
152+
t.Fatalf("configure global flags: %v", err)
153+
}
154+
155+
for flag, value := range tt.flagValues {
156+
err := cmd.Flags().Set(flag, value)
157+
if err != nil {
158+
if !tt.isValid {
159+
return
160+
}
161+
t.Fatalf("setting flag --%s=%s: %v", flag, value, err)
162+
}
163+
}
164+
165+
err = cmd.ValidateArgs(tt.argValues)
166+
if err != nil {
167+
if !tt.isValid {
168+
return
169+
}
170+
t.Fatalf("error parsing args: %v", err)
171+
}
172+
173+
err = cmd.ValidateRequiredFlags()
174+
if err != nil {
175+
if !tt.isValid {
176+
return
177+
}
178+
t.Fatalf("error validating flags: %v", err)
179+
}
180+
181+
model, err := parseInput(p, cmd, tt.argValues)
182+
if err != nil {
183+
if !tt.isValid {
184+
return
185+
}
186+
t.Fatalf("error parsing input: %v", err)
187+
}
188+
189+
if !tt.isValid {
190+
t.Fatalf("did not fail on invalid input")
191+
}
192+
diff := cmp.Diff(model, tt.expectedModel)
193+
if diff != "" {
194+
t.Fatalf("Data does not match: %s", diff)
195+
}
196+
})
197+
}
198+
}
199+
200+
func TestBuildRequest(t *testing.T) {
201+
tests := []struct {
202+
description string
203+
model *inputModel
204+
expectedRequest iaas.ApiAddServiceAccountToServerRequest
205+
}{
206+
{
207+
description: "base",
208+
model: fixtureInputModel(),
209+
expectedRequest: fixtureRequest(),
210+
},
211+
}
212+
213+
for _, tt := range tests {
214+
t.Run(tt.description, func(t *testing.T) {
215+
request := buildRequest(testCtx, tt.model, testClient)
216+
217+
diff := cmp.Diff(request, tt.expectedRequest,
218+
cmp.AllowUnexported(tt.expectedRequest),
219+
cmpopts.EquateComparable(testCtx),
220+
)
221+
if diff != "" {
222+
t.Fatalf("Data does not match: %s", diff)
223+
}
224+
})
225+
}
226+
}

0 commit comments

Comments
 (0)