diff --git a/docs/stackit_beta.md b/docs/stackit_beta.md index 4ab26fe12..b89e3dbbe 100644 --- a/docs/stackit_beta.md +++ b/docs/stackit_beta.md @@ -45,7 +45,7 @@ stackit beta [flags] * [stackit beta network-area](./stackit_beta_network-area.md) - Provides functionality for STACKIT Network Area (SNA) * [stackit beta network-interface](./stackit_beta_network-interface.md) - Provides functionality for network interfaces * [stackit beta public-ip](./stackit_beta_public-ip.md) - Provides functionality for public IPs -* [stackit beta security-group](./stackit_beta_security-group.md) - Provides functionality for security groups +* [stackit beta security-group](./stackit_beta_security-group.md) - Manage security groups * [stackit beta server](./stackit_beta_server.md) - Provides functionality for servers * [stackit beta sqlserverflex](./stackit_beta_sqlserverflex.md) - Provides functionality for SQLServer Flex * [stackit beta volume](./stackit_beta_volume.md) - Provides functionality for volumes diff --git a/docs/stackit_beta_security-group.md b/docs/stackit_beta_security-group.md index fcc28bdd7..0c3954e95 100644 --- a/docs/stackit_beta_security-group.md +++ b/docs/stackit_beta_security-group.md @@ -1,10 +1,10 @@ ## stackit beta security-group -Provides functionality for security groups +Manage security groups ### Synopsis -Provides functionality for security groups. +Manage the lifecycle of security groups and rules. ``` stackit beta security-group [flags] @@ -29,5 +29,10 @@ stackit beta security-group [flags] ### SEE ALSO * [stackit beta](./stackit_beta.md) - Contains beta STACKIT CLI commands +* [stackit beta security-group create](./stackit_beta_security-group_create.md) - Creates security groups +* [stackit beta security-group delete](./stackit_beta_security-group_delete.md) - Deletes a security group +* [stackit beta security-group describe](./stackit_beta_security-group_describe.md) - Describes security groups +* [stackit beta security-group list](./stackit_beta_security-group_list.md) - Lists security groups * [stackit beta security-group rule](./stackit_beta_security-group_rule.md) - Provides functionality for security group rules +* [stackit beta security-group update](./stackit_beta_security-group_update.md) - Updates a security group diff --git a/docs/stackit_beta_security-group_create.md b/docs/stackit_beta_security-group_create.md new file mode 100644 index 000000000..e2ebd9b21 --- /dev/null +++ b/docs/stackit_beta_security-group_create.md @@ -0,0 +1,46 @@ +## stackit beta security-group create + +Creates security groups + +### Synopsis + +Creates security groups. + +``` +stackit beta security-group create [flags] +``` + +### Examples + +``` + Create a named group + $ stackit beta security-group create --name my-new-group + + Create a named group with labels + $ stackit beta security-group create --name my-new-group --labels label1=value1,label2=value2 +``` + +### Options + +``` + --description string An optional description of the security group. + -h, --help Help for "stackit beta security-group create" + --labels stringToString Labels are key-value string pairs which can be attached to a network-interface. E.g. '--labels key1=value1,key2=value2,...' (default []) + --name string The name of the security group. + --stateful Create a stateful or a stateless security group +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"] + -p, --project-id string Project ID + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta security-group](./stackit_beta_security-group.md) - Manage security groups + diff --git a/docs/stackit_beta_security-group_delete.md b/docs/stackit_beta_security-group_delete.md new file mode 100644 index 000000000..dd3d7935f --- /dev/null +++ b/docs/stackit_beta_security-group_delete.md @@ -0,0 +1,39 @@ +## stackit beta security-group delete + +Deletes a security group + +### Synopsis + +Deletes a security group by its internal ID. + +``` +stackit beta security-group delete [flags] +``` + +### Examples + +``` + Delete a named group with ID "xxx" + $ stackit beta security-group delete xxx +``` + +### Options + +``` + -h, --help Help for "stackit beta security-group delete" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"] + -p, --project-id string Project ID + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta security-group](./stackit_beta_security-group.md) - Manage security groups + diff --git a/docs/stackit_beta_security-group_describe.md b/docs/stackit_beta_security-group_describe.md new file mode 100644 index 000000000..b1856566d --- /dev/null +++ b/docs/stackit_beta_security-group_describe.md @@ -0,0 +1,39 @@ +## stackit beta security-group describe + +Describes security groups + +### Synopsis + +Describes security groups by its internal ID. + +``` +stackit beta security-group describe [flags] +``` + +### Examples + +``` + Describe group "xxx" + $ stackit beta security-group describe xxx +``` + +### Options + +``` + -h, --help Help for "stackit beta security-group describe" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"] + -p, --project-id string Project ID + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta security-group](./stackit_beta_security-group.md) - Manage security groups + diff --git a/docs/stackit_beta_security-group_list.md b/docs/stackit_beta_security-group_list.md new file mode 100644 index 000000000..f64f6ec99 --- /dev/null +++ b/docs/stackit_beta_security-group_list.md @@ -0,0 +1,43 @@ +## stackit beta security-group list + +Lists security groups + +### Synopsis + +Lists security groups by its internal ID. + +``` +stackit beta security-group list [flags] +``` + +### Examples + +``` + List all groups + $ stackit beta security-group list + + List groups with labels + $ stackit beta security-group list --label-selector label1=value1,label2=value2 +``` + +### Options + +``` + -h, --help Help for "stackit beta security-group list" + --label-selector string Filter by label +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"] + -p, --project-id string Project ID + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta security-group](./stackit_beta_security-group.md) - Manage security groups + diff --git a/docs/stackit_beta_security-group_rule.md b/docs/stackit_beta_security-group_rule.md index a680f5bfc..8d47d0b21 100644 --- a/docs/stackit_beta_security-group_rule.md +++ b/docs/stackit_beta_security-group_rule.md @@ -28,7 +28,7 @@ stackit beta security-group rule [flags] ### SEE ALSO -* [stackit beta security-group](./stackit_beta_security-group.md) - Provides functionality for security groups +* [stackit beta security-group](./stackit_beta_security-group.md) - Manage security groups * [stackit beta security-group rule create](./stackit_beta_security-group_rule_create.md) - Creates a security group rule * [stackit beta security-group rule delete](./stackit_beta_security-group_rule_delete.md) - Deletes a security group rule * [stackit beta security-group rule describe](./stackit_beta_security-group_rule_describe.md) - Shows details of a security group rule diff --git a/docs/stackit_beta_security-group_update.md b/docs/stackit_beta_security-group_update.md new file mode 100644 index 000000000..acba6414e --- /dev/null +++ b/docs/stackit_beta_security-group_update.md @@ -0,0 +1,45 @@ +## stackit beta security-group update + +Updates a security group + +### Synopsis + +Updates a named security group + +``` +stackit beta security-group update [flags] +``` + +### Examples + +``` + Update the name of group "xxx" + $ stackit beta security-group update xxx --name my-new-name + + Update the labels of group "xxx" + $ stackit beta security-group update xxx --labels label1=value1,label2=value2 +``` + +### Options + +``` + --description string An optional description of the security group. + -h, --help Help for "stackit beta security-group update" + --labels stringToString Labels are key-value string pairs which can be attached to a network-interface. E.g. '--labels key1=value1,key2=value2,...' (default []) + --name string The name of the security group. +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"] + -p, --project-id string Project ID + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta security-group](./stackit_beta_security-group.md) - Manage security groups + diff --git a/internal/cmd/beta/security-group/create/create.go b/internal/cmd/beta/security-group/create/create.go new file mode 100644 index 000000000..e70273a44 --- /dev/null +++ b/internal/cmd/beta/security-group/create/create.go @@ -0,0 +1,169 @@ +package create + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/goccy/go-yaml" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" +) + +const ( + nameFlag = "name" + descriptionFlag = "description" + statefulFlag = "stateful" + labelsFlag = "labels" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + Labels *map[string]string + Description *string + Name *string + Stateful *bool +} + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Creates security groups", + Long: "Creates security groups.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample(`Create a named group`, `$ stackit beta security-group create --name my-new-group`), + examples.NewExample(`Create a named group with labels`, `$ stackit beta security-group create --name my-new-group --labels label1=value1,label2=value2`), + ), + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := context.Background() + model, err := parseInput(p, cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p) + if err != nil { + return err + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to create the security group %q?", *model.Name) + err = p.PromptForConfirmation(prompt) + if err != nil { + return err + } + } + + // Call API + request := buildRequest(ctx, model, apiClient) + + group, err := request.Execute() + if err != nil { + return fmt.Errorf("create security group: %w", err) + } + + if err := outputResult(p, model, group); err != nil { + return err + } + + return nil + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().String(nameFlag, "", "The name of the security group.") + cmd.Flags().String(descriptionFlag, "", "An optional description of the security group.") + cmd.Flags().Bool(statefulFlag, false, "Create a stateful or a stateless security group") + cmd.Flags().StringToString(labelsFlag, nil, "Labels are key-value string pairs which can be attached to a network-interface. E.g. '--labels key1=value1,key2=value2,...'") + + if err := flags.MarkFlagsRequired(cmd, nameFlag); err != nil { + cobra.CheckErr(err) + } +} + +func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + name := flags.FlagToStringValue(p, cmd, nameFlag) + + model := inputModel{ + GlobalFlagModel: globalFlags, + Name: &name, + + Labels: flags.FlagToStringToStringPointer(p, cmd, labelsFlag), + Description: flags.FlagToStringPointer(p, cmd, descriptionFlag), + Stateful: flags.FlagToBoolPointer(p, cmd, statefulFlag), + } + + if p.IsVerbosityDebug() { + modelStr, err := print.BuildDebugStrFromInputModel(model) + if err != nil { + p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) + } else { + p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) + } + } + + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiCreateSecurityGroupRequest { + request := apiClient.CreateSecurityGroup(ctx, model.ProjectId) + + var labelsMap *map[string]any + if model.Labels != nil && len(*model.Labels) > 0 { + // convert map[string]string to map[string]interface{} + labelsMap = utils.Ptr(map[string]interface{}{}) + for k, v := range *model.Labels { + (*labelsMap)[k] = v + } + } + payload := iaas.CreateSecurityGroupPayload{ + Description: model.Description, + Labels: labelsMap, + Name: model.Name, + Stateful: model.Stateful, + } + + return request.CreateSecurityGroupPayload(payload) +} + +func outputResult(p *print.Printer, model *inputModel, resp *iaas.SecurityGroup) error { + switch model.OutputFormat { + case print.JSONOutputFormat: + details, err := json.MarshalIndent(resp, "", " ") + if err != nil { + return fmt.Errorf("marshal security group: %w", err) + } + p.Outputln(string(details)) + + return nil + case print.YAMLOutputFormat: + details, err := yaml.MarshalWithOptions(resp, yaml.IndentSequence(true)) + if err != nil { + return fmt.Errorf("marshal security group: %w", err) + } + p.Outputln(string(details)) + + return nil + default: + p.Outputf("Created security group %q\n", *model.Name) + return nil + } +} diff --git a/internal/cmd/beta/security-group/create/create_test.go b/internal/cmd/beta/security-group/create/create_test.go new file mode 100644 index 000000000..1833e5c57 --- /dev/null +++ b/internal/cmd/beta/security-group/create/create_test.go @@ -0,0 +1,266 @@ +package create + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &iaas.APIClient{} + testProjectId = uuid.NewString() + testName = "new-security-group" + testDescription = "a test description" + testLabels = map[string]string{ + "fooKey": "fooValue", + "barKey": "barValue", + "bazKey": "bazValue", + } + testStateful = true +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + descriptionFlag: testDescription, + labelsFlag: "fooKey=fooValue,barKey=barValue,bazKey=bazValue", + statefulFlag: "true", + nameFlag: testName, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ProjectId: testProjectId, Verbosity: globalflags.VerbosityDefault}, + Labels: &testLabels, + Description: &testDescription, + Name: &testName, + Stateful: &testStateful, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func toStringAnyMapPtr(m map[string]string) map[string]any { + if m == nil { + return nil + } + result := map[string]any{} + for k, v := range m { + result[k] = v + } + return result +} +func fixtureRequest(mods ...func(request *iaas.ApiCreateSecurityGroupRequest)) iaas.ApiCreateSecurityGroupRequest { + request := testClient.CreateSecurityGroup(testCtx, testProjectId) + + request = request.CreateSecurityGroupPayload(iaas.CreateSecurityGroupPayload{ + Description: &testDescription, + Labels: utils.Ptr(toStringAnyMapPtr(testLabels)), + Name: &testName, + Rules: nil, + Stateful: &testStateful, + }) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "name missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, nameFlag) + }), + isValid: false, + }, + { + description: "no labels", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, labelsFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Labels = nil + }), + }, + { + description: "single label", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[labelsFlag] = "foo=bar" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Labels = &map[string]string{ + "foo": "bar", + } + }), + }, + { + description: "stateless security group", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[statefulFlag] = "false" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Stateful = utils.Ptr(false) + }), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := NewCmd(p) + if err := globalflags.Configure(cmd.Flags()); err != nil { + t.Errorf("cannot configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + if err := cmd.ValidateRequiredFlags(); err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(p, cmd) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest iaas.ApiCreateSecurityGroupRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + { + description: "no labels", + model: fixtureInputModel(func(model *inputModel) { + model.Labels = nil + }), + expectedRequest: fixtureRequest(func(request *iaas.ApiCreateSecurityGroupRequest) { + *request = request.CreateSecurityGroupPayload(iaas.CreateSecurityGroupPayload{ + Description: &testDescription, + Labels: nil, + Name: &testName, + Stateful: &testStateful, + }) + }), + }, + { + description: "stateless security group", + model: fixtureInputModel(func(model *inputModel) { + model.Stateful = utils.Ptr(false) + }), + expectedRequest: fixtureRequest(func(request *iaas.ApiCreateSecurityGroupRequest) { + *request = request.CreateSecurityGroupPayload(iaas.CreateSecurityGroupPayload{ + Description: &testDescription, + Labels: utils.Ptr(toStringAnyMapPtr(testLabels)), + Name: &testName, + Stateful: utils.Ptr(false), + }) + }), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/beta/security-group/delete/delete.go b/internal/cmd/beta/security-group/delete/delete.go new file mode 100644 index 000000000..580dbd96f --- /dev/null +++ b/internal/cmd/beta/security-group/delete/delete.go @@ -0,0 +1,110 @@ +package delete + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" + iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + SecurityGroupId string +} + +const groupIdArg = "GROUP_ID" + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: "delete", + Short: "Deletes a security group", + Long: "Deletes a security group by its internal ID.", + Args: args.SingleArg(groupIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample(`Delete a named group with ID "xxx"`, `$ stackit beta security-group delete xxx`), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(p, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p) + if err != nil { + return err + } + + projectLabel, err := projectname.GetProjectName(ctx, p, cmd) + if err != nil { + p.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } + + groupLabel, err := iaasUtils.GetSecurityGroupName(ctx, apiClient, model.ProjectId, model.SecurityGroupId) + if err != nil { + p.Warn("get security group name: %v", err) + groupLabel = model.SecurityGroupId + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to delete the security group %q for %q?", groupLabel, projectLabel) + err = p.PromptForConfirmation(prompt) + if err != nil { + return err + } + } + + // Call API + request := buildRequest(ctx, model, apiClient) + + if err := request.Execute(); err != nil { + return fmt.Errorf("delete security group: %w", err) + } + p.Info("Deleted security group %q for %q\n", groupLabel, projectLabel) + + return nil + }, + } + + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, cliArgs []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + SecurityGroupId: cliArgs[0], + } + + if p.IsVerbosityDebug() { + modelStr, err := print.BuildDebugStrFromInputModel(model) + if err != nil { + p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) + } else { + p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) + } + } + + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiDeleteSecurityGroupRequest { + request := apiClient.DeleteSecurityGroup(ctx, model.ProjectId, model.SecurityGroupId) + return request +} diff --git a/internal/cmd/beta/security-group/delete/delete_test.go b/internal/cmd/beta/security-group/delete/delete_test.go new file mode 100644 index 000000000..7666e1585 --- /dev/null +++ b/internal/cmd/beta/security-group/delete/delete_test.go @@ -0,0 +1,183 @@ +package delete + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &iaas.APIClient{} + testProjectId = uuid.NewString() + testGroupId = uuid.NewString() +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ProjectId: testProjectId, Verbosity: globalflags.VerbosityDefault}, + SecurityGroupId: testGroupId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *iaas.ApiDeleteSecurityGroupRequest)) iaas.ApiDeleteSecurityGroupRequest { + request := testClient.DeleteSecurityGroup(testCtx, testProjectId, testGroupId) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + flagValues map[string]string + args []string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + args: []string{testGroupId}, + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "no arguments", + flagValues: fixtureFlagValues(), + args: nil, + isValid: false, + }, + { + description: "multiple arguments", + flagValues: fixtureFlagValues(), + args: []string{"foo", "bar"}, + isValid: false, + }, + { + description: "invalid group id", + flagValues: fixtureFlagValues(), + args: []string{"foo"}, + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := NewCmd(p) + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + cmd.SetArgs(tt.args) + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + if err := cmd.ValidateArgs(tt.args); err != nil { + if !tt.isValid { + return + } + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(p, cmd, tt.args) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest iaas.ApiDeleteSecurityGroupRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/beta/security-group/describe/describe.go b/internal/cmd/beta/security-group/describe/describe.go new file mode 100644 index 000000000..68cd63eff --- /dev/null +++ b/internal/cmd/beta/security-group/describe/describe.go @@ -0,0 +1,148 @@ +package describe + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/goccy/go-yaml" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + SecurityGroupId string +} + +const groupIdArg = "GROUP_ID" + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: "describe", + Short: "Describes security groups", + Long: "Describes security groups by its internal ID.", + Args: args.SingleArg(groupIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample(`Describe group "xxx"`, `$ stackit beta security-group describe xxx`), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(p, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p) + if err != nil { + return err + } + + // Call API + request := buildRequest(ctx, model, apiClient) + + group, err := request.Execute() + if err != nil { + return fmt.Errorf("get security group: %w", err) + } + + if err := outputResult(p, model, group); err != nil { + return err + } + + return nil + }, + } + + return cmd +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiGetSecurityGroupRequest { + request := apiClient.GetSecurityGroup(ctx, model.ProjectId, model.SecurityGroupId) + return request +} + +func parseInput(p *print.Printer, cmd *cobra.Command, cliArgs []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + SecurityGroupId: cliArgs[0], + } + + if p.IsVerbosityDebug() { + modelStr, err := print.BuildDebugStrFromInputModel(model) + if err != nil { + p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) + } else { + p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) + } + } + + return &model, nil +} + +func outputResult(p *print.Printer, model *inputModel, resp *iaas.SecurityGroup) error { + switch model.OutputFormat { + case print.JSONOutputFormat: + details, err := json.MarshalIndent(resp, "", " ") + if err != nil { + return fmt.Errorf("marshal security group: %w", err) + } + p.Outputln(string(details)) + + return nil + case print.YAMLOutputFormat: + details, err := yaml.MarshalWithOptions(resp, yaml.IndentSequence(true)) + if err != nil { + return fmt.Errorf("marshal security group: %w", err) + } + p.Outputln(string(details)) + + return nil + default: + table := tables.NewTable() + if id := resp.Id; id != nil { + table.AddRow("ID", *id) + } + table.AddSeparator() + + if name := resp.Name; name != nil { + table.AddRow("NAME", *name) + table.AddSeparator() + } + + if description := resp.Description; description != nil { + table.AddRow("DESCRIPTION", *description) + table.AddSeparator() + } + + if resp.Labels != nil && len(*resp.Labels) > 0 { + labels := []string{} + for key, value := range *resp.Labels { + labels = append(labels, fmt.Sprintf("%s: %s", key, value)) + } + table.AddRow("LABELS", strings.Join(labels, "\n")) + table.AddSeparator() + } + + if err := table.Display(p); err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + } +} diff --git a/internal/cmd/beta/security-group/describe/describe_test.go b/internal/cmd/beta/security-group/describe/describe_test.go new file mode 100644 index 000000000..13a98fc84 --- /dev/null +++ b/internal/cmd/beta/security-group/describe/describe_test.go @@ -0,0 +1,194 @@ +package describe + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &iaas.APIClient{} + testProjectId = uuid.NewString() + testSecurityGroupId = []string{uuid.NewString()} +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ProjectId: testProjectId, Verbosity: globalflags.VerbosityDefault}, + SecurityGroupId: testSecurityGroupId[0], + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *iaas.ApiGetSecurityGroupRequest)) iaas.ApiGetSecurityGroupRequest { + request := testClient.GetSecurityGroup(testCtx, testProjectId, testSecurityGroupId[0]) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + flagValues map[string]string + isValid bool + args []string + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + expectedModel: fixtureInputModel(), + args: testSecurityGroupId, + isValid: true, + }, + { + description: "no values", + flagValues: map[string]string{}, + args: testSecurityGroupId, + isValid: false, + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + args: testSecurityGroupId, + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + args: testSecurityGroupId, + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + args: testSecurityGroupId, + isValid: false, + }, + { + description: "no group id passed", + flagValues: fixtureFlagValues(), + args: nil, + isValid: false, + }, + { + description: "multiple group ids passed", + flagValues: fixtureFlagValues(), + args: []string{uuid.NewString(), uuid.NewString()}, + isValid: false, + }, + { + description: "invalid group id passed", + flagValues: fixtureFlagValues(), + args: []string{"foobar"}, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := NewCmd(p) + if err := globalflags.Configure(cmd.Flags()); err != nil { + t.Errorf("cannot configure global flags: %v", err) + } + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + if err := cmd.ValidateRequiredFlags(); err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + if err := cmd.ValidateArgs(tt.args); err != nil { + if !tt.isValid { + return + } + } + + model, err := parseInput(p, cmd, tt.args) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest iaas.ApiGetSecurityGroupRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/beta/security-group/list/list.go b/internal/cmd/beta/security-group/list/list.go new file mode 100644 index 000000000..6c2bf6fcf --- /dev/null +++ b/internal/cmd/beta/security-group/list/list.go @@ -0,0 +1,151 @@ +package list + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/goccy/go-yaml" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + LabelSelector *string +} + +const ( + labelSelectorFlag = "label-selector" +) + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "Lists security groups", + Long: "Lists security groups by its internal ID.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample(`List all groups`, `$ stackit beta security-group list`), + examples.NewExample(`List groups with labels`, `$ stackit beta security-group list --label-selector label1=value1,label2=value2`), + ), + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := context.Background() + model, err := parseInput(p, cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p) + if err != nil { + return err + } + + projectLabel, err := projectname.GetProjectName(ctx, p, cmd) + if err != nil { + p.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } + + // Call API + request := buildRequest(ctx, model, apiClient) + + response, err := request.Execute() + if err != nil { + return fmt.Errorf("list security group: %w", err) + } + + if items := response.GetItems(); items == nil || len(*items) == 0 { + p.Info("No security groups found for project %q", projectLabel) + } else { + if err := outputResult(p, model.OutputFormat, *items); err != nil { + return fmt.Errorf("output security groups: %w", err) + } + } + + return nil + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().String(labelSelectorFlag, "", "Filter by label") +} + +func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + LabelSelector: flags.FlagToStringPointer(p, cmd, labelSelectorFlag), + } + + if p.IsVerbosityDebug() { + modelStr, err := print.BuildDebugStrFromInputModel(model) + if err != nil { + p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) + } else { + p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) + } + } + + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiListSecurityGroupsRequest { + request := apiClient.ListSecurityGroups(ctx, model.ProjectId) + if model.LabelSelector != nil { + request = request.LabelSelector(*model.LabelSelector) + } + + return request +} +func outputResult(p *print.Printer, outputFormat string, items []iaas.SecurityGroup) error { + switch outputFormat { + case print.JSONOutputFormat: + details, err := json.MarshalIndent(items, "", " ") + if err != nil { + return fmt.Errorf("marshal PostgreSQL Flex instance list: %w", err) + } + p.Outputln(string(details)) + + return nil + case print.YAMLOutputFormat: + details, err := yaml.MarshalWithOptions(items, yaml.IndentSequence(true)) + if err != nil { + return fmt.Errorf("marshal PostgreSQL Flex instance list: %w", err) + } + p.Outputln(string(details)) + + return nil + default: + table := tables.NewTable() + table.SetHeader("ID", "NAME", "STATEFUL") + for _, item := range items { + table.AddRow(utils.PtrString(item.Id), utils.PtrString(item.Name), utils.PtrString(item.Stateful)) + } + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + } +} diff --git a/internal/cmd/beta/security-group/list/list_test.go b/internal/cmd/beta/security-group/list/list_test.go new file mode 100644 index 000000000..c6f3a0c93 --- /dev/null +++ b/internal/cmd/beta/security-group/list/list_test.go @@ -0,0 +1,207 @@ +package list + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &iaas.APIClient{} + testProjectId = uuid.NewString() + testLabels = "fooKey=fooValue,barKey=barValue,bazKey=bazValue" +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + labelSelectorFlag: testLabels, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ProjectId: testProjectId, Verbosity: globalflags.VerbosityDefault}, + LabelSelector: utils.Ptr(testLabels), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *iaas.ApiListSecurityGroupsRequest)) iaas.ApiListSecurityGroupsRequest { + request := testClient.ListSecurityGroups(testCtx, testProjectId) + request = request.LabelSelector(testLabels) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "no labels", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, labelSelectorFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.LabelSelector = nil + }), + }, + { + description: "single label", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[labelSelectorFlag] = "foo=bar" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.LabelSelector = utils.Ptr("foo=bar") + }), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := NewCmd(p) + if err := globalflags.Configure(cmd.Flags()); err != nil { + t.Errorf("cannot configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + if err := cmd.ValidateRequiredFlags(); err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(p, cmd) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest iaas.ApiListSecurityGroupsRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + { + description: "no labels", + model: fixtureInputModel(func(model *inputModel) { + model.LabelSelector = utils.Ptr("") + }), + expectedRequest: fixtureRequest(func(request *iaas.ApiListSecurityGroupsRequest) { + *request = request.LabelSelector("") + }), + }, + { + description: "single label", + model: fixtureInputModel(func(model *inputModel) { + model.LabelSelector = utils.Ptr("foo=bar") + }), + expectedRequest: fixtureRequest(func(request *iaas.ApiListSecurityGroupsRequest) { + *request = request.LabelSelector("foo=bar") + }), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/beta/security-group/security_group.go b/internal/cmd/beta/security-group/security_group.go index 53f380d90..34e4bb73e 100644 --- a/internal/cmd/beta/security-group/security_group.go +++ b/internal/cmd/beta/security-group/security_group.go @@ -1,9 +1,15 @@ -package securitygroup +package security_group import ( + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/security-group/create" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/security-group/delete" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/security-group/describe" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/security-group/list" "github.com/stackitcloud/stackit-cli/internal/cmd/beta/security-group/rule" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/security-group/update" "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" @@ -12,8 +18,8 @@ import ( func NewCmd(p *print.Printer) *cobra.Command { cmd := &cobra.Command{ Use: "security-group", - Short: "Provides functionality for security groups", - Long: "Provides functionality for security groups.", + Short: "Manage security groups", + Long: "Manage the lifecycle of security groups and rules.", Args: args.NoArgs, Run: utils.CmdHelp, } @@ -22,5 +28,12 @@ func NewCmd(p *print.Printer) *cobra.Command { } func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(rule.NewCmd(p)) + cmd.AddCommand( + rule.NewCmd(p), + create.NewCmd(p), + delete.NewCmd(p), + describe.NewCmd(p), + list.NewCmd(p), + update.NewCmd(p), + ) } diff --git a/internal/cmd/beta/security-group/update/update.go b/internal/cmd/beta/security-group/update/update.go new file mode 100644 index 000000000..961229053 --- /dev/null +++ b/internal/cmd/beta/security-group/update/update.go @@ -0,0 +1,150 @@ +package update + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" + iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + Labels *map[string]string + Description *string + Name *string + SecurityGroupId string +} + +const groupNameArg = "GROUP_ID" + +const ( + nameArg = "name" + descriptionArg = "description" + labelsArg = "labels" +) + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: "update", + Short: "Updates a security group", + Long: "Updates a named security group", + Args: args.SingleArg(groupNameArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample(`Update the name of group "xxx"`, `$ stackit beta security-group update xxx --name my-new-name`), + examples.NewExample(`Update the labels of group "xxx"`, `$ stackit beta security-group update xxx --labels label1=value1,label2=value2`), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(p, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p) + if err != nil { + return err + } + + projectLabel, err := projectname.GetProjectName(ctx, p, cmd) + if err != nil { + p.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } + + groupLabel, err := iaasUtils.GetSecurityGroupName(ctx, apiClient, model.ProjectId, model.SecurityGroupId) + if err != nil { + p.Warn("cannot retrieve groupname: %v", err) + groupLabel = model.SecurityGroupId + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to update the security group %q?", groupLabel) + err = p.PromptForConfirmation(prompt) + if err != nil { + return err + } + } + + // Call API + req := buildRequest(ctx, model, apiClient) + + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("update security group: %w", err) + } + p.Info("Updated security group \"%v\" for %q\n", utils.PtrString(resp.Name), projectLabel) + + return nil + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().String(nameArg, "", "The name of the security group.") + cmd.Flags().String(descriptionArg, "", "An optional description of the security group.") + cmd.Flags().StringToString(labelsArg, nil, "Labels are key-value string pairs which can be attached to a network-interface. E.g. '--labels key1=value1,key2=value2,...'") +} + +func parseInput(p *print.Printer, cmd *cobra.Command, cliArgs []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + Labels: flags.FlagToStringToStringPointer(p, cmd, labelsArg), + Description: flags.FlagToStringPointer(p, cmd, descriptionArg), + Name: flags.FlagToStringPointer(p, cmd, nameArg), + SecurityGroupId: cliArgs[0], + } + + if model.Labels == nil && model.Description == nil && model.Name == nil { + return nil, fmt.Errorf("no flags have been passed") + } + + if p.IsVerbosityDebug() { + modelStr, err := print.BuildDebugStrFromInputModel(model) + if err != nil { + p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) + } else { + p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) + } + } + + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiUpdateSecurityGroupRequest { + request := apiClient.UpdateSecurityGroup(ctx, model.ProjectId, model.SecurityGroupId) + payload := iaas.NewUpdateSecurityGroupPayload() + payload.Description = model.Description + var labelsMap *map[string]any + if model.Labels != nil && len(*model.Labels) > 0 { + // convert map[string]string to map[string]interface{} + labelsMap = utils.Ptr(map[string]interface{}{}) + for k, v := range *model.Labels { + (*labelsMap)[k] = v + } + } + payload.Labels = labelsMap + payload.Name = model.Name + request = request.UpdateSecurityGroupPayload(*payload) + + return request +} diff --git a/internal/cmd/beta/security-group/update/update_test.go b/internal/cmd/beta/security-group/update/update_test.go new file mode 100644 index 000000000..f27cbfc25 --- /dev/null +++ b/internal/cmd/beta/security-group/update/update_test.go @@ -0,0 +1,291 @@ +package update + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &iaas.APIClient{} + testProjectId = uuid.NewString() + testGroupId = []string{uuid.NewString()} + testName = "new-security-group" + testDescription = "a test description" + testLabels = map[string]string{ + "fooKey": "fooValue", + "barKey": "barValue", + "bazKey": "bazValue", + } +) + +func toStringAnyMapPtr(m map[string]string) map[string]any { + if m == nil { + return nil + } + result := map[string]any{} + for k, v := range m { + result[k] = v + } + return result +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + descriptionArg: testDescription, + labelsArg: "fooKey=fooValue,barKey=barValue,bazKey=bazValue", + nameArg: testName, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ProjectId: testProjectId, Verbosity: globalflags.VerbosityDefault}, + Labels: &testLabels, + Description: &testDescription, + Name: &testName, + SecurityGroupId: testGroupId[0], + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *iaas.ApiUpdateSecurityGroupRequest)) iaas.ApiUpdateSecurityGroupRequest { + request := testClient.UpdateSecurityGroup(testCtx, testProjectId, testGroupId[0]) + request = request.UpdateSecurityGroupPayload(iaas.UpdateSecurityGroupPayload{ + Description: &testDescription, + Labels: utils.Ptr(toStringAnyMapPtr(testLabels)), + Name: &testName, + }) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + flagValues map[string]string + args []string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + args: testGroupId, + expectedModel: fixtureInputModel(), + }, + { + description: "no values but valid group id", + flagValues: map[string]string{ + projectIdFlag: testProjectId, + }, + args: testGroupId, + isValid: false, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Labels = nil + model.Name = nil + model.Description = nil + }), + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + args: testGroupId, + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + args: testGroupId, + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + args: testGroupId, + isValid: false, + }, + { + description: "no name passed", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, nameArg) + }), + args: testGroupId, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Name = nil + }), + isValid: true, + }, + { + description: "no description passed", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, descriptionArg) + }), + args: testGroupId, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Description = nil + }), + isValid: true, + }, + { + description: "no labels", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, labelsArg) + }), + args: testGroupId, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Labels = nil + }), + isValid: true, + }, + { + description: "single label", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[labelsArg] = "foo=bar" + }), + args: testGroupId, + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Labels = &map[string]string{ + "foo": "bar", + } + }), + }, + { + description: "no group id passed", + flagValues: fixtureFlagValues(), + args: nil, + isValid: false, + }, + { + description: "invalid group id passed", + flagValues: fixtureFlagValues(), + args: []string{"foobar"}, + isValid: false, + }, + { + description: "multiple group ids passed", + flagValues: fixtureFlagValues(), + args: []string{uuid.NewString(), uuid.NewString()}, + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := NewCmd(p) + if err := globalflags.Configure(cmd.Flags()); err != nil { + t.Errorf("cannot configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + if err := cmd.Flags().Set(flag, value); err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + if err := cmd.ValidateRequiredFlags(); err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + if err := cmd.ValidateArgs(tt.args); err != nil { + if !tt.isValid { + return + } + } + + model, err := parseInput(p, cmd, tt.args) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest iaas.ApiUpdateSecurityGroupRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + { + description: "no labels", + model: fixtureInputModel(func(model *inputModel) { + model.Labels = nil + }), + expectedRequest: fixtureRequest(func(request *iaas.ApiUpdateSecurityGroupRequest) { + *request = request.UpdateSecurityGroupPayload(iaas.UpdateSecurityGroupPayload{ + Description: &testDescription, + Labels: nil, + Name: &testName, + }) + }), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/pkg/utils/utils.go b/internal/pkg/utils/utils.go index 205bff1cf..dc1cfeffb 100644 --- a/internal/pkg/utils/utils.go +++ b/internal/pkg/utils/utils.go @@ -16,6 +16,15 @@ func Ptr[T any](v T) *T { return &v } +// PtrString creates a string representation of a passed object pointer or returns +// an empty string, if the passed object is _nil_. +func PtrString[T any](t *T) string { + if t != nil { + return fmt.Sprintf("%v", *t) + } + return "" +} + // Int64Ptr returns a pointer to an int64 // Needed because the Ptr function only returns pointer to int func Int64Ptr(i int64) *int64 {