diff --git a/docs/stackit_server_service-account_attach.md b/docs/stackit_server_service-account_attach.md index 0cf08c386..2cd7be4c7 100644 --- a/docs/stackit_server_service-account_attach.md +++ b/docs/stackit_server_service-account_attach.md @@ -7,21 +7,22 @@ Attach a service account to a server Attach a service account to a server ``` -stackit server service-account attach SERVICE_ACCOUNT_EMAIL [flags] +stackit server service-account attach [flags] ``` ### Examples ``` Attach a service account with mail "xxx@sa.stackit.cloud" to a server with ID "yyy" - $ stackit server service-account attach xxx@sa.stackit.cloud --server-id yyy + $ stackit server service-account attach --service-account-email xxx@sa.stackit.cloud --server-id yyy ``` ### Options ``` - -h, --help Help for "stackit server service-account attach" - -s, --server-id string Server ID + -h, --help Help for "stackit server service-account attach" + -s, --server-id string Server ID + -a, --service-account-email string Service Account Email ``` ### Options inherited from parent commands diff --git a/docs/stackit_server_service-account_detach.md b/docs/stackit_server_service-account_detach.md index 87806ced3..6155054fb 100644 --- a/docs/stackit_server_service-account_detach.md +++ b/docs/stackit_server_service-account_detach.md @@ -7,21 +7,22 @@ Detach a service account from a server Detach a service account from a server ``` -stackit server service-account detach SERVICE_ACCOUNT_EMAIL [flags] +stackit server service-account detach [flags] ``` ### Examples ``` Detach a service account with mail "xxx@sa.stackit.cloud" from a server "yyy" - $ stackit server service-account detach xxx@sa.stackit.cloud --server-id yyy + $ stackit server service-account detach --service-account-email xxx@sa.stackit.cloud --server-id yyy ``` ### Options ``` - -h, --help Help for "stackit server service-account detach" - -s, --server-id string Server id + -h, --help Help for "stackit server service-account detach" + -s, --server-id string Server id + -a, --service-account-email string Service Account Email ``` ### Options inherited from parent commands diff --git a/internal/cmd/server/service-account/attach/attach.go b/internal/cmd/server/service-account/attach/attach.go index e7c7ea762..920e703d8 100644 --- a/internal/cmd/server/service-account/attach/attach.go +++ b/internal/cmd/server/service-account/attach/attach.go @@ -3,6 +3,7 @@ package attach import ( "context" "fmt" + "strings" "github.com/stackitcloud/stackit-cli/internal/pkg/types" @@ -20,9 +21,11 @@ import ( ) const ( - serviceAccMailArg = "SERVICE_ACCOUNT_EMAIL" + serviceAccMailArg = "SERVICE_ACCOUNT_EMAIL" // Deprecated: positional argument is not used anymore, use the flag instead, will be removed 2026-12-03 serverIdFlag = "server-id" + + serviceAccFlag = "service-account-email" ) type inputModel struct { @@ -33,14 +36,14 @@ type inputModel struct { func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ - Use: fmt.Sprintf("attach %s", serviceAccMailArg), + Use: "attach", Short: "Attach a service account to a server", Long: "Attach a service account to a server", - Args: args.SingleArg(serviceAccMailArg, nil), + Args: args.SingleOptionalArg(serviceAccMailArg, nil), // Deprecated: positional argument is not used anymore, use the flag instead, will be removed 2026-12-03 Example: examples.Build( examples.NewExample( `Attach a service account with mail "xxx@sa.stackit.cloud" to a server with ID "yyy"`, - "$ stackit server service-account attach xxx@sa.stackit.cloud --server-id yyy", + "$ stackit server service-account attach --service-account-email xxx@sa.stackit.cloud --server-id yyy", ), ), RunE: func(cmd *cobra.Command, args []string) error { @@ -85,18 +88,31 @@ func NewCmd(params *types.CmdParams) *cobra.Command { func configureFlags(cmd *cobra.Command) { cmd.Flags().VarP(flags.UUIDFlag(), serverIdFlag, "s", "Server ID") - + cmd.Flags().VarP(flags.EmailFlag(), serviceAccFlag, "a", "Service Account Email") err := flags.MarkFlagsRequired(cmd, serverIdFlag) cobra.CheckErr(err) } func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { - serviceAccMail := inputArgs[0] globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} } + var serviceAccMail string + if cmd.Flags().Changed(serviceAccFlag) { + serviceAccMail = flags.FlagToStringValue(p, cmd, serviceAccFlag) + } else if len(inputArgs) > 0 { + serviceAccMail = inputArgs[0] + p.Warn("using a positional argument for the service account email is deprecated and will be removed on 2026-12-03. Please use '--%s' instead.\n", serviceAccFlag) + } else { + return nil, fmt.Errorf(`service account must be specified by using either the --%s flag or (deprecated) as a positional argument`, serviceAccFlag) + } + + if serviceAccMail == "" || !strings.Contains(serviceAccMail, "@") { + return nil, fmt.Errorf("invalid service account email format: %q", serviceAccMail) + } + model := inputModel{ GlobalFlagModel: globalFlags, ServerId: flags.FlagToStringValue(p, cmd, serverIdFlag), diff --git a/internal/cmd/server/service-account/detach/detach.go b/internal/cmd/server/service-account/detach/detach.go index 07b34db82..496b9512f 100644 --- a/internal/cmd/server/service-account/detach/detach.go +++ b/internal/cmd/server/service-account/detach/detach.go @@ -3,6 +3,7 @@ package detach import ( "context" "fmt" + "strings" "github.com/stackitcloud/stackit-cli/internal/pkg/types" @@ -20,9 +21,11 @@ import ( ) const ( - serviceAccMailArg = "SERVICE_ACCOUNT_EMAIL" + serviceAccMailArg = "SERVICE_ACCOUNT_EMAIL" // Deprecated: positional argument is not used anymore, use the flag instead, will be removed 2026-12-03 serverIdFlag = "server-id" + + serviceAccFlag = "service-account-email" ) type inputModel struct { @@ -33,14 +36,14 @@ type inputModel struct { func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ - Use: fmt.Sprintf("detach %s", serviceAccMailArg), + Use: "detach", Short: "Detach a service account from a server", Long: "Detach a service account from a server", - Args: args.SingleArg(serviceAccMailArg, nil), + Args: args.SingleOptionalArg(serviceAccMailArg, nil), // Deprecated: positional argument is not used anymore, use the flag instead, will be removed 2026-12-03 Example: examples.Build( examples.NewExample( `Detach a service account with mail "xxx@sa.stackit.cloud" from a server "yyy"`, - "$ stackit server service-account detach xxx@sa.stackit.cloud --server-id yyy", + "$ stackit server service-account detach --service-account-email xxx@sa.stackit.cloud --server-id yyy", ), ), RunE: func(cmd *cobra.Command, args []string) error { @@ -85,18 +88,31 @@ func NewCmd(params *types.CmdParams) *cobra.Command { func configureFlags(cmd *cobra.Command) { cmd.Flags().VarP(flags.UUIDFlag(), serverIdFlag, "s", "Server id") - + cmd.Flags().VarP(flags.EmailFlag(), serviceAccFlag, "a", "Service Account Email") err := flags.MarkFlagsRequired(cmd, serverIdFlag) cobra.CheckErr(err) } func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { - serviceAccMail := inputArgs[0] globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} } + var serviceAccMail string + if cmd.Flags().Changed(serviceAccFlag) { + serviceAccMail = flags.FlagToStringValue(p, cmd, serviceAccFlag) + } else if len(inputArgs) > 0 { + serviceAccMail = inputArgs[0] + p.Warn("using a positional argument for the service account email is deprecated and will be removed on 2026-12-03. Please use '--%s' instead.\n", serviceAccFlag) + } else { + return nil, fmt.Errorf(`service account must be specified by using either the --%s flag or (deprecated) as a positional argument`, serviceAccFlag) + } + + if serviceAccMail == "" || !strings.Contains(serviceAccMail, "@") { + return nil, fmt.Errorf("invalid service account email format: %q", serviceAccMail) + } + model := inputModel{ GlobalFlagModel: globalFlags, ServerId: flags.FlagToStringValue(p, cmd, serverIdFlag), diff --git a/internal/pkg/flags/email.go b/internal/pkg/flags/email.go new file mode 100644 index 000000000..203ea42a9 --- /dev/null +++ b/internal/pkg/flags/email.go @@ -0,0 +1,37 @@ +package flags + +import ( + "fmt" + "strings" + + "github.com/spf13/pflag" +) + +type emailFlag struct { + value string +} + +// Ensure the implementation satisfies the expected interface +var _ pflag.Value = &emailFlag{} + +// EmailFlag returns a flag which must be a valid Email. +func EmailFlag() *emailFlag { + return &emailFlag{} +} + +func (f *emailFlag) String() string { + return f.value +} + +func (f *emailFlag) Set(value string) error { + isEmail := value != "" && strings.Contains(value, "@") + if !isEmail { + return fmt.Errorf("invalid email address: %s", value) + } + f.value = value + return nil +} + +func (f *emailFlag) Type() string { + return "string" +} diff --git a/internal/pkg/flags/email_test.go b/internal/pkg/flags/email_test.go new file mode 100644 index 000000000..24af1f7c5 --- /dev/null +++ b/internal/pkg/flags/email_test.go @@ -0,0 +1,61 @@ +package flags + +import ( + "testing" + + "github.com/spf13/cobra" +) + +func TestEmailFlag(t *testing.T) { + tests := []struct { + description string + value string + isValid bool + }{ + { + description: "valid", + value: "test@test", + isValid: true, + }, + { + description: "empty", + value: "", + isValid: false, + }, + { + description: "invalid", + value: "invalid-email", + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + flag := EmailFlag() + cmd := &cobra.Command{ + Use: "test", + RunE: func(_ *cobra.Command, _ []string) error { + return nil + }, + } + cmd.Flags().Var(flag, "test-flag", "test") + + err := cmd.Flags().Set("test-flag", tt.value) + + if !tt.isValid && err == nil { + t.Fatalf("did not fail on invalid input") + } + if !tt.isValid { + return + } + + if err != nil { + t.Fatalf("failed on valid input: %v", err) + } + value := FlagToStringValue(nil, cmd, "test-flag") + if value != tt.value { + t.Fatalf("flag did not return set value") + } + }) + } +}