diff --git a/docs/stackit_beta.md b/docs/stackit_beta.md index 7046745ce..2f50dd3e8 100644 --- a/docs/stackit_beta.md +++ b/docs/stackit_beta.md @@ -42,6 +42,7 @@ stackit beta [flags] * [stackit](./stackit.md) - Manage STACKIT resources using the command line * [stackit beta alb](./stackit_beta_alb.md) - Manages application loadbalancers +* [stackit beta alb-waf](./stackit_beta_alb-waf.md) - Provides functionality for Application Load Balancer Web Application Firwall * [stackit beta cdn](./stackit_beta_cdn.md) - Manage CDN resources * [stackit beta edge-cloud](./stackit_beta_edge-cloud.md) - Provides functionality for Edge Cloud services. * [stackit beta intake](./stackit_beta_intake.md) - Provides functionality for intake diff --git a/docs/stackit_beta_alb-waf.md b/docs/stackit_beta_alb-waf.md new file mode 100644 index 000000000..535440edf --- /dev/null +++ b/docs/stackit_beta_alb-waf.md @@ -0,0 +1,34 @@ +## stackit beta alb-waf + +Provides functionality for Application Load Balancer Web Application Firwall + +### Synopsis + +Provides functionality for Application Load Balancer Web Application Firwall. + +``` +stackit beta alb-waf [flags] +``` + +### Options + +``` + -h, --help Help for "stackit beta alb-waf" +``` + +### 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 beta](./stackit_beta.md) - Contains beta STACKIT CLI commands +* [stackit beta alb-waf custom-rule-group](./stackit_beta_alb-waf_custom-rule-group.md) - Provides functionality for alb-waf Custom Rule Group + diff --git a/docs/stackit_beta_alb-waf_custom-rule-group.md b/docs/stackit_beta_alb-waf_custom-rule-group.md new file mode 100644 index 000000000..7aa63707f --- /dev/null +++ b/docs/stackit_beta_alb-waf_custom-rule-group.md @@ -0,0 +1,39 @@ +## stackit beta alb-waf custom-rule-group + +Provides functionality for alb-waf Custom Rule Group + +### Synopsis + +Provides functionality for alb-waf Custom Rule Group. + +``` +stackit beta alb-waf custom-rule-group [flags] +``` + +### Options + +``` + -h, --help Help for "stackit beta alb-waf custom-rule-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 + --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 beta alb-waf](./stackit_beta_alb-waf.md) - Provides functionality for Application Load Balancer Web Application Firwall +* [stackit beta alb-waf custom-rule-group create](./stackit_beta_alb-waf_custom-rule-group_create.md) - Creates an ALB WAF custom rule group +* [stackit beta alb-waf custom-rule-group delete](./stackit_beta_alb-waf_custom-rule-group_delete.md) - Deletes an ALB WAF custom rule group +* [stackit beta alb-waf custom-rule-group describe](./stackit_beta_alb-waf_custom-rule-group_describe.md) - Shows details of an ALB WAF custom rule group +* [stackit beta alb-waf custom-rule-group generate-payload](./stackit_beta_alb-waf_custom-rule-group_generate-payload.md) - Generates a payload to create/update an ALB WAF custom rule group +* [stackit beta alb-waf custom-rule-group list](./stackit_beta_alb-waf_custom-rule-group_list.md) - Lists all ALB WAF custom rule groups +* [stackit beta alb-waf custom-rule-group update](./stackit_beta_alb-waf_custom-rule-group_update.md) - Updates an ALB WAF custom rule group + diff --git a/docs/stackit_beta_alb-waf_custom-rule-group_create.md b/docs/stackit_beta_alb-waf_custom-rule-group_create.md new file mode 100644 index 000000000..f92876db8 --- /dev/null +++ b/docs/stackit_beta_alb-waf_custom-rule-group_create.md @@ -0,0 +1,46 @@ +## stackit beta alb-waf custom-rule-group create + +Creates an ALB WAF custom rule group + +### Synopsis + +Creates a STACKIT Application Load Balancer (ALB) Web Application Firewall (WAF) custom rule group. +The payload can be provided as a JSON string or a file path prefixed with "@". +See https://docs.api.stackit.cloud/documentation/alb-waf/version/v1 for information regarding the payload structure. + +``` +stackit beta alb-waf custom-rule-group create [flags] +``` + +### Examples + +``` + Create an ALB WAF custom rule group using an API payload sourced from the file "./payload.json" + $ stackit beta alb-waf custom-rule-group create --payload @./payload.json + + Create an ALB WAF custom rule group using an API payload provided as a JSON string + $ stackit beta alb-waf custom-rule-group create --payload "{...}" +``` + +### Options + +``` + -h, --help Help for "stackit beta alb-waf custom-rule-group create" + --payload string Request payload (JSON). Can be a string or a file path, if prefixed with "@" (example: @./payload.json). +``` + +### 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 beta alb-waf custom-rule-group](./stackit_beta_alb-waf_custom-rule-group.md) - Provides functionality for alb-waf Custom Rule Group + diff --git a/docs/stackit_beta_alb-waf_custom-rule-group_delete.md b/docs/stackit_beta_alb-waf_custom-rule-group_delete.md new file mode 100644 index 000000000..09930f66c --- /dev/null +++ b/docs/stackit_beta_alb-waf_custom-rule-group_delete.md @@ -0,0 +1,41 @@ +## stackit beta alb-waf custom-rule-group delete + +Deletes an ALB WAF custom rule group + +### Synopsis + +Deletes a STACKIT Application Load Balancer (ALB) Web Application Firewall (WAF) custom rule group. +A custom rule group can only be deleted if it is not referenced by any WAF configuration. + +``` +stackit beta alb-waf custom-rule-group delete CUSTOM_RULE_GROUP_NAME [flags] +``` + +### Examples + +``` + Delete an ALB WAF custom rule group with name "my-custom-rule-group" + $ stackit beta alb-waf custom-rule-group delete my-custom-rule-group +``` + +### Options + +``` + -h, --help Help for "stackit beta alb-waf custom-rule-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 + --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 beta alb-waf custom-rule-group](./stackit_beta_alb-waf_custom-rule-group.md) - Provides functionality for alb-waf Custom Rule Group + diff --git a/docs/stackit_beta_alb-waf_custom-rule-group_describe.md b/docs/stackit_beta_alb-waf_custom-rule-group_describe.md new file mode 100644 index 000000000..db7ffc8ec --- /dev/null +++ b/docs/stackit_beta_alb-waf_custom-rule-group_describe.md @@ -0,0 +1,40 @@ +## stackit beta alb-waf custom-rule-group describe + +Shows details of an ALB WAF custom rule group + +### Synopsis + +Shows details of a STACKIT Application Load Balancer (ALB) Web Application Firewall (WAF) custom rule group. + +``` +stackit beta alb-waf custom-rule-group describe CUSTOM_RULE_GROUP_NAME [flags] +``` + +### Examples + +``` + Show details of an ALB WAF custom rule group with name "my-custom-rule-group" + $ stackit beta alb-waf custom-rule-group describe my-custom-rule-group +``` + +### Options + +``` + -h, --help Help for "stackit beta alb-waf custom-rule-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 + --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 beta alb-waf custom-rule-group](./stackit_beta_alb-waf_custom-rule-group.md) - Provides functionality for alb-waf Custom Rule Group + diff --git a/docs/stackit_beta_alb-waf_custom-rule-group_generate-payload.md b/docs/stackit_beta_alb-waf_custom-rule-group_generate-payload.md new file mode 100644 index 000000000..078c0e95e --- /dev/null +++ b/docs/stackit_beta_alb-waf_custom-rule-group_generate-payload.md @@ -0,0 +1,54 @@ +## stackit beta alb-waf custom-rule-group generate-payload + +Generates a payload to create/update an ALB WAF custom rule group + +### Synopsis + +Generates a JSON payload with values to be used as --payload input for ALB WAF custom rule group creation or update. +If --name is set, the payload is generated with the current values of the given custom rule group, to be used with the update command. If unset, a payload with default values for the create command is generated. +See https://docs.api.stackit.cloud/documentation/alb-waf/version/v1 for information regarding the payload structure. + +``` +stackit beta alb-waf custom-rule-group generate-payload [flags] +``` + +### Examples + +``` + Generate a payload with default values, and adapt it with custom values for the different configuration options + $ stackit beta alb-waf custom-rule-group generate-payload --file-path ./payload.json + + $ stackit beta alb-waf custom-rule-group create --payload @./payload.json + + Generate a payload with the current values of an existing custom rule group, and adapt it with custom values for the different configuration options + $ stackit beta alb-waf custom-rule-group generate-payload --name my-custom-rule-group --file-path ./payload.json + + $ stackit beta alb-waf custom-rule-group update my-custom-rule-group --payload @./payload.json + + Generate a payload with the current values of an existing custom rule group, and preview it in the terminal + $ stackit beta alb-waf custom-rule-group generate-payload --name my-custom-rule-group +``` + +### Options + +``` + -f, --file-path string If set, writes the payload to the given file. If unset, writes the payload to the standard output + -h, --help Help for "stackit beta alb-waf custom-rule-group generate-payload" + -n, --name string If set, generates the payload with the current values of the given custom rule group (to be used with the update command). If unset, generates the payload with default values (to be used with the create command) +``` + +### 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 beta alb-waf custom-rule-group](./stackit_beta_alb-waf_custom-rule-group.md) - Provides functionality for alb-waf Custom Rule Group + diff --git a/docs/stackit_beta_alb-waf_custom-rule-group_list.md b/docs/stackit_beta_alb-waf_custom-rule-group_list.md new file mode 100644 index 000000000..0c5ac8cc7 --- /dev/null +++ b/docs/stackit_beta_alb-waf_custom-rule-group_list.md @@ -0,0 +1,44 @@ +## stackit beta alb-waf custom-rule-group list + +Lists all ALB WAF custom rule groups + +### Synopsis + +Lists all STACKIT Application Load Balancer (ALB) Web Application Firewall (WAF) custom rule groups. + +``` +stackit beta alb-waf custom-rule-group list [flags] +``` + +### Examples + +``` + List all ALB WAF custom rule groups + $ stackit beta alb-waf custom-rule-group list + + List the first 10 ALB WAF custom rule groups + $ stackit beta alb-waf custom-rule-group list --limit=10 +``` + +### Options + +``` + -h, --help Help for "stackit beta alb-waf custom-rule-group list" + --limit int Limit the output to the first n elements +``` + +### 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 beta alb-waf custom-rule-group](./stackit_beta_alb-waf_custom-rule-group.md) - Provides functionality for alb-waf Custom Rule Group + diff --git a/docs/stackit_beta_alb-waf_custom-rule-group_update.md b/docs/stackit_beta_alb-waf_custom-rule-group_update.md new file mode 100644 index 000000000..c88b23912 --- /dev/null +++ b/docs/stackit_beta_alb-waf_custom-rule-group_update.md @@ -0,0 +1,52 @@ +## stackit beta alb-waf custom-rule-group update + +Updates an ALB WAF custom rule group + +### Synopsis + +Updates a STACKIT Application Load Balancer (ALB) Web Application Firewall (WAF) custom rule group. +The rules of the custom rule group are replaced atomically: send the complete desired rule set, per-rule partial merge is not supported. +The payload can be provided as a JSON string or a file path prefixed with "@". The rule IDs and the rule behavior severity are managed by the server and cannot be set. +See https://docs.api.stackit.cloud/documentation/alb-waf/version/v1 for information regarding the payload structure. + +``` +stackit beta alb-waf custom-rule-group update CUSTOM_RULE_GROUP_NAME [flags] +``` + +### Examples + +``` + Update an ALB WAF custom rule group using an API payload sourced from the file "./payload.json" + $ stackit beta alb-waf custom-rule-group update my-custom-rule-group --payload @./payload.json + + Update an ALB WAF custom rule group using an API payload provided as a JSON string + $ stackit beta alb-waf custom-rule-group update my-custom-rule-group --payload "{...}" + + Generate a payload with the current values of an existing custom rule group, adapt it and update the custom rule group with it + $ stackit beta alb-waf custom-rule-group generate-payload --name my-custom-rule-group > ./payload.json + + $ stackit beta alb-waf custom-rule-group update my-custom-rule-group --payload @./payload.json +``` + +### Options + +``` + -h, --help Help for "stackit beta alb-waf custom-rule-group update" + --payload string Request payload (JSON). Can be a string or a file path, if prefixed with "@" (example: @./payload.json). +``` + +### 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 beta alb-waf custom-rule-group](./stackit_beta_alb-waf_custom-rule-group.md) - Provides functionality for alb-waf Custom Rule Group + diff --git a/go.mod b/go.mod index e1f0dc5bb..1faaffd05 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,7 @@ require ( github.com/spf13/viper v1.21.0 github.com/stackitcloud/stackit-sdk-go/core v0.26.0 github.com/stackitcloud/stackit-sdk-go/services/alb v0.14.2 + github.com/stackitcloud/stackit-sdk-go/services/albwaf v0.13.1 github.com/stackitcloud/stackit-sdk-go/services/authorization v0.15.2 github.com/stackitcloud/stackit-sdk-go/services/cdn v1.16.0 github.com/stackitcloud/stackit-sdk-go/services/dns v0.21.0 diff --git a/go.sum b/go.sum index a619b552d..4bc1ef4e0 100644 --- a/go.sum +++ b/go.sum @@ -594,6 +594,8 @@ github.com/stackitcloud/stackit-sdk-go/core v0.26.0 h1:jQEb9gkehfp6VCP6TcYk7BI10 github.com/stackitcloud/stackit-sdk-go/core v0.26.0/go.mod h1:WU1hhxnjXw2EV7CYa1nlEvNpMiRY6CvmIOaHuL3pOaA= github.com/stackitcloud/stackit-sdk-go/services/alb v0.14.2 h1:hGzfOJjlCRoFpri5eYIiwhE27qu02pKZLprKvbsTC/w= github.com/stackitcloud/stackit-sdk-go/services/alb v0.14.2/go.mod h1:eK6oRB5Tmpt6KbXQ4UYBGg2LgW5bPtVoncL9E8JSRww= +github.com/stackitcloud/stackit-sdk-go/services/albwaf v0.13.1 h1:NsUOizMbRrIyvxHGLzwL6WKHs1/CWXoFV6d1wPI6YsM= +github.com/stackitcloud/stackit-sdk-go/services/albwaf v0.13.1/go.mod h1:4M9G1I64kZwlXO32ZoIpt0GAN4SpZ1SYerwCVVIBGoE= github.com/stackitcloud/stackit-sdk-go/services/authorization v0.15.2 h1:b7WJ/vwxlVmNNX91kI3obqGcuoPAyaCbDL5aCMQ/sNg= github.com/stackitcloud/stackit-sdk-go/services/authorization v0.15.2/go.mod h1:T/JF25XGJ3GqER/1L2N//DgY8x5tY7gA3N+/0nvmOWY= github.com/stackitcloud/stackit-sdk-go/services/cdn v1.16.0 h1:Wqxx0PDTL2F5gqI5jjznuJY0TdqECltjA0aa/rHY63U= diff --git a/internal/cmd/beta/albwaf/albwaf.go b/internal/cmd/beta/albwaf/albwaf.go new file mode 100644 index 000000000..e5214a972 --- /dev/null +++ b/internal/cmd/beta/albwaf/albwaf.go @@ -0,0 +1,26 @@ +package albwaf + +import ( + customrulegroup "github.com/stackitcloud/stackit-cli/internal/cmd/beta/albwaf/custom-rule-group" + "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: "alb-waf", + Short: "Provides functionality for Application Load Balancer Web Application Firwall", + Long: "Provides functionality for Application Load Balancer Web Application Firwall.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(customrulegroup.NewCmd(params)) +} diff --git a/internal/cmd/beta/albwaf/custom-rule-group/create/create.go b/internal/cmd/beta/albwaf/custom-rule-group/create/create.go new file mode 100644 index 000000000..414d99efb --- /dev/null +++ b/internal/cmd/beta/albwaf/custom-rule-group/create/create.go @@ -0,0 +1,126 @@ +package create + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/spf13/cobra" + albwaf "github.com/stackitcloud/stackit-sdk-go/services/albwaf/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/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/albwaf/client" +) + +const ( + payloadFlag = "payload" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + Payload *albwaf.CreateCustomRuleGroupPayload +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Creates an ALB WAF custom rule group", + Long: fmt.Sprintf("%s\n%s\n%s", + "Creates a STACKIT Application Load Balancer (ALB) Web Application Firewall (WAF) custom rule group.", + "The payload can be provided as a JSON string or a file path prefixed with \"@\".", + "See https://docs.api.stackit.cloud/documentation/alb-waf/version/v1 for information regarding the payload structure.", + ), + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Create an ALB WAF custom rule group using an API payload sourced from the file "./payload.json"`, + "$ stackit beta alb-waf custom-rule-group create --payload @./payload.json"), + examples.NewExample( + `Create an ALB WAF custom rule group using an API payload provided as a JSON string`, + `$ stackit beta alb-waf custom-rule-group create --payload "{...}"`), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + 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 an ALB WAF custom rule group for project %q?", projectLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + _, err = req.Execute() + if err != nil { + return fmt.Errorf("create ALB WAF custom rule group: %w", err) + } + + params.Printer.Outputf("Created custom rule group with name %q \n", model.Payload.Name) + return nil + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.ReadFromFileFlag(), payloadFlag, `Request payload (JSON). Can be a string or a file path, if prefixed with "@" (example: @./payload.json).`) + + err := flags.MarkFlagsRequired(cmd, payloadFlag) + 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{} + } + + payloadValue := flags.FlagToStringPointer(p, cmd, payloadFlag) + var payload *albwaf.CreateCustomRuleGroupPayload + if payloadValue != nil { + payload = &albwaf.CreateCustomRuleGroupPayload{} + err := json.Unmarshal([]byte(*payloadValue), payload) + if err != nil { + return nil, fmt.Errorf("encode payload: %w", err) + } + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + Payload: payload, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *albwaf.APIClient) albwaf.ApiCreateCustomRuleGroupRequest { + req := apiClient.DefaultAPI.CreateCustomRuleGroup(ctx, model.ProjectId, model.Region) + req = req.CreateCustomRuleGroupPayload(*model.Payload) + return req +} diff --git a/internal/cmd/beta/albwaf/custom-rule-group/create/create_test.go b/internal/cmd/beta/albwaf/custom-rule-group/create/create_test.go new file mode 100644 index 000000000..de726bc47 --- /dev/null +++ b/internal/cmd/beta/albwaf/custom-rule-group/create/create_test.go @@ -0,0 +1,173 @@ +package create + +import ( + "context" + "testing" + + albwaf "github.com/stackitcloud/stackit-sdk-go/services/albwaf/v1api" + + "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" +) + +const ( + testRegion = "eu02" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &albwaf.APIClient{DefaultAPI: &albwaf.DefaultAPIService{}} + testProjectId = uuid.NewString() +) + +var testPayload = &albwaf.CreateCustomRuleGroupPayload{ + Name: "test-custom-rule-group", + Rules: []albwaf.CreateCustomRule{}, +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + payloadFlag: `{ + "name": "test-custom-rule-group", + "rules": [] +}`, + } + 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, + }, + Payload: testPayload, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *albwaf.ApiCreateCustomRuleGroupRequest)) albwaf.ApiCreateCustomRuleGroupRequest { + request := testClient.DefaultAPI.CreateCustomRuleGroup(testCtx, testProjectId, testRegion) + request = request.CreateCustomRuleGroupPayload(*testPayload) + 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: "payload is missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, payloadFlag) + }), + isValid: false, + }, + { + description: "payload is empty", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[payloadFlag] = "" + }), + isValid: false, + }, + { + description: "invalid json", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[payloadFlag] = "not json" + }), + isValid: false, + expectedModel: fixtureInputModel(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInputWithOptions(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, nil, tt.isValid, []testutils.TestingOption{ + testutils.WithCmpOptions(cmpopts.EquateEmpty()), + }) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest albwaf.ApiCreateCustomRuleGroupRequest + isValid bool + }{ + { + 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, albwaf.DefaultAPIService{}), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/beta/albwaf/custom-rule-group/custom-rule-group.go b/internal/cmd/beta/albwaf/custom-rule-group/custom-rule-group.go new file mode 100644 index 000000000..cda1a4613 --- /dev/null +++ b/internal/cmd/beta/albwaf/custom-rule-group/custom-rule-group.go @@ -0,0 +1,36 @@ +package customrulegroup + +import ( + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/albwaf/custom-rule-group/create" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/albwaf/custom-rule-group/delete" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/albwaf/custom-rule-group/describe" + generatepayload "github.com/stackitcloud/stackit-cli/internal/cmd/beta/albwaf/custom-rule-group/generate-payload" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/albwaf/custom-rule-group/list" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/albwaf/custom-rule-group/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: "custom-rule-group", + Short: "Provides functionality for alb-waf Custom Rule Group", + Long: "Provides functionality for alb-waf Custom Rule Group.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(generatepayload.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(update.NewCmd(params)) +} diff --git a/internal/cmd/beta/albwaf/custom-rule-group/delete/delete.go b/internal/cmd/beta/albwaf/custom-rule-group/delete/delete.go new file mode 100644 index 000000000..3feba8c6c --- /dev/null +++ b/internal/cmd/beta/albwaf/custom-rule-group/delete/delete.go @@ -0,0 +1,103 @@ +package delete + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "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/albwaf/client" + + "github.com/spf13/cobra" + albwaf "github.com/stackitcloud/stackit-sdk-go/services/albwaf/v1api" +) + +const ( + customRuleGroupNameArg = "CUSTOM_RULE_GROUP_NAME" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + Name string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("delete %s", customRuleGroupNameArg), + Short: "Deletes an ALB WAF custom rule group", + Long: fmt.Sprintf("%s\n%s", + "Deletes a STACKIT Application Load Balancer (ALB) Web Application Firewall (WAF) custom rule group.", + "A custom rule group can only be deleted if it is not referenced by any WAF configuration.", + ), + Args: args.SingleArg(customRuleGroupNameArg, nil), + Example: examples.Build( + examples.NewExample( + `Delete an ALB WAF custom rule group with name "my-custom-rule-group"`, + "$ stackit beta alb-waf custom-rule-group delete my-custom-rule-group", + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + 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 delete the ALB WAF custom rule group %q for project %q?", model.Name, projectLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + _, err = req.Execute() + if err != nil { + return fmt.Errorf("delete ALB WAF custom rule group: %w", err) + } + + params.Printer.Outputf("Custom rule group %q deleted.\n", model.Name) + return nil + }, + } + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + name := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + Name: name, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *albwaf.APIClient) albwaf.ApiDeleteCustomRuleGroupRequest { + return apiClient.DefaultAPI.DeleteCustomRuleGroup(ctx, model.ProjectId, model.Region, model.Name) +} diff --git a/internal/cmd/beta/albwaf/custom-rule-group/delete/delete_test.go b/internal/cmd/beta/albwaf/custom-rule-group/delete/delete_test.go new file mode 100644 index 000000000..2fb7ceddf --- /dev/null +++ b/internal/cmd/beta/albwaf/custom-rule-group/delete/delete_test.go @@ -0,0 +1,150 @@ +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" + albwaf "github.com/stackitcloud/stackit-sdk-go/services/albwaf/v1api" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "test") + testProjectId = uuid.NewString() + testRegion = "eu01" + testClient = &albwaf.APIClient{DefaultAPI: &albwaf.DefaultAPIService{}} + testCustomRgName = "my-test-custom-rule-group" +) + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testCustomRgName, + } + for _, m := range mods { + m(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 _, m := range mods { + m(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + Verbosity: globalflags.VerbosityDefault, + ProjectId: testProjectId, + Region: testRegion, + }, + Name: testCustomRgName, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *albwaf.ApiDeleteCustomRuleGroupRequest)) albwaf.ApiDeleteCustomRuleGroupRequest { + request := testClient.DefaultAPI.DeleteCustomRuleGroup(testCtx, testProjectId, testRegion, testCustomRgName) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argsValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argsValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no arg values", + argsValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "project id missing", + argsValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argsValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argsValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argsValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedResult albwaf.ApiDeleteCustomRuleGroupRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedResult: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedResult, + cmp.AllowUnexported(tt.expectedResult, albwaf.DefaultAPIService{}), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/beta/albwaf/custom-rule-group/describe/describe.go b/internal/cmd/beta/albwaf/custom-rule-group/describe/describe.go new file mode 100644 index 000000000..3ed50798d --- /dev/null +++ b/internal/cmd/beta/albwaf/custom-rule-group/describe/describe.go @@ -0,0 +1,168 @@ +package describe + +import ( + "context" + "fmt" + "strings" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/spf13/cobra" + sdkUtils "github.com/stackitcloud/stackit-sdk-go/core/utils" + albwaf "github.com/stackitcloud/stackit-sdk-go/services/albwaf/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/albwaf/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + customRuleGroupNameArg = "CUSTOM_RULE_GROUP_NAME" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + Name string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("describe %s", customRuleGroupNameArg), + Short: "Shows details of an ALB WAF custom rule group", + Long: "Shows details of a STACKIT Application Load Balancer (ALB) Web Application Firewall (WAF) custom rule group.", + Args: args.SingleArg(customRuleGroupNameArg, nil), + Example: examples.Build( + examples.NewExample( + `Show details of an ALB WAF custom rule group with name "my-custom-rule-group"`, + `$ stackit beta alb-waf custom-rule-group describe my-custom-rule-group`, + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("read ALB WAF custom rule group: %w", err) + } + + return outputResult(params.Printer, model.OutputFormat, resp) + }, + } + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + name := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + Name: name, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *albwaf.APIClient) albwaf.ApiGetCustomRuleGroupRequest { + return apiClient.DefaultAPI.GetCustomRuleGroup(ctx, model.ProjectId, model.Region, model.Name) +} + +func outputResult(p *print.Printer, outputFormat string, crg *albwaf.GetCustomRuleGroupResponse) error { + return p.OutputResult(outputFormat, crg, func() error { + if crg == nil { + return fmt.Errorf("custom rule group response is empty") + } + content := []tables.Table{buildOverviewTable(crg)} + + for i := range crg.Rules { + content = append(content, buildRuleTable(&crg.Rules[i])) + } + + err := tables.DisplayTables(p, content) + if err != nil { + return fmt.Errorf("display table: %w", err) + } + return nil + }) +} + +func buildOverviewTable(crg *albwaf.GetCustomRuleGroupResponse) tables.Table { + table := tables.NewTable() + table.SetTitle("Custom Rule Group") + table.AddRow("NAME", crg.Name) + table.AddSeparator() + table.AddRow("RULES", len(crg.Rules)) + table.AddSeparator() + if crg.Usage != nil { + table.AddRow("USED BY WAF CONFIGS", strings.Join(crg.Usage.Items, "\n")) + table.AddSeparator() + } + return table +} + +func buildRuleTable(rule *albwaf.GetCustomRule) tables.Table { + table := tables.NewTable() + table.SetTitle(fmt.Sprintf("Rule %d", rule.Id)) + table.AddRow("ID", rule.Id) + table.AddSeparator() + table.AddRow("DESCRIPTION", utils.PtrString(rule.Description)) + table.AddSeparator() + table.AddRow("ACTION", string(rule.Behavior.Action)) + table.AddSeparator() + table.AddRow("SEVERITY", string(rule.Behavior.Severity)) + table.AddSeparator() + table.AddRow("LOG", rule.Behavior.Log) + table.AddSeparator() + table.AddRow("LOG MESSAGE", utils.PtrString(rule.Behavior.LogMsg)) + table.AddSeparator() + for i := range rule.Conditions { + condition := &rule.Conditions[i] + table.AddRow("CONDITION VARIABLE", conditionVariableString(condition.Variable)) + table.AddSeparator() + table.AddRow("CONDITION OPERATOR", conditionOperatorString(condition.Operator)) + table.AddSeparator() + if len(condition.Transformations) > 0 { + table.AddRow("CONDITION TRANSFORMATIONS", strings.Join(sdkUtils.EnumSliceToStringSlice(condition.Transformations), ", ")) + table.AddSeparator() + } + } + return table +} + +// conditionVariableString renders a condition variable as "TYPE" or "TYPE:value". +func conditionVariableString(variable albwaf.ConditionVariable) string { + if variable.Value != nil && *variable.Value != "" { + return fmt.Sprintf("%s:%s", string(variable.Type), *variable.Value) + } + return string(variable.Type) +} + +// conditionOperatorString renders a condition operator as "TYPE value". +func conditionOperatorString(operator albwaf.ConditionOperator) string { + if operator.Value != nil && *operator.Value != "" { + return fmt.Sprintf("%s %q", string(operator.Type), *operator.Value) + } + return string(operator.Type) +} diff --git a/internal/cmd/beta/albwaf/custom-rule-group/describe/describe_test.go b/internal/cmd/beta/albwaf/custom-rule-group/describe/describe_test.go new file mode 100644 index 000000000..d127841aa --- /dev/null +++ b/internal/cmd/beta/albwaf/custom-rule-group/describe/describe_test.go @@ -0,0 +1,225 @@ +package describe + +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/testparams" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "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" + albwaf "github.com/stackitcloud/stackit-sdk-go/services/albwaf/v1api" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "test") + testProjectId = uuid.NewString() + testRegion = "eu01" + testClient = &albwaf.APIClient{DefaultAPI: &albwaf.DefaultAPIService{}} + testCustomRgName = "my-test-custom-rule-group" +) + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testCustomRgName, + } + for _, m := range mods { + m(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 _, m := range mods { + m(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + Verbosity: globalflags.VerbosityDefault, + ProjectId: testProjectId, + Region: testRegion, + }, + Name: testCustomRgName, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *albwaf.ApiGetCustomRuleGroupRequest)) albwaf.ApiGetCustomRuleGroupRequest { + request := testClient.DefaultAPI.GetCustomRuleGroup(testCtx, testProjectId, testRegion, testCustomRgName) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argsValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argsValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no arg values", + argsValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "project id missing", + argsValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argsValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argsValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argsValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedResult albwaf.ApiGetCustomRuleGroupRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedResult: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedResult, + cmp.AllowUnexported(tt.expectedResult, albwaf.DefaultAPIService{}), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + tests := []struct { + description string + outputFormat string + crg *albwaf.GetCustomRuleGroupResponse + wantErr bool + }{ + { + description: "empty", + outputFormat: "", + crg: nil, + wantErr: true, + }, + { + description: "base", + outputFormat: "", + crg: &albwaf.GetCustomRuleGroupResponse{ + Name: testCustomRgName, + Rules: []albwaf.GetCustomRule{ + { + Id: 1, + Description: utils.Ptr("block access to /admin"), + Behavior: albwaf.GetBehavior{ + Action: albwaf.ACTION_ACTION_DENY, + Log: true, + LogMsg: utils.Ptr("blocked"), + Severity: albwaf.SEVERITY_SEVERITY_WARNING, + }, + Conditions: []albwaf.Condition{ + { + Operator: albwaf.ConditionOperator{ + Type: albwaf.OPERATOR_OPERATOR_BEGINS_WITH, + Value: utils.Ptr("/admin"), + }, + Variable: albwaf.ConditionVariable{ + Type: albwaf.VARIABLE_VARIABLE_REQUEST_URI_RAW, + }, + Transformations: []albwaf.Transformation{ + albwaf.TRANSFORMATION_TRANSFORMATION_LOWERCASE, + }, + }, + }, + }, + }, + Usage: &albwaf.CRGUsage{ + Count: utils.Ptr(int32(1)), + Items: []string{"my-waf"}, + }, + }, + wantErr: false, + }, + { + description: "json output", + outputFormat: print.JSONOutputFormat, + crg: &albwaf.GetCustomRuleGroupResponse{ + Name: testCustomRgName, + Rules: []albwaf.GetCustomRule{}, + }, + wantErr: false, + }, + } + params := testparams.NewTestParams() + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + if err := outputResult(params.Printer, tt.outputFormat, tt.crg); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/albwaf/custom-rule-group/generate-payload/generate_payload.go b/internal/cmd/beta/albwaf/custom-rule-group/generate-payload/generate_payload.go new file mode 100644 index 000000000..033433149 --- /dev/null +++ b/internal/cmd/beta/albwaf/custom-rule-group/generate-payload/generate_payload.go @@ -0,0 +1,170 @@ +package generatepayload + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + albwaf "github.com/stackitcloud/stackit-sdk-go/services/albwaf/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/fileutils" + "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/albwaf/client" + albwafUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/albwaf/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +const ( + nameFlag = "name" + filePathFlag = "file-path" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + Name *string + FilePath *string +} + +var defaultCreateCustomRuleGroupPayload = albwaf.CreateCustomRuleGroupPayload{ + Name: "example-custom-rule-group", + Rules: []albwaf.CreateCustomRule{ + { + Behavior: albwaf.Behavior{ + Action: albwaf.ACTION_ACTION_DENY, + Log: new(true), + LogMsg: new(""), + }, + Conditions: []albwaf.Condition{ + { + Operator: albwaf.ConditionOperator{ + Type: albwaf.OPERATOR_OPERATOR_VALIDATE_UTF8_ENCODING, + Value: new(""), + }, + Variable: albwaf.ConditionVariable{ + Type: albwaf.VARIABLE_VARIABLE_RESPONSE_STATUS, + Value: new(""), + }, + }, + }, + Description: new(""), + }, + }, +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "generate-payload", + Short: "Generates a payload to create/update an ALB WAF custom rule group", + Long: fmt.Sprintf("%s\n%s\n%s", + "Generates a JSON payload with values to be used as --payload input for ALB WAF custom rule group creation or update.", + "If --name is set, the payload is generated with the current values of the given custom rule group, to be used with the update command. If unset, a payload with default values for the create command is generated.", + "See https://docs.api.stackit.cloud/documentation/alb-waf/version/v1 for information regarding the payload structure.", + ), + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Generate a payload with default values, and adapt it with custom values for the different configuration options`, + `$ stackit beta alb-waf custom-rule-group generate-payload --file-path ./payload.json`, + ``, + `$ stackit beta alb-waf custom-rule-group create --payload @./payload.json`), + examples.NewExample( + `Generate a payload with the current values of an existing custom rule group, and adapt it with custom values for the different configuration options`, + `$ stackit beta alb-waf custom-rule-group generate-payload --name my-custom-rule-group --file-path ./payload.json`, + ``, + `$ stackit beta alb-waf custom-rule-group update my-custom-rule-group --payload @./payload.json`), + examples.NewExample( + `Generate a payload with the current values of an existing custom rule group, and preview it in the terminal`, + `$ stackit beta alb-waf custom-rule-group generate-payload --name my-custom-rule-group`), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + if model.Name == nil { + payload := defaultCreateCustomRuleGroupPayload + return outputResult(params.Printer, model.FilePath, &payload) + } + + // Configure API client + 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 ALB WAF custom rule group: %w", err) + } + + payload := &albwaf.UpdateCustomRuleGroupPayload{ + Name: resp.Name, + Rules: albwafUtils.ToCreateCustomRules(resp.Rules), + } + return outputResult(params.Printer, model.FilePath, payload) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().StringP(nameFlag, "n", "", "If set, generates the payload with the current values of the given custom rule group (to be used with the update command). If unset, generates the payload with default values (to be used with the create command)") + cmd.Flags().StringP(filePathFlag, "f", "", "If set, writes the payload to the given file. If unset, writes the payload to the standard output") +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + + name := flags.FlagToStringPointer(p, cmd, nameFlag) + // If name is provided, the custom rule group is fetched from the API, so a project ID is needed as well + if name != nil && globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + Name: name, + FilePath: flags.FlagToStringPointer(p, cmd, filePathFlag), + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *albwaf.APIClient) albwaf.ApiGetCustomRuleGroupRequest { + return apiClient.DefaultAPI.GetCustomRuleGroup(ctx, model.ProjectId, model.Region, *model.Name) +} + +func outputResult(p *print.Printer, filePath *string, payload any) error { + if payload == nil { + return fmt.Errorf("payload is empty") + } + payloadBytes, err := json.MarshalIndent(payload, "", " ") + if err != nil { + return fmt.Errorf("marshal payload: %w", err) + } + + if filePath != nil { + err = fileutils.WriteToFile(utils.PtrString(filePath), string(payloadBytes)) + if err != nil { + return fmt.Errorf("write payload to the file: %w", err) + } + } else { + p.Outputln(string(payloadBytes)) + } + + return nil +} diff --git a/internal/cmd/beta/albwaf/custom-rule-group/generate-payload/generate_payload_test.go b/internal/cmd/beta/albwaf/custom-rule-group/generate-payload/generate_payload_test.go new file mode 100644 index 000000000..a46db6052 --- /dev/null +++ b/internal/cmd/beta/albwaf/custom-rule-group/generate-payload/generate_payload_test.go @@ -0,0 +1,219 @@ +package generatepayload + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + albwaf "github.com/stackitcloud/stackit-sdk-go/services/albwaf/v1api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &albwaf.APIClient{DefaultAPI: &albwaf.DefaultAPIService{}} +) + +const ( + testRegion = "eu02" +) + +var testProjectId = uuid.NewString() + +const ( + testName = "example-name" + testFilePath = "example-file" +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + nameFlag: testName, + filePathFlag: testFilePath, + } + 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, + }, + Name: utils.Ptr(testName), + FilePath: utils.Ptr(testFilePath), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *albwaf.ApiGetCustomRuleGroupRequest)) albwaf.ApiGetCustomRuleGroupRequest { + request := testClient.DefaultAPI.GetCustomRuleGroup(testCtx, testProjectId, testRegion, testName) + 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: true, + expectedModel: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{Verbosity: globalflags.VerbosityDefault}, + }, + }, + { + description: "name missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, nameFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Name = nil + }), + }, + { + description: "file path missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, filePathFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.FilePath = nil + }), + }, + { + description: "project id missing (needed when name is set)", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id missing but name unset", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + delete(flagValues, nameFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.ProjectId = "" + model.Name = nil + }), + }, + } + + 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 albwaf.ApiGetCustomRuleGroupRequest + }{ + { + 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, albwaf.DefaultAPIService{}), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + filePath *string + payload interface{} + } + filePathDummy := "/dummy.txt" + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{}, + wantErr: true, + }, + { + name: "missing payload", + args: args{ + filePath: &filePathDummy, + }, + wantErr: true, + }, + { + name: "create payload", + args: args{ + payload: &albwaf.CreateCustomRuleGroupPayload{}, + }, + wantErr: false, + }, + { + name: "update payload", + args: args{ + payload: &albwaf.UpdateCustomRuleGroupPayload{}, + }, + wantErr: false, + }, + } + params := testparams.NewTestParams() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(params.Printer, tt.args.filePath, tt.args.payload); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/albwaf/custom-rule-group/list/list.go b/internal/cmd/beta/albwaf/custom-rule-group/list/list.go new file mode 100644 index 000000000..51303154d --- /dev/null +++ b/internal/cmd/beta/albwaf/custom-rule-group/list/list.go @@ -0,0 +1,183 @@ +package list + +import ( + "context" + "fmt" + "math" + "strconv" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/spf13/cobra" + albwaf "github.com/stackitcloud/stackit-sdk-go/services/albwaf/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/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/albwaf/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + Limit *int64 +} + +const ( + limitFlag = "limit" + + // maxPageSize is the maximum number of items the API returns per page. + maxPageSize = 100 +) + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "Lists all ALB WAF custom rule groups", + Long: "Lists all STACKIT Application Load Balancer (ALB) Web Application Firewall (WAF) custom rule groups.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all ALB WAF custom rule groups`, + `$ stackit beta alb-waf custom-rule-group list`, + ), + examples.NewExample( + `List the first 10 ALB WAF custom rule groups`, + `$ stackit beta alb-waf custom-rule-group 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 + } + + // Configure API client + 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 + } else if projectLabel == "" { + projectLabel = model.ProjectId + } + + items, err := fetchCustomRuleGroups(ctx, model, apiClient) + if err != nil { + return fmt.Errorf("list ALB WAF custom rule groups: %w", err) + } + + return outputResult(params.Printer, model.OutputFormat, projectLabel, items) + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Int64(limitFlag, 0, "Limit the output to the first n elements") +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + 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: limit, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *albwaf.APIClient, pageId string, pageSize int64) albwaf.ApiListCustomRuleGroupRequest { + req := apiClient.DefaultAPI.ListCustomRuleGroup(ctx, model.ProjectId, model.Region) + req = req.PageSize(strconv.FormatInt(pageSize, 10)) + if pageId != "" { + req = req.PageId(pageId) + } + return req +} + +func fetchCustomRuleGroups(ctx context.Context, model *inputModel, apiClient *albwaf.APIClient) ([]albwaf.GetCustomRuleGroupResponse, error) { + var pageId string + var items []albwaf.GetCustomRuleGroupResponse + received := int64(0) + limit := int64(math.MaxInt64) + if model.Limit != nil { + limit = *model.Limit + } + for { + want := min(int64(maxPageSize), limit-received) + request := buildRequest(ctx, model, apiClient, pageId, want) + response, err := request.Execute() + if err != nil { + return nil, fmt.Errorf("list custom rule groups: %w", err) + } + if response.Items != nil { + items = append(items, response.Items...) + } + pageId = "" + if response.NextPageId != nil { + pageId = *response.NextPageId + } + received += want + if pageId == "" || received >= limit { + break + } + } + return items, nil +} + +func outputResult(p *print.Printer, outputFormat, projectLabel string, items []albwaf.GetCustomRuleGroupResponse) error { + return p.OutputResult(outputFormat, items, func() error { + if len(items) == 0 { + p.Outputf("No custom rule groups found for project %q\n", projectLabel) + return nil + } + + table := tables.NewTable() + table.SetHeader("NAME", "RULES", "USED BY") + for i := range items { + item := &items[i] + + var usedBy int + if item.Usage != nil && item.Usage.Count != nil { + usedBy = int(*item.Usage.Count) + } + + table.AddRow( + item.Name, + len(item.Rules), + usedBy, + ) + } + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + }) +} diff --git a/internal/cmd/beta/albwaf/custom-rule-group/list/list_test.go b/internal/cmd/beta/albwaf/custom-rule-group/list/list_test.go new file mode 100644 index 000000000..bd06bdb02 --- /dev/null +++ b/internal/cmd/beta/albwaf/custom-rule-group/list/list_test.go @@ -0,0 +1,187 @@ +package list + +import ( + "context" + "strconv" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/google/go-cmp/cmp/cmpopts" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + + "github.com/google/go-cmp/cmp" + "github.com/google/uuid" + albwaf "github.com/stackitcloud/stackit-sdk-go/services/albwaf/v1api" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &albwaf.APIClient{DefaultAPI: &albwaf.DefaultAPIService{}} + testProjectId = uuid.NewString() +) + +const ( + testRegion = "eu01" + testLimit int64 = 10 +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + limitFlag: strconv.Itoa(int(testLimit)), + } + 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: utils.Ptr(testLimit), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *albwaf.ApiListCustomRuleGroupRequest)) albwaf.ApiListCustomRuleGroupRequest { + request := testClient.DefaultAPI.ListCustomRuleGroup(testCtx, testProjectId, testRegion) + request = request.PageSize(strconv.FormatInt(maxPageSize, 10)) + 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: "limit invalid", + 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 albwaf.ApiListCustomRuleGroupRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient, "", maxPageSize) + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest, albwaf.DefaultAPIService{}), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func Test_outputResult(t *testing.T) { + type args struct { + outputFormat string + projectLabel string + items []albwaf.GetCustomRuleGroupResponse + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{ + outputFormat: "", + items: []albwaf.GetCustomRuleGroupResponse{}, + }, + wantErr: false, + }, + { + name: "output format json", + args: args{ + outputFormat: print.JSONOutputFormat, + items: []albwaf.GetCustomRuleGroupResponse{}, + }, + 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.items); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/albwaf/custom-rule-group/update/update.go b/internal/cmd/beta/albwaf/custom-rule-group/update/update.go new file mode 100644 index 000000000..6384f7393 --- /dev/null +++ b/internal/cmd/beta/albwaf/custom-rule-group/update/update.go @@ -0,0 +1,138 @@ +package update + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/spf13/cobra" + albwaf "github.com/stackitcloud/stackit-sdk-go/services/albwaf/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/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/albwaf/client" +) + +const ( + customRuleGroupNameArg = "CUSTOM_RULE_GROUP_NAME" + + payloadFlag = "payload" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + Name string + Payload *albwaf.UpdateCustomRuleGroupPayload +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("update %s", customRuleGroupNameArg), + Short: "Updates an ALB WAF custom rule group", + Long: fmt.Sprintf("%s\n%s\n%s\n%s", + "Updates a STACKIT Application Load Balancer (ALB) Web Application Firewall (WAF) custom rule group.", + "The rules of the custom rule group are replaced atomically: send the complete desired rule set, per-rule partial merge is not supported.", + "The payload can be provided as a JSON string or a file path prefixed with \"@\". The rule IDs and the rule behavior severity are managed by the server and cannot be set.", + "See https://docs.api.stackit.cloud/documentation/alb-waf/version/v1 for information regarding the payload structure.", + ), + Args: args.SingleArg(customRuleGroupNameArg, nil), + Example: examples.Build( + examples.NewExample( + `Update an ALB WAF custom rule group using an API payload sourced from the file "./payload.json"`, + "$ stackit beta alb-waf custom-rule-group update my-custom-rule-group --payload @./payload.json"), + examples.NewExample( + `Update an ALB WAF custom rule group using an API payload provided as a JSON string`, + `$ stackit beta alb-waf custom-rule-group update my-custom-rule-group --payload "{...}"`), + examples.NewExample( + `Generate a payload with the current values of an existing custom rule group, adapt it and update the custom rule group with it`, + `$ stackit beta alb-waf custom-rule-group generate-payload --name my-custom-rule-group > ./payload.json`, + ``, + `$ stackit beta alb-waf custom-rule-group update my-custom-rule-group --payload @./payload.json`), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + 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 the ALB WAF custom rule group %q for project %q?", model.Name, projectLabel) + err = params.Printer.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 ALB WAF custom rule group: %w", err) + } + + params.Printer.Outputf("Updated custom rule group %q.\n", resp.Name) + return nil + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.ReadFromFileFlag(), payloadFlag, `Request payload (JSON). Can be a string or a file path, if prefixed with "@" (example: @./payload.json).`) + + err := flags.MarkFlagsRequired(cmd, payloadFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + name := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + payloadValue := flags.FlagToStringPointer(p, cmd, payloadFlag) + var payload *albwaf.UpdateCustomRuleGroupPayload + if payloadValue != nil { + payload = &albwaf.UpdateCustomRuleGroupPayload{} + err := json.Unmarshal([]byte(*payloadValue), payload) + if err != nil { + return nil, fmt.Errorf("encode payload: %w", err) + } + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + Name: name, + Payload: payload, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *albwaf.APIClient) albwaf.ApiUpdateCustomRuleGroupRequest { + req := apiClient.DefaultAPI.UpdateCustomRuleGroup(ctx, model.ProjectId, model.Region, model.Name) + req = req.UpdateCustomRuleGroupPayload(*model.Payload) + return req +} diff --git a/internal/cmd/beta/albwaf/custom-rule-group/update/update_test.go b/internal/cmd/beta/albwaf/custom-rule-group/update/update_test.go new file mode 100644 index 000000000..ad07af1e3 --- /dev/null +++ b/internal/cmd/beta/albwaf/custom-rule-group/update/update_test.go @@ -0,0 +1,193 @@ +package update + +import ( + "context" + "testing" + + albwaf "github.com/stackitcloud/stackit-sdk-go/services/albwaf/v1api" + + "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" +) + +const ( + testRegion = "eu02" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &albwaf.APIClient{DefaultAPI: &albwaf.DefaultAPIService{}} + testProjectId = uuid.NewString() + testCustomRgName = "my-test-custom-rule-group" +) + +var testPayload = &albwaf.UpdateCustomRuleGroupPayload{ + Name: testCustomRgName, + Rules: []albwaf.CreateCustomRule{}, +} + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testCustomRgName, + } + for _, m := range mods { + m(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + payloadFlag: `{ + "name": "my-test-custom-rule-group", + "rules": [] +}`, + } + 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, + }, + Name: testCustomRgName, + Payload: testPayload, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *albwaf.ApiUpdateCustomRuleGroupRequest)) albwaf.ApiUpdateCustomRuleGroupRequest { + request := testClient.DefaultAPI.UpdateCustomRuleGroup(testCtx, testProjectId, testRegion, testCustomRgName) + request = request.UpdateCustomRuleGroupPayload(*testPayload) + 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 arg values", + argValues: []string{}, + flagValues: fixtureFlagValues(), + 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: "payload is missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, payloadFlag) + }), + isValid: false, + }, + { + description: "payload is empty", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[payloadFlag] = "" + }), + isValid: false, + }, + { + description: "invalid json", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[payloadFlag] = "not json" + }), + isValid: false, + expectedModel: fixtureInputModel(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInputWithOptions(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, nil, tt.isValid, []testutils.TestingOption{ + testutils.WithCmpOptions(cmpopts.EquateEmpty()), + }) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest albwaf.ApiUpdateCustomRuleGroupRequest + isValid bool + }{ + { + 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, albwaf.DefaultAPIService{}), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/beta/beta.go b/internal/cmd/beta/beta.go index f739b0c03..88533d43c 100644 --- a/internal/cmd/beta/beta.go +++ b/internal/cmd/beta/beta.go @@ -6,6 +6,7 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/cmd/beta/alb" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/albwaf" "github.com/stackitcloud/stackit-cli/internal/cmd/beta/cdn" "github.com/stackitcloud/stackit-cli/internal/cmd/beta/edge" "github.com/stackitcloud/stackit-cli/internal/cmd/beta/intake" @@ -45,6 +46,7 @@ func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { cmd.AddCommand(sqlserverflex.NewCmd(params)) cmd.AddCommand(sfs.NewCmd(params)) cmd.AddCommand(alb.NewCmd(params)) + cmd.AddCommand(albwaf.NewCmd(params)) cmd.AddCommand(edge.NewCmd(params)) cmd.AddCommand(intake.NewCmd(params)) cmd.AddCommand(cdn.NewCmd(params)) diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go index effb68051..c475da7c4 100644 --- a/internal/pkg/config/config.go +++ b/internal/pkg/config/config.go @@ -25,6 +25,7 @@ const ( AuthorizationCustomEndpointKey = "authorization_custom_endpoint" AlbCustomEndpoint = "alb_custom _endpoint" + AlbWafCustomEndpointKey = "albwaf_custom_endpoint" DNSCustomEndpointKey = "dns_custom_endpoint" EdgeCustomEndpointKey = "edge_custom_endpoint" LoadBalancerCustomEndpointKey = "load_balancer_custom_endpoint" @@ -92,6 +93,7 @@ var ConfigKeys = []string{ AllowedUrlDomainKey, AlbCustomEndpoint, + AlbWafCustomEndpointKey, AuthorizationCustomEndpointKey, CDNCustomEndpointKey, DNSCustomEndpointKey, diff --git a/internal/pkg/services/albwaf/client/client.go b/internal/pkg/services/albwaf/client/client.go new file mode 100644 index 000000000..9f85f9942 --- /dev/null +++ b/internal/pkg/services/albwaf/client/client.go @@ -0,0 +1,14 @@ +package client + +import ( + "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" + albwaf "github.com/stackitcloud/stackit-sdk-go/services/albwaf/v1api" +) + +func ConfigureClient(p *print.Printer, cliVersion string) (*albwaf.APIClient, error) { + return genericclient.ConfigureClientGeneric(p, cliVersion, viper.GetString(config.AlbWafCustomEndpointKey), false, genericclient.CreateApiClient[*albwaf.APIClient](albwaf.NewAPIClient)) +} diff --git a/internal/pkg/services/albwaf/utils/utils.go b/internal/pkg/services/albwaf/utils/utils.go new file mode 100644 index 000000000..f5540398b --- /dev/null +++ b/internal/pkg/services/albwaf/utils/utils.go @@ -0,0 +1,35 @@ +package utils + +import ( + albwaf "github.com/stackitcloud/stackit-sdk-go/services/albwaf/v1api" +) + +// ToCreateCustomRules converts read-model rules (from a GetCustomRuleGroup response) into the +// write-model rules used by create/update payloads. +// Server-assigned rule IDs and the behavior severity are dropped. +func ToCreateCustomRules(rules []albwaf.GetCustomRule) []albwaf.CreateCustomRule { + if rules == nil { + return nil + } + createRules := make([]albwaf.CreateCustomRule, len(rules)) + for i := range rules { + createRules[i] = *toCreateCustomRule(&rules[i]) + } + return createRules +} + +func toCreateCustomRule(rule *albwaf.GetCustomRule) *albwaf.CreateCustomRule { + return &albwaf.CreateCustomRule{ + Behavior: toBehavior(rule.Behavior), + Conditions: rule.Conditions, + Description: rule.Description, + } +} + +func toBehavior(behavior albwaf.GetBehavior) albwaf.Behavior { + return albwaf.Behavior{ + Action: behavior.Action, + Log: new(behavior.Log), + LogMsg: behavior.LogMsg, + } +} diff --git a/internal/pkg/services/albwaf/utils/utils_test.go b/internal/pkg/services/albwaf/utils/utils_test.go new file mode 100644 index 000000000..12ca5ee0d --- /dev/null +++ b/internal/pkg/services/albwaf/utils/utils_test.go @@ -0,0 +1,117 @@ +package utils + +import ( + "testing" + + albwaf "github.com/stackitcloud/stackit-sdk-go/services/albwaf/v1api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/google/go-cmp/cmp" +) + +func TestToCreateCustomRules(t *testing.T) { + tests := []struct { + description string + rules []albwaf.GetCustomRule + expected []albwaf.CreateCustomRule + }{ + { + description: "nil rules", + rules: nil, + expected: nil, + }, + { + description: "empty rules", + rules: []albwaf.GetCustomRule{}, + expected: []albwaf.CreateCustomRule{}, + }, + { + description: "drops id and severity, wraps log, passes conditions through", + rules: []albwaf.GetCustomRule{ + { + Id: 42, + Description: utils.Ptr("block /admin"), + Behavior: albwaf.GetBehavior{ + Action: albwaf.ACTION_ACTION_DENY, + Log: true, + LogMsg: utils.Ptr("blocked"), + Severity: albwaf.SEVERITY_SEVERITY_WARNING, + }, + Conditions: []albwaf.Condition{ + { + Operator: albwaf.ConditionOperator{ + Type: albwaf.OPERATOR_OPERATOR_BEGINS_WITH, + Value: utils.Ptr("/admin"), + }, + Variable: albwaf.ConditionVariable{ + Type: albwaf.VARIABLE_VARIABLE_REQUEST_URI_RAW, + }, + Transformations: []albwaf.Transformation{ + albwaf.TRANSFORMATION_TRANSFORMATION_LOWERCASE, + }, + }, + }, + }, + }, + expected: []albwaf.CreateCustomRule{ + { + Description: utils.Ptr("block /admin"), + Behavior: albwaf.Behavior{ + Action: albwaf.ACTION_ACTION_DENY, + Log: utils.Ptr(true), + LogMsg: utils.Ptr("blocked"), + }, + Conditions: []albwaf.Condition{ + { + Operator: albwaf.ConditionOperator{ + Type: albwaf.OPERATOR_OPERATOR_BEGINS_WITH, + Value: utils.Ptr("/admin"), + }, + Variable: albwaf.ConditionVariable{ + Type: albwaf.VARIABLE_VARIABLE_REQUEST_URI_RAW, + }, + Transformations: []albwaf.Transformation{ + albwaf.TRANSFORMATION_TRANSFORMATION_LOWERCASE, + }, + }, + }, + }, + }, + }, + { + description: "log false is wrapped, not omitted", + rules: []albwaf.GetCustomRule{ + { + Id: 1, + Behavior: albwaf.GetBehavior{ + Action: albwaf.ACTION_ACTION_ALLOW, + Log: false, + Severity: albwaf.SEVERITY_SEVERITY_INFO, + }, + Conditions: []albwaf.Condition{}, + }, + }, + expected: []albwaf.CreateCustomRule{ + { + Behavior: albwaf.Behavior{ + Action: albwaf.ACTION_ACTION_ALLOW, + Log: utils.Ptr(false), + }, + Conditions: []albwaf.Condition{}, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + output := ToCreateCustomRules(tt.rules) + + diff := cmp.Diff(output, tt.expected) + if diff != "" { + t.Fatalf("data does not match: %s", diff) + } + }) + } +}