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