From 9689ea8606be3b7729e3b3f2a155e78e2d56d5fb Mon Sep 17 00:00:00 2001 From: Matheus Politano Date: Fri, 25 Sep 2026 14:01:23 +0200 Subject: [PATCH 1/2] feat(cdn): Add cache_config attribute to distribution --- docs/data-sources/cdn_distribution.md | 13 +- docs/resources/cdn_distribution.md | 19 +- .../stackit_cdn_distribution/resource.tf | 6 + stackit/internal/services/cdn/cdn_acc_test.go | 28 + .../services/cdn/distribution/datasource.go | 57 +- .../cdn/distribution/datasource_test.go | 80 +++ .../services/cdn/distribution/resource.go | 571 ++++++++++++------ .../cdn/distribution/resource_test.go | 369 +++++++++++ .../cdn/testdata/resource-http-base.tf | 19 +- 9 files changed, 976 insertions(+), 186 deletions(-) diff --git a/docs/data-sources/cdn_distribution.md b/docs/data-sources/cdn_distribution.md index 2e7b817ab..25dc48304 100644 --- a/docs/data-sources/cdn_distribution.md +++ b/docs/data-sources/cdn_distribution.md @@ -51,10 +51,11 @@ Read-Only: - `backend` (Attributes) The configured backend for the distribution (see [below for nested schema](#nestedatt--config--backend)) - `blocked_ips` (List of String) Restricts access to your content by specifying a list of blocked IPv4 addresses. This feature enhances security and privacy by preventing these addresses from accessing your distribution. Note: once a value is set, removing the attribute from your configuration will retain the last known value in state; to clear it explicitly, set it to an empty list. +- `cache_config` (Attributes) Groups the cache options that influence how the CDN builds its cache key. Warning: enabling query-string vary produces one cache entry per unique query-string combination (unbounded when query_string_vary_parameters is empty). Each entry in cache_key_headers multiplies the number of cache variants by the number of distinct values observed for that header. Use these settings sparingly. Note: when the Image Optimizer is enabled, the CDN automatically enables query-string vary for image responses regardless of the query_string_vary_enabled setting. (see [below for nested schema](#nestedatt--config--cache_config)) - `default_cache_duration` (String) Sets the default cache duration for the distribution. The default cache duration is applied when a 'Cache-Control' header is not presented in the origin's response. We use ISO8601 duration format for cache duration (e.g. P1DT2H30M). Note: once a value is set, removing the attribute from your configuration will retain the last known value in state. - `forward_host_header` (Boolean) Enable this allows the 'Host' header to be passed through to the origin. - `monthly_limit_bytes` (Number) Sets the monthly limit of bandwidth in bytes that the pullzone is allowed to use. Note: once a value is set, removing the attribute from your configuration will retain the last known value in state. -- `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)) +- `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. Note: when the Image Optimizer is enabled, the CDN automatically enables query-string vary for image responses regardless of the query_string_vary_enabled setting. (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 - `strip_response_cookies` (Boolean) Enable this to prevent origin-level cookies from being forwarded to the end user. @@ -74,6 +75,16 @@ Read-Only: - `type` (String) The configured backend type. Possible values are: `http`, `bucket`. + +### Nested Schema for `config.cache_config` + +Read-Only: + +- `cache_key_headers` (List of String) HTTP request header names whose values participate in the cache key. Each entry multiplies the number of cache variants by the number of distinct values observed for that header. +- `query_string_vary_enabled` (Boolean) When true, the CDN varies its cache by the request query string. If query_string_vary_parameters is empty, every unique query-string combination produces its own cache entry; if non-empty, only the listed parameters influence the cache key. +- `query_string_vary_parameters` (List of String) Allowlist of query-string parameter names that participate in the cache key when query_string_vary_enabled is true. Ignored while query_string_vary_enabled is false, but still stored so it can be re-activated without losing the list. + + ### Nested Schema for `config.optimizer` diff --git a/docs/resources/cdn_distribution.md b/docs/resources/cdn_distribution.md index 5e5bad974..5e7239655 100644 --- a/docs/resources/cdn_distribution.md +++ b/docs/resources/cdn_distribution.md @@ -36,6 +36,12 @@ resource "stackit_cdn_distribution" "example_distribution" { optimizer = { enabled = true } + + cache_config = { + cache_key_headers = ["Accept-Language"] + query_string_vary_enabled = true + query_string_vary_parameters = ["page", "sort"] + } } } @@ -156,10 +162,11 @@ Optional: - `blocked_countries` (List of String) The configured countries where distribution of content is blocked - `blocked_ips` (List of String) Restricts access to your content by specifying a list of blocked IPv4 addresses. This feature enhances security and privacy by preventing these addresses from accessing your distribution. Note: once a value is set, removing the attribute from your configuration will retain the last known value in state; to clear it explicitly, set it to an empty list. +- `cache_config` (Attributes) Groups the cache options that influence how the CDN builds its cache key. Warning: enabling query-string vary produces one cache entry per unique query-string combination (unbounded when query_string_vary_parameters is empty). Each entry in cache_key_headers multiplies the number of cache variants by the number of distinct values observed for that header. Use these settings sparingly. Note: when the Image Optimizer is enabled, the CDN automatically enables query-string vary for image responses regardless of the query_string_vary_enabled setting. (see [below for nested schema](#nestedatt--config--cache_config)) - `default_cache_duration` (String) Sets the default cache duration for the distribution. The default cache duration is applied when a 'Cache-Control' header is not presented in the origin's response. We use ISO8601 duration format for cache duration (e.g. P1DT2H30M). Note: once a value is set, removing the attribute from your configuration will retain the last known value in state. - `forward_host_header` (Boolean) Enable this allows the 'Host' header to be passed through to the origin. - `monthly_limit_bytes` (Number) Sets the monthly limit of bandwidth in bytes that the pullzone is allowed to use. Note: once a value is set, removing the attribute from your configuration will retain the last known value in state. -- `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)) +- `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. Note: when the Image Optimizer is enabled, the CDN automatically enables query-string vary for image responses regardless of the query_string_vary_enabled setting. (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)) - `strip_response_cookies` (Boolean) Enable this to prevent origin-level cookies from being forwarded to the end user. - `tls` (Attributes) Configuration for TLS protocol versions. Note: Enabling older TLS versions (1.0, 1.1) is generally discouraged for security reasons. (see [below for nested schema](#nestedatt--config--tls)) @@ -191,6 +198,16 @@ Required: + +### Nested Schema for `config.cache_config` + +Optional: + +- `cache_key_headers` (List of String) HTTP request header names whose values participate in the cache key. Each entry multiplies the number of cache variants by the number of distinct values observed for that header. +- `query_string_vary_enabled` (Boolean) When true, the CDN varies its cache by the request query string. If query_string_vary_parameters is empty, every unique query-string combination produces its own cache entry; if non-empty, only the listed parameters influence the cache key. +- `query_string_vary_parameters` (List of String) Allowlist of query-string parameter names that participate in the cache key when query_string_vary_enabled is true. Ignored while query_string_vary_enabled is false, but still stored so it can be re-activated without losing the list. + + ### Nested Schema for `config.optimizer` diff --git a/examples/resources/stackit_cdn_distribution/resource.tf b/examples/resources/stackit_cdn_distribution/resource.tf index 96ca7caae..2ad67c399 100644 --- a/examples/resources/stackit_cdn_distribution/resource.tf +++ b/examples/resources/stackit_cdn_distribution/resource.tf @@ -18,6 +18,12 @@ resource "stackit_cdn_distribution" "example_distribution" { optimizer = { enabled = true } + + cache_config = { + cache_key_headers = ["Accept-Language"] + query_string_vary_enabled = true + query_string_vary_parameters = ["page", "sort"] + } } } diff --git a/stackit/internal/services/cdn/cdn_acc_test.go b/stackit/internal/services/cdn/cdn_acc_test.go index 698f848ac..11c49a1b0 100644 --- a/stackit/internal/services/cdn/cdn_acc_test.go +++ b/stackit/internal/services/cdn/cdn_acc_test.go @@ -136,6 +136,9 @@ var testConfigVarsHttp = config.Variables{ "forward_host_header": config.BoolVariable(true), "monthly_limit_bytes": config.IntegerVariable(104857600), "default_cache_duration": config.StringVariable("PT2H"), + "cache_key_headers": config.ListVariable(config.StringVariable("Authorization")), + "query_string_vary_enabled": config.BoolVariable(true), + "query_string_vary_parameters": config.ListVariable(config.StringVariable("utm_source"), config.StringVariable("page")), "waf": wafConfigVariable( "ENABLED", "FREE", @@ -167,6 +170,7 @@ func configVarsHttpUpdated() config.Variables { // Update small features updatedConfig["strip_response_cookies"] = config.BoolVariable(true) updatedConfig["forward_host_header"] = config.BoolVariable(false) + updatedConfig["query_string_vary_enabled"] = config.BoolVariable(false) return updatedConfig } @@ -263,6 +267,14 @@ func TestAccCDNDistributionHttp(t *testing.T) { resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.monthly_limit_bytes", testutil.ConvertConfigVariable(testConfigVarsHttp["monthly_limit_bytes"])), resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.default_cache_duration", testutil.ConvertConfigVariable(testConfigVarsHttp["default_cache_duration"])), + // Cache Config Checks + resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.cache_config.query_string_vary_enabled", "true"), + resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.cache_config.cache_key_headers.#", "1"), + resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.cache_config.cache_key_headers.0", "Authorization"), + resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.cache_config.query_string_vary_parameters.#", "2"), + resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.cache_config.query_string_vary_parameters.0", "utm_source"), + resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.cache_config.query_string_vary_parameters.1", "page"), + // WAF Checks testutil.CheckObjectAttr("stackit_cdn_distribution.distribution", "config.waf", testConfigVarsHttp["waf"]), @@ -380,6 +392,14 @@ func TestAccCDNDistributionHttp(t *testing.T) { resource.TestCheckResourceAttr("data.stackit_cdn_distribution.distribution", "config.monthly_limit_bytes", testutil.ConvertConfigVariable(testConfigVarsHttp["monthly_limit_bytes"])), resource.TestCheckResourceAttr("data.stackit_cdn_distribution.distribution", "config.default_cache_duration", testutil.ConvertConfigVariable(testConfigVarsHttp["default_cache_duration"])), + // Cache Config Checks inside Data Source + resource.TestCheckResourceAttr("data.stackit_cdn_distribution.distribution", "config.cache_config.query_string_vary_enabled", "true"), + resource.TestCheckResourceAttr("data.stackit_cdn_distribution.distribution", "config.cache_config.cache_key_headers.#", "1"), + resource.TestCheckResourceAttr("data.stackit_cdn_distribution.distribution", "config.cache_config.cache_key_headers.0", "Authorization"), + resource.TestCheckResourceAttr("data.stackit_cdn_distribution.distribution", "config.cache_config.query_string_vary_parameters.#", "2"), + resource.TestCheckResourceAttr("data.stackit_cdn_distribution.distribution", "config.cache_config.query_string_vary_parameters.0", "utm_source"), + resource.TestCheckResourceAttr("data.stackit_cdn_distribution.distribution", "config.cache_config.query_string_vary_parameters.1", "page"), + // WAF Checks inside Data Source testutil.CheckObjectAttr("data.stackit_cdn_distribution.distribution", "config.waf", testConfigVarsHttp["waf"]), @@ -435,6 +455,14 @@ func TestAccCDNDistributionHttp(t *testing.T) { resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.monthly_limit_bytes", testutil.ConvertConfigVariable(configVarsHttpUpdated()["monthly_limit_bytes"])), resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.default_cache_duration", testutil.ConvertConfigVariable(configVarsHttpUpdated()["default_cache_duration"])), + // Cache Config Checks + resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.cache_config.query_string_vary_enabled", "false"), + resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.cache_config.cache_key_headers.#", "1"), + resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.cache_config.cache_key_headers.0", "Authorization"), + resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.cache_config.query_string_vary_parameters.#", "2"), + resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.cache_config.query_string_vary_parameters.0", "utm_source"), + resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.cache_config.query_string_vary_parameters.1", "page"), + // Checking WAF Mutated Configurations testutil.CheckObjectAttr("stackit_cdn_distribution.distribution", "config.waf", configVarsHttpUpdated()["waf"]), diff --git a/stackit/internal/services/cdn/distribution/datasource.go b/stackit/internal/services/cdn/distribution/datasource.go index d3d6680f0..97877de76 100644 --- a/stackit/internal/services/cdn/distribution/datasource.go +++ b/stackit/internal/services/cdn/distribution/datasource.go @@ -52,6 +52,9 @@ var dataSourceConfigTypes = map[string]attr.Type{ "tls": types.ObjectType{ AttrTypes: tlsTypes, // Shared from resource.go }, + "cache_config": types.ObjectType{ + AttrTypes: cacheConfigTypes, // Shared from resource.go + }, "strip_response_cookies": types.BoolType, "forward_host_header": types.BoolType, } @@ -228,6 +231,26 @@ func (r *distributionDataSource) Schema(_ context.Context, _ datasource.SchemaRe }, }, }, + "cache_config": schema.SingleNestedAttribute{ + Description: schemaDescriptions["config_cache_config"], + Computed: true, + Attributes: map[string]schema.Attribute{ + "cache_key_headers": schema.ListAttribute{ + Description: schemaDescriptions["config_cache_config_cache_key_headers"], + Computed: true, + ElementType: types.StringType, + }, + "query_string_vary_enabled": schema.BoolAttribute{ + Description: schemaDescriptions["config_cache_config_query_string_vary_enabled"], + Computed: true, + }, + "query_string_vary_parameters": schema.ListAttribute{ + Description: schemaDescriptions["config_cache_config_query_string_vary_parameters"], + Computed: true, + ElementType: types.StringType, + }, + }, + }, "strip_response_cookies": schema.BoolAttribute{ Computed: true, Description: schemaDescriptions["config_strip_response_cookies"], @@ -691,7 +714,38 @@ func mapDataSourceFields(ctx context.Context, distribution *cdnSdk.Distribution, tlsVal, diagTls := types.ObjectValue(tlsTypes, tlsObjAttrs) if diagTls.HasError() { - return core.DiagsToError(diagWaf) + return core.DiagsToError(diagTls) + } + + var cacheKeyHeaders []attr.Value + if headers := distribution.Config.CacheConfig.CacheKeyHeaders; headers != nil { + for _, h := range headers { + cacheKeyHeaders = append(cacheKeyHeaders, types.StringValue(h)) + } + } + cacheKeyHeadersList, diags := types.ListValue(types.StringType, cacheKeyHeaders) + if diags.HasError() { + return core.DiagsToError(diags) + } + + var queryStringVaryParams []attr.Value + if params := distribution.Config.CacheConfig.QueryStringVaryParameters; params != nil { + for _, p := range params { + queryStringVaryParams = append(queryStringVaryParams, types.StringValue(p)) + } + } + queryStringVaryParamsList, diags := types.ListValue(types.StringType, queryStringVaryParams) + if diags.HasError() { + return core.DiagsToError(diags) + } + + cacheConfigVal, diagCache := types.ObjectValue(cacheConfigTypes, map[string]attr.Value{ + "cache_key_headers": cacheKeyHeadersList, + "query_string_vary_enabled": types.BoolValue(distribution.Config.CacheConfig.QueryStringVaryEnabled), + "query_string_vary_parameters": queryStringVaryParamsList, + }) + if diagCache.HasError() { + return core.DiagsToError(diagCache) } // blockedIps @@ -733,6 +787,7 @@ func mapDataSourceFields(ctx context.Context, distribution *cdnSdk.Distribution, "redirects": redirectsVal, "waf": wafVal, "tls": tlsVal, + "cache_config": cacheConfigVal, "strip_response_cookies": types.BoolValue(distribution.Config.StripResponseCookies), "forward_host_header": types.BoolValue(distribution.Config.ForwardHostHeader), }) diff --git a/stackit/internal/services/cdn/distribution/datasource_test.go b/stackit/internal/services/cdn/distribution/datasource_test.go index 67b56d6fc..ed2109094 100644 --- a/stackit/internal/services/cdn/distribution/datasource_test.go +++ b/stackit/internal/services/cdn/distribution/datasource_test.go @@ -62,6 +62,11 @@ func TestMapDataSourceFields(t *testing.T) { "enable_tls_10": types.BoolValue(false), "enable_tls_11": types.BoolValue(false), }) + defaultCacheConfig := types.ObjectValueMust(cacheConfigTypes, map[string]attr.Value{ + "cache_key_headers": types.ListValueMust(types.StringType, []attr.Value{}), + "query_string_vary_enabled": types.BoolValue(false), + "query_string_vary_parameters": types.ListValueMust(types.StringType, []attr.Value{}), + }) config := types.ObjectValueMust(dataSourceConfigTypes, map[string]attr.Value{ "backend": backend, "regions": regionsFixture, @@ -73,6 +78,7 @@ func TestMapDataSourceFields(t *testing.T) { "redirects": types.ObjectNull(redirectsTypes), "waf": emptyWaf, "tls": defaultTls, + "cache_config": defaultCacheConfig, "strip_response_cookies": types.BoolValue(false), "forward_host_header": types.BoolValue(false), }) @@ -252,6 +258,7 @@ func TestMapDataSourceFields(t *testing.T) { "redirects": types.ObjectNull(redirectsTypes), "waf": emptyWaf, "tls": defaultTls, + "cache_config": defaultCacheConfig, "strip_response_cookies": types.BoolValue(false), "forward_host_header": types.BoolValue(false), }) @@ -285,6 +292,7 @@ func TestMapDataSourceFields(t *testing.T) { "redirects": types.ObjectNull(redirectsTypes), "waf": emptyWaf, "tls": defaultTls, + "cache_config": defaultCacheConfig, "strip_response_cookies": types.BoolValue(false), "forward_host_header": types.BoolValue(false), }) @@ -312,6 +320,7 @@ func TestMapDataSourceFields(t *testing.T) { "redirects": types.ObjectNull(redirectsTypes), "waf": emptyWaf, "tls": defaultTls, + "cache_config": defaultCacheConfig, "strip_response_cookies": types.BoolValue(false), "forward_host_header": types.BoolValue(false), }) @@ -343,6 +352,7 @@ func TestMapDataSourceFields(t *testing.T) { "redirects": redirectsConfigExpected, "waf": emptyWaf, "tls": defaultTls, + "cache_config": defaultCacheConfig, "strip_response_cookies": types.BoolValue(false), "forward_host_header": types.BoolValue(false), }) @@ -365,6 +375,7 @@ func TestMapDataSourceFields(t *testing.T) { "redirects": types.ObjectNull(redirectsTypes), "waf": populatedWaf, "tls": defaultTls, + "cache_config": defaultCacheConfig, "strip_response_cookies": types.BoolValue(false), "forward_host_header": types.BoolValue(false), }) @@ -423,6 +434,7 @@ func TestMapDataSourceFields(t *testing.T) { "enable_tls_10": types.BoolValue(true), "enable_tls_11": types.BoolValue(true), }), + "cache_config": defaultCacheConfig, "strip_response_cookies": types.BoolValue(true), "forward_host_header": types.BoolValue(true), }) @@ -437,6 +449,74 @@ func TestMapDataSourceFields(t *testing.T) { }), IsValid: true, }, + "happy_path_with_cache_config": { + Expected: expectedModel(func(m *Model) { + m.Config = types.ObjectValueMust(dataSourceConfigTypes, map[string]attr.Value{ + "backend": backend, + "regions": regionsFixture, + "blocked_countries": blockedCountriesFixture, + "blocked_ips": types.ListValueMust(types.StringType, []attr.Value{}), + "default_cache_duration": types.StringNull(), + "monthly_limit_bytes": types.Int64Null(), + "optimizer": types.ObjectNull(optimizerTypes), + "redirects": types.ObjectNull(redirectsTypes), + "waf": emptyWaf, + "tls": defaultTls, + "cache_config": types.ObjectValueMust(cacheConfigTypes, map[string]attr.Value{ + "cache_key_headers": types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("Authorization"), + types.StringValue("Accept-Language"), + }), + "query_string_vary_enabled": types.BoolValue(true), + "query_string_vary_parameters": types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("utm_source"), + types.StringValue("page"), + }), + }), + "strip_response_cookies": types.BoolValue(false), + "forward_host_header": types.BoolValue(false), + }) + }), + Input: distributionFixture(func(d *cdnSdk.Distribution) { + d.Config.CacheConfig = cdnSdk.CacheConfig{ + CacheKeyHeaders: []string{"Authorization", "Accept-Language"}, + QueryStringVaryEnabled: true, + QueryStringVaryParameters: []string{"utm_source", "page"}, + } + }), + IsValid: true, + }, + "happy_path_with_cache_config_vary_disabled_with_parameters": { + Expected: expectedModel(func(m *Model) { + m.Config = types.ObjectValueMust(dataSourceConfigTypes, map[string]attr.Value{ + "backend": backend, + "regions": regionsFixture, + "blocked_countries": blockedCountriesFixture, + "blocked_ips": types.ListValueMust(types.StringType, []attr.Value{}), + "default_cache_duration": types.StringNull(), + "monthly_limit_bytes": types.Int64Null(), + "optimizer": types.ObjectNull(optimizerTypes), + "redirects": types.ObjectNull(redirectsTypes), + "waf": emptyWaf, + "tls": defaultTls, + "cache_config": types.ObjectValueMust(cacheConfigTypes, map[string]attr.Value{ + "cache_key_headers": types.ListValueMust(types.StringType, []attr.Value{}), + "query_string_vary_enabled": types.BoolValue(false), + "query_string_vary_parameters": types.ListValueMust(types.StringType, []attr.Value{types.StringValue("utm_source")}), + }), + "strip_response_cookies": types.BoolValue(false), + "forward_host_header": types.BoolValue(false), + }) + }), + Input: distributionFixture(func(d *cdnSdk.Distribution) { + d.Config.CacheConfig = cdnSdk.CacheConfig{ + CacheKeyHeaders: []string{}, + QueryStringVaryEnabled: false, + QueryStringVaryParameters: []string{"utm_source"}, + } + }), + IsValid: true, + }, "sad_path_distribution_nil": { Expected: nil, Input: nil, diff --git a/stackit/internal/services/cdn/distribution/resource.go b/stackit/internal/services/cdn/distribution/resource.go index fbd41ff8d..9ab457a41 100644 --- a/stackit/internal/services/cdn/distribution/resource.go +++ b/stackit/internal/services/cdn/distribution/resource.go @@ -63,7 +63,7 @@ var schemaDescriptions = map[string]string{ "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_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. Note: when the Image Optimizer is enabled, the CDN automatically enables query-string vary for image responses regardless of the query_string_vary_enabled setting.", "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", @@ -110,6 +110,11 @@ var schemaDescriptions = map[string]string{ "config_tls_enable_tls_11": "If set to true, the distribution will accept connections using TLS 1.1.", "config_strip_response_cookies": "Enable this to prevent origin-level cookies from being forwarded to the end user.", "config_forward_host_header": "Enable this allows the 'Host' header to be passed through to the origin.", + + "config_cache_config": "Groups the cache options that influence how the CDN builds its cache key. Warning: enabling query-string vary produces one cache entry per unique query-string combination (unbounded when query_string_vary_parameters is empty). Each entry in cache_key_headers multiplies the number of cache variants by the number of distinct values observed for that header. Use these settings sparingly. Note: when the Image Optimizer is enabled, the CDN automatically enables query-string vary for image responses regardless of the query_string_vary_enabled setting.", + "config_cache_config_query_string_vary_enabled": "When true, the CDN varies its cache by the request query string. If query_string_vary_parameters is empty, every unique query-string combination produces its own cache entry; if non-empty, only the listed parameters influence the cache key.", + "config_cache_config_query_string_vary_parameters": "Allowlist of query-string parameter names that participate in the cache key when query_string_vary_enabled is true. Ignored while query_string_vary_enabled is false, but still stored so it can be re-activated without losing the list.", + "config_cache_config_cache_key_headers": "HTTP request header names whose values participate in the cache key. Each entry multiplies the number of cache variants by the number of distinct values observed for that header.", } type Model struct { @@ -153,10 +158,17 @@ type distributionConfig struct { Optimizer types.Object `tfsdk:"optimizer"` // The optimizer configuration Waf types.Object `tfsdk:"waf"` // The WAF configuration Tls types.Object `tfsdk:"tls"` // The TLS configuration + CacheConfig types.Object `tfsdk:"cache_config"` // The cache configuration StripResponseCookies types.Bool `tfsdk:"strip_response_cookies"` // The Enable this to prevent origin-level cookies from being forwarded to the end user ForwardHostHeader types.Bool `tfsdk:"forward_host_header"` // The Enable this allows the 'Host' header to be passed through to the origin. } +type cacheConfigModel struct { + CacheKeyHeaders types.List `tfsdk:"cache_key_headers"` + QueryStringVaryEnabled types.Bool `tfsdk:"query_string_vary_enabled"` + QueryStringVaryParameters types.List `tfsdk:"query_string_vary_parameters"` +} + type optimizerConfig struct { Enabled types.Bool `tfsdk:"enabled"` } @@ -218,10 +230,19 @@ var configTypes = map[string]attr.Type{ "tls": types.ObjectType{ AttrTypes: tlsTypes, }, + "cache_config": types.ObjectType{ + AttrTypes: cacheConfigTypes, + }, "strip_response_cookies": types.BoolType, "forward_host_header": types.BoolType, } +var cacheConfigTypes = map[string]attr.Type{ + "cache_key_headers": types.ListType{ElemType: types.StringType}, + "query_string_vary_enabled": types.BoolType, + "query_string_vary_parameters": types.ListType{ElemType: types.StringType}, +} + var optimizerTypes = map[string]attr.Type{ "enabled": types.BoolType, } @@ -421,6 +442,37 @@ func (r *distributionResource) Schema(_ context.Context, _ resource.SchemaReques objectvalidator.AlsoRequires(path.MatchRelative().AtName("enabled")), }, }, + "cache_config": schema.SingleNestedAttribute{ + Description: schemaDescriptions["config_cache_config"], + Optional: true, + Computed: true, + Attributes: map[string]schema.Attribute{ + "cache_key_headers": schema.ListAttribute{ + Description: schemaDescriptions["config_cache_config_cache_key_headers"], + Optional: true, + Computed: true, + ElementType: types.StringType, + Validators: []validator.List{ + listvalidator.NoNullValues(), + }, + }, + "query_string_vary_enabled": schema.BoolAttribute{ + Description: schemaDescriptions["config_cache_config_query_string_vary_enabled"], + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + }, + "query_string_vary_parameters": schema.ListAttribute{ + Description: schemaDescriptions["config_cache_config_query_string_vary_parameters"], + Optional: true, + Computed: true, + ElementType: types.StringType, + Validators: []validator.List{ + listvalidator.NoNullValues(), + }, + }, + }, + }, "strip_response_cookies": schema.BoolAttribute{ Optional: true, Computed: true, @@ -909,189 +961,13 @@ func (r *distributionResource) Update(ctx context.Context, req resource.UpdateRe ctx = tflog.SetField(ctx, "project_id", projectId) ctx = tflog.SetField(ctx, "distribution_id", distributionId) - configModel := distributionConfig{} - diags = model.Config.As(ctx, &configModel, basetypes.ObjectAsOptions{ - UnhandledNullAsEmpty: false, - UnhandledUnknownAsEmpty: false, - }) - if diags.HasError() { - core.LogAndAddError(ctx, &resp.Diagnostics, "Update CDN distribution", "Error mapping plan config") + patchPayload, err := toPatchPayload(ctx, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Update CDN distribution", fmt.Sprintf("Error creating patch payload: %v", err)) return } - regions := []cdnSdk.Region{} - for _, r := range *configModel.Regions { - regionEnum, err := cdnSdk.NewRegionFromValue(r) - if err != nil { - core.LogAndAddError(ctx, &resp.Diagnostics, "Update CDN distribution", fmt.Sprintf("Map regions: %v", err)) - return - } - regions = append(regions, *regionEnum) - } - - // blockedCountries - var blockedCountries []string - if configModel.BlockedCountries != nil { - tempBlockedCountries := []string{} - for _, blockedCountry := range *configModel.BlockedCountries { - validatedBlockedCountry, err := validateCountryCode(blockedCountry) - if err != nil { - core.LogAndAddError(ctx, &resp.Diagnostics, "Update CDN distribution", fmt.Sprintf("Blocked countries: %v", err)) - return - } - tempBlockedCountries = append(tempBlockedCountries, validatedBlockedCountry) - } - blockedCountries = tempBlockedCountries - } - - // blockedIps - var blockedIps []string - if !utils.IsUndefined(configModel.BlockedIps) { - bipDiags := configModel.BlockedIps.ElementsAs(ctx, &blockedIps, false) - if bipDiags.HasError() { - core.LogAndAddError(ctx, &resp.Diagnostics, "Update CDN distribution", fmt.Sprintf("Blocked IPs: %v", core.DiagsToError(bipDiags))) - return - } - } - - // tls - var tls *cdnSdk.TlsConfigPatch - if !utils.IsUndefined(configModel.Tls) { - var tlsValue tlsConfig - diags = configModel.Tls.As(ctx, &tlsValue, basetypes.ObjectAsOptions{}) - if diags.HasError() { - core.LogAndAddError(ctx, &resp.Diagnostics, "Update CDN distribution", "Error mapping TLS config") - return - } - tls = &cdnSdk.TlsConfigPatch{ - EnableTls10: new(tlsValue.EnableTls10.ValueBool()), - EnableTls11: new(tlsValue.EnableTls11.ValueBool()), - } - } - - // redirects - redirectsConfig := convertRedirectconfig(configModel.Redirects) - - configPatchBackend := &cdnSdk.ConfigPatchBackend{} - - switch configModel.Backend.Type { - case "http": - geofencingPatch := map[string][]string{} - if configModel.Backend.Geofencing != nil { - gf := make(map[string][]string) - for url, countries := range *configModel.Backend.Geofencing { - countryStrings := make([]string, len(countries)) - for i, countryPtr := range countries { - if countryPtr == nil { - core.LogAndAddError(ctx, &resp.Diagnostics, "Update CDN distribution", fmt.Sprintf("Geofencing url %q has a null value", url)) - return - } - countryStrings[i] = *countryPtr - } - gf[url] = countryStrings - } - geofencingPatch = gf - } - - configPatchBackend.HttpBackendPatch = &cdnSdk.HttpBackendPatch{ - OriginRequestHeaders: configModel.Backend.OriginRequestHeaders, - OriginUrl: configModel.Backend.OriginURL, - Type: "http", - Geofencing: &geofencingPatch, - } - case "bucket": - configPatchBackend.BucketBackendPatch = &cdnSdk.BucketBackendPatch{ - Type: "bucket", - BucketUrl: configModel.Backend.BucketURL, - Region: configModel.Backend.Region, - } - if configModel.Backend.Credentials != nil { - configPatchBackend.BucketBackendPatch.Credentials = &cdnSdk.BucketCredentials{ - AccessKeyId: *configModel.Backend.Credentials.AccessKey, - SecretAccessKey: *configModel.Backend.Credentials.SecretKey, - } - } - } - - configPatch := &cdnSdk.ConfigPatch{ - Backend: configPatchBackend, - Regions: regions, - BlockedCountries: blockedCountries, - BlockedIps: blockedIps, - Redirects: redirectsConfig, - Tls: tls, - } - - // forwardHostHeader - if !utils.IsUndefined(configModel.ForwardHostHeader) { - configPatch.ForwardHostHeader = new(configModel.ForwardHostHeader.ValueBool()) - } - // stripResponseCookies - if !utils.IsUndefined(configModel.StripResponseCookies) { - configPatch.StripResponseCookies = configModel.StripResponseCookies.ValueBoolPointer() - } - if !utils.IsUndefined(configModel.DefaultCacheDuration) { - configPatch.DefaultCacheDuration = *cdnSdk.NewNullableString(conversion.StringValueToPointer(configModel.DefaultCacheDuration)) - } - if !utils.IsUndefined(configModel.MonthlyLimitBytes) { - configPatch.MonthlyLimitBytes = *cdnSdk.NewNullableInt64(conversion.Int64ValueToPointer(configModel.MonthlyLimitBytes)) - } - - configPatch.Waf = &cdnSdk.WafConfigPatch{ - Mode: new(cdnSdk.WAFMODE_DISABLED), - Type: new(cdnSdk.WAFTYPE_FREE), - } - - // Map WAF Update - if !utils.IsUndefined(configModel.Waf) { - var wafModel wafConfig - diags := configModel.Waf.As(ctx, &wafModel, basetypes.ObjectAsOptions{}) - - configPatch.Waf.Mode = new(cdnSdk.WafMode(wafModel.Mode.ValueString())) - configPatch.Waf.Type = new(cdnSdk.WafType(wafModel.Type.ValueString())) - configPatch.Waf.AllowedHttpVersions = conversion.TerraformStringSetToList(ctx, wafModel.AllowedHttpVersions, &diags) - configPatch.Waf.AllowedRequestContentTypes = conversion.TerraformStringSetToList(ctx, wafModel.AllowedRequestContentTypes, &diags) - configPatch.Waf.AllowedHttpMethods = conversion.TerraformStringSetToList(ctx, wafModel.AllowedHttpMethods, &diags) - configPatch.Waf.EnabledRuleIds = conversion.TerraformStringSetToList(ctx, wafModel.EnabledRuleIds, &diags) - configPatch.Waf.DisabledRuleIds = conversion.TerraformStringSetToList(ctx, wafModel.DisabledRuleIds, &diags) - configPatch.Waf.LogOnlyRuleIds = conversion.TerraformStringSetToList(ctx, wafModel.LogOnlyRuleIds, &diags) - configPatch.Waf.EnabledRuleGroupIds = conversion.TerraformStringSetToList(ctx, wafModel.EnabledRuleGroupIds, &diags) - configPatch.Waf.DisabledRuleGroupIds = conversion.TerraformStringSetToList(ctx, wafModel.DisabledRuleGroupIds, &diags) - configPatch.Waf.LogOnlyRuleGroupIds = conversion.TerraformStringSetToList(ctx, wafModel.LogOnlyRuleGroupIds, &diags) - configPatch.Waf.EnabledRuleCollectionIds = conversion.TerraformStringSetToList(ctx, wafModel.EnabledRuleCollectionIds, &diags) - configPatch.Waf.DisabledRuleCollectionIds = conversion.TerraformStringSetToList(ctx, wafModel.DisabledRuleCollectionIds, &diags) - configPatch.Waf.LogOnlyRuleCollectionIds = conversion.TerraformStringSetToList(ctx, wafModel.LogOnlyRuleCollectionIds, &diags) - - if diags.HasError() { - core.LogAndAddError(ctx, &resp.Diagnostics, "Update CDN distribution", "Error mapping WAF config") - return - } - - if !utils.IsUndefined(wafModel.ParanoiaLevel) { - configPatch.Waf.ParanoiaLevel = new(cdnSdk.WafParanoiaLevel(wafModel.ParanoiaLevel.ValueString())) - } - } - - if !utils.IsUndefined(configModel.Optimizer) { - var optimizerModel optimizerConfig - - diags = configModel.Optimizer.As(ctx, &optimizerModel, basetypes.ObjectAsOptions{}) - if diags.HasError() { - core.LogAndAddError(ctx, &resp.Diagnostics, "Update CDN distribution", "Error mapping optimizer config") - return - } - - optimizer := cdnSdk.NewOptimizerPatch() - if !utils.IsUndefined(optimizerModel.Enabled) { - optimizer.SetEnabled(optimizerModel.Enabled.ValueBool()) - } - configPatch.Optimizer = optimizer - } - - _, err := r.client.DefaultAPI.PatchDistribution(ctx, projectId, distributionId).PatchDistributionPayload(cdnSdk.PatchDistributionPayload{ - Config: configPatch, - IntentId: new(uuid.NewString()), - }).Execute() + _, err = r.client.DefaultAPI.PatchDistribution(ctx, projectId, distributionId).PatchDistributionPayload(*patchPayload).Execute() if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Update CDN distribution", fmt.Sprintf("Patch distribution: %v", err)) return @@ -1486,7 +1362,38 @@ func mapFields(ctx context.Context, distribution *cdnSdk.Distribution, model *Mo tlsVal, diagTls := types.ObjectValue(tlsTypes, tlsObjAttrs) if diagTls.HasError() { - return core.DiagsToError(diagWaf) + return core.DiagsToError(diagTls) + } + + var cacheKeyHeaders []attr.Value + if headers := distribution.Config.CacheConfig.CacheKeyHeaders; headers != nil { + for _, h := range headers { + cacheKeyHeaders = append(cacheKeyHeaders, types.StringValue(h)) + } + } + cacheKeyHeadersList, diags := types.ListValue(types.StringType, cacheKeyHeaders) + if diags.HasError() { + return core.DiagsToError(diags) + } + + var queryStringVaryParams []attr.Value + if params := distribution.Config.CacheConfig.QueryStringVaryParameters; params != nil { + for _, p := range params { + queryStringVaryParams = append(queryStringVaryParams, types.StringValue(p)) + } + } + queryStringVaryParamsList, diags := types.ListValue(types.StringType, queryStringVaryParams) + if diags.HasError() { + return core.DiagsToError(diags) + } + + cacheConfigVal, diagCache := types.ObjectValue(cacheConfigTypes, map[string]attr.Value{ + "cache_key_headers": cacheKeyHeadersList, + "query_string_vary_enabled": types.BoolValue(distribution.Config.CacheConfig.QueryStringVaryEnabled), + "query_string_vary_parameters": queryStringVaryParamsList, + }) + if diagCache.HasError() { + return core.DiagsToError(diagCache) } // blockedIps @@ -1516,6 +1423,7 @@ func mapFields(ctx context.Context, distribution *cdnSdk.Distribution, model *Mo "redirects": redirectsVal, "waf": wafVal, "tls": tlsVal, + "cache_config": cacheConfigVal, "strip_response_cookies": types.BoolValue(distribution.Config.StripResponseCookies), "forward_host_header": types.BoolValue(distribution.Config.ForwardHostHeader), }) @@ -1629,6 +1537,34 @@ func toCreatePayload(ctx context.Context, model *Model) (*cdnSdk.CreateDistribut wafPayload = &cfg.Waf } + var cacheConfig *cdnSdk.CacheConfigCreate + if !utils.IsUndefined(rawConfig.CacheConfig) { + var cacheModel cacheConfigModel + diags := rawConfig.CacheConfig.As(ctx, &cacheModel, basetypes.ObjectAsOptions{}) + if diags.HasError() { + return nil, core.DiagsToError(diags) + } + + cacheConfig = cdnSdk.NewCacheConfigCreate() + if !utils.IsUndefined(cacheModel.QueryStringVaryEnabled) { + cacheConfig.SetQueryStringVaryEnabled(cacheModel.QueryStringVaryEnabled.ValueBool()) + } + if !utils.IsUndefined(cacheModel.CacheKeyHeaders) { + headers, err := conversion.StringListToSlice(cacheModel.CacheKeyHeaders) + if err != nil { + return nil, err + } + cacheConfig.SetCacheKeyHeaders(headers) + } + if !utils.IsUndefined(cacheModel.QueryStringVaryParameters) { + params, err := conversion.StringListToSlice(cacheModel.QueryStringVaryParameters) + if err != nil { + return nil, err + } + cacheConfig.SetQueryStringVaryParameters(params) + } + } + payload := &cdnSdk.CreateDistributionPayload{ IntentId: new(uuid.NewString()), Regions: cfg.Regions, @@ -1639,6 +1575,7 @@ func toCreatePayload(ctx context.Context, model *Model) (*cdnSdk.CreateDistribut Redirects: cfg.Redirects, Waf: wafPayload, Tls: tls, + CacheConfig: cacheConfig, } if !utils.IsUndefined(rawConfig.ForwardHostHeader) { @@ -1657,6 +1594,239 @@ func toCreatePayload(ctx context.Context, model *Model) (*cdnSdk.CreateDistribut return payload, nil } +func toPatchPayload(ctx context.Context, model *Model) (*cdnSdk.PatchDistributionPayload, error) { + if model == nil { + return nil, fmt.Errorf("missing model") + } + + if model.Config.IsNull() || model.Config.IsUnknown() { + return nil, fmt.Errorf("config cannot be nil or unknown") + } + + configModel := distributionConfig{} + diags := model.Config.As(ctx, &configModel, basetypes.ObjectAsOptions{ + UnhandledNullAsEmpty: false, + UnhandledUnknownAsEmpty: false, + }) + if diags.HasError() { + return nil, core.DiagsToError(diags) + } + + regions := []cdnSdk.Region{} + if configModel.Regions != nil { + for _, r := range *configModel.Regions { + regionEnum, err := cdnSdk.NewRegionFromValue(r) + if err != nil { + return nil, fmt.Errorf("map regions: %w", err) + } + regions = append(regions, *regionEnum) + } + } + + // blockedCountries + var blockedCountries []string + if configModel.BlockedCountries != nil { + tempBlockedCountries := []string{} + for _, blockedCountry := range *configModel.BlockedCountries { + validatedBlockedCountry, err := validateCountryCode(blockedCountry) + if err != nil { + return nil, fmt.Errorf("blocked countries: %w", err) + } + tempBlockedCountries = append(tempBlockedCountries, validatedBlockedCountry) + } + blockedCountries = tempBlockedCountries + } + + // blockedIps + var blockedIps []string + if !utils.IsUndefined(configModel.BlockedIps) { + bipDiags := configModel.BlockedIps.ElementsAs(ctx, &blockedIps, false) + if bipDiags.HasError() { + return nil, fmt.Errorf("blocked IPs: %w", core.DiagsToError(bipDiags)) + } + } + + // tls + var tls *cdnSdk.TlsConfigPatch + if !utils.IsUndefined(configModel.Tls) { + var tlsValue tlsConfig + diags = configModel.Tls.As(ctx, &tlsValue, basetypes.ObjectAsOptions{}) + if diags.HasError() { + return nil, core.DiagsToError(diags) + } + tls = &cdnSdk.TlsConfigPatch{ + EnableTls10: new(tlsValue.EnableTls10.ValueBool()), + EnableTls11: new(tlsValue.EnableTls11.ValueBool()), + } + } + + // redirects + redirectsConfig := convertRedirectconfig(configModel.Redirects) + + configPatchBackend := &cdnSdk.ConfigPatchBackend{} + + switch configModel.Backend.Type { + case "http": + geofencingPatch := map[string][]string{} + if configModel.Backend.Geofencing != nil { + gf := make(map[string][]string) + for url, countries := range *configModel.Backend.Geofencing { + countryStrings := make([]string, len(countries)) + for i, countryPtr := range countries { + if countryPtr == nil { + return nil, fmt.Errorf("geofencing url %q has a null value", url) + } + countryStrings[i] = *countryPtr + } + gf[url] = countryStrings + } + geofencingPatch = gf + } + + configPatchBackend.HttpBackendPatch = &cdnSdk.HttpBackendPatch{ + OriginRequestHeaders: configModel.Backend.OriginRequestHeaders, + OriginUrl: configModel.Backend.OriginURL, + Type: "http", + Geofencing: &geofencingPatch, + } + case "bucket": + configPatchBackend.BucketBackendPatch = &cdnSdk.BucketBackendPatch{ + Type: "bucket", + BucketUrl: configModel.Backend.BucketURL, + Region: configModel.Backend.Region, + } + if configModel.Backend.Credentials != nil { + configPatchBackend.BucketBackendPatch.Credentials = &cdnSdk.BucketCredentials{ + AccessKeyId: *configModel.Backend.Credentials.AccessKey, + SecretAccessKey: *configModel.Backend.Credentials.SecretKey, + } + } + } + + configPatch := &cdnSdk.ConfigPatch{ + Backend: configPatchBackend, + Regions: regions, + BlockedCountries: blockedCountries, + BlockedIps: blockedIps, + Redirects: redirectsConfig, + Tls: tls, + } + + // forwardHostHeader + if !utils.IsUndefined(configModel.ForwardHostHeader) { + configPatch.ForwardHostHeader = new(configModel.ForwardHostHeader.ValueBool()) + } + // stripResponseCookies + if !utils.IsUndefined(configModel.StripResponseCookies) { + configPatch.StripResponseCookies = configModel.StripResponseCookies.ValueBoolPointer() + } + if !utils.IsUndefined(configModel.DefaultCacheDuration) { + configPatch.DefaultCacheDuration = *cdnSdk.NewNullableString(conversion.StringValueToPointer(configModel.DefaultCacheDuration)) + } + if !utils.IsUndefined(configModel.MonthlyLimitBytes) { + configPatch.MonthlyLimitBytes = *cdnSdk.NewNullableInt64(conversion.Int64ValueToPointer(configModel.MonthlyLimitBytes)) + } + + configPatch.Waf = &cdnSdk.WafConfigPatch{ + Mode: new(cdnSdk.WAFMODE_DISABLED), + Type: new(cdnSdk.WAFTYPE_FREE), + } + + // Map WAF Update + if !utils.IsUndefined(configModel.Waf) { + var wafModel wafConfig + diags := configModel.Waf.As(ctx, &wafModel, basetypes.ObjectAsOptions{}) + if diags.HasError() { + return nil, core.DiagsToError(diags) + } + + configPatch.Waf.Mode = new(cdnSdk.WafMode(wafModel.Mode.ValueString())) + configPatch.Waf.Type = new(cdnSdk.WafType(wafModel.Type.ValueString())) + configPatch.Waf.AllowedHttpVersions = conversion.TerraformStringSetToList(ctx, wafModel.AllowedHttpVersions, &diags) + configPatch.Waf.AllowedRequestContentTypes = conversion.TerraformStringSetToList(ctx, wafModel.AllowedRequestContentTypes, &diags) + configPatch.Waf.AllowedHttpMethods = conversion.TerraformStringSetToList(ctx, wafModel.AllowedHttpMethods, &diags) + configPatch.Waf.EnabledRuleIds = conversion.TerraformStringSetToList(ctx, wafModel.EnabledRuleIds, &diags) + configPatch.Waf.DisabledRuleIds = conversion.TerraformStringSetToList(ctx, wafModel.DisabledRuleIds, &diags) + configPatch.Waf.LogOnlyRuleIds = conversion.TerraformStringSetToList(ctx, wafModel.LogOnlyRuleIds, &diags) + configPatch.Waf.EnabledRuleGroupIds = conversion.TerraformStringSetToList(ctx, wafModel.EnabledRuleGroupIds, &diags) + configPatch.Waf.DisabledRuleGroupIds = conversion.TerraformStringSetToList(ctx, wafModel.DisabledRuleGroupIds, &diags) + configPatch.Waf.LogOnlyRuleGroupIds = conversion.TerraformStringSetToList(ctx, wafModel.LogOnlyRuleGroupIds, &diags) + configPatch.Waf.EnabledRuleCollectionIds = conversion.TerraformStringSetToList(ctx, wafModel.EnabledRuleCollectionIds, &diags) + configPatch.Waf.DisabledRuleCollectionIds = conversion.TerraformStringSetToList(ctx, wafModel.DisabledRuleCollectionIds, &diags) + configPatch.Waf.LogOnlyRuleCollectionIds = conversion.TerraformStringSetToList(ctx, wafModel.LogOnlyRuleCollectionIds, &diags) + + if diags.HasError() { + return nil, core.DiagsToError(diags) + } + + if !utils.IsUndefined(wafModel.ParanoiaLevel) { + configPatch.Waf.ParanoiaLevel = new(cdnSdk.WafParanoiaLevel(wafModel.ParanoiaLevel.ValueString())) + } + } + + if !utils.IsUndefined(configModel.Optimizer) { + var optimizerModel optimizerConfig + + diags = configModel.Optimizer.As(ctx, &optimizerModel, basetypes.ObjectAsOptions{}) + if diags.HasError() { + return nil, core.DiagsToError(diags) + } + + optimizer := cdnSdk.NewOptimizerPatch() + if !utils.IsUndefined(optimizerModel.Enabled) { + optimizer.SetEnabled(optimizerModel.Enabled.ValueBool()) + } + configPatch.Optimizer = optimizer + } + + if !utils.IsUndefined(configModel.CacheConfig) { + var cacheModel cacheConfigModel + diags = configModel.CacheConfig.As(ctx, &cacheModel, basetypes.ObjectAsOptions{}) + if diags.HasError() { + return nil, core.DiagsToError(diags) + } + + cacheConfigPatch := cdnSdk.NewCacheConfigPatch() + if !utils.IsUndefined(cacheModel.QueryStringVaryEnabled) { + cacheConfigPatch.SetQueryStringVaryEnabled(cacheModel.QueryStringVaryEnabled.ValueBool()) + } + if !utils.IsUndefined(cacheModel.CacheKeyHeaders) { + headers, err := conversion.StringListToSlice(cacheModel.CacheKeyHeaders) + if err != nil { + return nil, err + } + cacheConfigPatch.SetCacheKeyHeaders(headers) + } + if !utils.IsUndefined(cacheModel.QueryStringVaryParameters) { + params, err := conversion.StringListToSlice(cacheModel.QueryStringVaryParameters) + if err != nil { + return nil, err + } + cacheConfigPatch.SetQueryStringVaryParameters(params) + } + configPatch.CacheConfig = cacheConfigPatch + } else { + configPatch.CacheConfig = defaultCacheConfigPatch() + } + + return &cdnSdk.PatchDistributionPayload{ + Config: configPatch, + IntentId: new(uuid.NewString()), + }, nil +} + +// defaultCacheConfigPatch returns a CacheConfigPatch configured with the SDK defaults +// and empty lists to clear any previously set headers or parameters on partial update. +func defaultCacheConfigPatch() *cdnSdk.CacheConfigPatch { + createDefaults := cdnSdk.NewCacheConfigCreate() + + patch := cdnSdk.NewCacheConfigPatch() + patch.SetQueryStringVaryEnabled(createDefaults.GetQueryStringVaryEnabled()) + patch.SetCacheKeyHeaders([]string{}) + patch.SetQueryStringVaryParameters([]string{}) + return patch +} + func convertRedirectconfig(redirectConfigModel *redirectConfig) *cdnSdk.RedirectConfig { var redirectsConfig *cdnSdk.RedirectConfig if redirectConfigModel != nil { @@ -1825,6 +1995,42 @@ func convertConfig(ctx context.Context, model *Model) (*cdnSdk.Config, error) { } } + var cacheConfig cdnSdk.CacheConfig + if !utils.IsUndefined(configModel.CacheConfig) { + var cacheModel cacheConfigModel + diags := configModel.CacheConfig.As(ctx, &cacheModel, basetypes.ObjectAsOptions{}) + if diags.HasError() { + return nil, core.DiagsToError(diags) + } + + var cacheKeyHeaders []string + if !utils.IsUndefined(cacheModel.CacheKeyHeaders) { + headers, err := conversion.StringListToSlice(cacheModel.CacheKeyHeaders) + if err != nil { + return nil, err + } + cacheKeyHeaders = headers + } + var queryStringVaryParams []string + if !utils.IsUndefined(cacheModel.QueryStringVaryParameters) { + params, err := conversion.StringListToSlice(cacheModel.QueryStringVaryParameters) + if err != nil { + return nil, err + } + queryStringVaryParams = params + } + queryStringVaryEnabled := false + if !utils.IsUndefined(cacheModel.QueryStringVaryEnabled) { + queryStringVaryEnabled = cacheModel.QueryStringVaryEnabled.ValueBool() + } + + cacheConfig = cdnSdk.CacheConfig{ + CacheKeyHeaders: cacheKeyHeaders, + QueryStringVaryEnabled: queryStringVaryEnabled, + QueryStringVaryParameters: queryStringVaryParams, + } + } + cdnConfig := &cdnSdk.Config{ Backend: cdnSdk.ConfigBackend{}, Regions: regions, @@ -1832,6 +2038,7 @@ func convertConfig(ctx context.Context, model *Model) (*cdnSdk.Config, error) { BlockedIps: blockedIps, Redirects: redirectsConfig, Tls: tls, + CacheConfig: cacheConfig, } if !utils.IsUndefined(configModel.DefaultCacheDuration) { diff --git a/stackit/internal/services/cdn/distribution/resource_test.go b/stackit/internal/services/cdn/distribution/resource_test.go index a064b4d49..70557d1f1 100644 --- a/stackit/internal/services/cdn/distribution/resource_test.go +++ b/stackit/internal/services/cdn/distribution/resource_test.go @@ -23,6 +23,9 @@ func createTestConfig(vals map[string]attr.Value) types.Object { if _, ok := vals["monthly_limit_bytes"]; !ok { vals["monthly_limit_bytes"] = types.Int64Null() } + if _, ok := vals["cache_config"]; !ok { + vals["cache_config"] = types.ObjectNull(cacheConfigTypes) + } return types.ObjectValueMust(configTypes, vals) } @@ -42,6 +45,7 @@ func configFixture(mods ...func(vals map[string]attr.Value)) types.Object { "redirects": types.ObjectNull(redirectsTypes), "waf": types.ObjectNull(wafTypes), "tls": types.ObjectNull(tlsTypes), + "cache_config": types.ObjectNull(cacheConfigTypes), "strip_response_cookies": types.BoolUnknown(), "forward_host_header": types.BoolUnknown(), } @@ -528,6 +532,81 @@ func TestToCreatePayload(t *testing.T) { }, IsValid: true, }, + "happy_path_with_cache_config": { + Input: modelFixture(func(m *Model) { + m.Config = configFixture(func(v map[string]attr.Value) { + v["backend"] = backend + v["regions"] = regionsFixture + v["blocked_countries"] = blockedCountriesFixture + v["waf"] = defaultWaf + v["cache_config"] = types.ObjectValueMust(cacheConfigTypes, map[string]attr.Value{ + "cache_key_headers": types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("Authorization"), + types.StringValue("Accept-Language"), + }), + "query_string_vary_enabled": types.BoolValue(true), + "query_string_vary_parameters": types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("utm_source"), + types.StringValue("page"), + }), + }) + }) + }), + Expected: &cdnSdk.CreateDistributionPayload{ + Regions: []cdnSdk.Region{"EU", "US"}, + BlockedCountries: []string{"XX", "YY", "ZZ"}, + Waf: &expectedDefaultWafConfig, + CacheConfig: &cdnSdk.CacheConfigCreate{ + CacheKeyHeaders: []string{"Authorization", "Accept-Language"}, + QueryStringVaryEnabled: cdnSdk.PtrBool(true), + QueryStringVaryParameters: []string{"utm_source", "page"}, + }, + Backend: cdnSdk.CreateDistributionPayloadBackend{ + HttpBackendCreate: &cdnSdk.HttpBackendCreate{ + Geofencing: &map[string][]string{"https://de.mycoolapp.com": {"DE", "FR"}}, + OriginRequestHeaders: &map[string]string{"testHeader0": "testHeaderValue0", "testHeader1": "testHeaderValue1"}, + OriginUrl: "https://www.mycoolapp.com", + Type: "http", + }, + }, + }, + IsValid: true, + }, + "happy_path_with_cache_config_vary_disabled_with_parameters": { + Input: modelFixture(func(m *Model) { + m.Config = configFixture(func(v map[string]attr.Value) { + v["backend"] = backend + v["regions"] = regionsFixture + v["blocked_countries"] = blockedCountriesFixture + v["waf"] = defaultWaf + v["cache_config"] = types.ObjectValueMust(cacheConfigTypes, map[string]attr.Value{ + "cache_key_headers": types.ListNull(types.StringType), + "query_string_vary_enabled": types.BoolValue(false), + "query_string_vary_parameters": types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("utm_source"), + }), + }) + }) + }), + Expected: &cdnSdk.CreateDistributionPayload{ + Regions: []cdnSdk.Region{"EU", "US"}, + BlockedCountries: []string{"XX", "YY", "ZZ"}, + Waf: &expectedDefaultWafConfig, + CacheConfig: &cdnSdk.CacheConfigCreate{ + QueryStringVaryEnabled: cdnSdk.PtrBool(false), + QueryStringVaryParameters: []string{"utm_source"}, + }, + Backend: cdnSdk.CreateDistributionPayloadBackend{ + HttpBackendCreate: &cdnSdk.HttpBackendCreate{ + Geofencing: &map[string][]string{"https://de.mycoolapp.com": {"DE", "FR"}}, + OriginRequestHeaders: &map[string]string{"testHeader0": "testHeaderValue0", "testHeader1": "testHeaderValue1"}, + OriginUrl: "https://www.mycoolapp.com", + Type: "http", + }, + }, + }, + IsValid: true, + }, "sad_path_model_nil": { Input: nil, Expected: nil, @@ -563,6 +642,201 @@ func TestToCreatePayload(t *testing.T) { } } +func TestToPatchPayload(t *testing.T) { + headersMap := map[string]string{ + "testHeader0": "testHeaderValue0", + "testHeader1": "testHeaderValue1", + } + headers := map[string]attr.Value{ + "testHeader0": types.StringValue("testHeaderValue0"), + "testHeader1": types.StringValue("testHeaderValue1"), + } + originRequestHeaders := types.MapValueMust(types.StringType, headers) + geofencingCountries := types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("DE"), + types.StringValue("FR"), + }) + geofencing := types.MapValueMust(geofencingTypes.ElemType, map[string]attr.Value{ + "https://de.mycoolapp.com": geofencingCountries, + }) + backend := types.ObjectValueMust(backendTypes, map[string]attr.Value{ + "type": types.StringValue("http"), + "origin_url": types.StringValue("https://www.mycoolapp.com"), + "origin_request_headers": originRequestHeaders, + "geofencing": geofencing, + "bucket_url": types.StringNull(), + "region": types.StringNull(), + "credentials": types.ObjectNull(backendCredentialsTypes), + }) + regions := []attr.Value{types.StringValue("EU"), types.StringValue("US")} + regionsFixture := types.ListValueMust(types.StringType, regions) + blockedCountries := []attr.Value{types.StringValue("XX"), types.StringValue("YY"), types.StringValue("ZZ")} + blockedCountriesFixture := types.ListValueMust(types.StringType, blockedCountries) + + defaultWafPatch := &cdnSdk.WafConfigPatch{ + Mode: new(cdnSdk.WAFMODE_DISABLED), + Type: new(cdnSdk.WAFTYPE_FREE), + } + + modelFixture := func(mods ...func(*Model)) *Model { + model := &Model{ + DistributionId: types.StringValue("test-distribution-id"), + ProjectId: types.StringValue("test-project-id"), + Config: configFixture(), + } + for _, mod := range mods { + mod(model) + } + return model + } + + tests := map[string]struct { + Input *Model + Expected *cdnSdk.PatchDistributionPayload + IsValid bool + }{ + "happy_path_without_cache_config": { + Input: modelFixture(func(m *Model) { + m.Config = configFixture(func(v map[string]attr.Value) { + v["backend"] = backend + v["regions"] = regionsFixture + v["blocked_countries"] = blockedCountriesFixture + }) + }), + Expected: &cdnSdk.PatchDistributionPayload{ + Config: &cdnSdk.ConfigPatch{ + Regions: []cdnSdk.Region{"EU", "US"}, + BlockedCountries: []string{"XX", "YY", "ZZ"}, + Waf: defaultWafPatch, + CacheConfig: defaultCacheConfigPatch(), + Backend: &cdnSdk.ConfigPatchBackend{ + HttpBackendPatch: &cdnSdk.HttpBackendPatch{ + Geofencing: &map[string][]string{"https://de.mycoolapp.com": {"DE", "FR"}}, + OriginRequestHeaders: &headersMap, + OriginUrl: cdnSdk.PtrString("https://www.mycoolapp.com"), + Type: "http", + }, + }, + }, + }, + IsValid: true, + }, + "happy_path_with_cache_config": { + Input: modelFixture(func(m *Model) { + m.Config = configFixture(func(v map[string]attr.Value) { + v["backend"] = backend + v["regions"] = regionsFixture + v["blocked_countries"] = blockedCountriesFixture + v["cache_config"] = types.ObjectValueMust(cacheConfigTypes, map[string]attr.Value{ + "cache_key_headers": types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("Authorization"), + }), + "query_string_vary_enabled": types.BoolValue(true), + "query_string_vary_parameters": types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("utm_source"), + }), + }) + }) + }), + Expected: &cdnSdk.PatchDistributionPayload{ + Config: &cdnSdk.ConfigPatch{ + Regions: []cdnSdk.Region{"EU", "US"}, + BlockedCountries: []string{"XX", "YY", "ZZ"}, + Waf: defaultWafPatch, + CacheConfig: &cdnSdk.CacheConfigPatch{ + CacheKeyHeaders: []string{"Authorization"}, + QueryStringVaryEnabled: cdnSdk.PtrBool(true), + QueryStringVaryParameters: []string{"utm_source"}, + }, + Backend: &cdnSdk.ConfigPatchBackend{ + HttpBackendPatch: &cdnSdk.HttpBackendPatch{ + Geofencing: &map[string][]string{"https://de.mycoolapp.com": {"DE", "FR"}}, + OriginRequestHeaders: &headersMap, + OriginUrl: cdnSdk.PtrString("https://www.mycoolapp.com"), + Type: "http", + }, + }, + }, + }, + IsValid: true, + }, + "happy_path_cache_config_empty_lists_to_clear": { + Input: modelFixture(func(m *Model) { + m.Config = configFixture(func(v map[string]attr.Value) { + v["backend"] = backend + v["regions"] = regionsFixture + v["blocked_countries"] = blockedCountriesFixture + v["cache_config"] = types.ObjectValueMust(cacheConfigTypes, map[string]attr.Value{ + "cache_key_headers": types.ListValueMust(types.StringType, []attr.Value{}), + "query_string_vary_enabled": types.BoolValue(false), + "query_string_vary_parameters": types.ListValueMust(types.StringType, []attr.Value{}), + }) + }) + }), + Expected: &cdnSdk.PatchDistributionPayload{ + Config: &cdnSdk.ConfigPatch{ + Regions: []cdnSdk.Region{"EU", "US"}, + BlockedCountries: []string{"XX", "YY", "ZZ"}, + Waf: defaultWafPatch, + CacheConfig: &cdnSdk.CacheConfigPatch{ + CacheKeyHeaders: []string{}, + QueryStringVaryEnabled: cdnSdk.PtrBool(false), + QueryStringVaryParameters: []string{}, + }, + Backend: &cdnSdk.ConfigPatchBackend{ + HttpBackendPatch: &cdnSdk.HttpBackendPatch{ + Geofencing: &map[string][]string{"https://de.mycoolapp.com": {"DE", "FR"}}, + OriginRequestHeaders: &headersMap, + OriginUrl: cdnSdk.PtrString("https://www.mycoolapp.com"), + Type: "http", + }, + }, + }, + }, + IsValid: true, + }, + "sad_path_model_nil": { + Input: nil, + Expected: nil, + IsValid: false, + }, + "sad_path_config_error": { + Input: modelFixture(func(m *Model) { + m.Config = types.ObjectNull(configTypes) + }), + Expected: nil, + IsValid: false, + }, + } + + for tn, tc := range tests { + t.Run(tn, func(t *testing.T) { + res, err := toPatchPayload(context.Background(), tc.Input) + if err != nil && tc.IsValid { + t.Fatalf("Error converting model to patch payload: %v", err) + } + if err == nil && !tc.IsValid { + t.Fatalf("Should have failed") + } + if tc.IsValid { + tc.Expected.IntentId = res.IntentId + + diff := cmp.Diff(res, tc.Expected, + cmpopts.IgnoreUnexported( + cdnSdk.NullableString{}, + cdnSdk.NullableInt64{}, + cdnSdk.NullableConfigPatchLogSink{}, + ), + cmpopts.EquateEmpty(), + ) + if diff != "" { + t.Fatalf("Patch Payload not as expected: %s", diff) + } + } + }) + } +} + func TestConvertConfig(t *testing.T) { headers := map[string]attr.Value{ "testHeader0": types.StringValue("testHeaderValue0"), @@ -1133,6 +1407,11 @@ func TestMapFields(t *testing.T) { "enable_tls_10": types.BoolValue(false), "enable_tls_11": types.BoolValue(false), }) + defaultCacheConfig := types.ObjectValueMust(cacheConfigTypes, map[string]attr.Value{ + "cache_key_headers": types.ListValueMust(types.StringType, []attr.Value{}), + "query_string_vary_enabled": types.BoolValue(false), + "query_string_vary_parameters": types.ListValueMust(types.StringType, []attr.Value{}), + }) config := createTestConfig(map[string]attr.Value{ "backend": backend, "regions": regionsFixture, @@ -1141,6 +1420,7 @@ func TestMapFields(t *testing.T) { "redirects": types.ObjectNull(redirectsAttrTypes), "waf": defaultWaf, "tls": defaultTls, + "cache_config": defaultCacheConfig, "strip_response_cookies": types.BoolValue(false), "forward_host_header": types.BoolValue(false), }) @@ -1300,6 +1580,7 @@ func TestMapFields(t *testing.T) { "redirects": types.ObjectNull(redirectsAttrTypes), "waf": defaultWaf, "tls": defaultTls, + "cache_config": defaultCacheConfig, "strip_response_cookies": types.BoolValue(false), "forward_host_header": types.BoolValue(false), }) @@ -1330,6 +1611,7 @@ func TestMapFields(t *testing.T) { "redirects": types.ObjectNull(redirectsAttrTypes), "waf": defaultWaf, "tls": defaultTls, + "cache_config": defaultCacheConfig, "strip_response_cookies": types.BoolValue(false), "forward_host_header": types.BoolValue(false), }) @@ -1349,6 +1631,7 @@ func TestMapFields(t *testing.T) { "redirects": redirectsConfigExpected, "waf": defaultWaf, "tls": defaultTls, + "cache_config": defaultCacheConfig, "strip_response_cookies": types.BoolValue(false), "forward_host_header": types.BoolValue(false), }) @@ -1377,6 +1660,7 @@ func TestMapFields(t *testing.T) { "redirects": types.ObjectNull(redirectsAttrTypes), "waf": populatedWaf, "tls": defaultTls, + "cache_config": defaultCacheConfig, "strip_response_cookies": types.BoolValue(false), "forward_host_header": types.BoolValue(false), }) @@ -1399,6 +1683,7 @@ func TestMapFields(t *testing.T) { "enable_tls_10": types.BoolValue(true), "enable_tls_11": types.BoolValue(true), }), + "cache_config": defaultCacheConfig, "strip_response_cookies": types.BoolValue(false), "forward_host_header": types.BoolValue(false), }) @@ -1468,6 +1753,7 @@ func TestMapFields(t *testing.T) { "redirects": types.ObjectNull(redirectsAttrTypes), "waf": defaultWaf, "tls": defaultTls, + "cache_config": defaultCacheConfig, "strip_response_cookies": types.BoolValue(false), "forward_host_header": types.BoolValue(false), }) @@ -1482,6 +1768,7 @@ func TestMapFields(t *testing.T) { v["blocked_countries"] = blockedCountriesFixture v["waf"] = defaultWaf v["tls"] = defaultTls + v["cache_config"] = defaultCacheConfig v["strip_response_cookies"] = types.BoolValue(false) v["forward_host_header"] = types.BoolValue(false) v["blocked_ips"] = types.ListValueMust(types.StringType, []attr.Value{ @@ -1503,6 +1790,7 @@ func TestMapFields(t *testing.T) { v["blocked_countries"] = blockedCountriesFixture v["waf"] = defaultWaf v["tls"] = defaultTls + v["cache_config"] = defaultCacheConfig v["strip_response_cookies"] = types.BoolValue(false) v["forward_host_header"] = types.BoolValue(false) v["default_cache_duration"] = types.StringValue("P1DT2H30M") @@ -1521,6 +1809,7 @@ func TestMapFields(t *testing.T) { v["blocked_countries"] = blockedCountriesFixture v["waf"] = defaultWaf v["tls"] = defaultTls + v["cache_config"] = defaultCacheConfig v["strip_response_cookies"] = types.BoolValue(false) v["forward_host_header"] = types.BoolValue(false) v["monthly_limit_bytes"] = types.Int64Value(1073741824) @@ -1531,6 +1820,86 @@ func TestMapFields(t *testing.T) { }), IsValid: true, }, + "happy_path_with_cache_config": { + Expected: expectedModel(func(m *Model) { + m.Config = createTestConfig(map[string]attr.Value{ + "backend": backend, + "regions": regionsFixture, + "optimizer": types.ObjectNull(optimizerTypes), + "blocked_countries": blockedCountriesFixture, + "redirects": types.ObjectNull(redirectsAttrTypes), + "waf": defaultWaf, + "tls": defaultTls, + "cache_config": types.ObjectValueMust(cacheConfigTypes, map[string]attr.Value{ + "cache_key_headers": types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("Authorization"), + types.StringValue("Accept-Language"), + }), + "query_string_vary_enabled": types.BoolValue(true), + "query_string_vary_parameters": types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("utm_source"), + types.StringValue("page"), + }), + }), + "strip_response_cookies": types.BoolValue(false), + "forward_host_header": types.BoolValue(false), + }) + }), + Input: distributionFixture(func(d *cdnSdk.Distribution) { + d.Config.CacheConfig = cdnSdk.CacheConfig{ + CacheKeyHeaders: []string{"Authorization", "Accept-Language"}, + QueryStringVaryEnabled: true, + QueryStringVaryParameters: []string{"utm_source", "page"}, + } + }), + IsValid: true, + }, + "happy_path_cache_config_drift": { + InitialState: expectedModel(func(m *Model) { + m.Config = createTestConfig(map[string]attr.Value{ + "backend": backend, + "regions": regionsFixture, + "optimizer": types.ObjectNull(optimizerTypes), + "blocked_countries": blockedCountriesFixture, + "redirects": types.ObjectNull(redirectsAttrTypes), + "waf": defaultWaf, + "tls": defaultTls, + "cache_config": types.ObjectValueMust(cacheConfigTypes, map[string]attr.Value{ + "cache_key_headers": types.ListValueMust(types.StringType, []attr.Value{}), + "query_string_vary_enabled": types.BoolValue(true), + "query_string_vary_parameters": types.ListValueMust(types.StringType, []attr.Value{types.StringValue("param1")}), + }), + "strip_response_cookies": types.BoolValue(false), + "forward_host_header": types.BoolValue(false), + }) + }), + Expected: expectedModel(func(m *Model) { + m.Config = createTestConfig(map[string]attr.Value{ + "backend": backend, + "regions": regionsFixture, + "optimizer": types.ObjectNull(optimizerTypes), + "blocked_countries": blockedCountriesFixture, + "redirects": types.ObjectNull(redirectsAttrTypes), + "waf": defaultWaf, + "tls": defaultTls, + "cache_config": types.ObjectValueMust(cacheConfigTypes, map[string]attr.Value{ + "cache_key_headers": types.ListValueMust(types.StringType, []attr.Value{}), + "query_string_vary_enabled": types.BoolValue(false), + "query_string_vary_parameters": types.ListValueMust(types.StringType, []attr.Value{types.StringValue("param1")}), + }), + "strip_response_cookies": types.BoolValue(false), + "forward_host_header": types.BoolValue(false), + }) + }), + Input: distributionFixture(func(d *cdnSdk.Distribution) { + d.Config.CacheConfig = cdnSdk.CacheConfig{ + CacheKeyHeaders: []string{}, + QueryStringVaryEnabled: false, + QueryStringVaryParameters: []string{"param1"}, + } + }), + IsValid: true, + }, "sad_path_distribution_nil": { Expected: nil, Input: nil, diff --git a/stackit/internal/services/cdn/testdata/resource-http-base.tf b/stackit/internal/services/cdn/testdata/resource-http-base.tf index 16871ea92..2cd00e74f 100644 --- a/stackit/internal/services/cdn/testdata/resource-http-base.tf +++ b/stackit/internal/services/cdn/testdata/resource-http-base.tf @@ -24,6 +24,18 @@ variable "strip_response_cookies" {} variable "forward_host_header" {} variable "monthly_limit_bytes" {} variable "default_cache_duration" {} +variable "cache_key_headers" { + type = list(string) + default = null +} +variable "query_string_vary_enabled" { + type = bool + default = null +} +variable "query_string_vary_parameters" { + type = list(string) + default = null +} # dns variable "dns_zone_name" {} @@ -79,7 +91,12 @@ resource "stackit_cdn_distribution" "distribution" { forward_host_header = var.forward_host_header monthly_limit_bytes = var.monthly_limit_bytes default_cache_duration = var.default_cache_duration - waf = var.waf + cache_config = { + cache_key_headers = var.cache_key_headers + query_string_vary_enabled = var.query_string_vary_enabled + query_string_vary_parameters = var.query_string_vary_parameters + } + waf = var.waf backend = { type = var.backend_http_type origin_url = var.backend_origin_url From 06ee196978c58f233d3f8fba4af532d0528b16df Mon Sep 17 00:00:00 2001 From: Matheus Politano Date: Fri, 25 Sep 2026 17:44:48 +0200 Subject: [PATCH 2/2] chore: add default to patch cache config --- .../services/cdn/distribution/resource.go | 25 +++++++------------ .../cdn/distribution/resource_test.go | 13 +++++++--- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/stackit/internal/services/cdn/distribution/resource.go b/stackit/internal/services/cdn/distribution/resource.go index 9ab457a41..618d9c210 100644 --- a/stackit/internal/services/cdn/distribution/resource.go +++ b/stackit/internal/services/cdn/distribution/resource.go @@ -1779,6 +1779,14 @@ func toPatchPayload(ctx context.Context, model *Model) (*cdnSdk.PatchDistributio configPatch.Optimizer = optimizer } + // Explicitly set default values to work around unexpected SDK behavior. + // Without these defaults, removing the cache_config block leaves the existing + // cache configuration intact rather than resetting it. Enforcing these defaults + // ensures the configuration accurately reflects only what the user has explicitly defined. + cacheConfigPatch := cdnSdk.NewCacheConfigPatch() + cacheConfigPatch.SetCacheKeyHeaders([]string{}) + cacheConfigPatch.SetQueryStringVaryParameters([]string{}) + cacheConfigPatch.SetQueryStringVaryEnabled(false) if !utils.IsUndefined(configModel.CacheConfig) { var cacheModel cacheConfigModel diags = configModel.CacheConfig.As(ctx, &cacheModel, basetypes.ObjectAsOptions{}) @@ -1786,7 +1794,6 @@ func toPatchPayload(ctx context.Context, model *Model) (*cdnSdk.PatchDistributio return nil, core.DiagsToError(diags) } - cacheConfigPatch := cdnSdk.NewCacheConfigPatch() if !utils.IsUndefined(cacheModel.QueryStringVaryEnabled) { cacheConfigPatch.SetQueryStringVaryEnabled(cacheModel.QueryStringVaryEnabled.ValueBool()) } @@ -1804,10 +1811,8 @@ func toPatchPayload(ctx context.Context, model *Model) (*cdnSdk.PatchDistributio } cacheConfigPatch.SetQueryStringVaryParameters(params) } - configPatch.CacheConfig = cacheConfigPatch - } else { - configPatch.CacheConfig = defaultCacheConfigPatch() } + configPatch.CacheConfig = cacheConfigPatch return &cdnSdk.PatchDistributionPayload{ Config: configPatch, @@ -1815,18 +1820,6 @@ func toPatchPayload(ctx context.Context, model *Model) (*cdnSdk.PatchDistributio }, nil } -// defaultCacheConfigPatch returns a CacheConfigPatch configured with the SDK defaults -// and empty lists to clear any previously set headers or parameters on partial update. -func defaultCacheConfigPatch() *cdnSdk.CacheConfigPatch { - createDefaults := cdnSdk.NewCacheConfigCreate() - - patch := cdnSdk.NewCacheConfigPatch() - patch.SetQueryStringVaryEnabled(createDefaults.GetQueryStringVaryEnabled()) - patch.SetCacheKeyHeaders([]string{}) - patch.SetQueryStringVaryParameters([]string{}) - return patch -} - func convertRedirectconfig(redirectConfigModel *redirectConfig) *cdnSdk.RedirectConfig { var redirectsConfig *cdnSdk.RedirectConfig if redirectConfigModel != nil { diff --git a/stackit/internal/services/cdn/distribution/resource_test.go b/stackit/internal/services/cdn/distribution/resource_test.go index 70557d1f1..e8becf769 100644 --- a/stackit/internal/services/cdn/distribution/resource_test.go +++ b/stackit/internal/services/cdn/distribution/resource_test.go @@ -651,6 +651,11 @@ func TestToPatchPayload(t *testing.T) { "testHeader0": types.StringValue("testHeaderValue0"), "testHeader1": types.StringValue("testHeaderValue1"), } + defaultCacheConfigPatch := cdnSdk.NewCacheConfigPatch() + defaultCacheConfigPatch.SetCacheKeyHeaders([]string{}) + defaultCacheConfigPatch.SetQueryStringVaryParameters([]string{}) + defaultCacheConfigPatch.SetQueryStringVaryEnabled(false) + originRequestHeaders := types.MapValueMust(types.StringType, headers) geofencingCountries := types.ListValueMust(types.StringType, []attr.Value{ types.StringValue("DE"), @@ -708,7 +713,7 @@ func TestToPatchPayload(t *testing.T) { Regions: []cdnSdk.Region{"EU", "US"}, BlockedCountries: []string{"XX", "YY", "ZZ"}, Waf: defaultWafPatch, - CacheConfig: defaultCacheConfigPatch(), + CacheConfig: defaultCacheConfigPatch, Backend: &cdnSdk.ConfigPatchBackend{ HttpBackendPatch: &cdnSdk.HttpBackendPatch{ Geofencing: &map[string][]string{"https://de.mycoolapp.com": {"DE", "FR"}}, @@ -821,7 +826,8 @@ func TestToPatchPayload(t *testing.T) { if tc.IsValid { tc.Expected.IntentId = res.IntentId - diff := cmp.Diff(res, tc.Expected, + diff := cmp.Diff( + res, tc.Expected, cmpopts.IgnoreUnexported( cdnSdk.NullableString{}, cdnSdk.NullableInt64{}, @@ -1292,7 +1298,8 @@ func TestConvertConfig(t *testing.T) { t.Fatalf("Should have failed") } if tc.IsValid { - diff := cmp.Diff(res, tc.Expected, + diff := cmp.Diff( + res, tc.Expected, // The struct contains now a NullableString and NullableInt64. // Previously those were pointers which could be compared but the value of those // are unexported and therefore cmp cannot compare them.