From 7c989aa922761d9c13b310193de58402dd65232b Mon Sep 17 00:00:00 2001 From: Matheus Politano Date: Thu, 26 Mar 2026 17:49:55 +0100 Subject: [PATCH 01/20] feat: add redirects in convertConfig --- .../services/cdn/distribution/resource.go | 235 +++++++++++++++--- .../cdn/distribution/resource_test.go | 81 +++++- 2 files changed, 286 insertions(+), 30 deletions(-) diff --git a/stackit/internal/services/cdn/distribution/resource.go b/stackit/internal/services/cdn/distribution/resource.go index 47e36ffa4..56fe3699f 100644 --- a/stackit/internal/services/cdn/distribution/resource.go +++ b/stackit/internal/services/cdn/distribution/resource.go @@ -9,6 +9,8 @@ import ( "time" "github.com/google/uuid" + "github.com/hashicorp/terraform-plugin-framework-validators/int32validator" + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" "github.com/hashicorp/terraform-plugin-framework-validators/mapvalidator" "github.com/hashicorp/terraform-plugin-framework-validators/objectvalidator" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" @@ -17,8 +19,10 @@ import ( "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" "github.com/hashicorp/terraform-plugin-framework/resource/schema/listdefault" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" @@ -43,29 +47,38 @@ var ( ) var schemaDescriptions = map[string]string{ - "id": "Terraform's internal resource identifier. It is structured as \"`project_id`,`distribution_id`\".", - "distribution_id": "CDN distribution ID", - "project_id": "STACKIT project ID associated with the distribution", - "status": "Status of the distribution", - "created_at": "Time when the distribution was created", - "updated_at": "Time when the distribution was last updated", - "errors": "List of distribution errors", - "domains": "List of configured domains for the distribution", - "config": "The distribution configuration", - "config_backend": "The configured backend for the distribution", - "config_regions": "The configured regions where content will be hosted", - "config_backend_type": "The configured backend type. ", - "config_optimizer": "Configuration for the Image Optimizer. This is a paid feature that automatically optimizes images to reduce their file size for faster delivery, leading to improved website performance and a better user experience.", - "config_backend_origin_url": "The configured backend type http for the distribution", - "config_backend_origin_request_headers": "The configured type http origin request headers for the backend", - "config_backend_geofencing": "The configured type http to configure countries where content is allowed. A map of URLs to a list of countries", - "config_blocked_countries": "The configured countries where distribution of content is blocked", - "domain_name": "The name of the domain", - "domain_status": "The status of the domain", - "domain_type": "The type of the domain. Each distribution has one domain of type \"managed\", and domains of type \"custom\" may be additionally created by the user", - "domain_errors": "List of domain errors", - "config_backend_bucket_url": "The URL of the bucket (e.g. https://s3.example.com). Required if type is 'bucket'.", - "config_backend_region": "The region where the bucket is hosted. Required if type is 'bucket'.", + "id": "Terraform's internal resource identifier. It is structured as \"`project_id`,`distribution_id`\".", + "distribution_id": "CDN distribution ID", + "project_id": "STACKIT project ID associated with the distribution", + "status": "Status of the distribution", + "created_at": "Time when the distribution was created", + "updated_at": "Time when the distribution was last updated", + "errors": "List of distribution errors", + "domains": "List of configured domains for the distribution", + "config": "The distribution configuration", + "config_backend": "The configured backend for the distribution", + "config_regions": "The configured regions where content will be hosted", + "config_backend_type": "The configured backend type. ", + "config_optimizer": "Configuration for the Image Optimizer. This is a paid feature that automatically optimizes images to reduce their file size for faster delivery, leading to improved website performance and a better user experience.", + "config_backend_origin_url": "The configured backend type http for the distribution", + "config_backend_origin_request_headers": "The configured type http origin request headers for the backend", + "config_backend_geofencing": "The configured type http to configure countries where content is allowed. A map of URLs to a list of countries", + "config_blocked_countries": "The configured countries where distribution of content is blocked", + "config_redirects": "A wrapper for a list of redirect rules that allows for redirect settings on a distribution", + "config_redirects_rules": "A list of redirect rules. The order of rules matters for evaluation", + "config_redirects_rule_description": "An optional description for the redirect rule", + "config_redirects_rule_enabled": "A toggle to enable or disable the redirect rule. Default to true", + "config_redirects_rule_target_url": "The target URL to redirect to. Must be a valid URI", + "config_redirects_rule_status_code": "The HTTP status code for the redirect. Must be one of 301, 302, 303, 307, or 308.", + "config_redirects_rule_matchers": "A list of matchers that define when this rule should apply. At least one matcher is required", + "config_redirects_rule_matcher_values": "A list of glob patterns to match against the request path. At least one value is required. Examples: \"/shop/*\" or \"*/img/*\"", + "config_redirects_rule_match_condition": "Defines how multiple matchers within this rule are combined (ALL, ANY, NONE). Defaults to ANY.", + "domain_name": "The name of the domain", + "domain_status": "The status of the domain", + "domain_type": "The type of the domain. Each distribution has one domain of type \"managed\", and domains of type \"custom\" may be additionally created by the user", + "domain_errors": "List of domain errors", + "config_backend_bucket_url": "The URL of the bucket (e.g. https://s3.example.com). Required if type is 'bucket'.", + "config_backend_region": "The region where the bucket is hosted. Required if type is 'bucket'.", "config_backend_credentials_access_key_id": "The access key for the bucket. Required if type is 'bucket'.", "config_backend_credentials_secret_access_key": "The secret key for the bucket. Required if type is 'bucket'.", "config_backend_credentials": "The credentials for the bucket. Required if type is 'bucket'.", @@ -83,11 +96,30 @@ type Model struct { Config types.Object `tfsdk:"config"` // the configuration of the distribution } +type matcher struct { + Values []string `tfsdk:"values"` + ValueMatchCondition *string `tfsdk:"value_match_condition"` +} + +type redirectRule struct { + Description *string `tfsdk:"description"` + Enabled *bool `tfsdk:"enabled"` + TargetUrl string `tfsdk:"target_url"` + StatusCode int32 `tfsdk:"status_code"` + Matchers []matcher `tfsdk:"matchers"` + RuleMatchCondition *string `tfsdk:"rule_match_condition"` +} + +type redirectConfig struct { + Rules []redirectRule `tfsdk:"rules"` +} + type distributionConfig struct { - Backend backend `tfsdk:"backend"` // The backend associated with the distribution - Regions *[]string `tfsdk:"regions"` // The regions in which data will be cached - BlockedCountries *[]string `tfsdk:"blocked_countries"` // The countries for which content will be blocked - Optimizer types.Object `tfsdk:"optimizer"` // The optimizer configuration + Backend backend `tfsdk:"backend"` // The backend associated with the distribution + Redirects *redirectConfig `tfsdk:"redirects"` // A wrapper for a list of redirect rules that allows for redirect settings on a distribution + Regions *[]string `tfsdk:"regions"` // The regions in which data will be cached + BlockedCountries *[]string `tfsdk:"blocked_countries"` // The countries for which content will be blocked + Optimizer types.Object `tfsdk:"optimizer"` // The optimizer configuration } type optimizerConfig struct { @@ -95,7 +127,7 @@ type optimizerConfig struct { } type backend struct { - Type string `tfsdk:"type"` // The type of the backend. Currently, only "http" backend is supported + Type string `tfsdk:"type"` // The type of the backend. Currently, only "http" and "bucket" backend is supported OriginURL *string `tfsdk:"origin_url"` // The origin URL of the backend OriginRequestHeaders *map[string]string `tfsdk:"origin_request_headers"` // Request headers that should be added by the CDN distribution to incoming requests Geofencing *map[string][]*string `tfsdk:"geofencing"` // The geofencing is an object mapping multiple alternative origins to country codes. @@ -116,6 +148,9 @@ var configTypes = map[string]attr.Type{ "optimizer": types.ObjectType{ AttrTypes: optimizerTypes, }, + "redirects": types.ObjectType{ + AttrTypes: redirectsTypes, + }, } var optimizerTypes = map[string]attr.Type{ @@ -126,6 +161,32 @@ var geofencingTypes = types.MapType{ElemType: types.ListType{ ElemType: types.StringType, }} +var matcherTypes = map[string]attr.Type{ + "values": types.ListType{ElemType: types.StringType}, + "value_match_condition": types.StringType, +} + +var redirectRuleTypes = map[string]attr.Type{ + "description": types.StringType, + "enabled": types.BoolType, + "target_url": types.StringType, + "status_code": types.Int32Type, + "rule_match_condition": types.StringType, + "matchers": types.ListType{ + ElemType: types.ObjectType{ + AttrTypes: matcherTypes, + }, + }, +} + +var redirectsTypes = map[string]attr.Type{ + "rules": types.ListType{ + ElemType: types.ObjectType{ + AttrTypes: redirectRuleTypes, + }, + }, +} + var backendTypes = map[string]attr.Type{ "type": types.StringType, "origin_url": types.StringType, @@ -183,6 +244,8 @@ func (r *distributionResource) Metadata(_ context.Context, req resource.Metadata func (r *distributionResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { backendOptions := []string{"http", "bucket"} + matchCondition := []string{"ANY", "ALL", "NONE"} + statusCode := []int32{301, 302, 303, 307, 308} resp.Schema = schema.Schema{ MarkdownDescription: features.AddBetaDescription("CDN distribution data source schema.", core.Resource), Description: "CDN distribution data source schema.", @@ -267,6 +330,73 @@ func (r *distributionResource) Schema(_ context.Context, _ resource.SchemaReques objectvalidator.AlsoRequires(path.MatchRelative().AtName("enabled")), }, }, + "redirects": schema.SingleNestedAttribute{ + Required: true, + Description: schemaDescriptions["config_redirects"], + Attributes: map[string]schema.Attribute{ + "rules": schema.ListNestedAttribute{ + Description: schemaDescriptions["config_redirects_rules"], + Required: true, + Validators: []validator.List{ + listvalidator.SizeAtLeast(1), + }, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "description": schema.StringAttribute{ + Description: schemaDescriptions["config_redirects_rule_description"], + Optional: true, + }, + "enabled": schema.BoolAttribute{ + Optional: true, + Computed: true, + Description: schemaDescriptions["config_redirects_rule_enabled"], + Default: booldefault.StaticBool(true), + }, + "targetUrl": schema.StringAttribute{ + Required: true, + Description: schemaDescriptions["config_redirects_rule_target_url"], + }, + "statusCode": schema.Int32Attribute{ + Required: true, + Description: schemaDescriptions["config_redirects_rule_status_code"], + Validators: []validator.Int32{int32validator.OneOf(statusCode...)}, + }, + "ruleMatchCondition": schema.StringAttribute{ + Optional: true, + Computed: true, + Description: schemaDescriptions["config_redirects_rule_match_condition"], + Default: stringdefault.StaticString("ANY"), + Validators: []validator.String{stringvalidator.OneOfCaseInsensitive(matchCondition...)}, + }, + "matchers": schema.ListNestedAttribute{ + Description: schemaDescriptions["config_redirects_rule_matchers"], + Required: true, + Validators: []validator.List{ + listvalidator.SizeAtLeast(1), + }, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "values": schema.ListAttribute{ + Description: schemaDescriptions["config_redirects_rule_matcher_values"], + Required: true, + Validators: []validator.List{ + listvalidator.SizeAtLeast(1), + }, + }, + "ruleMatchCondition": schema.StringAttribute{ + Optional: true, + Description: schemaDescriptions["config_redirects_rule_match_condition"], + Default: stringdefault.StaticString("ANY"), + Computed: true, + Validators: []validator.String{stringvalidator.OneOfCaseInsensitive(matchCondition...)}, + }, + }, + }, + }}, + }, + }, + }, + }, "backend": schema.SingleNestedAttribute{ Required: true, Description: schemaDescriptions["config_backend"], @@ -1023,6 +1153,7 @@ func convertConfig(ctx context.Context, model *Model) (*cdn.Config, error) { if model == nil { return nil, errors.New("model cannot be nil") } + if model.Config.IsNull() || model.Config.IsUnknown() { return nil, errors.New("config cannot be nil or unknown") } @@ -1057,6 +1188,53 @@ func convertConfig(ctx context.Context, model *Model) (*cdn.Config, error) { } } + // redirects + var redirectsConfig *cdn.RedirectConfig + + if configModel.Redirects != nil { + sdkRules := []cdn.RedirectRule{} + + if len(configModel.Redirects.Rules) > 0 { + for _, rule := range configModel.Redirects.Rules { + matchers := []cdn.Matcher{} + for _, matcher := range rule.Matchers { + var matchCond *cdn.MatchCondition + if matcher.ValueMatchCondition != nil { + cond := cdn.MatchCondition(*matcher.ValueMatchCondition) + matchCond = &cond + } + + matchers = append(matchers, cdn.Matcher{ + Values: &matcher.Values, + ValueMatchCondition: matchCond, + }) + } + + var ruleMatchCond *cdn.MatchCondition + if rule.RuleMatchCondition != nil { + cond := cdn.MatchCondition(*rule.RuleMatchCondition) + ruleMatchCond = &cond + } + + statusCode := cdn.RedirectRuleStatusCode(rule.StatusCode) + targerUrl := rule.TargetUrl + + sdkConfigRule := cdn.RedirectRule{ + Description: rule.Description, + Enabled: rule.Enabled, + Matchers: &matchers, + RuleMatchCondition: ruleMatchCond, + StatusCode: &statusCode, + TargetUrl: &targerUrl, + } + sdkRules = append(sdkRules, sdkConfigRule) + } + } + redirectsConfig = &cdn.RedirectConfig{ + Rules: &sdkRules, + } + } + // geofencing geofencing := map[string][]string{} if configModel.Backend.Geofencing != nil { @@ -1080,6 +1258,7 @@ func convertConfig(ctx context.Context, model *Model) (*cdn.Config, error) { Backend: &cdn.ConfigBackend{}, Regions: ®ions, BlockedCountries: &blockedCountries, + Redirects: redirectsConfig, } if configModel.Backend.Type == "http" { diff --git a/stackit/internal/services/cdn/distribution/resource_test.go b/stackit/internal/services/cdn/distribution/resource_test.go index 9c4cda8c3..f2cf79ef1 100644 --- a/stackit/internal/services/cdn/distribution/resource_test.go +++ b/stackit/internal/services/cdn/distribution/resource_test.go @@ -8,6 +8,7 @@ import ( "github.com/google/go-cmp/cmp" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" "github.com/stackitcloud/stackit-sdk-go/services/cdn" ) @@ -203,12 +204,40 @@ func TestConvertConfig(t *testing.T) { blockedCountries := []attr.Value{types.StringValue("XX"), types.StringValue("YY"), types.StringValue("ZZ")} blockedCountriesFixture := types.ListValueMust(types.StringType, blockedCountries) optimizer := types.ObjectValueMust(optimizerTypes, map[string]attr.Value{"enabled": types.BoolValue(true)}) + + redirectsAttrTypes := configTypes["redirects"].(basetypes.ObjectType).AttrTypes + config := types.ObjectValueMust(configTypes, map[string]attr.Value{ "backend": backend, "regions": regionsFixture, "optimizer": types.ObjectNull(optimizerTypes), "blocked_countries": blockedCountriesFixture, + "redirects": types.ObjectNull(redirectsAttrTypes), + }) + + matcherValues := types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("/shop/*"), + }) + matcherVal := types.ObjectValueMust(matcherTypes, map[string]attr.Value{ + "values": matcherValues, + "value_match_condition": types.StringValue("ANY"), }) + matchersList := types.ListValueMust(types.ObjectType{AttrTypes: matcherTypes}, []attr.Value{matcherVal}) + + ruleVal := types.ObjectValueMust(redirectRuleTypes, map[string]attr.Value{ + "description": types.StringValue("Test redirect"), + "enabled": types.BoolValue(true), + "target_url": types.StringValue("https://example.com/redirect"), + "status_code": types.Int32Value(301), + "rule_match_condition": types.StringValue("ANY"), + "matchers": matchersList, + }) + rulesList := types.ListValueMust(types.ObjectType{AttrTypes: redirectRuleTypes}, []attr.Value{ruleVal}) + + redirectsConfigVal := types.ObjectValueMust(redirectsTypes, map[string]attr.Value{ + "rules": rulesList, + }) + modelFixture := func(mods ...func(*Model)) *Model { model := &Model{ DistributionId: types.StringValue("test-distribution-id"), @@ -220,6 +249,7 @@ func TestConvertConfig(t *testing.T) { } return model } + tests := map[string]struct { Input *Model Expected *cdn.Config @@ -253,6 +283,7 @@ func TestConvertConfig(t *testing.T) { "regions": regionsFixture, "optimizer": optimizer, "blocked_countries": blockedCountriesFixture, + "redirects": types.ObjectNull(redirectsAttrTypes), }) }), Expected: &cdn.Config{ @@ -275,6 +306,52 @@ func TestConvertConfig(t *testing.T) { }, IsValid: true, }, + "happy_path_with_redirects": { + Input: modelFixture(func(m *Model) { + m.Config = types.ObjectValueMust(configTypes, map[string]attr.Value{ + "backend": backend, + "regions": regionsFixture, + "optimizer": types.ObjectNull(optimizerTypes), + "blocked_countries": blockedCountriesFixture, + "redirects": redirectsConfigVal, // Injetando o mock aqui + }) + }), + Expected: &cdn.Config{ + Backend: &cdn.ConfigBackend{ + HttpBackend: &cdn.HttpBackend{ + OriginRequestHeaders: &map[string]string{ + "testHeader0": "testHeaderValue0", + "testHeader1": "testHeaderValue1", + }, + OriginUrl: cdn.PtrString("https://www.mycoolapp.com"), + Type: cdn.PtrString("http"), + Geofencing: &map[string][]string{ + "https://de.mycoolapp.com": {"DE", "FR"}, + }, + }, + }, + Regions: &[]cdn.Region{"EU", "US"}, + BlockedCountries: &[]string{"XX", "YY", "ZZ"}, + Redirects: &cdn.RedirectConfig{ + Rules: &[]cdn.RedirectRule{ + { + Description: cdn.PtrString("Test redirect"), + Enabled: cdn.PtrBool(true), + TargetUrl: cdn.PtrString("https://example.com/redirect"), + StatusCode: cdn.RedirectRuleStatusCode(301).Ptr(), + RuleMatchCondition: cdn.MatchCondition("ANY").Ptr(), + Matchers: &[]cdn.Matcher{ + { + Values: &[]string{"/shop/*"}, + ValueMatchCondition: cdn.MatchCondition("ANY").Ptr(), + }, + }, + }, + }, + }, + }, + IsValid: true, + }, "happy_path_bucket": { Input: modelFixture(func(m *Model) { creds := types.ObjectValueMust(backendCredentialsTypes, map[string]attr.Value{ @@ -295,6 +372,7 @@ func TestConvertConfig(t *testing.T) { "regions": regionsFixture, "blocked_countries": blockedCountriesFixture, "optimizer": types.ObjectNull(optimizerTypes), + "redirects": types.ObjectNull(redirectsAttrTypes), }) }), Expected: &cdn.Config{ @@ -303,8 +381,6 @@ func TestConvertConfig(t *testing.T) { Type: cdn.PtrString("bucket"), BucketUrl: cdn.PtrString("https://s3.example.com"), Region: cdn.PtrString("eu01"), - // Note: config does not return credentials - }, }, Regions: &[]cdn.Region{"EU", "US"}, @@ -325,6 +401,7 @@ func TestConvertConfig(t *testing.T) { IsValid: false, }, } + for tn, tc := range tests { t.Run(tn, func(t *testing.T) { res, err := convertConfig(context.Background(), tc.Input) From 7cca33f18d2ad400b83a099e7a056e4fa50e58ca Mon Sep 17 00:00:00 2001 From: Matheus Politano Date: Thu, 26 Mar 2026 18:14:25 +0100 Subject: [PATCH 02/20] feat: add redirect in toCreatePayload --- .../services/cdn/distribution/resource.go | 1 + .../cdn/distribution/resource_test.go | 71 +++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/stackit/internal/services/cdn/distribution/resource.go b/stackit/internal/services/cdn/distribution/resource.go index 56fe3699f..45bff3f43 100644 --- a/stackit/internal/services/cdn/distribution/resource.go +++ b/stackit/internal/services/cdn/distribution/resource.go @@ -1144,6 +1144,7 @@ func toCreatePayload(ctx context.Context, model *Model) (*cdn.CreateDistribution Backend: backend, BlockedCountries: cfg.BlockedCountries, Optimizer: optimizer, + Redirects: cfg.Redirects, } return payload, nil diff --git a/stackit/internal/services/cdn/distribution/resource_test.go b/stackit/internal/services/cdn/distribution/resource_test.go index f2cf79ef1..2af2ef55f 100644 --- a/stackit/internal/services/cdn/distribution/resource_test.go +++ b/stackit/internal/services/cdn/distribution/resource_test.go @@ -41,12 +41,40 @@ func TestToCreatePayload(t *testing.T) { optimizer := types.ObjectValueMust(optimizerTypes, map[string]attr.Value{ "enabled": types.BoolValue(true), }) + + redirectsAttrTypes := configTypes["redirects"].(basetypes.ObjectType).AttrTypes + config := types.ObjectValueMust(configTypes, map[string]attr.Value{ "backend": backend, "regions": regionsFixture, "blocked_countries": blockedCountriesFixture, "optimizer": types.ObjectNull(optimizerTypes), + "redirects": types.ObjectNull(redirectsAttrTypes), + }) + + matcherValues := types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("/shop/*"), + }) + matcherVal := types.ObjectValueMust(matcherTypes, map[string]attr.Value{ + "values": matcherValues, + "value_match_condition": types.StringValue("ANY"), }) + matchersList := types.ListValueMust(types.ObjectType{AttrTypes: matcherTypes}, []attr.Value{matcherVal}) + + ruleVal := types.ObjectValueMust(redirectRuleTypes, map[string]attr.Value{ + "description": types.StringValue("Test redirect"), + "enabled": types.BoolValue(true), + "target_url": types.StringValue("https://example.com/redirect"), + "status_code": types.Int32Value(301), + "rule_match_condition": types.StringValue("ANY"), + "matchers": matchersList, + }) + rulesList := types.ListValueMust(types.ObjectType{AttrTypes: redirectRuleTypes}, []attr.Value{ruleVal}) + + redirectsConfigVal := types.ObjectValueMust(redirectsTypes, map[string]attr.Value{ + "rules": rulesList, + }) + modelFixture := func(mods ...func(*Model)) *Model { model := &Model{ DistributionId: types.StringValue("test-distribution-id"), @@ -86,6 +114,7 @@ func TestToCreatePayload(t *testing.T) { "regions": regionsFixture, "optimizer": optimizer, "blocked_countries": blockedCountriesFixture, + "redirects": types.ObjectNull(redirectsAttrTypes), }) }), Expected: &cdn.CreateDistributionPayload{ @@ -103,6 +132,47 @@ func TestToCreatePayload(t *testing.T) { }, IsValid: true, }, + "happy_path_with_redirects": { + Input: modelFixture(func(m *Model) { + m.Config = types.ObjectValueMust(configTypes, map[string]attr.Value{ + "backend": backend, + "regions": regionsFixture, + "optimizer": types.ObjectNull(optimizerTypes), + "blocked_countries": blockedCountriesFixture, + "redirects": redirectsConfigVal, + }) + }), + Expected: &cdn.CreateDistributionPayload{ + Regions: &[]cdn.Region{"EU", "US"}, + BlockedCountries: &[]string{"XX", "YY", "ZZ"}, + Backend: &cdn.CreateDistributionPayloadBackend{ + HttpBackendCreate: &cdn.HttpBackendCreate{ + Geofencing: &map[string][]string{"https://de.mycoolapp.com": {"DE", "FR"}}, + OriginRequestHeaders: &map[string]string{"testHeader0": "testHeaderValue0", "testHeader1": "testHeaderValue1"}, + OriginUrl: cdn.PtrString("https://www.mycoolapp.com"), + Type: cdn.PtrString("http"), + }, + }, + Redirects: &cdn.RedirectConfig{ + Rules: &[]cdn.RedirectRule{ + { + Description: cdn.PtrString("Test redirect"), + Enabled: cdn.PtrBool(true), + TargetUrl: cdn.PtrString("https://example.com/redirect"), + StatusCode: cdn.RedirectRuleStatusCode(301).Ptr(), + RuleMatchCondition: cdn.MatchCondition("ANY").Ptr(), + Matchers: &[]cdn.Matcher{ + { + Values: &[]string{"/shop/*"}, + ValueMatchCondition: cdn.MatchCondition("ANY").Ptr(), + }, + }, + }, + }, + }, + }, + IsValid: true, + }, "happy_path_bucket": { Input: modelFixture(func(m *Model) { creds := types.ObjectValueMust(backendCredentialsTypes, map[string]attr.Value{ @@ -123,6 +193,7 @@ func TestToCreatePayload(t *testing.T) { "regions": regionsFixture, // reusing the existing one "blocked_countries": blockedCountriesFixture, "optimizer": types.ObjectNull(optimizerTypes), + "redirects": types.ObjectNull(redirectsAttrTypes), }) }), Expected: &cdn.CreateDistributionPayload{ From e850a14c1e8afaa35086f272d76873161ae09f47 Mon Sep 17 00:00:00 2001 From: Matheus Politano Date: Thu, 26 Mar 2026 18:21:13 +0100 Subject: [PATCH 03/20] feat: add redirect in the update function --- .../services/cdn/distribution/resource.go | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/stackit/internal/services/cdn/distribution/resource.go b/stackit/internal/services/cdn/distribution/resource.go index 45bff3f43..bb5133197 100644 --- a/stackit/internal/services/cdn/distribution/resource.go +++ b/stackit/internal/services/cdn/distribution/resource.go @@ -697,6 +697,51 @@ func (r *distributionResource) Update(ctx context.Context, req resource.UpdateRe blockedCountries = &tempBlockedCountries } + // redirects + var redirectsConfig *cdn.RedirectConfig + if configModel.Redirects != nil { + sdkRules := []cdn.RedirectRule{} + if len(configModel.Redirects.Rules) > 0 { + for _, rule := range configModel.Redirects.Rules { + matchers := []cdn.Matcher{} + for _, matcher := range rule.Matchers { + var matchCond *cdn.MatchCondition + if matcher.ValueMatchCondition != nil { + cond := cdn.MatchCondition(*matcher.ValueMatchCondition) + matchCond = &cond + } + + matchers = append(matchers, cdn.Matcher{ + Values: &matcher.Values, + ValueMatchCondition: matchCond, + }) + } + + var ruleMatchCond *cdn.MatchCondition + if rule.RuleMatchCondition != nil { + cond := cdn.MatchCondition(*rule.RuleMatchCondition) + ruleMatchCond = &cond + } + + statusCode := cdn.RedirectRuleStatusCode(rule.StatusCode) + targetUrl := rule.TargetUrl + + sdkConfigRule := cdn.RedirectRule{ + Description: rule.Description, + Enabled: rule.Enabled, + Matchers: &matchers, + RuleMatchCondition: ruleMatchCond, + StatusCode: &statusCode, + TargetUrl: &targetUrl, + } + sdkRules = append(sdkRules, sdkConfigRule) + } + } + redirectsConfig = &cdn.RedirectConfig{ + Rules: &sdkRules, + } + } + configPatchBackend := &cdn.ConfigPatchBackend{} if configModel.Backend.Type == "http" { From cf6f25511b137609fc001ebbeae4b0489fd1f7de Mon Sep 17 00:00:00 2001 From: Matheus Politano Date: Thu, 26 Mar 2026 18:34:05 +0100 Subject: [PATCH 04/20] feat: add in the map field mapfield --- .../services/cdn/distribution/resource.go | 95 +++++++++++++++++++ .../cdn/distribution/resource_test.go | 63 ++++++++++++ 2 files changed, 158 insertions(+) diff --git a/stackit/internal/services/cdn/distribution/resource.go b/stackit/internal/services/cdn/distribution/resource.go index bb5133197..bd134a414 100644 --- a/stackit/internal/services/cdn/distribution/resource.go +++ b/stackit/internal/services/cdn/distribution/resource.go @@ -786,6 +786,7 @@ func (r *distributionResource) Update(ctx context.Context, req resource.UpdateRe Backend: configPatchBackend, Regions: ®ions, BlockedCountries: blockedCountries, + Redirects: redirectsConfig, } if !utils.IsUndefined(configModel.Optimizer) { @@ -948,6 +949,99 @@ func mapFields(ctx context.Context, distribution *cdn.Distribution, model *Model } } + // redirects + redirectsVal := types.ObjectNull(redirectsTypes) + if distribution.Config != nil && distribution.Config.Redirects != nil && distribution.Config.Redirects.Rules != nil { + var tfRules []attr.Value + for _, r := range *distribution.Config.Redirects.Rules { + var tfMatchers []attr.Value + if r.Matchers != nil { + for _, m := range *r.Matchers { + var tfValues []attr.Value + if m.Values != nil { + for _, v := range *m.Values { + tfValues = append(tfValues, types.StringValue(v)) + } + } + tfValuesList, diags := types.ListValue(types.StringType, tfValues) + if diags.HasError() { + return core.DiagsToError(diags) + } + + tfValMatchCond := types.StringNull() + if m.ValueMatchCondition != nil { + tfValMatchCond = types.StringValue(string(*m.ValueMatchCondition)) + } + + tfMatcherObj, diags := types.ObjectValue(matcherTypes, map[string]attr.Value{ + "values": tfValuesList, + "value_match_condition": tfValMatchCond, + }) + if diags.HasError() { + return core.DiagsToError(diags) + } + tfMatchers = append(tfMatchers, tfMatcherObj) + } + } + + tfMatchersList, diags := types.ListValue(types.ObjectType{AttrTypes: matcherTypes}, tfMatchers) + if diags.HasError() { + return core.DiagsToError(diags) + } + + tfDesc := types.StringNull() + if r.Description != nil { + tfDesc = types.StringValue(*r.Description) + } + + tfEnabled := types.BoolNull() + if r.Enabled != nil { + tfEnabled = types.BoolValue(*r.Enabled) + } + + tfTargetUrl := types.StringNull() + if r.TargetUrl != nil { + tfTargetUrl = types.StringValue(*r.TargetUrl) + } + + tfStatusCode := types.Int32Null() + if r.StatusCode != nil { + tfStatusCode = types.Int32Value(int32(*r.StatusCode)) + } + + tfRuleMatchCond := types.StringNull() + if r.RuleMatchCondition != nil { + tfRuleMatchCond = types.StringValue(string(*r.RuleMatchCondition)) + } + + tfRuleObj, diags := types.ObjectValue(redirectRuleTypes, map[string]attr.Value{ + "description": tfDesc, + "enabled": tfEnabled, + "target_url": tfTargetUrl, + "status_code": tfStatusCode, + "rule_match_condition": tfRuleMatchCond, + "matchers": tfMatchersList, + }) + if diags.HasError() { + return core.DiagsToError(diags) + } + tfRules = append(tfRules, tfRuleObj) + } + + tfRulesList, diags := types.ListValue(types.ObjectType{AttrTypes: redirectRuleTypes}, tfRules) + if diags.HasError() { + return core.DiagsToError(diags) + } + + var objDiags diag.Diagnostics + redirectsVal, objDiags = types.ObjectValue(redirectsTypes, map[string]attr.Value{ + "rules": tfRulesList, + }) + if objDiags.HasError() { + return core.DiagsToError(objDiags) + } + } + // blockedCountries var blockedCountries []attr.Value if distribution.Config != nil && distribution.Config.BlockedCountries != nil { @@ -1085,6 +1179,7 @@ func mapFields(ctx context.Context, distribution *cdn.Distribution, model *Model "regions": modelRegions, "blocked_countries": modelBlockedCountries, "optimizer": optimizerVal, + "redirects": redirectsVal, }) if diags.HasError() { return core.DiagsToError(diags) diff --git a/stackit/internal/services/cdn/distribution/resource_test.go b/stackit/internal/services/cdn/distribution/resource_test.go index 2af2ef55f..fe859f0b1 100644 --- a/stackit/internal/services/cdn/distribution/resource_test.go +++ b/stackit/internal/services/cdn/distribution/resource_test.go @@ -521,11 +521,56 @@ func TestMapFields(t *testing.T) { optimizer := types.ObjectValueMust(optimizerTypes, map[string]attr.Value{ "enabled": types.BoolValue(true), }) + + redirectsAttrTypes := configTypes["redirects"].(basetypes.ObjectType).AttrTypes + config := types.ObjectValueMust(configTypes, map[string]attr.Value{ "backend": backend, "regions": regionsFixture, "blocked_countries": blockedCountriesFixture, "optimizer": types.ObjectNull(optimizerTypes), + "redirects": types.ObjectNull(redirectsAttrTypes), + }) + + redirectsInput := &cdn.RedirectConfig{ + Rules: &[]cdn.RedirectRule{ + { + Description: cdn.PtrString("Test redirect"), + Enabled: cdn.PtrBool(true), + TargetUrl: cdn.PtrString("https://example.com/redirect"), + StatusCode: cdn.RedirectRuleStatusCode(301).Ptr(), + RuleMatchCondition: cdn.MatchCondition("ANY").Ptr(), + Matchers: &[]cdn.Matcher{ + { + Values: &[]string{"/shop/*"}, + ValueMatchCondition: cdn.MatchCondition("ANY").Ptr(), + }, + }, + }, + }, + } + + matcherValuesExpected := types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("/shop/*"), + }) + matcherValExpected := types.ObjectValueMust(matcherTypes, map[string]attr.Value{ + "values": matcherValuesExpected, + "value_match_condition": types.StringValue("ANY"), + }) + matchersListExpected := types.ListValueMust(types.ObjectType{AttrTypes: matcherTypes}, []attr.Value{matcherValExpected}) + + ruleValExpected := types.ObjectValueMust(redirectRuleTypes, map[string]attr.Value{ + "description": types.StringValue("Test redirect"), + "enabled": types.BoolValue(true), + "target_url": types.StringValue("https://example.com/redirect"), + "status_code": types.Int32Value(301), + "rule_match_condition": types.StringValue("ANY"), + "matchers": matchersListExpected, + }) + rulesListExpected := types.ListValueMust(types.ObjectType{AttrTypes: redirectRuleTypes}, []attr.Value{ruleValExpected}) + + redirectsConfigExpected := types.ObjectValueMust(redirectsTypes, map[string]attr.Value{ + "rules": rulesListExpected, }) emtpyErrorsList := types.ListValueMust(types.StringType, []attr.Value{}) @@ -607,6 +652,7 @@ func TestMapFields(t *testing.T) { "regions": regionsFixture, "blocked_countries": blockedCountriesFixture, "optimizer": types.ObjectNull(optimizerTypes), + "redirects": types.ObjectNull(redirectsAttrTypes), }) tests := map[string]struct { Input *cdn.Distribution @@ -626,6 +672,7 @@ func TestMapFields(t *testing.T) { "regions": regionsFixture, "optimizer": optimizer, "blocked_countries": blockedCountriesFixture, + "redirects": types.ObjectNull(redirectsAttrTypes), }) }), Input: distributionFixture(func(d *cdn.Distribution) { @@ -651,6 +698,7 @@ func TestMapFields(t *testing.T) { "regions": regionsFixture, "optimizer": types.ObjectNull(optimizerTypes), "blocked_countries": blockedCountriesFixture, + "redirects": types.ObjectNull(redirectsAttrTypes), }) }), Input: distributionFixture(func(d *cdn.Distribution) { @@ -658,6 +706,21 @@ func TestMapFields(t *testing.T) { }), IsValid: true, }, + "happy_path_with_redirects": { + Expected: expectedModel(func(m *Model) { + m.Config = types.ObjectValueMust(configTypes, map[string]attr.Value{ + "backend": backend, + "regions": regionsFixture, + "optimizer": types.ObjectNull(optimizerTypes), + "blocked_countries": blockedCountriesFixture, + "redirects": redirectsConfigExpected, + }) + }), + Input: distributionFixture(func(d *cdn.Distribution) { + d.Config.Redirects = redirectsInput + }), + IsValid: true, + }, "happy_path_status_error": { Expected: expectedModel(func(m *Model) { m.Status = types.StringValue("ERROR") From 72d079cc3411ea0a9a2ebcefe1b5ebd68f53f9aa Mon Sep 17 00:00:00 2001 From: Matheus Politano Date: Fri, 27 Mar 2026 08:46:11 +0100 Subject: [PATCH 05/20] small fix and add datasource --- .../services/cdn/distribution/datasource.go | 148 ++++++++++++++++++ .../cdn/distribution/datasource_test.go | 59 +++++++ .../services/cdn/distribution/resource.go | 11 +- 3 files changed, 213 insertions(+), 5 deletions(-) diff --git a/stackit/internal/services/cdn/distribution/datasource.go b/stackit/internal/services/cdn/distribution/datasource.go index f352d863b..8ba61bc81 100644 --- a/stackit/internal/services/cdn/distribution/datasource.go +++ b/stackit/internal/services/cdn/distribution/datasource.go @@ -38,6 +38,9 @@ var dataSourceConfigTypes = map[string]attr.Type{ "optimizer": types.ObjectType{ AttrTypes: optimizerTypes, // Shared from resource.go }, + "redirects": types.ObjectType{ + AttrTypes: redirectsTypes, // Shared from resource.go + }, } type distributionDataSource struct { @@ -199,6 +202,57 @@ func (r *distributionDataSource) Schema(_ context.Context, _ datasource.SchemaRe }, }, }, + "redirects": schema.SingleNestedAttribute{ + Computed: true, + Description: schemaDescriptions["config_redirects"], + Attributes: map[string]schema.Attribute{ + "rules": schema.ListNestedAttribute{ + Description: schemaDescriptions["config_redirects_rules"], + Computed: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "description": schema.StringAttribute{ + Description: schemaDescriptions["config_redirects_rule_description"], + Computed: true, + }, + "enabled": schema.BoolAttribute{ + Computed: true, + Description: schemaDescriptions["config_redirects_rule_enabled"], + }, + "target_url": schema.StringAttribute{ + Computed: true, + Description: schemaDescriptions["config_redirects_rule_target_url"], + }, + "status_code": schema.Int32Attribute{ + Computed: true, + Description: schemaDescriptions["config_redirects_rule_status_code"], + }, + "rule_match_condition": schema.StringAttribute{ + Computed: true, + Description: schemaDescriptions["config_redirects_rule_match_condition"], + }, + "matchers": schema.ListNestedAttribute{ + Description: schemaDescriptions["config_redirects_rule_matchers"], + Computed: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "values": schema.ListAttribute{ + Description: schemaDescriptions["config_redirects_rule_matcher_values"], + Computed: true, + ElementType: types.StringType, + }, + "value_match_condition": schema.StringAttribute{ + Description: schemaDescriptions["config_redirects_rule_match_condition"], + Computed: true, + }, + }, + }, + }, + }, + }, + }, + }, + }, }, }, }, @@ -300,6 +354,99 @@ func mapDataSourceFields(ctx context.Context, distribution *cdn.Distribution, mo return core.DiagsToError(diags) } + // redirects + redirectsVal := types.ObjectNull(redirectsTypes) + if distribution.Config != nil && distribution.Config.Redirects != nil && distribution.Config.Redirects.Rules != nil { + var tfRules []attr.Value + for _, r := range *distribution.Config.Redirects.Rules { + var tfMatchers []attr.Value + if r.Matchers != nil { + for _, m := range *r.Matchers { + var tfValues []attr.Value + if m.Values != nil { + for _, v := range *m.Values { + tfValues = append(tfValues, types.StringValue(v)) + } + } + tfValuesList, diags := types.ListValue(types.StringType, tfValues) + if diags.HasError() { + return core.DiagsToError(diags) + } + + tfValMatchCond := types.StringNull() + if m.ValueMatchCondition != nil { + tfValMatchCond = types.StringValue(string(*m.ValueMatchCondition)) + } + + tfMatcherObj, diags := types.ObjectValue(matcherTypes, map[string]attr.Value{ + "values": tfValuesList, + "value_match_condition": tfValMatchCond, + }) + if diags.HasError() { + return core.DiagsToError(diags) + } + tfMatchers = append(tfMatchers, tfMatcherObj) + } + } + + tfMatchersList, diags := types.ListValue(types.ObjectType{AttrTypes: matcherTypes}, tfMatchers) + if diags.HasError() { + return core.DiagsToError(diags) + } + + tfDesc := types.StringNull() + if r.Description != nil { + tfDesc = types.StringValue(*r.Description) + } + + tfEnabled := types.BoolNull() + if r.Enabled != nil { + tfEnabled = types.BoolValue(*r.Enabled) + } + + tfTargetUrl := types.StringNull() + if r.TargetUrl != nil { + tfTargetUrl = types.StringValue(*r.TargetUrl) + } + + tfStatusCode := types.Int32Null() + if r.StatusCode != nil { + tfStatusCode = types.Int32Value(int32(*r.StatusCode)) + } + + tfRuleMatchCond := types.StringNull() + if r.RuleMatchCondition != nil { + tfRuleMatchCond = types.StringValue(string(*r.RuleMatchCondition)) + } + + tfRuleObj, diags := types.ObjectValue(redirectRuleTypes, map[string]attr.Value{ + "description": tfDesc, + "enabled": tfEnabled, + "target_url": tfTargetUrl, + "status_code": tfStatusCode, + "rule_match_condition": tfRuleMatchCond, + "matchers": tfMatchersList, + }) + if diags.HasError() { + return core.DiagsToError(diags) + } + tfRules = append(tfRules, tfRuleObj) + } + + tfRulesList, diags := types.ListValue(types.ObjectType{AttrTypes: redirectRuleTypes}, tfRules) + if diags.HasError() { + return core.DiagsToError(diags) + } + + var objDiags diag.Diagnostics + redirectsVal, objDiags = types.ObjectValue(redirectsTypes, map[string]attr.Value{ + "rules": tfRulesList, + }) + if objDiags.HasError() { + return core.DiagsToError(objDiags) + } + } + // Prepare Backend Values var backendValues map[string]attr.Value originRequestHeaders := types.MapNull(types.StringType) @@ -383,6 +530,7 @@ func mapDataSourceFields(ctx context.Context, distribution *cdn.Distribution, mo "regions": modelRegions, "blocked_countries": modelBlockedCountries, "optimizer": optimizerVal, + "redirects": redirectsVal, }) if diags.HasError() { return core.DiagsToError(diags) diff --git a/stackit/internal/services/cdn/distribution/datasource_test.go b/stackit/internal/services/cdn/distribution/datasource_test.go index 5bf117032..fb62b1874 100644 --- a/stackit/internal/services/cdn/distribution/datasource_test.go +++ b/stackit/internal/services/cdn/distribution/datasource_test.go @@ -8,6 +8,7 @@ import ( "github.com/google/go-cmp/cmp" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" "github.com/stackitcloud/stackit-sdk-go/services/cdn" ) @@ -39,13 +40,53 @@ func TestMapDataSourceFields(t *testing.T) { optimizer := types.ObjectValueMust(optimizerTypes, map[string]attr.Value{ "enabled": types.BoolValue(true), }) + redirectsAttrTypes := configTypes["redirects"].(basetypes.ObjectType).AttrTypes config := types.ObjectValueMust(dataSourceConfigTypes, map[string]attr.Value{ "backend": backend, "regions": regionsFixture, "blocked_countries": blockedCountriesFixture, "optimizer": types.ObjectNull(optimizerTypes), + "redirects": types.ObjectNull(redirectsAttrTypes), }) + redirectsInput := &cdn.RedirectConfig{ + Rules: &[]cdn.RedirectRule{ + { + Description: cdn.PtrString("Test redirect"), + Enabled: cdn.PtrBool(true), + TargetUrl: cdn.PtrString("https://example.com/redirect"), + StatusCode: cdn.RedirectRuleStatusCode(301).Ptr(), + RuleMatchCondition: cdn.MatchCondition("ANY").Ptr(), + Matchers: &[]cdn.Matcher{ + { + Values: &[]string{"/shop/*"}, + ValueMatchCondition: cdn.MatchCondition("ANY").Ptr(), + }, + }, + }, + }, + } + matcherValuesExpected := types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("/shop/*"), + }) + matcherValExpected := types.ObjectValueMust(matcherTypes, map[string]attr.Value{ + "values": matcherValuesExpected, + "value_match_condition": types.StringValue("ANY"), + }) + matchersListExpected := types.ListValueMust(types.ObjectType{AttrTypes: matcherTypes}, []attr.Value{matcherValExpected}) + + ruleValExpected := types.ObjectValueMust(redirectRuleTypes, map[string]attr.Value{ + "description": types.StringValue("Test redirect"), + "enabled": types.BoolValue(true), + "target_url": types.StringValue("https://example.com/redirect"), + "status_code": types.Int32Value(301), + "rule_match_condition": types.StringValue("ANY"), + "matchers": matchersListExpected, + }) + rulesListExpected := types.ListValueMust(types.ObjectType{AttrTypes: redirectRuleTypes}, []attr.Value{ruleValExpected}) + redirectsConfigExpected := types.ObjectValueMust(redirectsTypes, map[string]attr.Value{ + "rules": rulesListExpected, + }) emtpyErrorsList := types.ListValueMust(types.StringType, []attr.Value{}) managedDomain := types.ObjectValueMust(domainTypes, map[string]attr.Value{ "name": types.StringValue("test.stackit-cdn.com"), @@ -132,6 +173,7 @@ func TestMapDataSourceFields(t *testing.T) { "regions": regionsFixture, "optimizer": optimizer, "blocked_countries": blockedCountriesFixture, + "redirects": types.ObjectNull(redirectsAttrTypes), }) }), Input: distributionFixture(func(d *cdn.Distribution) { @@ -157,6 +199,7 @@ func TestMapDataSourceFields(t *testing.T) { "regions": regionsFixture, "blocked_countries": blockedCountriesFixture, "optimizer": types.ObjectNull(optimizerTypes), + "redirects": types.ObjectNull(redirectsAttrTypes), }) }), IsValid: true, @@ -176,6 +219,7 @@ func TestMapDataSourceFields(t *testing.T) { "regions": regionsFixture, "optimizer": types.ObjectNull(optimizerTypes), "blocked_countries": blockedCountriesFixture, + "redirects": types.ObjectNull(redirectsAttrTypes), }) }), Input: distributionFixture(func(d *cdn.Distribution) { @@ -192,6 +236,21 @@ func TestMapDataSourceFields(t *testing.T) { }), IsValid: true, }, + "happy_path_with_redirects": { + Expected: expectedModel(func(m *Model) { + m.Config = types.ObjectValueMust(dataSourceConfigTypes, map[string]attr.Value{ + "backend": backend, + "regions": regionsFixture, + "optimizer": types.ObjectNull(optimizerTypes), + "blocked_countries": blockedCountriesFixture, + "redirects": redirectsConfigExpected, + }) + }), + Input: distributionFixture(func(d *cdn.Distribution) { + d.Config.Redirects = redirectsInput + }), + IsValid: true, + }, "happy_path_custom_domain": { Expected: expectedModel(func(m *Model) { managedDomain := types.ObjectValueMust(domainTypes, map[string]attr.Value{ diff --git a/stackit/internal/services/cdn/distribution/resource.go b/stackit/internal/services/cdn/distribution/resource.go index bd134a414..774bf6bbb 100644 --- a/stackit/internal/services/cdn/distribution/resource.go +++ b/stackit/internal/services/cdn/distribution/resource.go @@ -331,7 +331,7 @@ func (r *distributionResource) Schema(_ context.Context, _ resource.SchemaReques }, }, "redirects": schema.SingleNestedAttribute{ - Required: true, + Optional: true, Description: schemaDescriptions["config_redirects"], Attributes: map[string]schema.Attribute{ "rules": schema.ListNestedAttribute{ @@ -352,16 +352,16 @@ func (r *distributionResource) Schema(_ context.Context, _ resource.SchemaReques Description: schemaDescriptions["config_redirects_rule_enabled"], Default: booldefault.StaticBool(true), }, - "targetUrl": schema.StringAttribute{ + "target_url": schema.StringAttribute{ Required: true, Description: schemaDescriptions["config_redirects_rule_target_url"], }, - "statusCode": schema.Int32Attribute{ + "status_code": schema.Int32Attribute{ Required: true, Description: schemaDescriptions["config_redirects_rule_status_code"], Validators: []validator.Int32{int32validator.OneOf(statusCode...)}, }, - "ruleMatchCondition": schema.StringAttribute{ + "rule_match_condition": schema.StringAttribute{ Optional: true, Computed: true, Description: schemaDescriptions["config_redirects_rule_match_condition"], @@ -379,11 +379,12 @@ func (r *distributionResource) Schema(_ context.Context, _ resource.SchemaReques "values": schema.ListAttribute{ Description: schemaDescriptions["config_redirects_rule_matcher_values"], Required: true, + ElementType: types.StringType, Validators: []validator.List{ listvalidator.SizeAtLeast(1), }, }, - "ruleMatchCondition": schema.StringAttribute{ + "value_match_condition": schema.StringAttribute{ Optional: true, Description: schemaDescriptions["config_redirects_rule_match_condition"], Default: stringdefault.StaticString("ANY"), From cafad57d46454b6b38160bf7a957e25c06e3a23b Mon Sep 17 00:00:00 2001 From: Matheus Politano Date: Fri, 27 Mar 2026 14:26:59 +0100 Subject: [PATCH 06/20] chore: update doc --- docs/data-sources/cdn_distribution.md | 31 ++++++++++++++++++++++ docs/resources/cdn_distribution.md | 37 +++++++++++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/docs/data-sources/cdn_distribution.md b/docs/data-sources/cdn_distribution.md index 1f046144c..099a24799 100644 --- a/docs/data-sources/cdn_distribution.md +++ b/docs/data-sources/cdn_distribution.md @@ -51,6 +51,7 @@ Read-Only: - `backend` (Attributes) The configured backend for the distribution (see [below for nested schema](#nestedatt--config--backend)) - `optimizer` (Attributes) Configuration for the Image Optimizer. This is a paid feature that automatically optimizes images to reduce their file size for faster delivery, leading to improved website performance and a better user experience. (see [below for nested schema](#nestedatt--config--optimizer)) +- `redirects` (Attributes) A wrapper for a list of redirect rules that allows for redirect settings on a distribution (see [below for nested schema](#nestedatt--config--redirects)) - `regions` (List of String) The configured regions where content will be hosted @@ -74,6 +75,36 @@ Read-Only: - `enabled` (Boolean) + +### Nested Schema for `config.redirects` + +Read-Only: + +- `rules` (Attributes List) A list of redirect rules. The order of rules matters for evaluation (see [below for nested schema](#nestedatt--config--redirects--rules)) + + +### Nested Schema for `config.redirects.rules` + +Read-Only: + +- `description` (String) An optional description for the redirect rule +- `enabled` (Boolean) A toggle to enable or disable the redirect rule. Default to true +- `matchers` (Attributes List) A list of matchers that define when this rule should apply. At least one matcher is required (see [below for nested schema](#nestedatt--config--redirects--rules--matchers)) +- `rule_match_condition` (String) Defines how multiple matchers within this rule are combined (ALL, ANY, NONE). Defaults to ANY. +- `status_code` (Number) The HTTP status code for the redirect. Must be one of 301, 302, 303, 307, or 308. +- `target_url` (String) The target URL to redirect to. Must be a valid URI + + +### Nested Schema for `config.redirects.rules.matchers` + +Read-Only: + +- `value_match_condition` (String) Defines how multiple matchers within this rule are combined (ALL, ANY, NONE). Defaults to ANY. +- `values` (List of String) A list of glob patterns to match against the request path. At least one value is required. Examples: "/shop/*" or "*/img/*" + + + + ### Nested Schema for `domains` diff --git a/docs/resources/cdn_distribution.md b/docs/resources/cdn_distribution.md index baa7971ce..7a424a129 100644 --- a/docs/resources/cdn_distribution.md +++ b/docs/resources/cdn_distribution.md @@ -96,6 +96,7 @@ Optional: - `blocked_countries` (List of String) The configured countries where distribution of content is blocked - `optimizer` (Attributes) Configuration for the Image Optimizer. This is a paid feature that automatically optimizes images to reduce their file size for faster delivery, leading to improved website performance and a better user experience. (see [below for nested schema](#nestedatt--config--optimizer)) +- `redirects` (Attributes) A wrapper for a list of redirect rules that allows for redirect settings on a distribution (see [below for nested schema](#nestedatt--config--redirects)) ### Nested Schema for `config.backend` @@ -131,6 +132,42 @@ Optional: - `enabled` (Boolean) + +### Nested Schema for `config.redirects` + +Required: + +- `rules` (Attributes List) A list of redirect rules. The order of rules matters for evaluation (see [below for nested schema](#nestedatt--config--redirects--rules)) + + +### Nested Schema for `config.redirects.rules` + +Required: + +- `matchers` (Attributes List) A list of matchers that define when this rule should apply. At least one matcher is required (see [below for nested schema](#nestedatt--config--redirects--rules--matchers)) +- `status_code` (Number) The HTTP status code for the redirect. Must be one of 301, 302, 303, 307, or 308. +- `target_url` (String) The target URL to redirect to. Must be a valid URI + +Optional: + +- `description` (String) An optional description for the redirect rule +- `enabled` (Boolean) A toggle to enable or disable the redirect rule. Default to true +- `rule_match_condition` (String) Defines how multiple matchers within this rule are combined (ALL, ANY, NONE). Defaults to ANY. + + +### Nested Schema for `config.redirects.rules.matchers` + +Required: + +- `values` (List of String) A list of glob patterns to match against the request path. At least one value is required. Examples: "/shop/*" or "*/img/*" + +Optional: + +- `value_match_condition` (String) Defines how multiple matchers within this rule are combined (ALL, ANY, NONE). Defaults to ANY. + + + + ### Nested Schema for `domains` From f2bcc1b1029fcb15346c8fed66b8db42988e4c0a Mon Sep 17 00:00:00 2001 From: Matheus Politano Date: Fri, 27 Mar 2026 16:01:32 +0100 Subject: [PATCH 07/20] chore: add resource example --- .../stackit_cdn_distribution/resource.tf | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/examples/resources/stackit_cdn_distribution/resource.tf b/examples/resources/stackit_cdn_distribution/resource.tf index 1e3d1dacd..4c37818bf 100644 --- a/examples/resources/stackit_cdn_distribution/resource.tf +++ b/examples/resources/stackit_cdn_distribution/resource.tf @@ -38,6 +38,24 @@ resource "stackit_cdn_distribution" "example_bucket_distribution" { optimizer = { enabled = false } + + redirects = { + rules = [ + { + description = "test redirect" + enabled = true + rule_match_condition = "ANY" + status_code = 302 + target_url = "https://stackit.de/" + matchers = [ + { + values = ["*/otherPath/"] + value_match_condition = "ANY" + } + ] + } + ] + } } } From 9addaea7a7ee08861bf6e327bad0836c11657b00 Mon Sep 17 00:00:00 2001 From: Matheus Politano Date: Tue, 31 Mar 2026 19:02:47 +0200 Subject: [PATCH 08/20] chore: add redirect in acc test and address linter issues --- stackit/internal/services/cdn/cdn_acc_test.go | 17 ++++++++++++++++- .../services/cdn/distribution/datasource.go | 2 +- .../cdn/distribution/datasource_test.go | 7 ++++++- .../services/cdn/distribution/resource.go | 10 ++++++---- .../services/cdn/distribution/resource_test.go | 18 +++++++++++++++--- .../cdn/testdata/resource-http-base.tf | 16 ++++++++++++++++ 6 files changed, 60 insertions(+), 10 deletions(-) diff --git a/stackit/internal/services/cdn/cdn_acc_test.go b/stackit/internal/services/cdn/cdn_acc_test.go index 42c2387f7..d14bbff21 100644 --- a/stackit/internal/services/cdn/cdn_acc_test.go +++ b/stackit/internal/services/cdn/cdn_acc_test.go @@ -92,12 +92,15 @@ var testConfigVarsHttp = config.Variables{ "origin_request_headers_value": config.StringVariable("x-custom-value"), "certificate": config.StringVariable(string(cert)), "private_key": config.StringVariable(string(key)), + "redirect_target_url": config.StringVariable("https://example.com"), + "redirect_status_code": config.IntegerVariable(301), + "redirect_matcher_value": config.StringVariable("/shop/*"), } func configVarsHttpUpdated() config.Variables { updatedConfig := maps.Clone(testConfigVarsHttp) updatedConfig["regions"] = config.ListVariable(config.StringVariable("EU"), config.StringVariable("US"), config.StringVariable("ASIA")) - + updatedConfig["redirect_target_url"] = config.StringVariable("https://example.com/updated") return updatedConfig } @@ -157,6 +160,11 @@ func TestAccCDNDistributionHttp(t *testing.T) { resource.TestCheckResourceAttrSet("stackit_cdn_distribution.distribution", "domains.0.name"), resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "domains.0.type", "managed"), resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "domains.0.status", "ACTIVE"), + resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.redirects.rules.#", "1"), + resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.redirects.rules.0.target_url", testutil.ConvertConfigVariable(testConfigVarsHttp["redirect_target_url"])), + resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.redirects.rules.0.status_code", testutil.ConvertConfigVariable(testConfigVarsHttp["redirect_status_code"])), + resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.redirects.rules.0.matchers.#", "1"), + resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.redirects.rules.0.matchers.0.values.0", testutil.ConvertConfigVariable(testConfigVarsHttp["redirect_matcher_value"])), resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.regions.#", "2"), resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.regions.0", "EU"), resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.regions.1", "US"), @@ -277,6 +285,9 @@ func TestAccCDNDistributionHttp(t *testing.T) { resource.TestCheckResourceAttr("data.stackit_cdn_distribution.distribution", "config.optimizer.enabled", testutil.ConvertConfigVariable(testConfigVarsHttp["optimizer"])), resource.TestCheckResourceAttr("data.stackit_cdn_distribution.distribution", "project_id", testutil.ProjectId), resource.TestCheckResourceAttr("data.stackit_cdn_distribution.distribution", "status", "ACTIVE"), + resource.TestCheckResourceAttr("data.stackit_cdn_distribution.distribution", "config.redirects.rules.#", "1"), + resource.TestCheckResourceAttr("data.stackit_cdn_distribution.distribution", "config.redirects.rules.0.target_url", testutil.ConvertConfigVariable(testConfigVarsHttp["redirect_target_url"])), + resource.TestCheckResourceAttr("data.stackit_cdn_distribution.distribution", "config.redirects.rules.0.status_code", testutil.ConvertConfigVariable(testConfigVarsHttp["redirect_status_code"])), resource.TestCheckResourceAttr("data.stackit_cdn_custom_domain.custom_domain", "status", "ACTIVE"), resource.TestCheckResourceAttr("data.stackit_cdn_custom_domain.custom_domain", "name", fullDomainNameHttp), @@ -319,6 +330,10 @@ func TestAccCDNDistributionHttp(t *testing.T) { "DE", ), + resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.redirects.rules.#", "1"), + resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.redirects.rules.0.target_url", "https://example.com/updated"), + resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.redirects.rules.0.status_code", testutil.ConvertConfigVariable(testConfigVarsHttp["redirect_status_code"])), + resource.TestCheckResourceAttr("stackit_cdn_custom_domain.custom_domain", "status", "ACTIVE"), resource.TestCheckResourceAttr("stackit_cdn_custom_domain.custom_domain", "name", fullDomainNameHttp), resource.TestCheckResourceAttr("stackit_cdn_custom_domain.custom_domain", "certificate.version", "1"), diff --git a/stackit/internal/services/cdn/distribution/datasource.go b/stackit/internal/services/cdn/distribution/datasource.go index 8ba61bc81..1eb289ff9 100644 --- a/stackit/internal/services/cdn/distribution/datasource.go +++ b/stackit/internal/services/cdn/distribution/datasource.go @@ -411,7 +411,7 @@ func mapDataSourceFields(ctx context.Context, distribution *cdn.Distribution, mo tfStatusCode := types.Int32Null() if r.StatusCode != nil { - tfStatusCode = types.Int32Value(int32(*r.StatusCode)) + tfStatusCode = types.Int32Value(int32(*r.StatusCode)) // nolint:gosec // HTTP status codes are safely within int32 bounds } tfRuleMatchCond := types.StringNull() diff --git a/stackit/internal/services/cdn/distribution/datasource_test.go b/stackit/internal/services/cdn/distribution/datasource_test.go index 0c2c0fe76..e675a86e4 100644 --- a/stackit/internal/services/cdn/distribution/datasource_test.go +++ b/stackit/internal/services/cdn/distribution/datasource_test.go @@ -40,7 +40,12 @@ func TestMapDataSourceFields(t *testing.T) { optimizer := types.ObjectValueMust(optimizerTypes, map[string]attr.Value{ "enabled": types.BoolValue(true), }) - redirectsAttrTypes := configTypes["redirects"].(basetypes.ObjectType).AttrTypes + // Safely assert the type + redirectsObjType, ok := configTypes["redirects"].(basetypes.ObjectType) + if !ok { + t.Fatalf("configTypes[\"redirects\"] is not of type basetypes.ObjectType") + } + redirectsAttrTypes := redirectsObjType.AttrTypes config := types.ObjectValueMust(dataSourceConfigTypes, map[string]attr.Value{ "backend": backend, "regions": regionsFixture, diff --git a/stackit/internal/services/cdn/distribution/resource.go b/stackit/internal/services/cdn/distribution/resource.go index 6998b4735..7ac5aca39 100644 --- a/stackit/internal/services/cdn/distribution/resource.go +++ b/stackit/internal/services/cdn/distribution/resource.go @@ -346,6 +346,8 @@ func (r *distributionResource) Schema(_ context.Context, _ resource.SchemaReques "description": schema.StringAttribute{ Description: schemaDescriptions["config_redirects_rule_description"], Optional: true, + Computed: true, + Default: stringdefault.StaticString(""), }, "enabled": schema.BoolAttribute{ Optional: true, @@ -971,7 +973,7 @@ func mapFields(ctx context.Context, distribution *cdn.Distribution, model *Model return core.DiagsToError(diags) } - tfValMatchCond := types.StringNull() + tfValMatchCond := types.StringValue("ANY") if m.ValueMatchCondition != nil { tfValMatchCond = types.StringValue(string(*m.ValueMatchCondition)) } @@ -992,12 +994,12 @@ func mapFields(ctx context.Context, distribution *cdn.Distribution, model *Model return core.DiagsToError(diags) } - tfDesc := types.StringNull() + tfDesc := types.StringValue("") if r.Description != nil { tfDesc = types.StringValue(*r.Description) } - tfEnabled := types.BoolNull() + tfEnabled := types.BoolValue(true) if r.Enabled != nil { tfEnabled = types.BoolValue(*r.Enabled) } @@ -1012,7 +1014,7 @@ func mapFields(ctx context.Context, distribution *cdn.Distribution, model *Model tfStatusCode = types.Int32Value(int32(*r.StatusCode)) } - tfRuleMatchCond := types.StringNull() + tfRuleMatchCond := types.StringValue("ANY") if r.RuleMatchCondition != nil { tfRuleMatchCond = types.StringValue(string(*r.RuleMatchCondition)) } diff --git a/stackit/internal/services/cdn/distribution/resource_test.go b/stackit/internal/services/cdn/distribution/resource_test.go index 143f863e5..1bae05a7c 100644 --- a/stackit/internal/services/cdn/distribution/resource_test.go +++ b/stackit/internal/services/cdn/distribution/resource_test.go @@ -42,7 +42,11 @@ func TestToCreatePayload(t *testing.T) { "enabled": types.BoolValue(true), }) - redirectsAttrTypes := configTypes["redirects"].(basetypes.ObjectType).AttrTypes + redirectsObjType, ok := configTypes["redirects"].(basetypes.ObjectType) + if !ok { + t.Fatalf("configTypes[\"redirects\"] is not of type basetypes.ObjectType") + } + redirectsAttrTypes := redirectsObjType.AttrTypes config := types.ObjectValueMust(configTypes, map[string]attr.Value{ "backend": backend, @@ -276,7 +280,11 @@ func TestConvertConfig(t *testing.T) { blockedCountriesFixture := types.ListValueMust(types.StringType, blockedCountries) optimizer := types.ObjectValueMust(optimizerTypes, map[string]attr.Value{"enabled": types.BoolValue(true)}) - redirectsAttrTypes := configTypes["redirects"].(basetypes.ObjectType).AttrTypes + redirectsObjType, ok := configTypes["redirects"].(basetypes.ObjectType) + if !ok { + t.Fatalf("configTypes[\"redirects\"] is not of type basetypes.ObjectType") + } + redirectsAttrTypes := redirectsObjType.AttrTypes config := types.ObjectValueMust(configTypes, map[string]attr.Value{ "backend": backend, @@ -524,7 +532,11 @@ func TestMapFields(t *testing.T) { "enabled": types.BoolValue(true), }) - redirectsAttrTypes := configTypes["redirects"].(basetypes.ObjectType).AttrTypes + redirectsObjType, ok := configTypes["redirects"].(basetypes.ObjectType) + if !ok { + t.Fatalf("configTypes[\"redirects\"] is not of type basetypes.ObjectType") + } + redirectsAttrTypes := redirectsObjType.AttrTypes config := types.ObjectValueMust(configTypes, map[string]attr.Value{ "backend": backend, diff --git a/stackit/internal/services/cdn/testdata/resource-http-base.tf b/stackit/internal/services/cdn/testdata/resource-http-base.tf index 3275fd0a5..026d1b990 100644 --- a/stackit/internal/services/cdn/testdata/resource-http-base.tf +++ b/stackit/internal/services/cdn/testdata/resource-http-base.tf @@ -9,6 +9,9 @@ variable "origin_request_headers_name" {} variable "origin_request_headers_value" {} variable "certificate" {} variable "private_key" {} +variable "redirect_target_url" {} +variable "redirect_status_code" {} +variable "redirect_matcher_value" {} # dns variable "dns_zone_name" {} @@ -39,6 +42,19 @@ resource "stackit_cdn_distribution" "distribution" { optimizer = { enabled = var.optimizer } + redirects = { + rules = [ + { + target_url = var.redirect_target_url + status_code = var.redirect_status_code + matchers = [ + { + values = [var.redirect_matcher_value] + } + ] + } + ] + } backend = { type = var.backend_http_type origin_url = var.backend_origin_url From 618c3a2922756d46178d8b5922c799637f1dbc8f Mon Sep 17 00:00:00 2001 From: Matheus Politano Date: Tue, 31 Mar 2026 19:03:00 +0200 Subject: [PATCH 09/20] chore: address linter issue --- stackit/internal/services/cdn/distribution/resource.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stackit/internal/services/cdn/distribution/resource.go b/stackit/internal/services/cdn/distribution/resource.go index 7ac5aca39..64b101aa4 100644 --- a/stackit/internal/services/cdn/distribution/resource.go +++ b/stackit/internal/services/cdn/distribution/resource.go @@ -1011,7 +1011,7 @@ func mapFields(ctx context.Context, distribution *cdn.Distribution, model *Model tfStatusCode := types.Int32Null() if r.StatusCode != nil { - tfStatusCode = types.Int32Value(int32(*r.StatusCode)) + tfStatusCode = types.Int32Value(int32(*r.StatusCode)) // nolint:gosec // HTTP status codes are safely within int32 bounds } tfRuleMatchCond := types.StringValue("ANY") From 9a22cefaf46af67df009081a37c816efc726e147 Mon Sep 17 00:00:00 2001 From: Matheus Politano Date: Tue, 31 Mar 2026 19:05:09 +0200 Subject: [PATCH 10/20] chore: add doc --- docs/resources/cdn_distribution.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/resources/cdn_distribution.md b/docs/resources/cdn_distribution.md index 7a424a129..f60345443 100644 --- a/docs/resources/cdn_distribution.md +++ b/docs/resources/cdn_distribution.md @@ -56,6 +56,24 @@ resource "stackit_cdn_distribution" "example_bucket_distribution" { optimizer = { enabled = false } + + redirects = { + rules = [ + { + description = "test redirect" + enabled = true + rule_match_condition = "ANY" + status_code = 302 + target_url = "https://stackit.de/" + matchers = [ + { + values = ["*/otherPath/"] + value_match_condition = "ANY" + } + ] + } + ] + } } } From cf1296789cfb3eb85f19e081f6b8019403e71dcc Mon Sep 17 00:00:00 2001 From: Matheus Politano Date: Wed, 1 Apr 2026 20:47:33 +0200 Subject: [PATCH 11/20] chore: adjust for the new sdk version --- .../services/cdn/distribution/datasource.go | 16 ++--- .../services/cdn/distribution/resource.go | 65 +++++++++---------- 2 files changed, 37 insertions(+), 44 deletions(-) diff --git a/stackit/internal/services/cdn/distribution/datasource.go b/stackit/internal/services/cdn/distribution/datasource.go index da4eefa5e..ec1bba135 100644 --- a/stackit/internal/services/cdn/distribution/datasource.go +++ b/stackit/internal/services/cdn/distribution/datasource.go @@ -356,15 +356,15 @@ func mapDataSourceFields(ctx context.Context, distribution *cdnSdk.Distribution, // redirects redirectsVal := types.ObjectNull(redirectsTypes) - if distribution.Config != nil && distribution.Config.Redirects != nil && distribution.Config.Redirects.Rules != nil { + if distribution.Config.Redirects != nil && distribution.Config.Redirects.Rules != nil { var tfRules []attr.Value - for _, r := range *distribution.Config.Redirects.Rules { + for _, r := range distribution.Config.Redirects.Rules { var tfMatchers []attr.Value if r.Matchers != nil { - for _, m := range *r.Matchers { + for _, m := range r.Matchers { var tfValues []attr.Value if m.Values != nil { - for _, v := range *m.Values { + for _, v := range m.Values { tfValues = append(tfValues, types.StringValue(v)) } } @@ -405,13 +405,13 @@ func mapDataSourceFields(ctx context.Context, distribution *cdnSdk.Distribution, } tfTargetUrl := types.StringNull() - if r.TargetUrl != nil { - tfTargetUrl = types.StringValue(*r.TargetUrl) + if r.TargetUrl != "" { + tfTargetUrl = types.StringValue(r.TargetUrl) } tfStatusCode := types.Int32Null() - if r.StatusCode != nil { - tfStatusCode = types.Int32Value(int32(*r.StatusCode)) // nolint:gosec // HTTP status codes are safely within int32 bounds + if r.StatusCode != 0 { + tfStatusCode = types.Int32Value(int32(r.StatusCode)) // nolint:gosec // HTTP status codes are safely within int32 bounds } tfRuleMatchCond := types.StringNull() diff --git a/stackit/internal/services/cdn/distribution/resource.go b/stackit/internal/services/cdn/distribution/resource.go index 71e87bf12..09982cb34 100644 --- a/stackit/internal/services/cdn/distribution/resource.go +++ b/stackit/internal/services/cdn/distribution/resource.go @@ -30,7 +30,6 @@ import ( "github.com/hashicorp/terraform-plugin-framework/types/basetypes" "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/stackitcloud/stackit-sdk-go/core/oapierror" - "github.com/stackitcloud/stackit-sdk-go/services/cdn" cdnSdk "github.com/stackitcloud/stackit-sdk-go/services/cdn/v1api" "github.com/stackitcloud/stackit-sdk-go/services/cdn/v1api/wait" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion" @@ -684,7 +683,7 @@ func (r *distributionResource) Update(ctx context.Context, req resource.UpdateRe // blockedCountries // Use a pointer to a slice to distinguish between an empty list (unblock all) and nil (no change). - var blockedCountries *[]string + var blockedCountries []string if configModel.BlockedCountries != nil { // Use a temporary slice tempBlockedCountries := []string{} @@ -699,7 +698,7 @@ func (r *distributionResource) Update(ctx context.Context, req resource.UpdateRe } // Point to the populated slice - blockedCountries = &tempBlockedCountries + blockedCountries = tempBlockedCountries } // redirects @@ -717,7 +716,7 @@ func (r *distributionResource) Update(ctx context.Context, req resource.UpdateRe } matchers = append(matchers, cdnSdk.Matcher{ - Values: &matcher.Values, + Values: matcher.Values, ValueMatchCondition: matchCond, }) } @@ -727,23 +726,21 @@ func (r *distributionResource) Update(ctx context.Context, req resource.UpdateRe cond := cdnSdk.MatchCondition(*rule.RuleMatchCondition) ruleMatchCond = &cond } - - statusCode := cdnSdk.RedirectRuleStatusCode(rule.StatusCode) targetUrl := rule.TargetUrl sdkConfigRule := cdnSdk.RedirectRule{ Description: rule.Description, Enabled: rule.Enabled, - Matchers: &matchers, + Matchers: matchers, RuleMatchCondition: ruleMatchCond, - StatusCode: &statusCode, - TargetUrl: &targetUrl, + StatusCode: rule.StatusCode, + TargetUrl: targetUrl, } sdkRules = append(sdkRules, sdkConfigRule) } } redirectsConfig = &cdnSdk.RedirectConfig{ - Rules: &sdkRules, + Rules: sdkRules, } } @@ -790,7 +787,7 @@ func (r *distributionResource) Update(ctx context.Context, req resource.UpdateRe configPatch := &cdnSdk.ConfigPatch{ Backend: configPatchBackend, - Regions: ®ions, + Regions: regions, BlockedCountries: blockedCountries, Redirects: redirectsConfig, } @@ -949,15 +946,15 @@ func mapFields(ctx context.Context, distribution *cdnSdk.Distribution, model *Mo // redirects redirectsVal := types.ObjectNull(redirectsTypes) - if distribution.Config != nil && distribution.Config.Redirects != nil && distribution.Config.Redirects.Rules != nil { + if distribution.Config.Redirects != nil && distribution.Config.Redirects.Rules != nil { var tfRules []attr.Value - for _, r := range *distribution.Config.Redirects.Rules { + for _, r := range distribution.Config.Redirects.Rules { var tfMatchers []attr.Value if r.Matchers != nil { - for _, m := range *r.Matchers { + for _, m := range r.Matchers { var tfValues []attr.Value if m.Values != nil { - for _, v := range *m.Values { + for _, v := range m.Values { tfValues = append(tfValues, types.StringValue(v)) } } @@ -998,13 +995,13 @@ func mapFields(ctx context.Context, distribution *cdnSdk.Distribution, model *Mo } tfTargetUrl := types.StringNull() - if r.TargetUrl != nil { - tfTargetUrl = types.StringValue(*r.TargetUrl) + if r.TargetUrl != "" { + tfTargetUrl = types.StringValue(r.TargetUrl) } tfStatusCode := types.Int32Null() - if r.StatusCode != nil { - tfStatusCode = types.Int32Value(int32(*r.StatusCode)) // nolint:gosec // HTTP status codes are safely within int32 bounds + if r.StatusCode > 0 { + tfStatusCode = types.Int32Value(int32(r.StatusCode)) // nolint:gosec // HTTP status codes are safely within int32 bounds } tfRuleMatchCond := types.StringValue("ANY") @@ -1275,9 +1272,8 @@ func toCreatePayload(ctx context.Context, model *Model) (*cdnSdk.CreateDistribut }, } } - intendId := uuid.NewString() payload := &cdnSdk.CreateDistributionPayload{ - IntentId: new(intendId), + IntentId: new(uuid.NewString()), Regions: cfg.Regions, Backend: *backend, BlockedCountries: cfg.BlockedCountries, @@ -1335,42 +1331,39 @@ func convertConfig(ctx context.Context, model *Model) (*cdnSdk.Config, error) { if len(configModel.Redirects.Rules) > 0 { for _, rule := range configModel.Redirects.Rules { - matchers := []cdn.Matcher{} + matchers := []cdnSdk.Matcher{} for _, matcher := range rule.Matchers { - var matchCond *cdn.MatchCondition + var matchCond *cdnSdk.MatchCondition if matcher.ValueMatchCondition != nil { - cond := cdn.MatchCondition(*matcher.ValueMatchCondition) + cond := cdnSdk.MatchCondition(*matcher.ValueMatchCondition) matchCond = &cond } - matchers = append(matchers, cdn.Matcher{ - Values: &matcher.Values, + matchers = append(matchers, cdnSdk.Matcher{ + Values: matcher.Values, ValueMatchCondition: matchCond, }) } - var ruleMatchCond *cdn.MatchCondition + var ruleMatchCond *cdnSdk.MatchCondition if rule.RuleMatchCondition != nil { - cond := cdn.MatchCondition(*rule.RuleMatchCondition) + cond := cdnSdk.MatchCondition(*rule.RuleMatchCondition) ruleMatchCond = &cond } - statusCode := cdn.RedirectRuleStatusCode(rule.StatusCode) - targerUrl := rule.TargetUrl - - sdkConfigRule := cdn.RedirectRule{ + sdkConfigRule := cdnSdk.RedirectRule{ Description: rule.Description, Enabled: rule.Enabled, - Matchers: &matchers, + Matchers: matchers, RuleMatchCondition: ruleMatchCond, - StatusCode: &statusCode, - TargetUrl: &targerUrl, + StatusCode: rule.StatusCode, + TargetUrl: rule.TargetUrl, } sdkRules = append(sdkRules, sdkConfigRule) } } redirectsConfig = &cdnSdk.RedirectConfig{ - Rules: &sdkRules, + Rules: sdkRules, } } From 45905c6e208a19a4b6873cde0d37d6fe5a005f06 Mon Sep 17 00:00:00 2001 From: Matheus Politano Date: Wed, 1 Apr 2026 20:58:11 +0200 Subject: [PATCH 12/20] chore: implement new sdk in cdn_acc_test --- stackit/internal/services/cdn/cdn_acc_test.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/stackit/internal/services/cdn/cdn_acc_test.go b/stackit/internal/services/cdn/cdn_acc_test.go index 26eaf701b..293ef0962 100644 --- a/stackit/internal/services/cdn/cdn_acc_test.go +++ b/stackit/internal/services/cdn/cdn_acc_test.go @@ -21,8 +21,8 @@ import ( "github.com/hashicorp/terraform-plugin-testing/helper/acctest" "github.com/hashicorp/terraform-plugin-testing/helper/resource" "github.com/hashicorp/terraform-plugin-testing/terraform" - "github.com/stackitcloud/stackit-sdk-go/services/cdn" - "github.com/stackitcloud/stackit-sdk-go/services/cdn/wait" + cdnSdk "github.com/stackitcloud/stackit-sdk-go/services/cdn/v1api" + "github.com/stackitcloud/stackit-sdk-go/services/cdn/v1api/wait" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/testutil" ) @@ -478,7 +478,7 @@ func TestAccCDNDistributionBucket(t *testing.T) { func testAccCheckCDNDistributionDestroy(s *terraform.State) error { ctx := context.Background() - client, err := cdn.NewAPIClient(testutil.NewConfigBuilder().BuildClientOptions(testutil.CdnCustomEndpoint, false)...) + client, err := cdnSdk.NewAPIClient(testutil.NewConfigBuilder().BuildClientOptions(testutil.CdnCustomEndpoint, false)...) if err != nil { return fmt.Errorf("creating client: %w", err) } @@ -493,11 +493,11 @@ func testAccCheckCDNDistributionDestroy(s *terraform.State) error { } for _, dist := range distributionsToDestroy { - _, err := client.DeleteDistribution(ctx, testutil.ProjectId, dist).Execute() + _, err := client.DefaultAPI.DeleteDistribution(ctx, testutil.ProjectId, dist).Execute() if err != nil { return fmt.Errorf("destroying CDN distribution %s during CheckDestroy: %w", dist, err) } - _, err = wait.DeleteDistributionWaitHandler(ctx, client, testutil.ProjectId, dist).WaitWithContext(ctx) + _, err = wait.DeleteDistributionWaitHandler(ctx, client.DefaultAPI, testutil.ProjectId, dist).WaitWithContext(ctx) if err != nil { return fmt.Errorf("destroying CDN distribution %s during CheckDestroy: waiting for deletion %w", dist, err) } From 23d6bd923710e71c21e7de0c23e04ee01a21b78b Mon Sep 17 00:00:00 2001 From: Matheus Politano Date: Sun, 12 Apr 2026 19:12:12 +0200 Subject: [PATCH 13/20] feat: add waf in resource --- .../services/cdn/distribution/resource.go | 304 ++++++++++++++++++ .../cdn/distribution/resource_test.go | 246 +++++++++++++- 2 files changed, 549 insertions(+), 1 deletion(-) diff --git a/stackit/internal/services/cdn/distribution/resource.go b/stackit/internal/services/cdn/distribution/resource.go index 09982cb34..2f90e8c88 100644 --- a/stackit/internal/services/cdn/distribution/resource.go +++ b/stackit/internal/services/cdn/distribution/resource.go @@ -6,6 +6,7 @@ import ( "fmt" "maps" "net/http" + "sort" "strings" "time" @@ -83,6 +84,22 @@ var schemaDescriptions = map[string]string{ "config_backend_credentials_access_key_id": "The access key for the bucket. Required if type is 'bucket'.", "config_backend_credentials_secret_access_key": "The secret key for the bucket. Required if type is 'bucket'.", "config_backend_credentials": "The credentials for the bucket. Required if type is 'bucket'.", + "config_waf": "Configuration of the WAF of a distribution.", + "waf_mode": "The WAF mode. ENABLED actively blocks, LOG_ONLY logs matches but never blocks, DISABLED completely turns off inspection.", + "waf_type": "Enable or disable the Premium WAF. FREE or PREMIUM.", + "waf_paranoia_level": "Defines how aggressively the WAF should action on requests (L1 to L4).", + "waf_allowed_http_versions": "Restricts which HTTP protocol versions are accepted.", + "waf_allowed_request_content_types": "Restricts which Content-Type headers are accepted in request bodies.", + "waf_allowed_http_methods": "Restricts which HTTP methods the distribution accepts.", + "waf_enabled_rule_ids": "Ids of the WAF rules explicitly enabled.", + "waf_disabled_rule_ids": "Ids of WAF Rules explicitly disabled.", + "waf_log_only_rule_ids": "Ids of WAF Rules explicitly marked as Log Only.", + "waf_enabled_rule_group_ids": "Ids of WAF Rule Groups explicitly enabled.", + "waf_disabled_rule_group_ids": "Ids of WAF Rule Groups explicitly disabled.", + "waf_log_only_rule_group_ids": "Ids of WAF Rule Groups explicitly marked as log Only.", + "waf_enabled_rule_collection_ids": "Ids of WAF Collections explicitly enabled.", + "waf_disabled_rule_collection_ids": "Ids of WAF Collections explicitly disabled.", + "waf_log_only_rule_collection_ids": "Ids of WAF Collections explicitly marked as log Only.", } type Model struct { @@ -121,6 +138,7 @@ type distributionConfig struct { Regions *[]string `tfsdk:"regions"` // The regions in which data will be cached BlockedCountries *[]string `tfsdk:"blocked_countries"` // The countries for which content will be blocked Optimizer types.Object `tfsdk:"optimizer"` // The optimizer configuration + Waf types.Object `tfsdk:"waf"` // The WAF configuration } type optimizerConfig struct { @@ -137,6 +155,24 @@ type backend struct { Credentials *backendCredentials `tfsdk:"credentials"` } +type wafConfig struct { + Mode types.String `tfsdk:"mode"` + Type types.String `tfsdk:"type"` + ParanoiaLevel types.String `tfsdk:"paranoia_level"` + AllowedHttpVersions types.List `tfsdk:"allowed_http_versions"` + AllowedRequestContentTypes types.List `tfsdk:"allowed_request_content_types"` + AllowedHttpMethods types.List `tfsdk:"allowed_http_methods"` + EnabledRuleIds types.List `tfsdk:"enabled_rule_ids"` + DisabledRuleIds types.List `tfsdk:"disabled_rule_ids"` + LogOnlyRuleIds types.List `tfsdk:"log_only_rule_ids"` + EnabledRuleGroupIds types.List `tfsdk:"enabled_rule_group_ids"` + DisabledRuleGroupIds types.List `tfsdk:"disabled_rule_group_ids"` + LogOnlyRuleGroupIds types.List `tfsdk:"log_only_rule_group_ids"` + EnabledRuleCollectionIds types.List `tfsdk:"enabled_rule_collection_ids"` + DisabledRuleCollectionIds types.List `tfsdk:"disabled_rule_collection_ids"` + LogOnlyRuleCollectionIds types.List `tfsdk:"log_only_rule_collection_ids"` +} + type backendCredentials struct { AccessKey *string `tfsdk:"access_key_id"` SecretKey *string `tfsdk:"secret_access_key"` @@ -152,6 +188,9 @@ var configTypes = map[string]attr.Type{ "redirects": types.ObjectType{ AttrTypes: redirectsTypes, }, + "waf": types.ObjectType{ + AttrTypes: wafTypes, + }, } var optimizerTypes = map[string]attr.Type{ @@ -188,6 +227,24 @@ var redirectsTypes = map[string]attr.Type{ }, } +var wafTypes = map[string]attr.Type{ + "mode": types.StringType, + "type": types.StringType, + "paranoia_level": types.StringType, + "allowed_http_versions": types.ListType{ElemType: types.StringType}, + "allowed_request_content_types": types.ListType{ElemType: types.StringType}, + "allowed_http_methods": types.ListType{ElemType: types.StringType}, + "enabled_rule_ids": types.ListType{ElemType: types.StringType}, + "disabled_rule_ids": types.ListType{ElemType: types.StringType}, + "log_only_rule_ids": types.ListType{ElemType: types.StringType}, + "enabled_rule_group_ids": types.ListType{ElemType: types.StringType}, + "disabled_rule_group_ids": types.ListType{ElemType: types.StringType}, + "log_only_rule_group_ids": types.ListType{ElemType: types.StringType}, + "enabled_rule_collection_ids": types.ListType{ElemType: types.StringType}, + "disabled_rule_collection_ids": types.ListType{ElemType: types.StringType}, + "log_only_rule_collection_ids": types.ListType{ElemType: types.StringType}, +} + var backendTypes = map[string]attr.Type{ "type": types.StringType, "origin_url": types.StringType, @@ -401,6 +458,114 @@ func (r *distributionResource) Schema(_ context.Context, _ resource.SchemaReques }, }, }, + "waf": schema.SingleNestedAttribute{ + Description: schemaDescriptions["config_waf"], + Optional: true, + Computed: true, + Attributes: map[string]schema.Attribute{ + "mode": schema.StringAttribute{ + Optional: true, + Computed: true, + Description: schemaDescriptions["waf_mode"], + Default: stringdefault.StaticString("DISABLED"), + }, + "type": schema.StringAttribute{ + Optional: true, + Computed: true, + Description: schemaDescriptions["waf_type"], + Default: stringdefault.StaticString("FREE"), + }, + "paranoia_level": schema.StringAttribute{ + Optional: true, + Computed: true, + Description: schemaDescriptions["waf_paranoia_level"], + }, + "allowed_http_versions": schema.ListAttribute{ + Optional: true, + Computed: true, + ElementType: types.StringType, + Description: schemaDescriptions["waf_allowed_http_versions"], + Default: listdefault.StaticValue(types.ListValueMust(types.StringType, []attr.Value{})), + }, + "allowed_request_content_types": schema.ListAttribute{ + Optional: true, + Computed: true, + ElementType: types.StringType, + Description: schemaDescriptions["waf_allowed_request_content_types"], + Default: listdefault.StaticValue(types.ListValueMust(types.StringType, []attr.Value{})), + }, + "allowed_http_methods": schema.ListAttribute{ + Optional: true, + Computed: true, + ElementType: types.StringType, + Description: schemaDescriptions["waf_allowed_http_methods"], + Default: listdefault.StaticValue(types.ListValueMust(types.StringType, []attr.Value{})), + }, + "enabled_rule_ids": schema.ListAttribute{ + Optional: true, + Computed: true, + ElementType: types.StringType, + Description: schemaDescriptions["waf_enabled_rule_ids"], + Default: listdefault.StaticValue(types.ListValueMust(types.StringType, []attr.Value{})), + }, + "disabled_rule_ids": schema.ListAttribute{ + Optional: true, + Computed: true, + ElementType: types.StringType, + Description: schemaDescriptions["waf_disabled_rule_ids"], + Default: listdefault.StaticValue(types.ListValueMust(types.StringType, []attr.Value{})), + }, + "log_only_rule_ids": schema.ListAttribute{ + Optional: true, + Computed: true, + ElementType: types.StringType, + Description: schemaDescriptions["waf_log_only_rule_ids"], + Default: listdefault.StaticValue(types.ListValueMust(types.StringType, []attr.Value{})), + }, + "enabled_rule_group_ids": schema.ListAttribute{ + Optional: true, + Computed: true, + ElementType: types.StringType, + Description: schemaDescriptions["waf_enabled_rule_group_ids"], + Default: listdefault.StaticValue(types.ListValueMust(types.StringType, []attr.Value{})), + }, + "disabled_rule_group_ids": schema.ListAttribute{ + Optional: true, + Computed: true, + ElementType: types.StringType, + Description: schemaDescriptions["waf_disabled_rule_group_ids"], + Default: listdefault.StaticValue(types.ListValueMust(types.StringType, []attr.Value{})), + }, + "log_only_rule_group_ids": schema.ListAttribute{ + Optional: true, + Computed: true, + ElementType: types.StringType, + Description: schemaDescriptions["waf_log_only_rule_group_ids"], + Default: listdefault.StaticValue(types.ListValueMust(types.StringType, []attr.Value{})), + }, + "enabled_rule_collection_ids": schema.ListAttribute{ + Optional: true, + Computed: true, + ElementType: types.StringType, + Description: schemaDescriptions["waf_enabled_rule_collection_ids"], + Default: listdefault.StaticValue(types.ListValueMust(types.StringType, []attr.Value{})), + }, + "disabled_rule_collection_ids": schema.ListAttribute{ + Optional: true, + Computed: true, + ElementType: types.StringType, + Description: schemaDescriptions["waf_disabled_rule_collection_ids"], + Default: listdefault.StaticValue(types.ListValueMust(types.StringType, []attr.Value{})), + }, + "log_only_rule_collection_ids": schema.ListAttribute{ + Optional: true, + Computed: true, + ElementType: types.StringType, + Description: schemaDescriptions["waf_log_only_rule_collection_ids"], + Default: listdefault.StaticValue(types.ListValueMust(types.StringType, []attr.Value{})), + }, + }, + }, "backend": schema.SingleNestedAttribute{ Required: true, Description: schemaDescriptions["config_backend"], @@ -792,6 +957,39 @@ func (r *distributionResource) Update(ctx context.Context, req resource.UpdateRe Redirects: redirectsConfig, } + if !utils.IsUndefined(configModel.Waf) { + var wafModel wafConfig + diags := configModel.Waf.As(ctx, &wafModel, basetypes.ObjectAsOptions{}) + if diags.HasError() { + core.LogAndAddError(ctx, &resp.Diagnostics, "Update CDN distribution", "Error mapping WAF config") + return + } + + wafPatch := cdnSdk.WafConfigPatch{ + Mode: new(cdnSdk.WafMode(wafModel.Mode.ValueString())), + Type: new(cdnSdk.WafType(wafModel.Type.ValueString())), + AllowedHttpVersions: getSortedWafList(ctx, wafModel.AllowedHttpVersions), + AllowedRequestContentTypes: getSortedWafList(ctx, wafModel.AllowedRequestContentTypes), + AllowedHttpMethods: getSortedWafList(ctx, wafModel.AllowedHttpMethods), + EnabledRuleIds: getSortedWafList(ctx, wafModel.EnabledRuleIds), + DisabledRuleIds: getSortedWafList(ctx, wafModel.DisabledRuleIds), + LogOnlyRuleIds: getSortedWafList(ctx, wafModel.LogOnlyRuleIds), + EnabledRuleGroupIds: getSortedWafList(ctx, wafModel.EnabledRuleGroupIds), + DisabledRuleGroupIds: getSortedWafList(ctx, wafModel.DisabledRuleGroupIds), + LogOnlyRuleGroupIds: getSortedWafList(ctx, wafModel.LogOnlyRuleGroupIds), + EnabledRuleCollectionIds: getSortedWafList(ctx, wafModel.EnabledRuleCollectionIds), + DisabledRuleCollectionIds: getSortedWafList(ctx, wafModel.DisabledRuleCollectionIds), + LogOnlyRuleCollectionIds: getSortedWafList(ctx, wafModel.LogOnlyRuleCollectionIds), + } + + if !utils.IsUndefined(wafModel.ParanoiaLevel) { + pl := cdnSdk.WafParanoiaLevel(wafModel.ParanoiaLevel.ValueString()) + wafPatch.ParanoiaLevel = &pl + } + + configPatch.Waf = &wafPatch + } + if !utils.IsUndefined(configModel.Optimizer) { var optimizerModel optimizerConfig @@ -1156,6 +1354,50 @@ func mapFields(ctx context.Context, distribution *cdnSdk.Distribution, model *Mo return core.DiagsToError(diags) } + // Map Waf + wafVal := types.ObjectNull(wafTypes) + wafObjAttrs := map[string]attr.Value{ + "mode": types.StringValue(string(distribution.Config.Waf.Mode)), + "type": types.StringValue(string(distribution.Config.Waf.Type)), + } + + if distribution.Config.Waf.ParanoiaLevel != nil { + wafObjAttrs["paranoia_level"] = types.StringValue(string(*distribution.Config.Waf.ParanoiaLevel)) + } else { + wafObjAttrs["paranoia_level"] = types.StringNull() + } + + wafObjAttrs["allowed_http_versions"] = mapWafListToHCL(distribution.Config.Waf.AllowedHttpVersions) + wafObjAttrs["allowed_request_content_types"] = mapWafListToHCL(distribution.Config.Waf.AllowedRequestContentTypes) + wafObjAttrs["allowed_http_methods"] = mapWafListToHCL(distribution.Config.Waf.AllowedHttpMethods) + wafObjAttrs["enabled_rule_ids"] = mapWafListToHCL(distribution.Config.Waf.EnabledRuleIds) + wafObjAttrs["disabled_rule_ids"] = mapWafListToHCL(distribution.Config.Waf.DisabledRuleIds) + wafObjAttrs["log_only_rule_ids"] = mapWafListToHCL(distribution.Config.Waf.LogOnlyRuleIds) + wafObjAttrs["enabled_rule_group_ids"] = mapWafListToHCL(distribution.Config.Waf.EnabledRuleGroupIds) + wafObjAttrs["disabled_rule_group_ids"] = mapWafListToHCL(distribution.Config.Waf.DisabledRuleGroupIds) + wafObjAttrs["log_only_rule_group_ids"] = mapWafListToHCL(distribution.Config.Waf.LogOnlyRuleGroupIds) + wafObjAttrs["enabled_rule_collection_ids"] = mapWafListToHCL(distribution.Config.Waf.EnabledRuleCollectionIds) + wafObjAttrs["disabled_rule_collection_ids"] = mapWafListToHCL(distribution.Config.Waf.DisabledRuleCollectionIds) + wafObjAttrs["log_only_rule_collection_ids"] = mapWafListToHCL(distribution.Config.Waf.LogOnlyRuleCollectionIds) + + // Prevent state drift if WAF wasn't in the config at all, but API returned default empty WAF + // By checking if the old config Waf block was null, we can avoid recreating a WAF block + // if the returned one matches the API default (FREE/DISABLED and empty lists). + isEmptyDefault := distribution.Config.Waf.Mode == cdnSdk.WAFMODE_DISABLED && + distribution.Config.Waf.Type == cdnSdk.WAFTYPE_FREE && + len(distribution.Config.Waf.AllowedHttpMethods) == 0 && + len(distribution.Config.Waf.EnabledRuleIds) == 0 + + if isEmptyDefault && oldConfig.Waf.IsNull() { + wafVal = types.ObjectNull(wafTypes) + } else { + var diagWaf diag.Diagnostics + wafVal, diagWaf = types.ObjectValue(wafTypes, wafObjAttrs) + if diagWaf.HasError() { + return core.DiagsToError(diagWaf) + } + } + optimizerVal := types.ObjectNull(optimizerTypes) if o := distribution.Config.Optimizer; o != nil { optimizerEnabled, ok := o.GetEnabledOk() @@ -1175,6 +1417,7 @@ func mapFields(ctx context.Context, distribution *cdnSdk.Distribution, model *Mo "blocked_countries": modelBlockedCountries, "optimizer": optimizerVal, "redirects": redirectsVal, + "waf": wafVal, }) if diags.HasError() { return core.DiagsToError(diags) @@ -1234,6 +1477,7 @@ func toCreatePayload(ctx context.Context, model *Model) (*cdnSdk.CreateDistribut if cfg.Optimizer != nil { optimizer = cdnSdk.NewOptimizer(cfg.Optimizer.GetEnabled()) } + var backend *cdnSdk.CreateDistributionPayloadBackend if cfg.Backend.HttpBackend != nil { backend = &cdnSdk.CreateDistributionPayloadBackend{ @@ -1279,6 +1523,7 @@ func toCreatePayload(ctx context.Context, model *Model) (*cdnSdk.CreateDistribut BlockedCountries: cfg.BlockedCountries, Optimizer: optimizer, Redirects: cfg.Redirects, + Waf: &cfg.Waf, } return payload, nil @@ -1393,6 +1638,36 @@ func convertConfig(ctx context.Context, model *Model) (*cdnSdk.Config, error) { Redirects: redirectsConfig, } + if !utils.IsUndefined(configModel.Waf) { + var wafModel wafConfig + diags := configModel.Waf.As(ctx, &wafModel, basetypes.ObjectAsOptions{}) + if diags.HasError() { + return nil, core.DiagsToError(diags) + } + + cdnConfig.Waf = cdnSdk.WafConfig{ + Mode: cdnSdk.WafMode(wafModel.Mode.ValueString()), + Type: cdnSdk.WafType(wafModel.Type.ValueString()), + AllowedHttpVersions: getSortedWafList(ctx, wafModel.AllowedHttpVersions), + AllowedRequestContentTypes: getSortedWafList(ctx, wafModel.AllowedRequestContentTypes), + AllowedHttpMethods: getSortedWafList(ctx, wafModel.AllowedHttpMethods), + EnabledRuleIds: getSortedWafList(ctx, wafModel.EnabledRuleIds), + DisabledRuleIds: getSortedWafList(ctx, wafModel.DisabledRuleIds), + LogOnlyRuleIds: getSortedWafList(ctx, wafModel.LogOnlyRuleIds), + EnabledRuleGroupIds: getSortedWafList(ctx, wafModel.EnabledRuleGroupIds), + DisabledRuleGroupIds: getSortedWafList(ctx, wafModel.DisabledRuleGroupIds), + LogOnlyRuleGroupIds: getSortedWafList(ctx, wafModel.LogOnlyRuleGroupIds), + EnabledRuleCollectionIds: getSortedWafList(ctx, wafModel.EnabledRuleCollectionIds), + DisabledRuleCollectionIds: getSortedWafList(ctx, wafModel.DisabledRuleCollectionIds), + LogOnlyRuleCollectionIds: getSortedWafList(ctx, wafModel.LogOnlyRuleCollectionIds), + } + + if !utils.IsUndefined(wafModel.ParanoiaLevel) { + pl := cdnSdk.WafParanoiaLevel(wafModel.ParanoiaLevel.ValueString()) + cdnConfig.Waf.ParanoiaLevel = &pl + } + } + switch configModel.Backend.Type { case "http": originRequestHeaders := map[string]string{} @@ -1449,3 +1724,32 @@ func validateCountryCode(country string) (string, error) { return upperCountry, nil } + +// getSortedWafList extracts strings from HCL list, sorts them and returns the slice +func getSortedWafList(ctx context.Context, tfList basetypes.ListValue) []string { + if tfList.IsNull() || tfList.IsUnknown() { + return []string{} + } + var elements []string + diags := tfList.ElementsAs(ctx, &elements, true) + if diags.HasError() { + return []string{} + } + sort.Strings(elements) + return elements +} + +// mapWafListToHCL guarantees the returned HCL List is sorted +func mapWafListToHCL(apiList []string) basetypes.ListValue { + if len(apiList) == 0 { + return types.ListValueMust(types.StringType, []attr.Value{}) + } + sorted := make([]string, len(apiList)) + copy(sorted, apiList) + sort.Strings(sorted) + var elements []attr.Value + for _, val := range sorted { + elements = append(elements, types.StringValue(val)) + } + return types.ListValueMust(types.StringType, elements) +} diff --git a/stackit/internal/services/cdn/distribution/resource_test.go b/stackit/internal/services/cdn/distribution/resource_test.go index b6c64b20b..4649eba04 100644 --- a/stackit/internal/services/cdn/distribution/resource_test.go +++ b/stackit/internal/services/cdn/distribution/resource_test.go @@ -26,6 +26,7 @@ func TestToCreatePayload(t *testing.T) { geofencing := types.MapValueMust(geofencingTypes.ElemType, map[string]attr.Value{ "https://de.mycoolapp.com": geofencingCountries, }) + backend := types.ObjectValueMust(backendTypes, map[string]attr.Value{ "type": types.StringValue("http"), "origin_url": types.StringValue("https://www.mycoolapp.com"), @@ -42,7 +43,40 @@ func TestToCreatePayload(t *testing.T) { optimizer := types.ObjectValueMust(optimizerTypes, map[string]attr.Value{ "enabled": types.BoolValue(true), }) - + emptyWafList := types.ListValueMust(types.StringType, []attr.Value{}) + expectedDefaultWafConfig := cdnSdk.WafConfig{ + Mode: cdnSdk.WafMode("DISABLED"), + Type: cdnSdk.WafType("FREE"), + AllowedHttpVersions: []string{}, + AllowedRequestContentTypes: []string{}, + AllowedHttpMethods: []string{}, + EnabledRuleIds: []string{}, + DisabledRuleIds: []string{}, + LogOnlyRuleIds: []string{}, + EnabledRuleGroupIds: []string{}, + DisabledRuleGroupIds: []string{}, + LogOnlyRuleGroupIds: []string{}, + EnabledRuleCollectionIds: []string{}, + DisabledRuleCollectionIds: []string{}, + LogOnlyRuleCollectionIds: []string{}, + } + defaultWaf := types.ObjectValueMust(wafTypes, map[string]attr.Value{ + "mode": types.StringValue("DISABLED"), + "type": types.StringValue("FREE"), + "paranoia_level": types.StringNull(), + "allowed_http_versions": emptyWafList, + "allowed_request_content_types": emptyWafList, + "allowed_http_methods": emptyWafList, + "enabled_rule_ids": emptyWafList, + "disabled_rule_ids": emptyWafList, + "log_only_rule_ids": emptyWafList, + "enabled_rule_group_ids": emptyWafList, + "disabled_rule_group_ids": emptyWafList, + "log_only_rule_group_ids": emptyWafList, + "enabled_rule_collection_ids": emptyWafList, + "disabled_rule_collection_ids": emptyWafList, + "log_only_rule_collection_ids": emptyWafList, + }) redirectsObjType, ok := configTypes["redirects"].(basetypes.ObjectType) if !ok { t.Fatalf("configTypes[\"redirects\"] is not of type basetypes.ObjectType") @@ -55,6 +89,7 @@ func TestToCreatePayload(t *testing.T) { "blocked_countries": blockedCountriesFixture, "optimizer": types.ObjectNull(optimizerTypes), "redirects": types.ObjectNull(redirectsAttrTypes), + "waf": defaultWaf, }) matcherValues := types.ListValueMust(types.StringType, []attr.Value{ @@ -79,6 +114,46 @@ func TestToCreatePayload(t *testing.T) { redirectsConfigVal := types.ObjectValueMust(redirectsTypes, map[string]attr.Value{ "rules": rulesList, }) + populatedWafList := types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("rule1"), + types.StringValue("rule2"), + }) + populatedWaf := types.ObjectValueMust(wafTypes, map[string]attr.Value{ + "mode": types.StringValue("ENABLED"), + "type": types.StringValue("PREMIUM"), + "paranoia_level": types.StringValue("L2"), + "allowed_http_versions": populatedWafList, + "allowed_request_content_types": populatedWafList, + "allowed_http_methods": populatedWafList, + "enabled_rule_ids": populatedWafList, + "disabled_rule_ids": populatedWafList, + "log_only_rule_ids": populatedWafList, + "enabled_rule_group_ids": populatedWafList, + "disabled_rule_group_ids": populatedWafList, + "log_only_rule_group_ids": populatedWafList, + "enabled_rule_collection_ids": populatedWafList, + "disabled_rule_collection_ids": populatedWafList, + "log_only_rule_collection_ids": populatedWafList, + }) + + expectedParanoiaLevel := cdnSdk.WafParanoiaLevel("L2") + expectedWafConfig := cdnSdk.WafConfig{ + Mode: cdnSdk.WafMode("ENABLED"), + Type: cdnSdk.WafType("PREMIUM"), + ParanoiaLevel: &expectedParanoiaLevel, + AllowedHttpVersions: []string{"rule1", "rule2"}, + AllowedRequestContentTypes: []string{"rule1", "rule2"}, + AllowedHttpMethods: []string{"rule1", "rule2"}, + EnabledRuleIds: []string{"rule1", "rule2"}, + DisabledRuleIds: []string{"rule1", "rule2"}, + LogOnlyRuleIds: []string{"rule1", "rule2"}, + EnabledRuleGroupIds: []string{"rule1", "rule2"}, + DisabledRuleGroupIds: []string{"rule1", "rule2"}, + LogOnlyRuleGroupIds: []string{"rule1", "rule2"}, + EnabledRuleCollectionIds: []string{"rule1", "rule2"}, + DisabledRuleCollectionIds: []string{"rule1", "rule2"}, + LogOnlyRuleCollectionIds: []string{"rule1", "rule2"}, + } modelFixture := func(mods ...func(*Model)) *Model { model := &Model{ @@ -109,6 +184,7 @@ func TestToCreatePayload(t *testing.T) { Type: "http", }, }, + Waf: &expectedDefaultWafConfig, }, IsValid: true, }, @@ -120,12 +196,14 @@ func TestToCreatePayload(t *testing.T) { "optimizer": optimizer, "blocked_countries": blockedCountriesFixture, "redirects": types.ObjectNull(redirectsAttrTypes), + "waf": defaultWaf, }) }), Expected: &cdnSdk.CreateDistributionPayload{ Regions: []cdnSdk.Region{"EU", "US"}, Optimizer: cdnSdk.NewOptimizer(true), BlockedCountries: []string{"XX", "YY", "ZZ"}, + Waf: &expectedDefaultWafConfig, Backend: cdnSdk.CreateDistributionPayloadBackend{ HttpBackendCreate: &cdnSdk.HttpBackendCreate{ Geofencing: &map[string][]string{"https://de.mycoolapp.com": {"DE", "FR"}}, @@ -145,11 +223,13 @@ func TestToCreatePayload(t *testing.T) { "optimizer": types.ObjectNull(optimizerTypes), "blocked_countries": blockedCountriesFixture, "redirects": redirectsConfigVal, + "waf": defaultWaf, }) }), Expected: &cdnSdk.CreateDistributionPayload{ Regions: []cdnSdk.Region{"EU", "US"}, BlockedCountries: []string{"XX", "YY", "ZZ"}, + Waf: &expectedDefaultWafConfig, Backend: cdnSdk.CreateDistributionPayloadBackend{ HttpBackendCreate: &cdnSdk.HttpBackendCreate{ Geofencing: &map[string][]string{"https://de.mycoolapp.com": {"DE", "FR"}}, @@ -199,9 +279,11 @@ func TestToCreatePayload(t *testing.T) { "blocked_countries": blockedCountriesFixture, "optimizer": types.ObjectNull(optimizerTypes), "redirects": types.ObjectNull(redirectsAttrTypes), + "waf": defaultWaf, }) }), Expected: &cdnSdk.CreateDistributionPayload{ + Waf: &expectedDefaultWafConfig, Backend: cdnSdk.CreateDistributionPayloadBackend{ BucketBackendCreate: &cdnSdk.BucketBackendCreate{ Type: "bucket", @@ -218,6 +300,32 @@ func TestToCreatePayload(t *testing.T) { }, IsValid: true, }, + "happy_path_with_waf": { + Input: modelFixture(func(m *Model) { + m.Config = types.ObjectValueMust(configTypes, map[string]attr.Value{ + "backend": backend, + "regions": regionsFixture, + "optimizer": types.ObjectNull(optimizerTypes), + "blocked_countries": blockedCountriesFixture, + "redirects": types.ObjectNull(redirectsAttrTypes), + "waf": populatedWaf, + }) + }), + Expected: &cdnSdk.CreateDistributionPayload{ + Regions: []cdnSdk.Region{"EU", "US"}, + BlockedCountries: []string{"XX", "YY", "ZZ"}, + Waf: &expectedWafConfig, + Backend: cdnSdk.CreateDistributionPayloadBackend{ + HttpBackendCreate: &cdnSdk.HttpBackendCreate{ + Geofencing: &map[string][]string{"https://de.mycoolapp.com": {"DE", "FR"}}, + OriginRequestHeaders: &map[string]string{"testHeader0": "testHeaderValue0", "testHeader1": "testHeaderValue1"}, + OriginUrl: "https://www.mycoolapp.com", + Type: "http", + }, + }, + }, + IsValid: true, + }, "sad_path_model_nil": { Input: nil, Expected: nil, @@ -293,6 +401,7 @@ func TestConvertConfig(t *testing.T) { "optimizer": types.ObjectNull(optimizerTypes), "blocked_countries": blockedCountriesFixture, "redirects": types.ObjectNull(redirectsAttrTypes), + "waf": types.ObjectNull(wafTypes), }) matcherValues := types.ListValueMust(types.StringType, []attr.Value{ @@ -313,7 +422,46 @@ func TestConvertConfig(t *testing.T) { "matchers": matchersList, }) rulesList := types.ListValueMust(types.ObjectType{AttrTypes: redirectRuleTypes}, []attr.Value{ruleVal}) + populatedWafList := types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("rule1"), + types.StringValue("rule2"), + }) + populatedWaf := types.ObjectValueMust(wafTypes, map[string]attr.Value{ + "mode": types.StringValue("ENABLED"), + "type": types.StringValue("PREMIUM"), + "paranoia_level": types.StringValue("L2"), + "allowed_http_versions": populatedWafList, + "allowed_request_content_types": populatedWafList, + "allowed_http_methods": populatedWafList, + "enabled_rule_ids": populatedWafList, + "disabled_rule_ids": populatedWafList, + "log_only_rule_ids": populatedWafList, + "enabled_rule_group_ids": populatedWafList, + "disabled_rule_group_ids": populatedWafList, + "log_only_rule_group_ids": populatedWafList, + "enabled_rule_collection_ids": populatedWafList, + "disabled_rule_collection_ids": populatedWafList, + "log_only_rule_collection_ids": populatedWafList, + }) + expectedParanoiaLevel := cdnSdk.WafParanoiaLevel("L2") + expectedWafConfig := cdnSdk.WafConfig{ + Mode: cdnSdk.WafMode("ENABLED"), + Type: cdnSdk.WafType("PREMIUM"), + ParanoiaLevel: &expectedParanoiaLevel, + AllowedHttpVersions: []string{"rule1", "rule2"}, + AllowedRequestContentTypes: []string{"rule1", "rule2"}, + AllowedHttpMethods: []string{"rule1", "rule2"}, + EnabledRuleIds: []string{"rule1", "rule2"}, + DisabledRuleIds: []string{"rule1", "rule2"}, + LogOnlyRuleIds: []string{"rule1", "rule2"}, + EnabledRuleGroupIds: []string{"rule1", "rule2"}, + DisabledRuleGroupIds: []string{"rule1", "rule2"}, + LogOnlyRuleGroupIds: []string{"rule1", "rule2"}, + EnabledRuleCollectionIds: []string{"rule1", "rule2"}, + DisabledRuleCollectionIds: []string{"rule1", "rule2"}, + LogOnlyRuleCollectionIds: []string{"rule1", "rule2"}, + } redirectsConfigVal := types.ObjectValueMust(redirectsTypes, map[string]attr.Value{ "rules": rulesList, }) @@ -364,6 +512,7 @@ func TestConvertConfig(t *testing.T) { "optimizer": optimizer, "blocked_countries": blockedCountriesFixture, "redirects": types.ObjectNull(redirectsAttrTypes), + "waf": types.ObjectNull(wafTypes), }) }), Expected: &cdnSdk.Config{ @@ -385,6 +534,36 @@ func TestConvertConfig(t *testing.T) { BlockedCountries: []string{"XX", "YY", "ZZ"}, }, IsValid: true, + }, "happy_path_with_waf": { + Input: modelFixture(func(m *Model) { + m.Config = types.ObjectValueMust(configTypes, map[string]attr.Value{ + "backend": backend, + "regions": regionsFixture, + "optimizer": types.ObjectNull(optimizerTypes), + "blocked_countries": blockedCountriesFixture, + "redirects": types.ObjectNull(redirectsAttrTypes), + "waf": populatedWaf, + }) + }), + Expected: &cdnSdk.Config{ + Backend: cdnSdk.ConfigBackend{ + HttpBackend: &cdnSdk.HttpBackend{ + OriginRequestHeaders: map[string]string{ + "testHeader0": "testHeaderValue0", + "testHeader1": "testHeaderValue1", + }, + OriginUrl: "https://www.mycoolapp.com", + Type: "http", + Geofencing: map[string][]string{ + "https://de.mycoolapp.com": {"DE", "FR"}, + }, + }, + }, + Regions: []cdnSdk.Region{"EU", "US"}, + BlockedCountries: []string{"XX", "YY", "ZZ"}, + Waf: expectedWafConfig, + }, + IsValid: true, }, "happy_path_with_redirects": { Input: modelFixture(func(m *Model) { @@ -394,6 +573,7 @@ func TestConvertConfig(t *testing.T) { "optimizer": types.ObjectNull(optimizerTypes), "blocked_countries": blockedCountriesFixture, "redirects": redirectsConfigVal, // Injetando o mock aqui + "waf": types.ObjectNull(wafTypes), }) }), Expected: &cdnSdk.Config{ @@ -453,6 +633,7 @@ func TestConvertConfig(t *testing.T) { "blocked_countries": blockedCountriesFixture, "optimizer": types.ObjectNull(optimizerTypes), "redirects": types.ObjectNull(redirectsAttrTypes), + "waf": types.ObjectNull(wafTypes), }) }), Expected: &cdnSdk.Config{ @@ -545,13 +726,53 @@ func TestMapFields(t *testing.T) { t.Fatalf("configTypes[\"redirects\"] is not of type basetypes.ObjectType") } redirectsAttrTypes := redirectsObjType.AttrTypes + populatedWafList := types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("rule1"), + types.StringValue("rule2"), + }) + populatedWaf := types.ObjectValueMust(wafTypes, map[string]attr.Value{ + "mode": types.StringValue("ENABLED"), + "type": types.StringValue("PREMIUM"), + "paranoia_level": types.StringValue("L2"), + "allowed_http_versions": populatedWafList, + "allowed_request_content_types": populatedWafList, + "allowed_http_methods": populatedWafList, + "enabled_rule_ids": populatedWafList, + "disabled_rule_ids": populatedWafList, + "log_only_rule_ids": populatedWafList, + "enabled_rule_group_ids": populatedWafList, + "disabled_rule_group_ids": populatedWafList, + "log_only_rule_group_ids": populatedWafList, + "enabled_rule_collection_ids": populatedWafList, + "disabled_rule_collection_ids": populatedWafList, + "log_only_rule_collection_ids": populatedWafList, + }) + expectedParanoiaLevel := cdnSdk.WafParanoiaLevel("L2") + expectedWafConfig := cdnSdk.WafConfig{ + Mode: cdnSdk.WafMode("ENABLED"), + Type: cdnSdk.WafType("PREMIUM"), + ParanoiaLevel: &expectedParanoiaLevel, + AllowedHttpVersions: []string{"rule1", "rule2"}, + AllowedRequestContentTypes: []string{"rule1", "rule2"}, + AllowedHttpMethods: []string{"rule1", "rule2"}, + EnabledRuleIds: []string{"rule1", "rule2"}, + DisabledRuleIds: []string{"rule1", "rule2"}, + LogOnlyRuleIds: []string{"rule1", "rule2"}, + EnabledRuleGroupIds: []string{"rule1", "rule2"}, + DisabledRuleGroupIds: []string{"rule1", "rule2"}, + LogOnlyRuleGroupIds: []string{"rule1", "rule2"}, + EnabledRuleCollectionIds: []string{"rule1", "rule2"}, + DisabledRuleCollectionIds: []string{"rule1", "rule2"}, + LogOnlyRuleCollectionIds: []string{"rule1", "rule2"}, + } config := types.ObjectValueMust(configTypes, map[string]attr.Value{ "backend": backend, "regions": regionsFixture, "blocked_countries": blockedCountriesFixture, "optimizer": types.ObjectNull(optimizerTypes), "redirects": types.ObjectNull(redirectsAttrTypes), + "waf": types.ObjectNull(wafTypes), }) redirectsInput := &cdnSdk.RedirectConfig{ @@ -636,6 +857,10 @@ func TestMapFields(t *testing.T) { Regions: []cdnSdk.Region{"EU", "US"}, BlockedCountries: []string{"XX", "YY", "ZZ"}, Optimizer: nil, + Waf: cdnSdk.WafConfig{ + Mode: cdnSdk.WAFMODE_DISABLED, + Type: cdnSdk.WAFTYPE_FREE, + }, }, CreatedAt: createdAt, Domains: []cdnSdk.Domain{ @@ -675,6 +900,7 @@ func TestMapFields(t *testing.T) { "blocked_countries": blockedCountriesFixture, "optimizer": types.ObjectNull(optimizerTypes), "redirects": types.ObjectNull(redirectsAttrTypes), + "waf": types.ObjectNull(wafTypes), }) tests := map[string]struct { Input *cdnSdk.Distribution @@ -695,6 +921,7 @@ func TestMapFields(t *testing.T) { "optimizer": optimizer, "blocked_countries": blockedCountriesFixture, "redirects": types.ObjectNull(redirectsAttrTypes), + "waf": types.ObjectNull(wafTypes), }) }), Input: distributionFixture(func(d *cdnSdk.Distribution) { @@ -721,6 +948,7 @@ func TestMapFields(t *testing.T) { "optimizer": types.ObjectNull(optimizerTypes), "blocked_countries": blockedCountriesFixture, "redirects": types.ObjectNull(redirectsAttrTypes), + "waf": types.ObjectNull(wafTypes), // <-- Change this }) }), Input: distributionFixture(func(d *cdnSdk.Distribution) { @@ -736,6 +964,7 @@ func TestMapFields(t *testing.T) { "optimizer": types.ObjectNull(optimizerTypes), "blocked_countries": blockedCountriesFixture, "redirects": redirectsConfigExpected, + "waf": types.ObjectNull(wafTypes), // <-- Change this }) }), Input: distributionFixture(func(d *cdnSdk.Distribution) { @@ -751,6 +980,21 @@ func TestMapFields(t *testing.T) { d.Status = "ERROR" }), IsValid: true, + }, "happy_path_with_waf": { + Expected: expectedModel(func(m *Model) { + m.Config = types.ObjectValueMust(configTypes, map[string]attr.Value{ + "backend": backend, + "regions": regionsFixture, + "optimizer": types.ObjectNull(optimizerTypes), + "blocked_countries": blockedCountriesFixture, + "redirects": types.ObjectNull(redirectsAttrTypes), + "waf": populatedWaf, + }) + }), + Input: distributionFixture(func(d *cdnSdk.Distribution) { + d.Config.Waf = expectedWafConfig + }), + IsValid: true, }, "happy_path_custom_domain": { Expected: expectedModel(func(m *Model) { From 731fb3ff1778712fb8dbcef4f1d4f2c888a23602 Mon Sep 17 00:00:00 2001 From: Matheus Politano Date: Sun, 12 Apr 2026 19:33:39 +0200 Subject: [PATCH 14/20] feat: add waf in datasource --- .../services/cdn/distribution/datasource.go | 117 ++++++++++++++++++ .../cdn/distribution/datasource_test.go | 66 ++++++++++ 2 files changed, 183 insertions(+) diff --git a/stackit/internal/services/cdn/distribution/datasource.go b/stackit/internal/services/cdn/distribution/datasource.go index ec1bba135..cfe5598b5 100644 --- a/stackit/internal/services/cdn/distribution/datasource.go +++ b/stackit/internal/services/cdn/distribution/datasource.go @@ -41,6 +41,9 @@ var dataSourceConfigTypes = map[string]attr.Type{ "redirects": types.ObjectType{ AttrTypes: redirectsTypes, // Shared from resource.go }, + "waf": types.ObjectType{ + AttrTypes: wafTypes, // Shared from resource.go + }, } type distributionDataSource struct { @@ -253,6 +256,84 @@ func (r *distributionDataSource) Schema(_ context.Context, _ datasource.SchemaRe }, }, }, + "waf": schema.SingleNestedAttribute{ + Description: schemaDescriptions["config_waf"], + Computed: true, + Attributes: map[string]schema.Attribute{ + "mode": schema.StringAttribute{ + Computed: true, + Description: schemaDescriptions["waf_mode"], + }, + "type": schema.StringAttribute{ + Computed: true, + Description: schemaDescriptions["waf_type"], + }, + "paranoia_level": schema.StringAttribute{ + Computed: true, + Description: schemaDescriptions["waf_paranoia_level"], + }, + "allowed_http_versions": schema.ListAttribute{ + Computed: true, + ElementType: types.StringType, + Description: schemaDescriptions["waf_allowed_http_versions"], + }, + "allowed_request_content_types": schema.ListAttribute{ + Computed: true, + ElementType: types.StringType, + Description: schemaDescriptions["waf_allowed_request_content_types"], + }, + "allowed_http_methods": schema.ListAttribute{ + Computed: true, + ElementType: types.StringType, + Description: schemaDescriptions["waf_allowed_http_methods"], + }, + "enabled_rule_ids": schema.ListAttribute{ + Computed: true, + ElementType: types.StringType, + Description: schemaDescriptions["waf_enabled_rule_ids"], + }, + "disabled_rule_ids": schema.ListAttribute{ + Computed: true, + ElementType: types.StringType, + Description: schemaDescriptions["waf_disabled_rule_ids"], + }, + "log_only_rule_ids": schema.ListAttribute{ + Computed: true, + ElementType: types.StringType, + Description: schemaDescriptions["waf_log_only_rule_ids"], + }, + "enabled_rule_group_ids": schema.ListAttribute{ + Computed: true, + ElementType: types.StringType, + Description: schemaDescriptions["waf_enabled_rule_group_ids"], + }, + "disabled_rule_group_ids": schema.ListAttribute{ + Computed: true, + ElementType: types.StringType, + Description: schemaDescriptions["waf_disabled_rule_group_ids"], + }, + "log_only_rule_group_ids": schema.ListAttribute{ + Computed: true, + ElementType: types.StringType, + Description: schemaDescriptions["waf_log_only_rule_group_ids"], + }, + "enabled_rule_collection_ids": schema.ListAttribute{ + Computed: true, + ElementType: types.StringType, + Description: schemaDescriptions["waf_enabled_rule_collection_ids"], + }, + "disabled_rule_collection_ids": schema.ListAttribute{ + Computed: true, + ElementType: types.StringType, + Description: schemaDescriptions["waf_disabled_rule_collection_ids"], + }, + "log_only_rule_collection_ids": schema.ListAttribute{ + Computed: true, + ElementType: types.StringType, + Description: schemaDescriptions["waf_log_only_rule_collection_ids"], + }, + }, + }, }, }, }, @@ -510,6 +591,41 @@ func mapDataSourceFields(ctx context.Context, distribution *cdnSdk.Distribution, return core.DiagsToError(diags) } + // Map Waf + wafVal := types.ObjectNull(wafTypes) + if distribution.Config.Waf.Mode != "" { + wafObjAttrs := map[string]attr.Value{ + "mode": types.StringValue(string(distribution.Config.Waf.Mode)), + "type": types.StringValue(string(distribution.Config.Waf.Type)), + } + + if distribution.Config.Waf.ParanoiaLevel != nil { + wafObjAttrs["paranoia_level"] = types.StringValue(string(*distribution.Config.Waf.ParanoiaLevel)) + } else { + wafObjAttrs["paranoia_level"] = types.StringNull() + } + + // Uses the mapWafListToHCL defined in resource.go + wafObjAttrs["allowed_http_versions"] = mapWafListToHCL(distribution.Config.Waf.AllowedHttpVersions) + wafObjAttrs["allowed_request_content_types"] = mapWafListToHCL(distribution.Config.Waf.AllowedRequestContentTypes) + wafObjAttrs["allowed_http_methods"] = mapWafListToHCL(distribution.Config.Waf.AllowedHttpMethods) + wafObjAttrs["enabled_rule_ids"] = mapWafListToHCL(distribution.Config.Waf.EnabledRuleIds) + wafObjAttrs["disabled_rule_ids"] = mapWafListToHCL(distribution.Config.Waf.DisabledRuleIds) + wafObjAttrs["log_only_rule_ids"] = mapWafListToHCL(distribution.Config.Waf.LogOnlyRuleIds) + wafObjAttrs["enabled_rule_group_ids"] = mapWafListToHCL(distribution.Config.Waf.EnabledRuleGroupIds) + wafObjAttrs["disabled_rule_group_ids"] = mapWafListToHCL(distribution.Config.Waf.DisabledRuleGroupIds) + wafObjAttrs["log_only_rule_group_ids"] = mapWafListToHCL(distribution.Config.Waf.LogOnlyRuleGroupIds) + wafObjAttrs["enabled_rule_collection_ids"] = mapWafListToHCL(distribution.Config.Waf.EnabledRuleCollectionIds) + wafObjAttrs["disabled_rule_collection_ids"] = mapWafListToHCL(distribution.Config.Waf.DisabledRuleCollectionIds) + wafObjAttrs["log_only_rule_collection_ids"] = mapWafListToHCL(distribution.Config.Waf.LogOnlyRuleCollectionIds) + + var diagWaf diag.Diagnostics + wafVal, diagWaf = types.ObjectValue(wafTypes, wafObjAttrs) + if diagWaf.HasError() { + return core.DiagsToError(diagWaf) + } + } + // Optimizer optimizerVal := types.ObjectNull(optimizerTypes) if o := distribution.Config.Optimizer; o != nil { @@ -531,6 +647,7 @@ func mapDataSourceFields(ctx context.Context, distribution *cdnSdk.Distribution, "blocked_countries": modelBlockedCountries, "optimizer": optimizerVal, "redirects": redirectsVal, + "waf": wafVal, }) if diags.HasError() { return core.DiagsToError(diags) diff --git a/stackit/internal/services/cdn/distribution/datasource_test.go b/stackit/internal/services/cdn/distribution/datasource_test.go index df8440f4b..7aac342b6 100644 --- a/stackit/internal/services/cdn/distribution/datasource_test.go +++ b/stackit/internal/services/cdn/distribution/datasource_test.go @@ -52,6 +52,7 @@ func TestMapDataSourceFields(t *testing.T) { "blocked_countries": blockedCountriesFixture, "optimizer": types.ObjectNull(optimizerTypes), "redirects": types.ObjectNull(redirectsAttrTypes), + "waf": types.ObjectNull(wafTypes), }) redirectsInput := cdnSdk.RedirectConfig{ Rules: []cdnSdk.RedirectRule{ @@ -100,6 +101,49 @@ func TestMapDataSourceFields(t *testing.T) { "errors": types.ListValueMust(types.StringType, []attr.Value{}), }) domains := types.ListValueMust(types.ObjectType{AttrTypes: domainTypes}, []attr.Value{managedDomain}) + + // WAF Fixtures + populatedWafList := types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("rule1"), + types.StringValue("rule2"), + }) + populatedWaf := types.ObjectValueMust(wafTypes, map[string]attr.Value{ + "mode": types.StringValue("ENABLED"), + "type": types.StringValue("PREMIUM"), + "paranoia_level": types.StringValue("L2"), + "allowed_http_versions": populatedWafList, + "allowed_request_content_types": populatedWafList, + "allowed_http_methods": populatedWafList, + "enabled_rule_ids": populatedWafList, + "disabled_rule_ids": populatedWafList, + "log_only_rule_ids": populatedWafList, + "enabled_rule_group_ids": populatedWafList, + "disabled_rule_group_ids": populatedWafList, + "log_only_rule_group_ids": populatedWafList, + "enabled_rule_collection_ids": populatedWafList, + "disabled_rule_collection_ids": populatedWafList, + "log_only_rule_collection_ids": populatedWafList, + }) + + expectedParanoiaLevel := cdnSdk.WafParanoiaLevel("L2") + expectedWafConfig := cdnSdk.WafConfig{ + Mode: cdnSdk.WafMode("ENABLED"), + Type: cdnSdk.WafType("PREMIUM"), + ParanoiaLevel: &expectedParanoiaLevel, + AllowedHttpVersions: []string{"rule1", "rule2"}, + AllowedRequestContentTypes: []string{"rule1", "rule2"}, + AllowedHttpMethods: []string{"rule1", "rule2"}, + EnabledRuleIds: []string{"rule1", "rule2"}, + DisabledRuleIds: []string{"rule1", "rule2"}, + LogOnlyRuleIds: []string{"rule1", "rule2"}, + EnabledRuleGroupIds: []string{"rule1", "rule2"}, + DisabledRuleGroupIds: []string{"rule1", "rule2"}, + LogOnlyRuleGroupIds: []string{"rule1", "rule2"}, + EnabledRuleCollectionIds: []string{"rule1", "rule2"}, + DisabledRuleCollectionIds: []string{"rule1", "rule2"}, + LogOnlyRuleCollectionIds: []string{"rule1", "rule2"}, + } + expectedModel := func(mods ...func(*Model)) *Model { model := &Model{ ID: types.StringValue("test-project-id,test-distribution-id"), @@ -117,6 +161,7 @@ func TestMapDataSourceFields(t *testing.T) { } return model } + distributionFixture := func(mods ...func(*cdnSdk.Distribution)) *cdnSdk.Distribution { distribution := &cdnSdk.Distribution{ Config: cdnSdk.Config{ @@ -161,6 +206,7 @@ func TestMapDataSourceFields(t *testing.T) { "origin_request_headers": types.MapNull(types.StringType), "geofencing": types.MapNull(geofencingTypes.ElemType), }) + tests := map[string]struct { Input *cdnSdk.Distribution Expected *Model @@ -179,6 +225,7 @@ func TestMapDataSourceFields(t *testing.T) { "optimizer": optimizer, "blocked_countries": blockedCountriesFixture, "redirects": types.ObjectNull(redirectsAttrTypes), + "waf": types.ObjectNull(wafTypes), }) }), Input: distributionFixture(func(d *cdnSdk.Distribution) { @@ -205,6 +252,7 @@ func TestMapDataSourceFields(t *testing.T) { "blocked_countries": blockedCountriesFixture, "optimizer": types.ObjectNull(optimizerTypes), "redirects": types.ObjectNull(redirectsAttrTypes), + "waf": types.ObjectNull(wafTypes), }) }), IsValid: true, @@ -225,6 +273,7 @@ func TestMapDataSourceFields(t *testing.T) { "optimizer": types.ObjectNull(optimizerTypes), "blocked_countries": blockedCountriesFixture, "redirects": types.ObjectNull(redirectsAttrTypes), + "waf": types.ObjectNull(wafTypes), }) }), Input: distributionFixture(func(d *cdnSdk.Distribution) { @@ -249,6 +298,7 @@ func TestMapDataSourceFields(t *testing.T) { "optimizer": types.ObjectNull(optimizerTypes), "blocked_countries": blockedCountriesFixture, "redirects": redirectsConfigExpected, + "waf": types.ObjectNull(wafTypes), }) }), Input: distributionFixture(func(d *cdnSdk.Distribution) { @@ -256,6 +306,22 @@ func TestMapDataSourceFields(t *testing.T) { }), IsValid: true, }, + "happy_path_with_waf": { + Expected: expectedModel(func(m *Model) { + m.Config = types.ObjectValueMust(dataSourceConfigTypes, map[string]attr.Value{ + "backend": backend, + "regions": regionsFixture, + "optimizer": types.ObjectNull(optimizerTypes), + "blocked_countries": blockedCountriesFixture, + "redirects": types.ObjectNull(redirectsAttrTypes), + "waf": populatedWaf, + }) + }), + Input: distributionFixture(func(d *cdnSdk.Distribution) { + d.Config.Waf = expectedWafConfig + }), + IsValid: true, + }, "happy_path_custom_domain": { Expected: expectedModel(func(m *Model) { managedDomain := types.ObjectValueMust(domainTypes, map[string]attr.Value{ From 162d0d8f703fbe60b7db33e72f0018fee8071492 Mon Sep 17 00:00:00 2001 From: Matheus Politano Date: Mon, 13 Apr 2026 10:54:00 +0200 Subject: [PATCH 15/20] fix acc test --- stackit/internal/services/cdn/cdn_acc_test.go | 3 + .../services/cdn/distribution/resource.go | 71 ++++++++++++++----- 2 files changed, 57 insertions(+), 17 deletions(-) diff --git a/stackit/internal/services/cdn/cdn_acc_test.go b/stackit/internal/services/cdn/cdn_acc_test.go index 293ef0962..644833358 100644 --- a/stackit/internal/services/cdn/cdn_acc_test.go +++ b/stackit/internal/services/cdn/cdn_acc_test.go @@ -94,6 +94,9 @@ var testConfigVarsHttp = config.Variables{ "redirect_target_url": config.StringVariable("https://example.com"), "redirect_status_code": config.IntegerVariable(301), "redirect_matcher_value": config.StringVariable("/shop/*"), + "waf_mode": config.StringVariable("LOG_ONLY"), + "waf_type": config.StringVariable("PREMIUM"), + "waf_enabled_rule_ids": config.ListVariable(config.StringVariable("@builtin/crs/request/941120")), } func configVarsHttpUpdated() config.Variables { diff --git a/stackit/internal/services/cdn/distribution/resource.go b/stackit/internal/services/cdn/distribution/resource.go index 2f90e8c88..0cc010d3c 100644 --- a/stackit/internal/services/cdn/distribution/resource.go +++ b/stackit/internal/services/cdn/distribution/resource.go @@ -304,6 +304,20 @@ func (r *distributionResource) Schema(_ context.Context, _ resource.SchemaReques backendOptions := []string{"http", "bucket"} matchCondition := []string{"ANY", "ALL", "NONE"} statusCode := []int32{301, 302, 303, 307, 308} + defaultWafConfigAllowedHttpVersions := sortedStringListToAttrValueList([]string{ + "HTTP/1.0", "HTTP/1.1", "HTTP/2", "HTTP/2.0", + }) + defaultWafConfigAllowedRequestContentTypes := sortedStringListToAttrValueList([]string{ + "application/x-www-form-urlencoded", "multipart/form-data", "multipart/related", + "text/xml", "application/xml", "application/soap+xml", "application/x-amf", + "application/json", "application/octet-stream", "application/csp-report", + "application/xss-auditor-report", "text/plain", + }) + defaultWafConfigAllowedHttpMethods := sortedStringListToAttrValueList([]string{ + "GET", "HEAD", "POST", "PUT", "DELETE", + "CONNECT", "OPTIONS", "TRACE", "PATCH", + }) + resp.Schema = schema.Schema{ MarkdownDescription: features.AddBetaDescription("CDN distribution data source schema.", core.Resource), Description: "CDN distribution data source schema.", @@ -485,21 +499,21 @@ func (r *distributionResource) Schema(_ context.Context, _ resource.SchemaReques Computed: true, ElementType: types.StringType, Description: schemaDescriptions["waf_allowed_http_versions"], - Default: listdefault.StaticValue(types.ListValueMust(types.StringType, []attr.Value{})), + Default: listdefault.StaticValue(types.ListValueMust(types.StringType, defaultWafConfigAllowedHttpVersions)), }, "allowed_request_content_types": schema.ListAttribute{ Optional: true, Computed: true, ElementType: types.StringType, Description: schemaDescriptions["waf_allowed_request_content_types"], - Default: listdefault.StaticValue(types.ListValueMust(types.StringType, []attr.Value{})), + Default: listdefault.StaticValue(types.ListValueMust(types.StringType, defaultWafConfigAllowedRequestContentTypes)), }, "allowed_http_methods": schema.ListAttribute{ Optional: true, Computed: true, ElementType: types.StringType, Description: schemaDescriptions["waf_allowed_http_methods"], - Default: listdefault.StaticValue(types.ListValueMust(types.StringType, []attr.Value{})), + Default: listdefault.StaticValue(types.ListValueMust(types.StringType, defaultWafConfigAllowedHttpMethods)), }, "enabled_rule_ids": schema.ListAttribute{ Optional: true, @@ -1385,10 +1399,9 @@ func mapFields(ctx context.Context, distribution *cdnSdk.Distribution, model *Mo // if the returned one matches the API default (FREE/DISABLED and empty lists). isEmptyDefault := distribution.Config.Waf.Mode == cdnSdk.WAFMODE_DISABLED && distribution.Config.Waf.Type == cdnSdk.WAFTYPE_FREE && - len(distribution.Config.Waf.AllowedHttpMethods) == 0 && len(distribution.Config.Waf.EnabledRuleIds) == 0 - if isEmptyDefault && oldConfig.Waf.IsNull() { + if isEmptyDefault && utils.IsUndefined(oldConfig.Waf) { wafVal = types.ObjectNull(wafTypes) } else { var diagWaf diag.Diagnostics @@ -1469,10 +1482,21 @@ func toCreatePayload(ctx context.Context, model *Model) (*cdnSdk.CreateDistribut if model == nil { return nil, fmt.Errorf("missing model") } + + var rawConfig distributionConfig + diags := model.Config.As(ctx, &rawConfig, basetypes.ObjectAsOptions{ + UnhandledNullAsEmpty: false, + UnhandledUnknownAsEmpty: false, + }) + if diags.HasError() { + return nil, core.DiagsToError(diags) + } + cfg, err := convertConfig(ctx, model) if err != nil { return nil, err } + var optimizer *cdnSdk.Optimizer if cfg.Optimizer != nil { optimizer = cdnSdk.NewOptimizer(cfg.Optimizer.GetEnabled()) @@ -1489,16 +1513,6 @@ func toCreatePayload(ctx context.Context, model *Model) (*cdnSdk.CreateDistribut }, } } else if cfg.Backend.BucketBackend != nil { - // We need to parse the model again to access the credentials, - // as convertConfig returns the SDK Config struct which hides them. - var rawConfig distributionConfig - diags := model.Config.As(ctx, &rawConfig, basetypes.ObjectAsOptions{ - UnhandledNullAsEmpty: false, - UnhandledUnknownAsEmpty: false, - }) - if diags.HasError() { - return nil, core.DiagsToError(diags) - } var accessKey, secretKey *string if rawConfig.Backend.Credentials != nil { accessKey = rawConfig.Backend.Credentials.AccessKey @@ -1516,6 +1530,13 @@ func toCreatePayload(ctx context.Context, model *Model) (*cdnSdk.CreateDistribut }, } } + + // Conditionally set the WAF payload to nil if it's not defined + var wafPayload *cdnSdk.WafConfig + if !utils.IsUndefined(rawConfig.Waf) { + wafPayload = &cfg.Waf + } + payload := &cdnSdk.CreateDistributionPayload{ IntentId: new(uuid.NewString()), Regions: cfg.Regions, @@ -1523,7 +1544,7 @@ func toCreatePayload(ctx context.Context, model *Model) (*cdnSdk.CreateDistribut BlockedCountries: cfg.BlockedCountries, Optimizer: optimizer, Redirects: cfg.Redirects, - Waf: &cfg.Waf, + Waf: wafPayload, // Now passes nil if omitted } return payload, nil @@ -1728,7 +1749,7 @@ func validateCountryCode(country string) (string, error) { // getSortedWafList extracts strings from HCL list, sorts them and returns the slice func getSortedWafList(ctx context.Context, tfList basetypes.ListValue) []string { if tfList.IsNull() || tfList.IsUnknown() { - return []string{} + return nil } var elements []string diags := tfList.ElementsAs(ctx, &elements, true) @@ -1753,3 +1774,19 @@ func mapWafListToHCL(apiList []string) basetypes.ListValue { } return types.ListValueMust(types.StringType, elements) } + +// sortedStringListToAttrValueList sorts a slice of strings and converts it +// to a slice of attr.Value for use in Terraform schema defaults. +func sortedStringListToAttrValueList(items []string) []attr.Value { + sortedItems := make([]string, len(items)) + copy(sortedItems, items) + + sort.Strings(sortedItems) + + attrValues := make([]attr.Value, len(sortedItems)) + for i, val := range sortedItems { + attrValues[i] = types.StringValue(val) + } + + return attrValues +} From bd94faa9e278ce8f878f32eee63794a791951608 Mon Sep 17 00:00:00 2001 From: Matheus Politano Date: Mon, 13 Apr 2026 14:32:24 +0200 Subject: [PATCH 16/20] feat: improve waf to avoid state drift --- docs/data-sources/cdn_distribution.md | 23 +++ docs/resources/cdn_distribution.md | 23 +++ .../services/cdn/distribution/resource.go | 153 +++++++++++------- .../cdn/testdata/resource-http-base.tf | 8 + 4 files changed, 150 insertions(+), 57 deletions(-) diff --git a/docs/data-sources/cdn_distribution.md b/docs/data-sources/cdn_distribution.md index 099a24799..4ff668f24 100644 --- a/docs/data-sources/cdn_distribution.md +++ b/docs/data-sources/cdn_distribution.md @@ -53,6 +53,7 @@ Read-Only: - `optimizer` (Attributes) Configuration for the Image Optimizer. This is a paid feature that automatically optimizes images to reduce their file size for faster delivery, leading to improved website performance and a better user experience. (see [below for nested schema](#nestedatt--config--optimizer)) - `redirects` (Attributes) A wrapper for a list of redirect rules that allows for redirect settings on a distribution (see [below for nested schema](#nestedatt--config--redirects)) - `regions` (List of String) The configured regions where content will be hosted +- `waf` (Attributes) Configuration of the Web Application Firewall (WAF) for the distribution. Removing this block from your configuration will completely disable the WAF. (see [below for nested schema](#nestedatt--config--waf)) ### Nested Schema for `config.backend` @@ -105,6 +106,28 @@ Read-Only: + +### Nested Schema for `config.waf` + +Read-Only: + +- `allowed_http_methods` (List of String) Restricts which HTTP methods the distribution accepts. If provided, the list must contain at least one item. If omitted, the API applies the following defaults: `GET`, `HEAD`, `POST`, `PUT`, `DELETE`, `CONNECT`, `OPTIONS`, `TRACE`, `PATCH`. +- `allowed_http_versions` (List of String) Restricts which HTTP protocol versions are accepted. If provided, the list must contain at least one item. If omitted, the API applies the following defaults: `HTTP/1.0`, `HTTP/1.1`, `HTTP/2`, `HTTP/2.0`. +- `allowed_request_content_types` (List of String) Restricts which Content-Type headers are accepted in request bodies. If provided, the list must contain at least one item. If omitted, the API applies the following defaults: `application/x-www-form-urlencoded`, `multipart/form-data`, `multipart/related`, `text/xml`, `application/xml`, `application/soap+xml`, `application/x-amf`, `application/json`, `application/octet-stream`, `application/csp-report`, `application/xss-auditor-report`, `text/plain`. +- `disabled_rule_collection_ids` (List of String) List of WAF Collection IDs explicitly disabled. Can be set to an empty list to clear previously set rules. To view available rule collections, please consult the API documentation: https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections +- `disabled_rule_group_ids` (List of String) List of WAF Rule Group IDs explicitly disabled. Can be set to an empty list to clear previously set rules. To view available rule groups, please consult the API documentation: https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections +- `disabled_rule_ids` (List of String) List of WAF rule IDs explicitly disabled. Can be set to an empty list to clear previously set rules. To view available rules, please consult the API documentation: https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections +- `enabled_rule_collection_ids` (List of String) List of WAF Collection IDs explicitly enabled. Can be set to an empty list to clear previously set rules. To view available rule collections, please consult the API documentation: https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections +- `enabled_rule_group_ids` (List of String) List of WAF Rule Group IDs explicitly enabled. Can be set to an empty list to clear previously set rules. To view available rule groups, please consult the API documentation: https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections +- `enabled_rule_ids` (List of String) List of WAF rule IDs explicitly enabled. Can be set to an empty list to clear previously set rules. To view available rules, please consult the API documentation: https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections +- `log_only_rule_collection_ids` (List of String) List of WAF Collection IDs explicitly marked as Log Only. Can be set to an empty list to clear previously set rules. To view available rule collections, please consult the API documentation: https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections +- `log_only_rule_group_ids` (List of String) List of WAF Rule Group IDs explicitly marked as Log Only. Can be set to an empty list to clear previously set rules. To view available rule groups, please consult the API documentation: https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections +- `log_only_rule_ids` (List of String) List of WAF rule IDs explicitly marked as Log Only. Can be set to an empty list to clear previously set rules. To view available rules, please consult the API documentation: https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections +- `mode` (String) The operating mode of the WAF. 'ENABLED' actively blocks threats, 'LOG_ONLY' logs matches without blocking, and 'DISABLED' completely turns off inspection. Defaults to 'DISABLED'. +- `paranoia_level` (String) Defines how aggressively the WAF should act on requests. Valid values are 'L1' to 'L4'. Defaults to 'L1'. +- `type` (String) The tier of the WAF. Valid values are 'FREE' or 'PREMIUM'. Defaults to 'FREE'. + + ### Nested Schema for `domains` diff --git a/docs/resources/cdn_distribution.md b/docs/resources/cdn_distribution.md index f60345443..47f591bbe 100644 --- a/docs/resources/cdn_distribution.md +++ b/docs/resources/cdn_distribution.md @@ -115,6 +115,7 @@ Optional: - `blocked_countries` (List of String) The configured countries where distribution of content is blocked - `optimizer` (Attributes) Configuration for the Image Optimizer. This is a paid feature that automatically optimizes images to reduce their file size for faster delivery, leading to improved website performance and a better user experience. (see [below for nested schema](#nestedatt--config--optimizer)) - `redirects` (Attributes) A wrapper for a list of redirect rules that allows for redirect settings on a distribution (see [below for nested schema](#nestedatt--config--redirects)) +- `waf` (Attributes) Configuration of the Web Application Firewall (WAF) for the distribution. Removing this block from your configuration will completely disable the WAF. (see [below for nested schema](#nestedatt--config--waf)) ### Nested Schema for `config.backend` @@ -186,6 +187,28 @@ Optional: + +### Nested Schema for `config.waf` + +Optional: + +- `allowed_http_methods` (List of String) Restricts which HTTP methods the distribution accepts. If provided, the list must contain at least one item. If omitted, the API applies the following defaults: `GET`, `HEAD`, `POST`, `PUT`, `DELETE`, `CONNECT`, `OPTIONS`, `TRACE`, `PATCH`. +- `allowed_http_versions` (List of String) Restricts which HTTP protocol versions are accepted. If provided, the list must contain at least one item. If omitted, the API applies the following defaults: `HTTP/1.0`, `HTTP/1.1`, `HTTP/2`, `HTTP/2.0`. +- `allowed_request_content_types` (List of String) Restricts which Content-Type headers are accepted in request bodies. If provided, the list must contain at least one item. If omitted, the API applies the following defaults: `application/x-www-form-urlencoded`, `multipart/form-data`, `multipart/related`, `text/xml`, `application/xml`, `application/soap+xml`, `application/x-amf`, `application/json`, `application/octet-stream`, `application/csp-report`, `application/xss-auditor-report`, `text/plain`. +- `disabled_rule_collection_ids` (List of String) List of WAF Collection IDs explicitly disabled. Can be set to an empty list to clear previously set rules. To view available rule collections, please consult the API documentation: https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections +- `disabled_rule_group_ids` (List of String) List of WAF Rule Group IDs explicitly disabled. Can be set to an empty list to clear previously set rules. To view available rule groups, please consult the API documentation: https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections +- `disabled_rule_ids` (List of String) List of WAF rule IDs explicitly disabled. Can be set to an empty list to clear previously set rules. To view available rules, please consult the API documentation: https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections +- `enabled_rule_collection_ids` (List of String) List of WAF Collection IDs explicitly enabled. Can be set to an empty list to clear previously set rules. To view available rule collections, please consult the API documentation: https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections +- `enabled_rule_group_ids` (List of String) List of WAF Rule Group IDs explicitly enabled. Can be set to an empty list to clear previously set rules. To view available rule groups, please consult the API documentation: https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections +- `enabled_rule_ids` (List of String) List of WAF rule IDs explicitly enabled. Can be set to an empty list to clear previously set rules. To view available rules, please consult the API documentation: https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections +- `log_only_rule_collection_ids` (List of String) List of WAF Collection IDs explicitly marked as Log Only. Can be set to an empty list to clear previously set rules. To view available rule collections, please consult the API documentation: https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections +- `log_only_rule_group_ids` (List of String) List of WAF Rule Group IDs explicitly marked as Log Only. Can be set to an empty list to clear previously set rules. To view available rule groups, please consult the API documentation: https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections +- `log_only_rule_ids` (List of String) List of WAF rule IDs explicitly marked as Log Only. Can be set to an empty list to clear previously set rules. To view available rules, please consult the API documentation: https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections +- `mode` (String) The operating mode of the WAF. 'ENABLED' actively blocks threats, 'LOG_ONLY' logs matches without blocking, and 'DISABLED' completely turns off inspection. Defaults to 'DISABLED'. +- `paranoia_level` (String) Defines how aggressively the WAF should act on requests. Valid values are 'L1' to 'L4'. Defaults to 'L1'. +- `type` (String) The tier of the WAF. Valid values are 'FREE' or 'PREMIUM'. Defaults to 'FREE'. + + ### Nested Schema for `domains` diff --git a/stackit/internal/services/cdn/distribution/resource.go b/stackit/internal/services/cdn/distribution/resource.go index 0cc010d3c..a561a328b 100644 --- a/stackit/internal/services/cdn/distribution/resource.go +++ b/stackit/internal/services/cdn/distribution/resource.go @@ -84,22 +84,22 @@ var schemaDescriptions = map[string]string{ "config_backend_credentials_access_key_id": "The access key for the bucket. Required if type is 'bucket'.", "config_backend_credentials_secret_access_key": "The secret key for the bucket. Required if type is 'bucket'.", "config_backend_credentials": "The credentials for the bucket. Required if type is 'bucket'.", - "config_waf": "Configuration of the WAF of a distribution.", - "waf_mode": "The WAF mode. ENABLED actively blocks, LOG_ONLY logs matches but never blocks, DISABLED completely turns off inspection.", - "waf_type": "Enable or disable the Premium WAF. FREE or PREMIUM.", - "waf_paranoia_level": "Defines how aggressively the WAF should action on requests (L1 to L4).", - "waf_allowed_http_versions": "Restricts which HTTP protocol versions are accepted.", - "waf_allowed_request_content_types": "Restricts which Content-Type headers are accepted in request bodies.", - "waf_allowed_http_methods": "Restricts which HTTP methods the distribution accepts.", - "waf_enabled_rule_ids": "Ids of the WAF rules explicitly enabled.", - "waf_disabled_rule_ids": "Ids of WAF Rules explicitly disabled.", - "waf_log_only_rule_ids": "Ids of WAF Rules explicitly marked as Log Only.", - "waf_enabled_rule_group_ids": "Ids of WAF Rule Groups explicitly enabled.", - "waf_disabled_rule_group_ids": "Ids of WAF Rule Groups explicitly disabled.", - "waf_log_only_rule_group_ids": "Ids of WAF Rule Groups explicitly marked as log Only.", - "waf_enabled_rule_collection_ids": "Ids of WAF Collections explicitly enabled.", - "waf_disabled_rule_collection_ids": "Ids of WAF Collections explicitly disabled.", - "waf_log_only_rule_collection_ids": "Ids of WAF Collections explicitly marked as log Only.", + "config_waf": "Configuration of the Web Application Firewall (WAF) for the distribution. Removing this block from your configuration will completely disable the WAF.", + "waf_mode": "The operating mode of the WAF. 'ENABLED' actively blocks threats, 'LOG_ONLY' logs matches without blocking, and 'DISABLED' completely turns off inspection. Defaults to 'DISABLED'.", + "waf_type": "The tier of the WAF. Valid values are 'FREE' or 'PREMIUM'. Defaults to 'FREE'.", + "waf_paranoia_level": "Defines how aggressively the WAF should act on requests. Valid values are 'L1' to 'L4'. Defaults to 'L1'.", + "waf_allowed_http_versions": "Restricts which HTTP protocol versions are accepted. If provided, the list must contain at least one item. If omitted, the API applies the following defaults: `HTTP/1.0`, `HTTP/1.1`, `HTTP/2`, `HTTP/2.0`.", + "waf_allowed_request_content_types": "Restricts which Content-Type headers are accepted in request bodies. If provided, the list must contain at least one item. If omitted, the API applies the following defaults: `application/x-www-form-urlencoded`, `multipart/form-data`, `multipart/related`, `text/xml`, `application/xml`, `application/soap+xml`, `application/x-amf`, `application/json`, `application/octet-stream`, `application/csp-report`, `application/xss-auditor-report`, `text/plain`.", + "waf_allowed_http_methods": "Restricts which HTTP methods the distribution accepts. If provided, the list must contain at least one item. If omitted, the API applies the following defaults: `GET`, `HEAD`, `POST`, `PUT`, `DELETE`, `CONNECT`, `OPTIONS`, `TRACE`, `PATCH`.", + "waf_enabled_rule_ids": "List of WAF rule IDs explicitly enabled. Can be set to an empty list to clear previously set rules. To view available rules, please consult the API documentation: https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections", + "waf_disabled_rule_ids": "List of WAF rule IDs explicitly disabled. Can be set to an empty list to clear previously set rules. To view available rules, please consult the API documentation: https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections", + "waf_log_only_rule_ids": "List of WAF rule IDs explicitly marked as Log Only. Can be set to an empty list to clear previously set rules. To view available rules, please consult the API documentation: https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections", + "waf_enabled_rule_group_ids": "List of WAF Rule Group IDs explicitly enabled. Can be set to an empty list to clear previously set rules. To view available rule groups, please consult the API documentation: https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections", + "waf_disabled_rule_group_ids": "List of WAF Rule Group IDs explicitly disabled. Can be set to an empty list to clear previously set rules. To view available rule groups, please consult the API documentation: https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections", + "waf_log_only_rule_group_ids": "List of WAF Rule Group IDs explicitly marked as Log Only. Can be set to an empty list to clear previously set rules. To view available rule groups, please consult the API documentation: https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections", + "waf_enabled_rule_collection_ids": "List of WAF Collection IDs explicitly enabled. Can be set to an empty list to clear previously set rules. To view available rule collections, please consult the API documentation: https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections", + "waf_disabled_rule_collection_ids": "List of WAF Collection IDs explicitly disabled. Can be set to an empty list to clear previously set rules. To view available rule collections, please consult the API documentation: https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections", + "waf_log_only_rule_collection_ids": "List of WAF Collection IDs explicitly marked as Log Only. Can be set to an empty list to clear previously set rules. To view available rule collections, please consult the API documentation: https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections", } type Model struct { @@ -493,6 +493,7 @@ func (r *distributionResource) Schema(_ context.Context, _ resource.SchemaReques Optional: true, Computed: true, Description: schemaDescriptions["waf_paranoia_level"], + Default: stringdefault.StaticString("L1"), }, "allowed_http_versions": schema.ListAttribute{ Optional: true, @@ -826,8 +827,15 @@ func (r *distributionResource) Read(ctx context.Context, req resource.ReadReques } func (r *distributionResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform - var model Model - diags := req.Plan.Get(ctx, &model) + var planModel Model + diags := req.Plan.Get(ctx, &planModel) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + var stateModel Model + diags = req.State.Get(ctx, &stateModel) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return @@ -835,23 +843,33 @@ func (r *distributionResource) Update(ctx context.Context, req resource.UpdateRe ctx = core.InitProviderContext(ctx) - projectId := model.ProjectId.ValueString() - distributionId := model.DistributionId.ValueString() + projectId := planModel.ProjectId.ValueString() + distributionId := planModel.DistributionId.ValueString() ctx = tflog.SetField(ctx, "project_id", projectId) ctx = tflog.SetField(ctx, "distribution_id", distributionId) - configModel := distributionConfig{} - diags = model.Config.As(ctx, &configModel, basetypes.ObjectAsOptions{ + configPlanModel := distributionConfig{} + diags = planModel.Config.As(ctx, &configPlanModel, basetypes.ObjectAsOptions{ UnhandledNullAsEmpty: false, UnhandledUnknownAsEmpty: false, }) if diags.HasError() { - core.LogAndAddError(ctx, &resp.Diagnostics, "Update CDN distribution", "Error mapping config") + core.LogAndAddError(ctx, &resp.Diagnostics, "Update CDN distribution", "Error mapping plan config") + return + } + + configStateModel := distributionConfig{} + diags = stateModel.Config.As(ctx, &configStateModel, basetypes.ObjectAsOptions{ + UnhandledNullAsEmpty: false, + UnhandledUnknownAsEmpty: false, + }) + if diags.HasError() { + core.LogAndAddError(ctx, &resp.Diagnostics, "Update CDN distribution", "Error mapping state config") return } regions := []cdnSdk.Region{} - for _, r := range *configModel.Regions { + for _, r := range *configPlanModel.Regions { regionEnum, err := cdnSdk.NewRegionFromValue(r) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Update CDN distribution", fmt.Sprintf("Map regions: %v", err)) @@ -861,13 +879,10 @@ func (r *distributionResource) Update(ctx context.Context, req resource.UpdateRe } // blockedCountries - // Use a pointer to a slice to distinguish between an empty list (unblock all) and nil (no change). var blockedCountries []string - if configModel.BlockedCountries != nil { - // Use a temporary slice + if configPlanModel.BlockedCountries != nil { tempBlockedCountries := []string{} - - for _, blockedCountry := range *configModel.BlockedCountries { + for _, blockedCountry := range *configPlanModel.BlockedCountries { validatedBlockedCountry, err := validateCountryCode(blockedCountry) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Update CDN distribution", fmt.Sprintf("Blocked countries: %v", err)) @@ -875,17 +890,15 @@ func (r *distributionResource) Update(ctx context.Context, req resource.UpdateRe } tempBlockedCountries = append(tempBlockedCountries, validatedBlockedCountry) } - - // Point to the populated slice blockedCountries = tempBlockedCountries } // redirects var redirectsConfig *cdnSdk.RedirectConfig - if configModel.Redirects != nil { + if configPlanModel.Redirects != nil { sdkRules := []cdnSdk.RedirectRule{} - if len(configModel.Redirects.Rules) > 0 { - for _, rule := range configModel.Redirects.Rules { + if len(configPlanModel.Redirects.Rules) > 0 { + for _, rule := range configPlanModel.Redirects.Rules { matchers := []cdnSdk.Matcher{} for _, matcher := range rule.Matchers { var matchCond *cdnSdk.MatchCondition @@ -925,12 +938,12 @@ func (r *distributionResource) Update(ctx context.Context, req resource.UpdateRe configPatchBackend := &cdnSdk.ConfigPatchBackend{} - switch configModel.Backend.Type { + switch configPlanModel.Backend.Type { case "http": geofencingPatch := map[string][]string{} - if configModel.Backend.Geofencing != nil { + if configPlanModel.Backend.Geofencing != nil { gf := make(map[string][]string) - for url, countries := range *configModel.Backend.Geofencing { + for url, countries := range *configPlanModel.Backend.Geofencing { countryStrings := make([]string, len(countries)) for i, countryPtr := range countries { if countryPtr == nil { @@ -945,21 +958,21 @@ func (r *distributionResource) Update(ctx context.Context, req resource.UpdateRe } configPatchBackend.HttpBackendPatch = &cdnSdk.HttpBackendPatch{ - OriginRequestHeaders: configModel.Backend.OriginRequestHeaders, - OriginUrl: configModel.Backend.OriginURL, + OriginRequestHeaders: configPlanModel.Backend.OriginRequestHeaders, + OriginUrl: configPlanModel.Backend.OriginURL, Type: "http", Geofencing: &geofencingPatch, } case "bucket": configPatchBackend.BucketBackendPatch = &cdnSdk.BucketBackendPatch{ Type: "bucket", - BucketUrl: configModel.Backend.BucketURL, - Region: configModel.Backend.Region, + BucketUrl: configPlanModel.Backend.BucketURL, + Region: configPlanModel.Backend.Region, } - if configModel.Backend.Credentials != nil { + if configPlanModel.Backend.Credentials != nil { configPatchBackend.BucketBackendPatch.Credentials = &cdnSdk.BucketCredentials{ - AccessKeyId: *configModel.Backend.Credentials.AccessKey, - SecretAccessKey: *configModel.Backend.Credentials.SecretKey, + AccessKeyId: *configPlanModel.Backend.Credentials.AccessKey, + SecretAccessKey: *configPlanModel.Backend.Credentials.SecretKey, } } } @@ -971,9 +984,10 @@ func (r *distributionResource) Update(ctx context.Context, req resource.UpdateRe Redirects: redirectsConfig, } - if !utils.IsUndefined(configModel.Waf) { + // Map WAF Update/Removal + if !utils.IsUndefined(configPlanModel.Waf) { var wafModel wafConfig - diags := configModel.Waf.As(ctx, &wafModel, basetypes.ObjectAsOptions{}) + diags := configPlanModel.Waf.As(ctx, &wafModel, basetypes.ObjectAsOptions{}) if diags.HasError() { core.LogAndAddError(ctx, &resp.Diagnostics, "Update CDN distribution", "Error mapping WAF config") return @@ -1001,13 +1015,39 @@ func (r *distributionResource) Update(ctx context.Context, req resource.UpdateRe wafPatch.ParanoiaLevel = &pl } + configPatch.Waf = &wafPatch + + } else if !utils.IsUndefined(configStateModel.Waf) { + // User explicitly removed the WAF block from their terraform configuration + modeDisabled := cdnSdk.WafMode(cdnSdk.WAFMODE_DISABLED) + typeFree := cdnSdk.WafType(cdnSdk.WAFTYPE_FREE) + + wafPatch := cdnSdk.WafConfigPatch{ + Mode: &modeDisabled, + Type: &typeFree, + + // Send empty arrays to clear rules, keeping the API happy + EnabledRuleIds: []string{}, + DisabledRuleIds: []string{}, + LogOnlyRuleIds: []string{}, + EnabledRuleGroupIds: []string{}, + DisabledRuleGroupIds: []string{}, + LogOnlyRuleGroupIds: []string{}, + EnabledRuleCollectionIds: []string{}, + DisabledRuleCollectionIds: []string{}, + LogOnlyRuleCollectionIds: []string{}, + + // Intentionally omitted (nil) to avoid the 422 Unprocessable Entity error: + // AllowedHttpVersions, AllowedRequestContentTypes, AllowedHttpMethods + } + configPatch.Waf = &wafPatch } - if !utils.IsUndefined(configModel.Optimizer) { + if !utils.IsUndefined(configPlanModel.Optimizer) { var optimizerModel optimizerConfig - diags = configModel.Optimizer.As(ctx, &optimizerModel, basetypes.ObjectAsOptions{}) + diags = configPlanModel.Optimizer.As(ctx, &optimizerModel, basetypes.ObjectAsOptions{}) if diags.HasError() { core.LogAndAddError(ctx, &resp.Diagnostics, "Update CDN distribution", "Error mapping optimizer config") return @@ -1037,13 +1077,13 @@ func (r *distributionResource) Update(ctx context.Context, req resource.UpdateRe return } - err = mapFields(ctx, &waitResp.Distribution, &model) + err = mapFields(ctx, &waitResp.Distribution, &planModel) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Update CDN distribution", fmt.Sprintf("Processing API payload: %v", err)) return } - diags = resp.State.Set(ctx, model) + diags = resp.State.Set(ctx, planModel) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return @@ -1394,14 +1434,13 @@ func mapFields(ctx context.Context, distribution *cdnSdk.Distribution, model *Mo wafObjAttrs["disabled_rule_collection_ids"] = mapWafListToHCL(distribution.Config.Waf.DisabledRuleCollectionIds) wafObjAttrs["log_only_rule_collection_ids"] = mapWafListToHCL(distribution.Config.Waf.LogOnlyRuleCollectionIds) - // Prevent state drift if WAF wasn't in the config at all, but API returned default empty WAF - // By checking if the old config Waf block was null, we can avoid recreating a WAF block - // if the returned one matches the API default (FREE/DISABLED and empty lists). - isEmptyDefault := distribution.Config.Waf.Mode == cdnSdk.WAFMODE_DISABLED && - distribution.Config.Waf.Type == cdnSdk.WAFTYPE_FREE && - len(distribution.Config.Waf.EnabledRuleIds) == 0 + // Safely determine if the API considers the WAF completely disabled + isWafDisabled := distribution.Config.Waf.Mode == cdnSdk.WAFMODE_DISABLED && + distribution.Config.Waf.Type == cdnSdk.WAFTYPE_FREE - if isEmptyDefault && utils.IsUndefined(oldConfig.Waf) { + // If the WAF is disabled in the API, and there wasn't a WAF block in the user's previous state, + // keep it null to prevent state drift from residual backend default values. + if isWafDisabled && utils.IsUndefined(oldConfig.Waf) { wafVal = types.ObjectNull(wafTypes) } else { var diagWaf diag.Diagnostics diff --git a/stackit/internal/services/cdn/testdata/resource-http-base.tf b/stackit/internal/services/cdn/testdata/resource-http-base.tf index 026d1b990..9eb97dd71 100644 --- a/stackit/internal/services/cdn/testdata/resource-http-base.tf +++ b/stackit/internal/services/cdn/testdata/resource-http-base.tf @@ -12,6 +12,9 @@ variable "private_key" {} variable "redirect_target_url" {} variable "redirect_status_code" {} variable "redirect_matcher_value" {} +variable "waf_mode" {} +variable "waf_type" {} +variable "waf_enabled_rule_ids" {} # dns variable "dns_zone_name" {} @@ -55,6 +58,11 @@ resource "stackit_cdn_distribution" "distribution" { } ] } + waf = { + mode = var.waf_mode + type = var.waf_type + enabled_rule_ids = var.waf_enabled_rule_ids + } backend = { type = var.backend_http_type origin_url = var.backend_origin_url From 312cca03bf8c68e0460897873e77c8dbbbb4e62b Mon Sep 17 00:00:00 2001 From: Matheus Politano Date: Mon, 13 Apr 2026 14:45:37 +0200 Subject: [PATCH 17/20] fyi: run fmt and fix linter issues --- stackit/internal/services/cdn/distribution/resource.go | 9 +-------- .../internal/services/cdn/testdata/resource-http-base.tf | 4 ++-- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/stackit/internal/services/cdn/distribution/resource.go b/stackit/internal/services/cdn/distribution/resource.go index a561a328b..af6c0518c 100644 --- a/stackit/internal/services/cdn/distribution/resource.go +++ b/stackit/internal/services/cdn/distribution/resource.go @@ -992,7 +992,6 @@ func (r *distributionResource) Update(ctx context.Context, req resource.UpdateRe core.LogAndAddError(ctx, &resp.Diagnostics, "Update CDN distribution", "Error mapping WAF config") return } - wafPatch := cdnSdk.WafConfigPatch{ Mode: new(cdnSdk.WafMode(wafModel.Mode.ValueString())), Type: new(cdnSdk.WafType(wafModel.Type.ValueString())), @@ -1009,14 +1008,11 @@ func (r *distributionResource) Update(ctx context.Context, req resource.UpdateRe DisabledRuleCollectionIds: getSortedWafList(ctx, wafModel.DisabledRuleCollectionIds), LogOnlyRuleCollectionIds: getSortedWafList(ctx, wafModel.LogOnlyRuleCollectionIds), } - if !utils.IsUndefined(wafModel.ParanoiaLevel) { pl := cdnSdk.WafParanoiaLevel(wafModel.ParanoiaLevel.ValueString()) wafPatch.ParanoiaLevel = &pl } - configPatch.Waf = &wafPatch - } else if !utils.IsUndefined(configStateModel.Waf) { // User explicitly removed the WAF block from their terraform configuration modeDisabled := cdnSdk.WafMode(cdnSdk.WAFMODE_DISABLED) @@ -1025,7 +1021,6 @@ func (r *distributionResource) Update(ctx context.Context, req resource.UpdateRe wafPatch := cdnSdk.WafConfigPatch{ Mode: &modeDisabled, Type: &typeFree, - // Send empty arrays to clear rules, keeping the API happy EnabledRuleIds: []string{}, DisabledRuleIds: []string{}, @@ -1036,11 +1031,9 @@ func (r *distributionResource) Update(ctx context.Context, req resource.UpdateRe EnabledRuleCollectionIds: []string{}, DisabledRuleCollectionIds: []string{}, LogOnlyRuleCollectionIds: []string{}, - // Intentionally omitted (nil) to avoid the 422 Unprocessable Entity error: // AllowedHttpVersions, AllowedRequestContentTypes, AllowedHttpMethods } - configPatch.Waf = &wafPatch } @@ -1409,7 +1402,6 @@ func mapFields(ctx context.Context, distribution *cdnSdk.Distribution, model *Mo } // Map Waf - wafVal := types.ObjectNull(wafTypes) wafObjAttrs := map[string]attr.Value{ "mode": types.StringValue(string(distribution.Config.Waf.Mode)), "type": types.StringValue(string(distribution.Config.Waf.Type)), @@ -1440,6 +1432,7 @@ func mapFields(ctx context.Context, distribution *cdnSdk.Distribution, model *Mo // If the WAF is disabled in the API, and there wasn't a WAF block in the user's previous state, // keep it null to prevent state drift from residual backend default values. + var wafVal attr.Value if isWafDisabled && utils.IsUndefined(oldConfig.Waf) { wafVal = types.ObjectNull(wafTypes) } else { diff --git a/stackit/internal/services/cdn/testdata/resource-http-base.tf b/stackit/internal/services/cdn/testdata/resource-http-base.tf index 9eb97dd71..ffe89606b 100644 --- a/stackit/internal/services/cdn/testdata/resource-http-base.tf +++ b/stackit/internal/services/cdn/testdata/resource-http-base.tf @@ -59,8 +59,8 @@ resource "stackit_cdn_distribution" "distribution" { ] } waf = { - mode = var.waf_mode - type = var.waf_type + mode = var.waf_mode + type = var.waf_type enabled_rule_ids = var.waf_enabled_rule_ids } backend = { From a15c7f74b4a49b67ce56def428c5503bd685de87 Mon Sep 17 00:00:00 2001 From: Matheus Politano Date: Mon, 13 Apr 2026 15:18:00 +0200 Subject: [PATCH 18/20] feat: add doc and improve descriptions --- docs/data-sources/cdn_distribution.md | 18 +++--- docs/resources/cdn_distribution.md | 56 +++++++++++++++---- .../stackit_cdn_distribution/resource.tf | 33 +++++++++++ .../services/cdn/distribution/resource.go | 22 ++++---- 4 files changed, 98 insertions(+), 31 deletions(-) diff --git a/docs/data-sources/cdn_distribution.md b/docs/data-sources/cdn_distribution.md index 4ff668f24..ac6ad8de1 100644 --- a/docs/data-sources/cdn_distribution.md +++ b/docs/data-sources/cdn_distribution.md @@ -114,15 +114,15 @@ Read-Only: - `allowed_http_methods` (List of String) Restricts which HTTP methods the distribution accepts. If provided, the list must contain at least one item. If omitted, the API applies the following defaults: `GET`, `HEAD`, `POST`, `PUT`, `DELETE`, `CONNECT`, `OPTIONS`, `TRACE`, `PATCH`. - `allowed_http_versions` (List of String) Restricts which HTTP protocol versions are accepted. If provided, the list must contain at least one item. If omitted, the API applies the following defaults: `HTTP/1.0`, `HTTP/1.1`, `HTTP/2`, `HTTP/2.0`. - `allowed_request_content_types` (List of String) Restricts which Content-Type headers are accepted in request bodies. If provided, the list must contain at least one item. If omitted, the API applies the following defaults: `application/x-www-form-urlencoded`, `multipart/form-data`, `multipart/related`, `text/xml`, `application/xml`, `application/soap+xml`, `application/x-amf`, `application/json`, `application/octet-stream`, `application/csp-report`, `application/xss-auditor-report`, `text/plain`. -- `disabled_rule_collection_ids` (List of String) List of WAF Collection IDs explicitly disabled. Can be set to an empty list to clear previously set rules. To view available rule collections, please consult the API documentation: https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections -- `disabled_rule_group_ids` (List of String) List of WAF Rule Group IDs explicitly disabled. Can be set to an empty list to clear previously set rules. To view available rule groups, please consult the API documentation: https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections -- `disabled_rule_ids` (List of String) List of WAF rule IDs explicitly disabled. Can be set to an empty list to clear previously set rules. To view available rules, please consult the API documentation: https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections -- `enabled_rule_collection_ids` (List of String) List of WAF Collection IDs explicitly enabled. Can be set to an empty list to clear previously set rules. To view available rule collections, please consult the API documentation: https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections -- `enabled_rule_group_ids` (List of String) List of WAF Rule Group IDs explicitly enabled. Can be set to an empty list to clear previously set rules. To view available rule groups, please consult the API documentation: https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections -- `enabled_rule_ids` (List of String) List of WAF rule IDs explicitly enabled. Can be set to an empty list to clear previously set rules. To view available rules, please consult the API documentation: https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections -- `log_only_rule_collection_ids` (List of String) List of WAF Collection IDs explicitly marked as Log Only. Can be set to an empty list to clear previously set rules. To view available rule collections, please consult the API documentation: https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections -- `log_only_rule_group_ids` (List of String) List of WAF Rule Group IDs explicitly marked as Log Only. Can be set to an empty list to clear previously set rules. To view available rule groups, please consult the API documentation: https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections -- `log_only_rule_ids` (List of String) List of WAF rule IDs explicitly marked as Log Only. Can be set to an empty list to clear previously set rules. To view available rules, please consult the API documentation: https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections +- `disabled_rule_collection_ids` (List of String) List of WAF Collection IDs explicitly disabled. Can be set to an empty list to clear previously set rules. Precedence hierarchy: Specific Rules override Groups, and Groups override Collections. To view available rule collections, please consult the API documentation: https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections +- `disabled_rule_group_ids` (List of String) List of WAF Rule Group IDs explicitly disabled. Can be set to an empty list to clear previously set rules. Precedence hierarchy: Specific Rules override Groups, and Groups override Collections. To view available rule groups, please consult the API documentation: https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections +- `disabled_rule_ids` (List of String) List of WAF rule IDs explicitly disabled. Can be set to an empty list to clear previously set rules. Precedence hierarchy: Specific Rules override Groups, and Groups override Collections. For example, an explicitly disabled Rule ID takes precedence over an enabled Group ID. To view available rules, please consult the API documentation: https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections +- `enabled_rule_collection_ids` (List of String) List of WAF Collection IDs explicitly enabled. Can be set to an empty list to clear previously set rules. Precedence hierarchy: Specific Rules override Groups, and Groups override Collections. To view available rule collections, please consult the API documentation: https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections +- `enabled_rule_group_ids` (List of String) List of WAF Rule Group IDs explicitly enabled. Can be set to an empty list to clear previously set rules. Precedence hierarchy: Specific Rules override Groups, and Groups override Collections. To view available rule groups, please consult the API documentation: https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections +- `enabled_rule_ids` (List of String) List of WAF rule IDs explicitly enabled. Can be set to an empty list to clear previously set rules. Precedence hierarchy: Specific Rules override Groups, and Groups override Collections. For example, an explicitly enabled Rule ID takes precedence over a disabled Group ID. To view available rules, please consult the API documentation: https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections +- `log_only_rule_collection_ids` (List of String) List of WAF Collection IDs explicitly marked as Log Only. Can be set to an empty list to clear previously set rules. Precedence hierarchy: Specific Rules override Groups, and Groups override Collections. To view available rule collections, please consult the API documentation: https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections +- `log_only_rule_group_ids` (List of String) List of WAF Rule Group IDs explicitly marked as Log Only. Can be set to an empty list to clear previously set rules. Precedence hierarchy: Specific Rules override Groups, and Groups override Collections. To view available rule groups, please consult the API documentation: https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections +- `log_only_rule_ids` (List of String) List of WAF rule IDs explicitly marked as Log Only. Can be set to an empty list to clear previously set rules. Precedence hierarchy: Specific Rules override Groups, and Groups override Collections. To view available rules, please consult the API documentation: https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections - `mode` (String) The operating mode of the WAF. 'ENABLED' actively blocks threats, 'LOG_ONLY' logs matches without blocking, and 'DISABLED' completely turns off inspection. Defaults to 'DISABLED'. - `paranoia_level` (String) Defines how aggressively the WAF should act on requests. Valid values are 'L1' to 'L4'. Defaults to 'L1'. - `type` (String) The tier of the WAF. Valid values are 'FREE' or 'PREMIUM'. Defaults to 'FREE'. diff --git a/docs/resources/cdn_distribution.md b/docs/resources/cdn_distribution.md index 47f591bbe..72f3ba49d 100644 --- a/docs/resources/cdn_distribution.md +++ b/docs/resources/cdn_distribution.md @@ -74,6 +74,39 @@ resource "stackit_cdn_distribution" "example_bucket_distribution" { } ] } + +# WAF Configuration + # + # Precedence Hierarchy: Specific Rules > Groups > Collections + # In this example, the entire "@builtin/crs/request" collection is ENABLED. + # However, because specific Rule IDs have a higher precedence, the rule + # "@builtin/crs/request/942151" is explicitly DISABLED, overriding the collection setting. + # + # To view all available collections, groups, and rules, consult the API documentation: + # https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections + waf = { + mode = "ENABLED" + type = "PREMIUM" + paranoia_level = "L1" + allowed_http_versions = ["HTTP/1.0", "HTTP/1.1"] + allowed_http_methods = ["GET"] + allowed_request_content_types = ["text/plain"] + + # Collections + enabled_rule_collection_ids = ["@builtin/crs/request"] + disabled_rule_collection_ids = [] + log_only_rule_collection_ids = ["@builtin/crs/response"] + + # Groups + enabled_rule_group_ids = [] + disabled_rule_group_ids = [] + log_only_rule_group_ids = [] + + # Specific Rules (Highest Precedence) + enabled_rule_ids = ["@builtin/crs/request/913100"] + disabled_rule_ids = ["@builtin/crs/request/942151"] + log_only_rule_ids = ["@builtin/crs/response/954120"] + } } } @@ -190,21 +223,24 @@ Optional: ### Nested Schema for `config.waf` +Required: + +- `mode` (String) The operating mode of the WAF. 'ENABLED' actively blocks threats, 'LOG_ONLY' logs matches without blocking, and 'DISABLED' completely turns off inspection. Defaults to 'DISABLED'. + Optional: - `allowed_http_methods` (List of String) Restricts which HTTP methods the distribution accepts. If provided, the list must contain at least one item. If omitted, the API applies the following defaults: `GET`, `HEAD`, `POST`, `PUT`, `DELETE`, `CONNECT`, `OPTIONS`, `TRACE`, `PATCH`. - `allowed_http_versions` (List of String) Restricts which HTTP protocol versions are accepted. If provided, the list must contain at least one item. If omitted, the API applies the following defaults: `HTTP/1.0`, `HTTP/1.1`, `HTTP/2`, `HTTP/2.0`. - `allowed_request_content_types` (List of String) Restricts which Content-Type headers are accepted in request bodies. If provided, the list must contain at least one item. If omitted, the API applies the following defaults: `application/x-www-form-urlencoded`, `multipart/form-data`, `multipart/related`, `text/xml`, `application/xml`, `application/soap+xml`, `application/x-amf`, `application/json`, `application/octet-stream`, `application/csp-report`, `application/xss-auditor-report`, `text/plain`. -- `disabled_rule_collection_ids` (List of String) List of WAF Collection IDs explicitly disabled. Can be set to an empty list to clear previously set rules. To view available rule collections, please consult the API documentation: https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections -- `disabled_rule_group_ids` (List of String) List of WAF Rule Group IDs explicitly disabled. Can be set to an empty list to clear previously set rules. To view available rule groups, please consult the API documentation: https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections -- `disabled_rule_ids` (List of String) List of WAF rule IDs explicitly disabled. Can be set to an empty list to clear previously set rules. To view available rules, please consult the API documentation: https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections -- `enabled_rule_collection_ids` (List of String) List of WAF Collection IDs explicitly enabled. Can be set to an empty list to clear previously set rules. To view available rule collections, please consult the API documentation: https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections -- `enabled_rule_group_ids` (List of String) List of WAF Rule Group IDs explicitly enabled. Can be set to an empty list to clear previously set rules. To view available rule groups, please consult the API documentation: https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections -- `enabled_rule_ids` (List of String) List of WAF rule IDs explicitly enabled. Can be set to an empty list to clear previously set rules. To view available rules, please consult the API documentation: https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections -- `log_only_rule_collection_ids` (List of String) List of WAF Collection IDs explicitly marked as Log Only. Can be set to an empty list to clear previously set rules. To view available rule collections, please consult the API documentation: https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections -- `log_only_rule_group_ids` (List of String) List of WAF Rule Group IDs explicitly marked as Log Only. Can be set to an empty list to clear previously set rules. To view available rule groups, please consult the API documentation: https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections -- `log_only_rule_ids` (List of String) List of WAF rule IDs explicitly marked as Log Only. Can be set to an empty list to clear previously set rules. To view available rules, please consult the API documentation: https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections -- `mode` (String) The operating mode of the WAF. 'ENABLED' actively blocks threats, 'LOG_ONLY' logs matches without blocking, and 'DISABLED' completely turns off inspection. Defaults to 'DISABLED'. +- `disabled_rule_collection_ids` (List of String) List of WAF Collection IDs explicitly disabled. Can be set to an empty list to clear previously set rules. Precedence hierarchy: Specific Rules override Groups, and Groups override Collections. To view available rule collections, please consult the API documentation: https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections +- `disabled_rule_group_ids` (List of String) List of WAF Rule Group IDs explicitly disabled. Can be set to an empty list to clear previously set rules. Precedence hierarchy: Specific Rules override Groups, and Groups override Collections. To view available rule groups, please consult the API documentation: https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections +- `disabled_rule_ids` (List of String) List of WAF rule IDs explicitly disabled. Can be set to an empty list to clear previously set rules. Precedence hierarchy: Specific Rules override Groups, and Groups override Collections. For example, an explicitly disabled Rule ID takes precedence over an enabled Group ID. To view available rules, please consult the API documentation: https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections +- `enabled_rule_collection_ids` (List of String) List of WAF Collection IDs explicitly enabled. Can be set to an empty list to clear previously set rules. Precedence hierarchy: Specific Rules override Groups, and Groups override Collections. To view available rule collections, please consult the API documentation: https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections +- `enabled_rule_group_ids` (List of String) List of WAF Rule Group IDs explicitly enabled. Can be set to an empty list to clear previously set rules. Precedence hierarchy: Specific Rules override Groups, and Groups override Collections. To view available rule groups, please consult the API documentation: https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections +- `enabled_rule_ids` (List of String) List of WAF rule IDs explicitly enabled. Can be set to an empty list to clear previously set rules. Precedence hierarchy: Specific Rules override Groups, and Groups override Collections. For example, an explicitly enabled Rule ID takes precedence over a disabled Group ID. To view available rules, please consult the API documentation: https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections +- `log_only_rule_collection_ids` (List of String) List of WAF Collection IDs explicitly marked as Log Only. Can be set to an empty list to clear previously set rules. Precedence hierarchy: Specific Rules override Groups, and Groups override Collections. To view available rule collections, please consult the API documentation: https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections +- `log_only_rule_group_ids` (List of String) List of WAF Rule Group IDs explicitly marked as Log Only. Can be set to an empty list to clear previously set rules. Precedence hierarchy: Specific Rules override Groups, and Groups override Collections. To view available rule groups, please consult the API documentation: https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections +- `log_only_rule_ids` (List of String) List of WAF rule IDs explicitly marked as Log Only. Can be set to an empty list to clear previously set rules. Precedence hierarchy: Specific Rules override Groups, and Groups override Collections. To view available rules, please consult the API documentation: https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections - `paranoia_level` (String) Defines how aggressively the WAF should act on requests. Valid values are 'L1' to 'L4'. Defaults to 'L1'. - `type` (String) The tier of the WAF. Valid values are 'FREE' or 'PREMIUM'. Defaults to 'FREE'. diff --git a/examples/resources/stackit_cdn_distribution/resource.tf b/examples/resources/stackit_cdn_distribution/resource.tf index 4c37818bf..c532b0ee0 100644 --- a/examples/resources/stackit_cdn_distribution/resource.tf +++ b/examples/resources/stackit_cdn_distribution/resource.tf @@ -56,6 +56,39 @@ resource "stackit_cdn_distribution" "example_bucket_distribution" { } ] } + + # WAF Configuration + # + # Precedence Hierarchy: Specific Rules > Groups > Collections + # In this example, the entire "@builtin/crs/request" collection is ENABLED. + # However, because specific Rule IDs have a higher precedence, the rule + # "@builtin/crs/request/942151" is explicitly DISABLED, overriding the collection setting. + # + # To view all available collections, groups, and rules, consult the API documentation: + # https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections + waf = { + mode = "ENABLED" + type = "PREMIUM" + paranoia_level = "L1" + allowed_http_versions = ["HTTP/1.0", "HTTP/1.1"] + allowed_http_methods = ["GET"] + allowed_request_content_types = ["text/plain"] + + # Collections + enabled_rule_collection_ids = ["@builtin/crs/request"] + disabled_rule_collection_ids = [] + log_only_rule_collection_ids = ["@builtin/crs/response"] + + # Groups + enabled_rule_group_ids = [] + disabled_rule_group_ids = [] + log_only_rule_group_ids = [] + + # Specific Rules (Highest Precedence) + enabled_rule_ids = ["@builtin/crs/request/913100"] + disabled_rule_ids = ["@builtin/crs/request/942151"] + log_only_rule_ids = ["@builtin/crs/response/954120"] + } } } diff --git a/stackit/internal/services/cdn/distribution/resource.go b/stackit/internal/services/cdn/distribution/resource.go index af6c0518c..41cf7cd64 100644 --- a/stackit/internal/services/cdn/distribution/resource.go +++ b/stackit/internal/services/cdn/distribution/resource.go @@ -91,15 +91,15 @@ var schemaDescriptions = map[string]string{ "waf_allowed_http_versions": "Restricts which HTTP protocol versions are accepted. If provided, the list must contain at least one item. If omitted, the API applies the following defaults: `HTTP/1.0`, `HTTP/1.1`, `HTTP/2`, `HTTP/2.0`.", "waf_allowed_request_content_types": "Restricts which Content-Type headers are accepted in request bodies. If provided, the list must contain at least one item. If omitted, the API applies the following defaults: `application/x-www-form-urlencoded`, `multipart/form-data`, `multipart/related`, `text/xml`, `application/xml`, `application/soap+xml`, `application/x-amf`, `application/json`, `application/octet-stream`, `application/csp-report`, `application/xss-auditor-report`, `text/plain`.", "waf_allowed_http_methods": "Restricts which HTTP methods the distribution accepts. If provided, the list must contain at least one item. If omitted, the API applies the following defaults: `GET`, `HEAD`, `POST`, `PUT`, `DELETE`, `CONNECT`, `OPTIONS`, `TRACE`, `PATCH`.", - "waf_enabled_rule_ids": "List of WAF rule IDs explicitly enabled. Can be set to an empty list to clear previously set rules. To view available rules, please consult the API documentation: https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections", - "waf_disabled_rule_ids": "List of WAF rule IDs explicitly disabled. Can be set to an empty list to clear previously set rules. To view available rules, please consult the API documentation: https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections", - "waf_log_only_rule_ids": "List of WAF rule IDs explicitly marked as Log Only. Can be set to an empty list to clear previously set rules. To view available rules, please consult the API documentation: https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections", - "waf_enabled_rule_group_ids": "List of WAF Rule Group IDs explicitly enabled. Can be set to an empty list to clear previously set rules. To view available rule groups, please consult the API documentation: https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections", - "waf_disabled_rule_group_ids": "List of WAF Rule Group IDs explicitly disabled. Can be set to an empty list to clear previously set rules. To view available rule groups, please consult the API documentation: https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections", - "waf_log_only_rule_group_ids": "List of WAF Rule Group IDs explicitly marked as Log Only. Can be set to an empty list to clear previously set rules. To view available rule groups, please consult the API documentation: https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections", - "waf_enabled_rule_collection_ids": "List of WAF Collection IDs explicitly enabled. Can be set to an empty list to clear previously set rules. To view available rule collections, please consult the API documentation: https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections", - "waf_disabled_rule_collection_ids": "List of WAF Collection IDs explicitly disabled. Can be set to an empty list to clear previously set rules. To view available rule collections, please consult the API documentation: https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections", - "waf_log_only_rule_collection_ids": "List of WAF Collection IDs explicitly marked as Log Only. Can be set to an empty list to clear previously set rules. To view available rule collections, please consult the API documentation: https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections", + "waf_enabled_rule_ids": "List of WAF rule IDs explicitly enabled. Can be set to an empty list to clear previously set rules. Precedence hierarchy: Specific Rules override Groups, and Groups override Collections. For example, an explicitly enabled Rule ID takes precedence over a disabled Group ID. To view available rules, please consult the API documentation: https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections", + "waf_disabled_rule_ids": "List of WAF rule IDs explicitly disabled. Can be set to an empty list to clear previously set rules. Precedence hierarchy: Specific Rules override Groups, and Groups override Collections. For example, an explicitly disabled Rule ID takes precedence over an enabled Group ID. To view available rules, please consult the API documentation: https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections", + "waf_log_only_rule_ids": "List of WAF rule IDs explicitly marked as Log Only. Can be set to an empty list to clear previously set rules. Precedence hierarchy: Specific Rules override Groups, and Groups override Collections. To view available rules, please consult the API documentation: https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections", + "waf_enabled_rule_group_ids": "List of WAF Rule Group IDs explicitly enabled. Can be set to an empty list to clear previously set rules. Precedence hierarchy: Specific Rules override Groups, and Groups override Collections. To view available rule groups, please consult the API documentation: https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections", + "waf_disabled_rule_group_ids": "List of WAF Rule Group IDs explicitly disabled. Can be set to an empty list to clear previously set rules. Precedence hierarchy: Specific Rules override Groups, and Groups override Collections. To view available rule groups, please consult the API documentation: https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections", + "waf_log_only_rule_group_ids": "List of WAF Rule Group IDs explicitly marked as Log Only. Can be set to an empty list to clear previously set rules. Precedence hierarchy: Specific Rules override Groups, and Groups override Collections. To view available rule groups, please consult the API documentation: https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections", + "waf_enabled_rule_collection_ids": "List of WAF Collection IDs explicitly enabled. Can be set to an empty list to clear previously set rules. Precedence hierarchy: Specific Rules override Groups, and Groups override Collections. To view available rule collections, please consult the API documentation: https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections", + "waf_disabled_rule_collection_ids": "List of WAF Collection IDs explicitly disabled. Can be set to an empty list to clear previously set rules. Precedence hierarchy: Specific Rules override Groups, and Groups override Collections. To view available rule collections, please consult the API documentation: https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections", + "waf_log_only_rule_collection_ids": "List of WAF Collection IDs explicitly marked as Log Only. Can be set to an empty list to clear previously set rules. Precedence hierarchy: Specific Rules override Groups, and Groups override Collections. To view available rule collections, please consult the API documentation: https://internal-docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections", } type Model struct { @@ -478,10 +478,8 @@ func (r *distributionResource) Schema(_ context.Context, _ resource.SchemaReques Computed: true, Attributes: map[string]schema.Attribute{ "mode": schema.StringAttribute{ - Optional: true, - Computed: true, + Required: true, Description: schemaDescriptions["waf_mode"], - Default: stringdefault.StaticString("DISABLED"), }, "type": schema.StringAttribute{ Optional: true, From d3929ad07071d2b0cba3d31437c6cd270b36df01 Mon Sep 17 00:00:00 2001 From: Matheus Politano Date: Mon, 13 Apr 2026 15:28:54 +0200 Subject: [PATCH 19/20] fyi: improve some validations and description --- docs/data-sources/cdn_distribution.md | 4 +-- docs/resources/cdn_distribution.md | 28 +++++++++---------- .../services/cdn/distribution/datasource.go | 2 +- .../services/cdn/distribution/resource.go | 11 +++++++- 4 files changed, 27 insertions(+), 18 deletions(-) diff --git a/docs/data-sources/cdn_distribution.md b/docs/data-sources/cdn_distribution.md index ac6ad8de1..255ebaf2f 100644 --- a/docs/data-sources/cdn_distribution.md +++ b/docs/data-sources/cdn_distribution.md @@ -27,7 +27,7 @@ data "stackit_cdn_distribution" "example" { ### Required -- `distribution_id` (String) STACKIT project ID associated with the distribution +- `distribution_id` (String) CDN distribution ID - `project_id` (String) STACKIT project ID associated with the distribution ### Read-Only @@ -61,7 +61,7 @@ Read-Only: Read-Only: - `bucket_url` (String) The URL of the bucket (e.g. https://s3.example.com). Required if type is 'bucket'. -- `geofencing` (Map of List of String) The configured type http to configure countries where content is allowed. A map of URLs to a list of countries +- `geofencing` (Map of List of String) Routes users from specific countries to alternative origins (HTTP backend required). Configure this by mapping the alternative origin URL to a list of country codes - `origin_request_headers` (Map of String) The configured type http origin request headers for the backend - `origin_url` (String) The configured backend type http for the distribution - `region` (String) The region where the bucket is hosted. Required if type is 'bucket'. diff --git a/docs/resources/cdn_distribution.md b/docs/resources/cdn_distribution.md index 72f3ba49d..5de2f41ab 100644 --- a/docs/resources/cdn_distribution.md +++ b/docs/resources/cdn_distribution.md @@ -75,7 +75,7 @@ resource "stackit_cdn_distribution" "example_bucket_distribution" { ] } -# WAF Configuration + # WAF Configuration # # Precedence Hierarchy: Specific Rules > Groups > Collections # In this example, the entire "@builtin/crs/request" collection is ENABLED. @@ -91,21 +91,21 @@ resource "stackit_cdn_distribution" "example_bucket_distribution" { allowed_http_versions = ["HTTP/1.0", "HTTP/1.1"] allowed_http_methods = ["GET"] allowed_request_content_types = ["text/plain"] - + # Collections - enabled_rule_collection_ids = ["@builtin/crs/request"] - disabled_rule_collection_ids = [] - log_only_rule_collection_ids = ["@builtin/crs/response"] - + enabled_rule_collection_ids = ["@builtin/crs/request"] + disabled_rule_collection_ids = [] + log_only_rule_collection_ids = ["@builtin/crs/response"] + # Groups - enabled_rule_group_ids = [] - disabled_rule_group_ids = [] - log_only_rule_group_ids = [] - + enabled_rule_group_ids = [] + disabled_rule_group_ids = [] + log_only_rule_group_ids = [] + # Specific Rules (Highest Precedence) - enabled_rule_ids = ["@builtin/crs/request/913100"] - disabled_rule_ids = ["@builtin/crs/request/942151"] - log_only_rule_ids = ["@builtin/crs/response/954120"] + enabled_rule_ids = ["@builtin/crs/request/913100"] + disabled_rule_ids = ["@builtin/crs/request/942151"] + log_only_rule_ids = ["@builtin/crs/response/954120"] } } } @@ -161,7 +161,7 @@ Optional: - `bucket_url` (String) The URL of the bucket (e.g. https://s3.example.com). Required if type is 'bucket'. - `credentials` (Attributes) The credentials for the bucket. Required if type is 'bucket'. (see [below for nested schema](#nestedatt--config--backend--credentials)) -- `geofencing` (Map of List of String) The configured type http to configure countries where content is allowed. A map of URLs to a list of countries +- `geofencing` (Map of List of String) Routes users from specific countries to alternative origins (HTTP backend required). Configure this by mapping the alternative origin URL to a list of country codes - `origin_request_headers` (Map of String) The configured type http origin request headers for the backend - `origin_url` (String) The configured backend type http for the distribution - `region` (String) The region where the bucket is hosted. Required if type is 'bucket'. diff --git a/stackit/internal/services/cdn/distribution/datasource.go b/stackit/internal/services/cdn/distribution/datasource.go index cfe5598b5..7a86f342f 100644 --- a/stackit/internal/services/cdn/distribution/datasource.go +++ b/stackit/internal/services/cdn/distribution/datasource.go @@ -93,7 +93,7 @@ func (r *distributionDataSource) Schema(_ context.Context, _ datasource.SchemaRe Computed: true, }, "distribution_id": schema.StringAttribute{ - Description: schemaDescriptions["project_id"], + Description: schemaDescriptions["distribution_id"], Required: true, Validators: []validator.String{ validate.UUID(), diff --git a/stackit/internal/services/cdn/distribution/resource.go b/stackit/internal/services/cdn/distribution/resource.go index 41cf7cd64..01c2f8052 100644 --- a/stackit/internal/services/cdn/distribution/resource.go +++ b/stackit/internal/services/cdn/distribution/resource.go @@ -64,7 +64,7 @@ var schemaDescriptions = map[string]string{ "config_optimizer": "Configuration for the Image Optimizer. This is a paid feature that automatically optimizes images to reduce their file size for faster delivery, leading to improved website performance and a better user experience.", "config_backend_origin_url": "The configured backend type http for the distribution", "config_backend_origin_request_headers": "The configured type http origin request headers for the backend", - "config_backend_geofencing": "The configured type http to configure countries where content is allowed. A map of URLs to a list of countries", + "config_backend_geofencing": "Routes users from specific countries to alternative origins (HTTP backend required). Configure this by mapping the alternative origin URL to a list of country codes", "config_blocked_countries": "The configured countries where distribution of content is blocked", "config_redirects": "A wrapper for a list of redirect rules that allows for redirect settings on a distribution", "config_redirects_rules": "A list of redirect rules. The order of rules matters for evaluation", @@ -499,6 +499,9 @@ func (r *distributionResource) Schema(_ context.Context, _ resource.SchemaReques ElementType: types.StringType, Description: schemaDescriptions["waf_allowed_http_versions"], Default: listdefault.StaticValue(types.ListValueMust(types.StringType, defaultWafConfigAllowedHttpVersions)), + Validators: []validator.List{ + listvalidator.SizeAtLeast(1), + }, }, "allowed_request_content_types": schema.ListAttribute{ Optional: true, @@ -506,6 +509,9 @@ func (r *distributionResource) Schema(_ context.Context, _ resource.SchemaReques ElementType: types.StringType, Description: schemaDescriptions["waf_allowed_request_content_types"], Default: listdefault.StaticValue(types.ListValueMust(types.StringType, defaultWafConfigAllowedRequestContentTypes)), + Validators: []validator.List{ + listvalidator.SizeAtLeast(1), + }, }, "allowed_http_methods": schema.ListAttribute{ Optional: true, @@ -513,6 +519,9 @@ func (r *distributionResource) Schema(_ context.Context, _ resource.SchemaReques ElementType: types.StringType, Description: schemaDescriptions["waf_allowed_http_methods"], Default: listdefault.StaticValue(types.ListValueMust(types.StringType, defaultWafConfigAllowedHttpMethods)), + Validators: []validator.List{ + listvalidator.SizeAtLeast(1), + }, }, "enabled_rule_ids": schema.ListAttribute{ Optional: true, From 100d9f6c2cc0725ff484ffde4a10029ca8c7e4b1 Mon Sep 17 00:00:00 2001 From: Matheus Politano Date: Mon, 13 Apr 2026 15:49:56 +0200 Subject: [PATCH 20/20] fyi: add checks in acc_test --- stackit/internal/services/cdn/cdn_acc_test.go | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/stackit/internal/services/cdn/cdn_acc_test.go b/stackit/internal/services/cdn/cdn_acc_test.go index 644833358..9281dc693 100644 --- a/stackit/internal/services/cdn/cdn_acc_test.go +++ b/stackit/internal/services/cdn/cdn_acc_test.go @@ -103,6 +103,11 @@ func configVarsHttpUpdated() config.Variables { updatedConfig := maps.Clone(testConfigVarsHttp) updatedConfig["regions"] = config.ListVariable(config.StringVariable("EU"), config.StringVariable("US"), config.StringVariable("ASIA")) updatedConfig["redirect_target_url"] = config.StringVariable("https://example.com/updated") + + // Update WAF configuration to test mutation + updatedConfig["waf_mode"] = config.StringVariable("ENABLED") + updatedConfig["waf_enabled_rule_ids"] = config.ListVariable(config.StringVariable("@builtin/crs/request/941120"), config.StringVariable("@builtin/crs/request/941130")) + return updatedConfig } @@ -183,6 +188,12 @@ func TestAccCDNDistributionHttp(t *testing.T) { "DE", ), resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.optimizer.enabled", testutil.ConvertConfigVariable(testConfigVarsHttp["optimizer"])), + // WAF Checks + resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.waf.mode", testutil.ConvertConfigVariable(testConfigVarsHttp["waf_mode"])), + resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.waf.type", testutil.ConvertConfigVariable(testConfigVarsHttp["waf_type"])), + resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.waf.enabled_rule_ids.#", "1"), + resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.waf.enabled_rule_ids.0", "@builtin/crs/request/941120"), + resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "project_id", testutil.ProjectId), resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "status", "ACTIVE"), ), @@ -285,6 +296,13 @@ func TestAccCDNDistributionHttp(t *testing.T) { resource.TestCheckResourceAttr("data.stackit_cdn_distribution.distribution", "config.blocked_countries.#", "1"), resource.TestCheckResourceAttr("data.stackit_cdn_distribution.distribution", "config.blocked_countries.0", "CU"), resource.TestCheckResourceAttr("data.stackit_cdn_distribution.distribution", "config.optimizer.enabled", testutil.ConvertConfigVariable(testConfigVarsHttp["optimizer"])), + + // WAF Checks inside Data Source + resource.TestCheckResourceAttr("data.stackit_cdn_distribution.distribution", "config.waf.mode", testutil.ConvertConfigVariable(testConfigVarsHttp["waf_mode"])), + resource.TestCheckResourceAttr("data.stackit_cdn_distribution.distribution", "config.waf.type", testutil.ConvertConfigVariable(testConfigVarsHttp["waf_type"])), + resource.TestCheckResourceAttr("data.stackit_cdn_distribution.distribution", "config.waf.enabled_rule_ids.#", "1"), + resource.TestCheckResourceAttr("data.stackit_cdn_distribution.distribution", "config.waf.enabled_rule_ids.0", "@builtin/crs/request/941120"), + resource.TestCheckResourceAttr("data.stackit_cdn_distribution.distribution", "project_id", testutil.ProjectId), resource.TestCheckResourceAttr("data.stackit_cdn_distribution.distribution", "status", "ACTIVE"), resource.TestCheckResourceAttr("data.stackit_cdn_distribution.distribution", "config.redirects.rules.#", "1"), @@ -319,6 +337,14 @@ func TestAccCDNDistributionHttp(t *testing.T) { resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.blocked_countries.#", "1"), resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.blocked_countries.0", "CU"), resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.optimizer.enabled", testutil.ConvertConfigVariable(testConfigVarsHttp["optimizer"])), + + // Checking WAF Mutated Configurations + resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.waf.mode", "ENABLED"), + resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.waf.type", testutil.ConvertConfigVariable(testConfigVarsHttp["waf_type"])), + resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.waf.enabled_rule_ids.#", "2"), + resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.waf.enabled_rule_ids.0", "@builtin/crs/request/941120"), + resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.waf.enabled_rule_ids.1", "@builtin/crs/request/941130"), + resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "project_id", testutil.ProjectId), resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "status", "ACTIVE"), resource.TestCheckResourceAttr(