diff --git a/docs/stackit.md b/docs/stackit.md index 5f6b1107a..a7c02e2c2 100644 --- a/docs/stackit.md +++ b/docs/stackit.md @@ -59,6 +59,7 @@ stackit [flags] * [stackit service-account](./stackit_service-account.md) - Provides functionality for service accounts * [stackit ske](./stackit_ske.md) - Provides functionality for SKE * [stackit sqlserverflex](./stackit_sqlserverflex.md) - Provides functionality for SQLServer Flex +* [stackit ufw](./stackit_ufw.md) - Provides functionality for UFW * [stackit valkey](./stackit_valkey.md) - Provides functionality for Valkey * [stackit volume](./stackit_volume.md) - Provides functionality for volumes diff --git a/docs/stackit_ufw.md b/docs/stackit_ufw.md new file mode 100644 index 000000000..a2accf7ee --- /dev/null +++ b/docs/stackit_ufw.md @@ -0,0 +1,34 @@ +## stackit ufw + +Provides functionality for UFW + +### Synopsis + +Provides functionality for STACKIT Unified Firewall (UFW). + +``` +stackit ufw [flags] +``` + +### Options + +``` + -h, --help Help for "stackit ufw" +``` + +### 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 + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, (one of: [debug, info, warning, error]) (default "info") +``` + +### SEE ALSO + +* [stackit](./stackit.md) - Manage STACKIT resources using the command line +* [stackit ufw rules](./stackit_ufw_rules.md) - Provides functionality for UFW rules + diff --git a/docs/stackit_ufw_rules.md b/docs/stackit_ufw_rules.md new file mode 100644 index 000000000..6aa88a9f0 --- /dev/null +++ b/docs/stackit_ufw_rules.md @@ -0,0 +1,38 @@ +## stackit ufw rules + +Provides functionality for UFW rules + +### Synopsis + +Provides functionality for STACKIT Unified Firewall (UFW) rules. + +``` +stackit ufw rules [flags] +``` + +### Options + +``` + -h, --help Help for "stackit ufw rules" +``` + +### 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 + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, (one of: [debug, info, warning, error]) (default "info") +``` + +### SEE ALSO + +* [stackit ufw](./stackit_ufw.md) - Provides functionality for UFW +* [stackit ufw rules create](./stackit_ufw_rules_create.md) - Creates a UFW rule instance +* [stackit ufw rules delete](./stackit_ufw_rules_delete.md) - Deletes a UFW rule instance +* [stackit ufw rules describe](./stackit_ufw_rules_describe.md) - Shows details of a UFW rule instance +* [stackit ufw rules list](./stackit_ufw_rules_list.md) - Lists all UFW rules +* [stackit ufw rules update](./stackit_ufw_rules_update.md) - Updates a UFW rule instance + diff --git a/docs/stackit_ufw_rules_create.md b/docs/stackit_ufw_rules_create.md new file mode 100644 index 000000000..ecb898ee8 --- /dev/null +++ b/docs/stackit_ufw_rules_create.md @@ -0,0 +1,47 @@ +## stackit ufw rules create + +Creates a UFW rule instance + +### Synopsis + +Creates a STACKIT Unified Firewall (UFW) rule instance. + +``` +stackit ufw rules create [flags] +``` + +### Examples + +``` + Create a UFW rule instance of type ACL with sourceIp "1.1.1.1/32" of product "Redis" for instance with id=ID + $ stackit ufw rules create --product redis --sourceIp 1.1.1.1/32 --type ACL --instanceId ID + + Create a UFW rule instance of type ACL with sourceIp "2.2.2.2/32" of product "Edge Cloud" for instance with id=ID + $ stackit ufw rules create --product edge-cloud --sourceIp 2.2.2.2/32 --type ACL --instanceId ID +``` + +### Options + +``` + -h, --help Help for "stackit ufw rules create" + -i, --instanceId string Instance ID that will have attached your rule + --product string The source service (e.g., Edge Cloud, Redis) where you want to attach a rule + -s, --sourceIp string The IP (CIDR) to which the rule applies (e.g. 192.168.0.1/32) + -t, --type string Type (ACL/SecurityRule/SecurityGroup) You can check /provider-options route for them. Unfortunately, this field could be only ACL for the CLI version +``` + +### 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 + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, (one of: [debug, info, warning, error]) (default "info") +``` + +### SEE ALSO + +* [stackit ufw rules](./stackit_ufw_rules.md) - Provides functionality for UFW rules + diff --git a/docs/stackit_ufw_rules_delete.md b/docs/stackit_ufw_rules_delete.md new file mode 100644 index 000000000..bc39fcddd --- /dev/null +++ b/docs/stackit_ufw_rules_delete.md @@ -0,0 +1,40 @@ +## stackit ufw rules delete + +Deletes a UFW rule instance + +### Synopsis + +Deletes a STACKIT Unified Firewall (UFW) rule instance. + +``` +stackit ufw rules delete INSTANCE_ID [flags] +``` + +### Examples + +``` + Delete a UFW rule instance with ID "xxx" + $ stackit ufw rules delete xxx +``` + +### Options + +``` + -h, --help Help for "stackit ufw rules 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 + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, (one of: [debug, info, warning, error]) (default "info") +``` + +### SEE ALSO + +* [stackit ufw rules](./stackit_ufw_rules.md) - Provides functionality for UFW rules + diff --git a/docs/stackit_ufw_rules_describe.md b/docs/stackit_ufw_rules_describe.md new file mode 100644 index 000000000..d638d8775 --- /dev/null +++ b/docs/stackit_ufw_rules_describe.md @@ -0,0 +1,43 @@ +## stackit ufw rules describe + +Shows details of a UFW rule instance + +### Synopsis + +Shows details of a STACKIT Unified Firewall (UFW) rule instance. + +``` +stackit ufw rules describe INSTANCE_ID [flags] +``` + +### Examples + +``` + Get details of a UFW rule instance with ID "xxx" + $ stackit ufw rules describe xxx + + Get details of a UFW rule instance with ID "xxx" in JSON format + $ stackit ufw rules describe xxx --output-format json +``` + +### Options + +``` + -h, --help Help for "stackit ufw rules 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 + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, (one of: [debug, info, warning, error]) (default "info") +``` + +### SEE ALSO + +* [stackit ufw rules](./stackit_ufw_rules.md) - Provides functionality for UFW rules + diff --git a/docs/stackit_ufw_rules_list.md b/docs/stackit_ufw_rules_list.md new file mode 100644 index 000000000..4e26be844 --- /dev/null +++ b/docs/stackit_ufw_rules_list.md @@ -0,0 +1,47 @@ +## stackit ufw rules list + +Lists all UFW rules + +### Synopsis + +Lists all STACKIT Unified Firewall (UFW) rules. + +``` +stackit ufw rules list [flags] +``` + +### Examples + +``` + List all UFW rules + $ stackit ufw rules list + + List all UFW rules in JSON format + $ stackit ufw rules list --output-format json + + List up to 10 UFW rules + $ stackit ufw rules list --limit 10 +``` + +### Options + +``` + -h, --help Help for "stackit ufw rules list" + --limit int Maximum number of entries to list +``` + +### 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 + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, (one of: [debug, info, warning, error]) (default "info") +``` + +### SEE ALSO + +* [stackit ufw rules](./stackit_ufw_rules.md) - Provides functionality for UFW rules + diff --git a/docs/stackit_ufw_rules_update.md b/docs/stackit_ufw_rules_update.md new file mode 100644 index 000000000..87a196bca --- /dev/null +++ b/docs/stackit_ufw_rules_update.md @@ -0,0 +1,41 @@ +## stackit ufw rules update + +Updates a UFW rule instance + +### Synopsis + +Updates a STACKIT Unified Firewall (UFW) rule instance. + +``` +stackit ufw rules update INSTANCE_ID [flags] +``` + +### Examples + +``` + Update a UFW rule instance with "1.1.1.1/32" as sourceIp for instance with ID "xxx" + $ stackit ufw rules update xxx --sourceIp 1.1.1.1/32 +``` + +### Options + +``` + -h, --help Help for "stackit ufw rules update" + -s, --sourceIp string The IP (CIDR) to which the rule applies (e.g. 192.168.0.1/32) +``` + +### 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 + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, (one of: [debug, info, warning, error]) (default "info") +``` + +### SEE ALSO + +* [stackit ufw rules](./stackit_ufw_rules.md) - Provides functionality for UFW rules + diff --git a/go.mod b/go.mod index 816f36fe4..644b196b4 100644 --- a/go.mod +++ b/go.mod @@ -38,6 +38,7 @@ require ( github.com/stackitcloud/stackit-sdk-go/services/serviceenablement v1.7.1 github.com/stackitcloud/stackit-sdk-go/services/ske v1.21.1 github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex v1.18.0 + github.com/stackitcloud/stackit-sdk-go/services/ufw v0.1.0 github.com/stackitcloud/stackit-sdk-go/services/valkey v0.3.0 github.com/stackitcloud/stackit-sdk-go/services/vpn v0.15.0 github.com/zalando/go-keyring v0.2.8 diff --git a/go.sum b/go.sum index 6be121cfb..40c49011a 100644 --- a/go.sum +++ b/go.sum @@ -397,6 +397,8 @@ github.com/jingyugao/rowserrcheck v1.1.1 h1:zibz55j/MJtLsjP1OF4bSdgXxwL1b+Vn7Tjz github.com/jingyugao/rowserrcheck v1.1.1/go.mod h1:4yvlZSDb3IyDTUZJUmpZfm2Hwok+Dtp+nu2qOq+er9c= github.com/jjti/go-spancheck v0.6.5 h1:lmi7pKxa37oKYIMScialXUK6hP3iY5F1gu+mLBPgYB8= github.com/jjti/go-spancheck v0.6.5/go.mod h1:aEogkeatBrbYsyW6y5TgDfihCulDYciL1B7rG2vSsrU= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= @@ -452,6 +454,8 @@ github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69 github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/macabu/inamedparam v0.2.0 h1:VyPYpOc10nkhI2qeNUdh3Zket4fcZjEWe35poddBCpE= github.com/macabu/inamedparam v0.2.0/go.mod h1:+Pee9/YfGe5LJ62pYXqB89lJ+0k5bsR8Wgz/C0Zlq3U= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/manuelarte/embeddedstructfieldcheck v0.4.0 h1:3mAIyaGRtjK6EO9E73JlXLtiy7ha80b2ZVGyacxgfww= github.com/manuelarte/embeddedstructfieldcheck v0.4.0/go.mod h1:z8dFSyXqp+fC6NLDSljRJeNQJJDWnY7RoWFzV3PC6UM= github.com/manuelarte/funcorder v0.5.0 h1:llMuHXXbg7tD0i/LNw8vGnkDTHFpTnWqKPI85Rknc+8= @@ -672,6 +676,10 @@ github.com/stackitcloud/stackit-sdk-go/services/ske v1.21.1 h1:dSkUoaMip0uA8tnRE github.com/stackitcloud/stackit-sdk-go/services/ske v1.21.1/go.mod h1:TbqmZhLMofmfl+HhVl6oHYcI3zvXTm1vRjN3A/fOkM4= github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex v1.18.0 h1:k4dgOFQvkBhSNTtmFOZwMbkKf6ZCEGB/D1WQgiO9gwc= github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex v1.18.0/go.mod h1:AiUoMAqQcOlMgDtkVJlqI7P/VGD5xjN3dYjERGnwN/M= +github.com/stackitcloud/stackit-sdk-go/services/ufw v0.1.0 h1:LnDUxI7v507T341Smff/fVzSSRP59uIOUmcHAbUf1hE= +github.com/stackitcloud/stackit-sdk-go/services/ufw v0.1.0/go.mod h1:c/JxzGai01jCK/5OCi0TxCUiQ1Gl7SboGuErflWxLqQ= +github.com/stackitcloud/stackit-sdk-go/services/valkey v0.2.0 h1:RP9ITvjsPhA2+Xi9MhqYP9xkEsURyxYmRB3JKXIB5zc= +github.com/stackitcloud/stackit-sdk-go/services/valkey v0.2.0/go.mod h1:etzt/a723p327dqha1V72cCoBWjUBS2DtHy3ZTac0SA= github.com/stackitcloud/stackit-sdk-go/services/valkey v0.3.0 h1:XC238ExUFeDHOlJzu4gcspsbcr5v70T/cuNuC/8tUZQ= github.com/stackitcloud/stackit-sdk-go/services/valkey v0.3.0/go.mod h1:etzt/a723p327dqha1V72cCoBWjUBS2DtHy3ZTac0SA= github.com/stackitcloud/stackit-sdk-go/services/vpn v0.15.0 h1:JpMJjWBa6fwNNcAHoq00v+8+DwMD0/fe4WPjvbHdY2o= diff --git a/internal/cmd/root.go b/internal/cmd/root.go index bbb05aca3..8cb0be99b 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -6,6 +6,7 @@ import ( "time" "github.com/stackitcloud/stackit-cli/internal/cmd/sqlserverflex" + "github.com/stackitcloud/stackit-cli/internal/cmd/ufw" "github.com/stackitcloud/stackit-cli/internal/pkg/types" affinityGroups "github.com/stackitcloud/stackit-cli/internal/cmd/affinity-groups" @@ -207,6 +208,7 @@ func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { cmd.AddCommand(kms.NewCmd(params)) cmd.AddCommand(sqlserverflex.NewCmd(params)) cmd.AddCommand(valkey.NewCmd(params)) + cmd.AddCommand(ufw.NewCmd(params)) } // traverseCommands calls f for c and all of its children. diff --git a/internal/cmd/ufw/rules/create/create.go b/internal/cmd/ufw/rules/create/create.go new file mode 100644 index 000000000..662cd0598 --- /dev/null +++ b/internal/cmd/ufw/rules/create/create.go @@ -0,0 +1,188 @@ +package create + +import ( + "context" + "fmt" + + ufw "github.com/stackitcloud/stackit-sdk-go/services/ufw/v1api" + + "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/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/stackitcloud/stackit-sdk-go/services/ufw/v1api/wait" + + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/ufw/client" + + "github.com/spf13/cobra" +) + +const ( + productFlag = "product" + typeFlag = "type" + sourceIpFlag = "sourceIp" + instanceIdFlag = "instanceId" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + + Product *string + Type *string + SourceIp *string + InstanceId *string + Direction *string + Description *string + EtherType *string + PortRange *string + Protocol *string + Offset *int32 + SecurityGroupId *string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Creates a UFW rule instance", + Long: "Creates a STACKIT Unified Firewall (UFW) rule instance.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Create a UFW rule instance of type ACL with sourceIp "1.1.1.1/32" of product "Redis" for instance with id=ID`, + "$ stackit ufw rules create --product redis --sourceIp 1.1.1.1/32 --type ACL --instanceId ID"), + examples.NewExample( + `Create a UFW rule instance of type ACL with sourceIp "2.2.2.2/32" of product "Edge Cloud" for instance with id=ID`, + "$ stackit ufw rules create --product edge-cloud --sourceIp 2.2.2.2/32 --type ACL --instanceId ID"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } + + prompt := fmt.Sprintf("Are you sure you want to create a UFW rule instance for project %q?", projectLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + req, err := buildRequest(ctx, model, apiClient) + if err != nil { + return err + } + + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("create UFW rule instance: %w", err) + } + instanceId := resp.RefId + + if !model.Async { + err := spinner.Run(params.Printer, "Creating ufw rule instance", func() error { + _, err = wait.CreateRuleWaitHandler(ctx, apiClient.DefaultAPI, model.ProjectId, model.Region, *instanceId).WaitWithContext(ctx) + return err + }) + if err != nil { + return fmt.Errorf("wait for UFW rule instance creation: %w", err) + } + } + + return outputResult(params.Printer, model.OutputFormat, model.Async, projectLabel, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().String(productFlag, "", "The source service (e.g., Edge Cloud, Redis) where you want to attach a rule") + cmd.Flags().StringP(typeFlag, "t", "", "Type (ACL/SecurityRule/SecurityGroup) You can check /provider-options route for them. Unfortunately, this field could be only ACL for the CLI version") + cmd.Flags().StringP(sourceIpFlag, "s", "", "The IP (CIDR) to which the rule applies (e.g. 192.168.0.1/32)") + cmd.Flags().StringP(instanceIdFlag, "i", "", "Instance ID that will have attached your rule") + + err := flags.MarkFlagsRequired(cmd, instanceIdFlag) + cobra.CheckErr(err) + + err = flags.MarkFlagsRequired(cmd, productFlag) + cobra.CheckErr(err) + + err = flags.MarkFlagsRequired(cmd, sourceIpFlag) + cobra.CheckErr(err) + + err = flags.MarkFlagsRequired(cmd, typeFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + if globalFlags.Region == "" { + return nil, &errors.RegionError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + + Product: flags.FlagToStringPointer(p, cmd, productFlag), + Type: flags.FlagToStringPointer(p, cmd, typeFlag), + SourceIp: flags.FlagToStringPointer(p, cmd, sourceIpFlag), + InstanceId: flags.FlagToStringPointer(p, cmd, instanceIdFlag), + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *ufw.APIClient) (ufw.ApiCreateRuleRequest, error) { + req := apiClient.DefaultAPI.CreateRule(ctx, model.ProjectId, model.Region) + + if *model.Type != "ACL" { + return req, fmt.Errorf("invalid rule type: %s, only ACL type supported for now", *model.Type) + } + + req = req.CreateRulePayload(ufw.CreateRulePayload{ + Product: *model.Product, + Type: *model.Type, + SourceIP: *model.SourceIp, + InstanceId: *model.InstanceId, + }) + + return req, nil +} + +func outputResult(p *print.Printer, outputFormat string, async bool, projectLabel string, rule *ufw.CreateRuleResponse) error { + if rule == nil { + return fmt.Errorf("response is nil") + } + + return p.OutputResult(outputFormat, rule, func() error { + operationState := "Created" + if async { + operationState = "Triggered creation of" + } + p.Outputf("%s rule for project %q. Rule refID: %s\n", operationState, projectLabel, utils.PtrString(rule.RefId)) + return nil + }) +} diff --git a/internal/cmd/ufw/rules/create/create_test.go b/internal/cmd/ufw/rules/create/create_test.go new file mode 100644 index 000000000..40573546d --- /dev/null +++ b/internal/cmd/ufw/rules/create/create_test.go @@ -0,0 +1,265 @@ +package create + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + + ufw "github.com/stackitcloud/stackit-sdk-go/services/ufw/v1api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &ufw.APIClient{DefaultAPI: &ufw.DefaultAPIService{}} + testProjectId = uuid.NewString() + testInstanceId = uuid.NewString() +) + +const ( + testRegion = "eu01" + testProduct = "redis" + testType = "ACL" + testWrongType = "SecurityGroup" + testSourceIp = "1.1.1.1/32" +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + productFlag: testProduct, + typeFlag: testType, + sourceIpFlag: testSourceIp, + instanceIdFlag: testInstanceId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + Product: new(testProduct), + Type: new(testType), + SourceIp: new(testSourceIp), + InstanceId: new(testInstanceId), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *ufw.ApiCreateRuleRequest)) ufw.ApiCreateRuleRequest { + request := testClient.DefaultAPI.CreateRule(testCtx, testProjectId, testRegion) + request = request.CreateRulePayload(ufw.CreateRulePayload{ + Product: testProduct, + Type: testType, + SourceIP: testSourceIp, + InstanceId: testInstanceId, + }) + 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: "required fields only", + flagValues: map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + productFlag: testProduct, + typeFlag: testType, + sourceIpFlag: testSourceIp, + instanceIdFlag: testInstanceId, + }, + isValid: true, + expectedModel: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + Product: new(testProduct), + Type: new(testType), + SourceIp: new(testSourceIp), + InstanceId: new(testInstanceId), + }, + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "region missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.RegionFlag) + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, nil, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest ufw.ApiCreateRuleRequest + isValid bool + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + { + description: "required fields only", + model: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + Product: new(testProduct), + Type: new(testType), + SourceIp: new(testSourceIp), + InstanceId: new(testInstanceId), + }, + expectedRequest: testClient.DefaultAPI.CreateRule(testCtx, testProjectId, testRegion). + CreateRulePayload(ufw.CreateRulePayload{ + Product: testProduct, + Type: testType, + SourceIP: testSourceIp, + InstanceId: testInstanceId, + }), + }, + { + description: "required fields only but wrong type", + model: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + Product: new(testProduct), + Type: new(testWrongType), + SourceIp: new(testSourceIp), + InstanceId: new(testInstanceId), + }, + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request, err := buildRequest(testCtx, tt.model, testClient) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error building request: %v", err) + } + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx, ufw.DefaultAPIService{}), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + async bool + projectLabel string + rule *ufw.CreateRuleResponse + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{}, + wantErr: true, + }, + { + name: "set empty response", + args: args{ + rule: &ufw.CreateRuleResponse{}, + }, + wantErr: false, + }, + } + + params := testparams.NewTestParams() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(params.Printer, tt.args.outputFormat, tt.args.async, tt.args.projectLabel, tt.args.rule); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/ufw/rules/delete/delete.go b/internal/cmd/ufw/rules/delete/delete.go new file mode 100644 index 000000000..f08e410b6 --- /dev/null +++ b/internal/cmd/ufw/rules/delete/delete.go @@ -0,0 +1,111 @@ +package delete + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + ufw "github.com/stackitcloud/stackit-sdk-go/services/ufw/v1api" + "github.com/stackitcloud/stackit-sdk-go/services/ufw/v1api/wait" + + "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/ufw/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + instanceIdArg = "INSTANCE_ID" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + InstanceId string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("delete %s", instanceIdArg), + Short: "Deletes a UFW rule instance", + Long: "Deletes a STACKIT Unified Firewall (UFW) rule instance.", + Args: args.SingleArg(instanceIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Delete a UFW rule instance with ID "xxx"`, + "$ stackit ufw rules delete xxx"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + prompt := fmt.Sprintf("Are you sure you want to delete instance %q? (This cannot be undone)", model.InstanceId) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + req := buildRequest(ctx, model, apiClient) + _, err = req.Execute() + if err != nil { + return fmt.Errorf("delete UFW rule instance: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + err := spinner.Run(params.Printer, "Deleting instance", func() error { + _, err = wait.DeleteRuleWaitHandler(ctx, apiClient.DefaultAPI, model.ProjectId, model.Region, model.InstanceId).WaitWithContext(ctx) + return err + }) + if err != nil { + return fmt.Errorf("wait for UFW rule instance deletion: %w", err) + } + } + + operationState := "Deleted" + if model.Async { + operationState = "Triggered deletion of" + } + params.Printer.Outputf("%s instance %q\n", operationState, model.InstanceId) + return nil + }, + } + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + instanceId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + if globalFlags.Region == "" { + return nil, &errors.RegionError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + InstanceId: instanceId, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *ufw.APIClient) ufw.ApiDeleteRuleRequest { + return apiClient.DefaultAPI.DeleteRule(ctx, model.ProjectId, model.Region, model.InstanceId) +} diff --git a/internal/cmd/ufw/rules/delete/delete_test.go b/internal/cmd/ufw/rules/delete/delete_test.go new file mode 100644 index 000000000..10f643ed9 --- /dev/null +++ b/internal/cmd/ufw/rules/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/testutils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + ufw "github.com/stackitcloud/stackit-sdk-go/services/ufw/v1api" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &ufw.APIClient{DefaultAPI: &ufw.DefaultAPIService{}} + testProjectId = uuid.NewString() + testInstanceId = uuid.NewString() +) + +const testRegion = "eu01" + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testInstanceId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + InstanceId: testInstanceId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *ufw.ApiDeleteRuleRequest)) ufw.ApiDeleteRuleRequest { + request := testClient.DefaultAPI.DeleteRule(testCtx, testProjectId, testRegion, testInstanceId) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + argValues: []string{}, + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "no arg values", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "no flag values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "region missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.RegionFlag) + }), + isValid: false, + }, + { + description: "instance id invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "instance id invalid 2", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest ufw.ApiDeleteRuleRequest + }{ + { + 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, ufw.DefaultAPIService{}), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/ufw/rules/describe/describe.go b/internal/cmd/ufw/rules/describe/describe.go new file mode 100644 index 000000000..71a199007 --- /dev/null +++ b/internal/cmd/ufw/rules/describe/describe.go @@ -0,0 +1,124 @@ +package describe + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + ufw "github.com/stackitcloud/stackit-sdk-go/services/ufw/v1api" + + "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/ufw/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + instanceIdArg = "INSTANCE_ID" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + InstanceId string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("describe %s", instanceIdArg), + Short: "Shows details of a UFW rule instance", + Long: "Shows details of a STACKIT Unified Firewall (UFW) rule instance.", + Args: args.SingleArg(instanceIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Get details of a UFW rule instance with ID "xxx"`, + "$ stackit ufw rules describe xxx"), + examples.NewExample( + `Get details of a UFW rule instance with ID "xxx" in JSON format`, + "$ stackit ufw rules describe xxx --output-format json"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("read UFW rule instance: %w", err) + } + + return outputResult(params.Printer, model.OutputFormat, resp) + }, + } + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + instanceId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + if globalFlags.Region == "" { + return nil, &errors.RegionError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + InstanceId: instanceId, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *ufw.APIClient) ufw.ApiGetRuleRequest { + return apiClient.DefaultAPI.GetRule(ctx, model.ProjectId, model.Region, model.InstanceId) +} + +func outputResult(p *print.Printer, outputFormat string, rule *ufw.RuleResponse) error { + return p.OutputResult(outputFormat, rule, func() error { + if rule == nil { + return fmt.Errorf("no instance rule passed") + } + + table := tables.NewTable() + table.AddRow("PRODUCT", utils.PtrString(&rule.Product)) + table.AddSeparator() + table.AddRow("SOURCE", utils.PtrString(&rule.SourceIP)) + table.AddSeparator() + table.AddRow("DEPLOYMENT TARGET", utils.PtrString(&rule.InstanceName)) + table.AddSeparator() + table.AddRow("PROTOCOL", utils.PtrString(&rule.Protocol)) + table.AddSeparator() + table.AddRow("DIRECTION", utils.PtrString(&rule.Direction)) + table.AddSeparator() + table.AddRow("PORT RANGE", fmt.Sprintf("%d", rule.PortRange)) + table.AddSeparator() + table.AddRow("ETHER TYPE", utils.PtrString(&rule.EtherType)) + table.AddSeparator() + table.AddRow("STATUS", utils.PtrString(&rule.Status)) + table.AddSeparator() + + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + }) +} diff --git a/internal/cmd/ufw/rules/describe/describe_test.go b/internal/cmd/ufw/rules/describe/describe_test.go new file mode 100644 index 000000000..b22071568 --- /dev/null +++ b/internal/cmd/ufw/rules/describe/describe_test.go @@ -0,0 +1,218 @@ +package describe + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + ufw "github.com/stackitcloud/stackit-sdk-go/services/ufw/v1api" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &ufw.APIClient{DefaultAPI: &ufw.DefaultAPIService{}} + testProjectId = uuid.NewString() + testInstanceId = uuid.NewString() +) + +const testRegion = "eu01" + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testInstanceId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + InstanceId: testInstanceId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *ufw.ApiGetRuleRequest)) ufw.ApiGetRuleRequest { + request := testClient.DefaultAPI.GetRule(testCtx, testProjectId, testRegion, testInstanceId) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + argValues: []string{}, + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "no arg values", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "no flag values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "region missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.RegionFlag) + }), + isValid: false, + }, + { + description: "instance id invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "instance id invalid 2", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest ufw.ApiGetRuleRequest + }{ + { + 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, ufw.DefaultAPIService{}), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + rule *ufw.RuleResponse + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{}, + wantErr: true, + }, + { + name: "set empty instance", + args: args{ + rule: &ufw.RuleResponse{}, + }, + wantErr: false, + }, + } + + params := testparams.NewTestParams() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(params.Printer, tt.args.outputFormat, tt.args.rule); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/ufw/rules/list/list.go b/internal/cmd/ufw/rules/list/list.go new file mode 100644 index 000000000..bae22f072 --- /dev/null +++ b/internal/cmd/ufw/rules/list/list.go @@ -0,0 +1,170 @@ +package list + +import ( + "context" + "fmt" + + ufw "github.com/stackitcloud/stackit-sdk-go/services/ufw/v1api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + serviceEnablementClient "github.com/stackitcloud/stackit-cli/internal/pkg/services/service-enablement/client" + serviceEnablementUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/service-enablement/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "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/ufw/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" +) + +const ( + limitFlag = "limit" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + Limit *int64 +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "Lists all UFW rules", + Long: "Lists all STACKIT Unified Firewall (UFW) rules.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all UFW rules`, + "$ stackit ufw rules list"), + examples.NewExample( + `List all UFW rules in JSON format`, + "$ stackit ufw rules list --output-format json"), + examples.NewExample( + `List up to 10 UFW rules`, + "$ stackit ufw rules list --limit 10"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + serviceEnablementApiClient, err := serviceEnablementClient.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + enabled, enabledErr := serviceEnablementUtils.ProjectEnabled(ctx, serviceEnablementApiClient.DefaultAPI, model.ProjectId, model.Region) + if enabledErr != nil { + return fmt.Errorf("check if project is enabled failed: %w", enabledErr) + } + if !enabled { + return &errors.ServiceDisabledError{ + Service: "ufw", + } + } + return fmt.Errorf("get UFW rules: %w", err) + } + rules := resp.Rules + + if model.Limit != nil && len(rules) > int(*model.Limit) { + rules = rules[:*model.Limit] + } + + projectLabel := model.ProjectId + if len(rules) == 0 { + projectLabel, err = projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + } + } + + if resp.Rules == nil { + params.Printer.Info("(...) %s", projectLabel) + return nil + } + + return outputResult(params.Printer, model.OutputFormat, projectLabel, rules) + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + if globalFlags.Region == "" { + return nil, &errors.RegionError{} + } + + limit := flags.FlagToInt64Pointer(p, cmd, limitFlag) + if limit != nil && *limit < 1 { + return nil, &errors.FlagValidationError{ + Flag: limitFlag, + Details: "must be greater than 0", + } + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + Limit: flags.FlagToInt64Pointer(p, cmd, limitFlag), + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *ufw.APIClient) ufw.ApiListRulesRequest { + req := apiClient.DefaultAPI.ListRules(ctx, model.ProjectId, model.Region) + return req +} + +func outputResult(p *print.Printer, outputFormat, projectLabel string, resources []ufw.RuleResponse) error { + return p.OutputResult(outputFormat, resources, func() error { + if len(resources) == 0 { + p.Outputf("No rules found for project %q\n", projectLabel) + return nil + } + + table := tables.NewTable() + table.SetHeader("PRODUCT", "SOURCE", "DEPLOYMENT TARGET", "INSTANCE ID", "PROTOCOL", + "DIRECTION", "PORT RANGE", "ETHER TYPE", "STATUS") + for i := range resources { + resource := resources[i] + table.AddRow(resource.Product, resource.SourceIP, utils.PtrString(resource.InstanceName), resource.InstanceId, + utils.PtrString(resource.Protocol), utils.PtrString(resource.Direction), utils.PtrString(resource.PortRange), + utils.PtrString(resource.EtherType), resource.Status) + } + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + return nil + }) +} diff --git a/internal/cmd/ufw/rules/list/list_test.go b/internal/cmd/ufw/rules/list/list_test.go new file mode 100644 index 000000000..faf7f55ea --- /dev/null +++ b/internal/cmd/ufw/rules/list/list_test.go @@ -0,0 +1,200 @@ +package list + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + ufw "github.com/stackitcloud/stackit-sdk-go/services/ufw/v1api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &ufw.APIClient{DefaultAPI: &ufw.DefaultAPIService{}} + testProjectId = uuid.NewString() +) + +const testRegion = "eu01" + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + limitFlag: "10", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + Limit: new(int64(10)), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *ufw.ApiListRulesRequest)) ufw.ApiListRulesRequest { + request := testClient.DefaultAPI.ListRules(testCtx, testProjectId, testRegion) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []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, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "region missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.RegionFlag) + }), + isValid: false, + }, + { + description: "limit invalid", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "invalid" + }), + isValid: false, + }, + { + description: "limit invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "0" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest ufw.ApiListRulesRequest + }{ + { + 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, ufw.DefaultAPIService{}), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + projectLabel string + resources []ufw.RuleResponse + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{}, + wantErr: false, + }, + { + name: "set empty resources slice", + args: args{ + resources: []ufw.RuleResponse{}, + }, + wantErr: false, + }, + { + name: "set empty resource in resources slice", + args: args{ + resources: []ufw.RuleResponse{{}}, + }, + wantErr: false, + }, + } + + params := testparams.NewTestParams() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(params.Printer, tt.args.outputFormat, tt.args.projectLabel, tt.args.resources); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/ufw/rules/rules.go b/internal/cmd/ufw/rules/rules.go new file mode 100644 index 000000000..84de5b705 --- /dev/null +++ b/internal/cmd/ufw/rules/rules.go @@ -0,0 +1,34 @@ +package rules + +import ( + "github.com/stackitcloud/stackit-cli/internal/cmd/ufw/rules/create" + "github.com/stackitcloud/stackit-cli/internal/cmd/ufw/rules/delete" + "github.com/stackitcloud/stackit-cli/internal/cmd/ufw/rules/describe" + "github.com/stackitcloud/stackit-cli/internal/cmd/ufw/rules/list" + "github.com/stackitcloud/stackit-cli/internal/cmd/ufw/rules/update" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "rules", + Short: "Provides functionality for UFW rules", + Long: "Provides functionality for STACKIT Unified Firewall (UFW) rules.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(update.NewCmd(params)) +} diff --git a/internal/cmd/ufw/rules/update/update.go b/internal/cmd/ufw/rules/update/update.go new file mode 100644 index 000000000..ee16657e4 --- /dev/null +++ b/internal/cmd/ufw/rules/update/update.go @@ -0,0 +1,156 @@ +package update + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + ufw "github.com/stackitcloud/stackit-sdk-go/services/ufw/v1api" + "github.com/stackitcloud/stackit-sdk-go/services/ufw/v1api/wait" + + "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/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/ufw/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/stackitcloud/stackit-cli/internal/pkg/print" +) + +const ( + instanceIdArg = "INSTANCE_ID" + + sourceIpFlag = "sourceIp" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + InstanceId string + + SourceIp *string + Direction *string + Description *string + EtherType *string + PortRange *string + Protocol *string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("update %s", instanceIdArg), + Short: "Updates a UFW rule instance", + Long: "Updates a STACKIT Unified Firewall (UFW) rule instance.", + Args: args.SingleArg(instanceIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Update a UFW rule instance with "1.1.1.1/32" as sourceIp for instance with ID "xxx"`, + "$ stackit ufw rules update xxx --sourceIp 1.1.1.1/32"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } + + prompt := fmt.Sprintf("Are you sure you want to update a UFW rule instance for project %q?", projectLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + req := buildRequest(ctx, model, apiClient) + + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("update UFW rule instance: %w", err) + } + instanceId := resp.RefId + + if !model.Async { + err := spinner.Run(params.Printer, "Updating ufw rule instance", func() error { + _, err = wait.UpdateRuleWaitHandler(ctx, apiClient.DefaultAPI, model.ProjectId, model.Region, *instanceId).WaitWithContext(ctx) + return err + }) + if err != nil { + return fmt.Errorf("wait for UFW rule instance updating process: %w", err) + } + } + + return outputResult(params.Printer, model.OutputFormat, model.Async, projectLabel, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().StringP(sourceIpFlag, "s", "", "The IP (CIDR) to which the rule applies (e.g. 192.168.0.1/32)") + + err := flags.MarkFlagsRequired(cmd, sourceIpFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + instanceId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + if globalFlags.Region == "" { + return nil, &errors.RegionError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + InstanceId: instanceId, + + SourceIp: flags.FlagToStringPointer(p, cmd, sourceIpFlag), + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *ufw.APIClient) ufw.ApiUpdateRuleRequest { + req := apiClient.DefaultAPI.UpdateRule(ctx, model.ProjectId, model.Region, model.InstanceId) + + req = req.UpdateRulePayload(ufw.UpdateRulePayload{ + SourceIP: *model.SourceIp, + }) + + return req +} + +func outputResult(p *print.Printer, outputFormat string, async bool, projectLabel string, rule *ufw.UpdateRuleResponse) error { + if rule == nil { + return fmt.Errorf("response is nil") + } + + return p.OutputResult(outputFormat, rule, func() error { + operationState := "Updated" + if async { + operationState = "Triggered updating process of" + } + p.Outputf("%s rule for project %q. New rule refID: %s\n", operationState, projectLabel, utils.PtrString(rule.RefId)) + return nil + }) +} diff --git a/internal/cmd/ufw/rules/update/update_test.go b/internal/cmd/ufw/rules/update/update_test.go new file mode 100644 index 000000000..5315d3226 --- /dev/null +++ b/internal/cmd/ufw/rules/update/update_test.go @@ -0,0 +1,253 @@ +package update + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + ufw "github.com/stackitcloud/stackit-sdk-go/services/ufw/v1api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &ufw.APIClient{DefaultAPI: &ufw.DefaultAPIService{}} + testProjectId = uuid.NewString() + testRuleRefId = uuid.NewString() +) + +const ( + testRegion = "eu01" + testSourceIp = "1.1.1.1/32" +) + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testRuleRefId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + sourceIpFlag: testSourceIp, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + InstanceId: testRuleRefId, + SourceIp: new(testSourceIp), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *ufw.ApiUpdateRuleRequest)) ufw.ApiUpdateRuleRequest { + request := testClient.DefaultAPI.UpdateRule(testCtx, testProjectId, testRegion, testRuleRefId) + request = request.UpdateRulePayload(ufw.UpdateRulePayload{ + SourceIP: testSourceIp, + }) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + argValues: []string{}, + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "no flag values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "required flags only", + argValues: fixtureArgValues(), + flagValues: map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + sourceIpFlag: testSourceIp, + }, + isValid: true, + expectedModel: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + InstanceId: testRuleRefId, + SourceIp: new(testSourceIp), + }, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "region missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.RegionFlag) + }), + isValid: false, + }, + { + description: "source IP missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, sourceIpFlag) + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest ufw.ApiUpdateRuleRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + { + description: "required fields only", + model: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + InstanceId: testRuleRefId, + SourceIp: new(testSourceIp), + }, + expectedRequest: testClient.DefaultAPI.UpdateRule(testCtx, testProjectId, testRegion, testRuleRefId). + UpdateRulePayload(ufw.UpdateRulePayload{ + SourceIP: testSourceIp, + }), + }, + } + + 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, ufw.DefaultAPIService{}), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + async bool + projectLabel string + rule *ufw.UpdateRuleResponse + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{}, + wantErr: true, + }, + { + name: "set empty response", + args: args{ + rule: &ufw.UpdateRuleResponse{}, + }, + wantErr: false, + }, + } + + params := testparams.NewTestParams() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(params.Printer, tt.args.outputFormat, tt.args.async, tt.args.projectLabel, tt.args.rule); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/ufw/ufw.go b/internal/cmd/ufw/ufw.go new file mode 100644 index 000000000..5ec53ac0b --- /dev/null +++ b/internal/cmd/ufw/ufw.go @@ -0,0 +1,26 @@ +package ufw + +import ( + "github.com/stackitcloud/stackit-cli/internal/cmd/ufw/rules" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "ufw", + Short: "Provides functionality for UFW", + Long: "Provides functionality for STACKIT Unified Firewall (UFW).", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(rules.NewCmd(params)) +} diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go index 2365ed8b4..b944e6eb3 100644 --- a/internal/pkg/config/config.go +++ b/internal/pkg/config/config.go @@ -56,6 +56,7 @@ const ( CDNCustomEndpointKey = "cdn_custom_endpoint" IntakeCustomEndpointKey = "intake_custom_endpoint" LogsCustomEndpointKey = "logs_custom_endpoint" + UfwCustomEndpointKey = "ufw_custom_endpoint" ValkeyCustomEndpointKey = "valkey_custom_endpoint" VPNCustomEndpointKey = "vpn_custom_endpoint" diff --git a/internal/pkg/services/ufw/client/client.go b/internal/pkg/services/ufw/client/client.go new file mode 100644 index 000000000..c29ec58a3 --- /dev/null +++ b/internal/pkg/services/ufw/client/client.go @@ -0,0 +1,15 @@ +package client + +import ( + ufw "github.com/stackitcloud/stackit-sdk-go/services/ufw/v1api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/config" + genericclient "github.com/stackitcloud/stackit-cli/internal/pkg/generic-client" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + + "github.com/spf13/viper" +) + +func ConfigureClient(p *print.Printer, cliVersion string) (*ufw.APIClient, error) { + return genericclient.ConfigureClientGeneric(p, cliVersion, viper.GetString(config.UfwCustomEndpointKey), false, ufw.NewAPIClient) +} diff --git a/internal/pkg/services/ufw/utils/utils.go b/internal/pkg/services/ufw/utils/utils.go new file mode 100644 index 000000000..d4b585bf7 --- /dev/null +++ b/internal/pkg/services/ufw/utils/utils.go @@ -0,0 +1 @@ +package utils diff --git a/internal/pkg/services/ufw/utils/utils_test.go b/internal/pkg/services/ufw/utils/utils_test.go new file mode 100644 index 000000000..d4b585bf7 --- /dev/null +++ b/internal/pkg/services/ufw/utils/utils_test.go @@ -0,0 +1 @@ +package utils