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..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" + } + ] + } + ] + } } } @@ -96,6 +114,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 +150,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` 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" + } + ] + } + ] + } } } diff --git a/stackit/internal/services/cdn/cdn_acc_test.go b/stackit/internal/services/cdn/cdn_acc_test.go index d4a771fc7..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" ) @@ -91,12 +91,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 } @@ -156,6 +159,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"), @@ -276,6 +284,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), @@ -318,6 +329,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"), @@ -463,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) } @@ -478,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) } diff --git a/stackit/internal/services/cdn/distribution/datasource.go b/stackit/internal/services/cdn/distribution/datasource.go index 1613d6d09..ec1bba135 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 *cdnSdk.Distribution, return core.DiagsToError(diags) } + // redirects + redirectsVal := types.ObjectNull(redirectsTypes) + if 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 != "" { + tfTargetUrl = types.StringValue(r.TargetUrl) + } + + tfStatusCode := types.Int32Null() + if r.StatusCode != 0 { + tfStatusCode = types.Int32Value(int32(r.StatusCode)) // nolint:gosec // HTTP status codes are safely within int32 bounds + } + + 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 *cdnSdk.Distribution, "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 977082cb0..df8440f4b 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" cdnSdk "github.com/stackitcloud/stackit-sdk-go/services/cdn/v1api" ) @@ -39,13 +40,58 @@ func TestMapDataSourceFields(t *testing.T) { optimizer := types.ObjectValueMust(optimizerTypes, map[string]attr.Value{ "enabled": types.BoolValue(true), }) + // 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, "blocked_countries": blockedCountriesFixture, "optimizer": types.ObjectNull(optimizerTypes), + "redirects": types.ObjectNull(redirectsAttrTypes), + }) + redirectsInput := cdnSdk.RedirectConfig{ + Rules: []cdnSdk.RedirectRule{ + { + Description: cdnSdk.PtrString("Test redirect"), + Enabled: cdnSdk.PtrBool(true), + TargetUrl: "https://example.com/redirect", + StatusCode: 301, + RuleMatchCondition: cdnSdk.MatchCondition("ANY").Ptr(), + Matchers: []cdnSdk.Matcher{ + { + Values: []string{"/shop/*"}, + ValueMatchCondition: cdnSdk.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 +178,7 @@ func TestMapDataSourceFields(t *testing.T) { "regions": regionsFixture, "optimizer": optimizer, "blocked_countries": blockedCountriesFixture, + "redirects": types.ObjectNull(redirectsAttrTypes), }) }), Input: distributionFixture(func(d *cdnSdk.Distribution) { @@ -157,6 +204,7 @@ func TestMapDataSourceFields(t *testing.T) { "regions": regionsFixture, "blocked_countries": blockedCountriesFixture, "optimizer": types.ObjectNull(optimizerTypes), + "redirects": types.ObjectNull(redirectsAttrTypes), }) }), IsValid: true, @@ -176,6 +224,7 @@ func TestMapDataSourceFields(t *testing.T) { "regions": regionsFixture, "optimizer": types.ObjectNull(optimizerTypes), "blocked_countries": blockedCountriesFixture, + "redirects": types.ObjectNull(redirectsAttrTypes), }) }), Input: distributionFixture(func(d *cdnSdk.Distribution) { @@ -192,6 +241,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 *cdnSdk.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 34ce3c684..09982cb34 100644 --- a/stackit/internal/services/cdn/distribution/resource.go +++ b/stackit/internal/services/cdn/distribution/resource.go @@ -10,6 +10,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" @@ -18,8 +20,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" @@ -44,29 +48,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'.", @@ -84,11 +97,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 { @@ -96,7 +128,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. @@ -117,6 +149,9 @@ var configTypes = map[string]attr.Type{ "optimizer": types.ObjectType{ AttrTypes: optimizerTypes, }, + "redirects": types.ObjectType{ + AttrTypes: redirectsTypes, + }, } var optimizerTypes = map[string]attr.Type{ @@ -127,6 +162,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, @@ -184,6 +245,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.", @@ -268,6 +331,76 @@ func (r *distributionResource) Schema(_ context.Context, _ resource.SchemaReques objectvalidator.AlsoRequires(path.MatchRelative().AtName("enabled")), }, }, + "redirects": schema.SingleNestedAttribute{ + Optional: 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, + Computed: true, + Default: stringdefault.StaticString(""), + }, + "enabled": schema.BoolAttribute{ + Optional: true, + Computed: true, + Description: schemaDescriptions["config_redirects_rule_enabled"], + Default: booldefault.StaticBool(true), + }, + "target_url": schema.StringAttribute{ + Required: true, + Description: schemaDescriptions["config_redirects_rule_target_url"], + }, + "status_code": schema.Int32Attribute{ + Required: true, + Description: schemaDescriptions["config_redirects_rule_status_code"], + Validators: []validator.Int32{int32validator.OneOf(statusCode...)}, + }, + "rule_match_condition": 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, + ElementType: types.StringType, + Validators: []validator.List{ + listvalidator.SizeAtLeast(1), + }, + }, + "value_match_condition": 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"], @@ -550,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{} @@ -565,7 +698,50 @@ func (r *distributionResource) Update(ctx context.Context, req resource.UpdateRe } // Point to the populated slice - blockedCountries = &tempBlockedCountries + blockedCountries = tempBlockedCountries + } + + // redirects + var redirectsConfig *cdnSdk.RedirectConfig + if configModel.Redirects != nil { + sdkRules := []cdnSdk.RedirectRule{} + if len(configModel.Redirects.Rules) > 0 { + for _, rule := range configModel.Redirects.Rules { + matchers := []cdnSdk.Matcher{} + for _, matcher := range rule.Matchers { + var matchCond *cdnSdk.MatchCondition + if matcher.ValueMatchCondition != nil { + cond := cdnSdk.MatchCondition(*matcher.ValueMatchCondition) + matchCond = &cond + } + + matchers = append(matchers, cdnSdk.Matcher{ + Values: matcher.Values, + ValueMatchCondition: matchCond, + }) + } + + var ruleMatchCond *cdnSdk.MatchCondition + if rule.RuleMatchCondition != nil { + cond := cdnSdk.MatchCondition(*rule.RuleMatchCondition) + ruleMatchCond = &cond + } + targetUrl := rule.TargetUrl + + sdkConfigRule := cdnSdk.RedirectRule{ + Description: rule.Description, + Enabled: rule.Enabled, + Matchers: matchers, + RuleMatchCondition: ruleMatchCond, + StatusCode: rule.StatusCode, + TargetUrl: targetUrl, + } + sdkRules = append(sdkRules, sdkConfigRule) + } + } + redirectsConfig = &cdnSdk.RedirectConfig{ + Rules: sdkRules, + } } configPatchBackend := &cdnSdk.ConfigPatchBackend{} @@ -612,7 +788,8 @@ func (r *distributionResource) Update(ctx context.Context, req resource.UpdateRe configPatch := &cdnSdk.ConfigPatch{ Backend: configPatchBackend, Regions: regions, - BlockedCountries: *blockedCountries, + BlockedCountries: blockedCountries, + Redirects: redirectsConfig, } if !utils.IsUndefined(configModel.Optimizer) { @@ -767,6 +944,99 @@ func mapFields(ctx context.Context, distribution *cdnSdk.Distribution, model *Mo } } + // redirects + redirectsVal := types.ObjectNull(redirectsTypes) + if 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.StringValue("ANY") + 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.StringValue("") + if r.Description != nil { + tfDesc = types.StringValue(*r.Description) + } + + tfEnabled := types.BoolValue(true) + if r.Enabled != nil { + tfEnabled = types.BoolValue(*r.Enabled) + } + + tfTargetUrl := types.StringNull() + if r.TargetUrl != "" { + tfTargetUrl = types.StringValue(r.TargetUrl) + } + + tfStatusCode := types.Int32Null() + if r.StatusCode > 0 { + tfStatusCode = types.Int32Value(int32(r.StatusCode)) // nolint:gosec // HTTP status codes are safely within int32 bounds + } + + tfRuleMatchCond := types.StringValue("ANY") + 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.BlockedCountries != nil { @@ -904,6 +1174,7 @@ func mapFields(ctx context.Context, distribution *cdnSdk.Distribution, model *Mo "regions": modelRegions, "blocked_countries": modelBlockedCountries, "optimizer": optimizerVal, + "redirects": redirectsVal, }) if diags.HasError() { return core.DiagsToError(diags) @@ -1001,13 +1272,13 @@ func toCreatePayload(ctx context.Context, model *Model) (*cdnSdk.CreateDistribut }, } } - payload := &cdnSdk.CreateDistributionPayload{ IntentId: new(uuid.NewString()), Regions: cfg.Regions, Backend: *backend, BlockedCountries: cfg.BlockedCountries, Optimizer: optimizer, + Redirects: cfg.Redirects, } return payload, nil @@ -1017,6 +1288,7 @@ func convertConfig(ctx context.Context, model *Model) (*cdnSdk.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") } @@ -1051,6 +1323,50 @@ func convertConfig(ctx context.Context, model *Model) (*cdnSdk.Config, error) { } } + // redirects + var redirectsConfig *cdnSdk.RedirectConfig + + if configModel.Redirects != nil { + sdkRules := []cdnSdk.RedirectRule{} + + if len(configModel.Redirects.Rules) > 0 { + for _, rule := range configModel.Redirects.Rules { + matchers := []cdnSdk.Matcher{} + for _, matcher := range rule.Matchers { + var matchCond *cdnSdk.MatchCondition + if matcher.ValueMatchCondition != nil { + cond := cdnSdk.MatchCondition(*matcher.ValueMatchCondition) + matchCond = &cond + } + + matchers = append(matchers, cdnSdk.Matcher{ + Values: matcher.Values, + ValueMatchCondition: matchCond, + }) + } + + var ruleMatchCond *cdnSdk.MatchCondition + if rule.RuleMatchCondition != nil { + cond := cdnSdk.MatchCondition(*rule.RuleMatchCondition) + ruleMatchCond = &cond + } + + sdkConfigRule := cdnSdk.RedirectRule{ + Description: rule.Description, + Enabled: rule.Enabled, + Matchers: matchers, + RuleMatchCondition: ruleMatchCond, + StatusCode: rule.StatusCode, + TargetUrl: rule.TargetUrl, + } + sdkRules = append(sdkRules, sdkConfigRule) + } + } + redirectsConfig = &cdnSdk.RedirectConfig{ + Rules: sdkRules, + } + } + // geofencing geofencing := map[string][]string{} if configModel.Backend.Geofencing != nil { @@ -1074,6 +1390,7 @@ func convertConfig(ctx context.Context, model *Model) (*cdnSdk.Config, error) { Backend: cdnSdk.ConfigBackend{}, Regions: regions, BlockedCountries: blockedCountries, + Redirects: redirectsConfig, } switch configModel.Backend.Type { diff --git a/stackit/internal/services/cdn/distribution/resource_test.go b/stackit/internal/services/cdn/distribution/resource_test.go index 39c528349..b6c64b20b 100644 --- a/stackit/internal/services/cdn/distribution/resource_test.go +++ b/stackit/internal/services/cdn/distribution/resource_test.go @@ -9,6 +9,7 @@ import ( "github.com/google/go-cmp/cmp/cmpopts" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" cdnSdk "github.com/stackitcloud/stackit-sdk-go/services/cdn/v1api" ) @@ -41,12 +42,44 @@ func TestToCreatePayload(t *testing.T) { optimizer := types.ObjectValueMust(optimizerTypes, map[string]attr.Value{ "enabled": types.BoolValue(true), }) + + 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, "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 +119,7 @@ func TestToCreatePayload(t *testing.T) { "regions": regionsFixture, "optimizer": optimizer, "blocked_countries": blockedCountriesFixture, + "redirects": types.ObjectNull(redirectsAttrTypes), }) }), Expected: &cdnSdk.CreateDistributionPayload{ @@ -103,6 +137,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: &cdnSdk.CreateDistributionPayload{ + Regions: []cdnSdk.Region{"EU", "US"}, + BlockedCountries: []string{"XX", "YY", "ZZ"}, + 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", + }, + }, + Redirects: &cdnSdk.RedirectConfig{ + Rules: []cdnSdk.RedirectRule{ + { + Description: cdnSdk.PtrString("Test redirect"), + Enabled: cdnSdk.PtrBool(true), + TargetUrl: "https://example.com/redirect", + StatusCode: 301, + RuleMatchCondition: cdnSdk.MatchCondition("ANY").Ptr(), + Matchers: []cdnSdk.Matcher{ + { + Values: []string{"/shop/*"}, + ValueMatchCondition: cdnSdk.MatchCondition("ANY").Ptr(), + }, + }, + }, + }, + }, + }, + IsValid: true, + }, "happy_path_bucket": { Input: modelFixture(func(m *Model) { creds := types.ObjectValueMust(backendCredentialsTypes, map[string]attr.Value{ @@ -123,6 +198,7 @@ func TestToCreatePayload(t *testing.T) { "regions": regionsFixture, // reusing the existing one "blocked_countries": blockedCountriesFixture, "optimizer": types.ObjectNull(optimizerTypes), + "redirects": types.ObjectNull(redirectsAttrTypes), }) }), Expected: &cdnSdk.CreateDistributionPayload{ @@ -204,12 +280,44 @@ 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)}) + + 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, "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"), @@ -221,6 +329,7 @@ func TestConvertConfig(t *testing.T) { } return model } + tests := map[string]struct { Input *Model Expected *cdnSdk.Config @@ -254,6 +363,7 @@ func TestConvertConfig(t *testing.T) { "regions": regionsFixture, "optimizer": optimizer, "blocked_countries": blockedCountriesFixture, + "redirects": types.ObjectNull(redirectsAttrTypes), }) }), Expected: &cdnSdk.Config{ @@ -276,6 +386,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: &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"}, + Redirects: &cdnSdk.RedirectConfig{ + Rules: []cdnSdk.RedirectRule{ + { + Description: cdnSdk.PtrString("Test redirect"), + Enabled: cdnSdk.PtrBool(true), + TargetUrl: "https://example.com/redirect", + StatusCode: 301, + RuleMatchCondition: cdnSdk.MatchCondition("ANY").Ptr(), + Matchers: []cdnSdk.Matcher{ + { + Values: []string{"/shop/*"}, + ValueMatchCondition: cdnSdk.MatchCondition("ANY").Ptr(), + }, + }, + }, + }, + }, + }, + IsValid: true, + }, "happy_path_bucket": { Input: modelFixture(func(m *Model) { creds := types.ObjectValueMust(backendCredentialsTypes, map[string]attr.Value{ @@ -296,6 +452,7 @@ func TestConvertConfig(t *testing.T) { "regions": regionsFixture, "blocked_countries": blockedCountriesFixture, "optimizer": types.ObjectNull(optimizerTypes), + "redirects": types.ObjectNull(redirectsAttrTypes), }) }), Expected: &cdnSdk.Config{ @@ -326,6 +483,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) @@ -381,11 +539,60 @@ func TestMapFields(t *testing.T) { optimizer := types.ObjectValueMust(optimizerTypes, map[string]attr.Value{ "enabled": types.BoolValue(true), }) + + 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, "regions": regionsFixture, "blocked_countries": blockedCountriesFixture, "optimizer": types.ObjectNull(optimizerTypes), + "redirects": types.ObjectNull(redirectsAttrTypes), + }) + + redirectsInput := &cdnSdk.RedirectConfig{ + Rules: []cdnSdk.RedirectRule{ + { + Description: cdnSdk.PtrString("Test redirect"), + Enabled: cdnSdk.PtrBool(true), + TargetUrl: "https://example.com/redirect", + StatusCode: 301, + RuleMatchCondition: cdnSdk.MatchCondition("ANY").Ptr(), + Matchers: []cdnSdk.Matcher{ + { + Values: []string{"/shop/*"}, + ValueMatchCondition: cdnSdk.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{}) @@ -467,6 +674,7 @@ func TestMapFields(t *testing.T) { "regions": regionsFixture, "blocked_countries": blockedCountriesFixture, "optimizer": types.ObjectNull(optimizerTypes), + "redirects": types.ObjectNull(redirectsAttrTypes), }) tests := map[string]struct { Input *cdnSdk.Distribution @@ -486,6 +694,7 @@ func TestMapFields(t *testing.T) { "regions": regionsFixture, "optimizer": optimizer, "blocked_countries": blockedCountriesFixture, + "redirects": types.ObjectNull(redirectsAttrTypes), }) }), Input: distributionFixture(func(d *cdnSdk.Distribution) { @@ -511,6 +720,7 @@ func TestMapFields(t *testing.T) { "regions": regionsFixture, "optimizer": types.ObjectNull(optimizerTypes), "blocked_countries": blockedCountriesFixture, + "redirects": types.ObjectNull(redirectsAttrTypes), }) }), Input: distributionFixture(func(d *cdnSdk.Distribution) { @@ -518,6 +728,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 *cdnSdk.Distribution) { + d.Config.Redirects = redirectsInput + }), + IsValid: true, + }, "happy_path_status_error": { Expected: expectedModel(func(m *Model) { m.Status = types.StringValue("ERROR") 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