diff --git a/docs/data-sources/cdn_distribution.md b/docs/data-sources/cdn_distribution.md index 2e7b817ab..b323eb815 100644 --- a/docs/data-sources/cdn_distribution.md +++ b/docs/data-sources/cdn_distribution.md @@ -129,15 +129,15 @@ Read-Only: - `allowed_http_methods` (Set of String) Restricts which HTTP methods the distribution accepts. If provided, the set must contain at least one item. Case you removed waf will retain the last known state and if omitted, the API applies the following defaults: `GET`, `HEAD`, `POST`, `PUT`, `DELETE`, `CONNECT`, `OPTIONS`, `TRACE`, `PATCH`. - `allowed_http_versions` (Set of String) Restricts which HTTP protocol versions are accepted. If provided, the set must contain at least one item. If omitted, the API applies the following defaults: `HTTP/1.0`, `HTTP/1.1`, `HTTP/2`, `HTTP/2.0`. - `allowed_request_content_types` (Set of String) Restricts which Content-Type headers are accepted in request bodies. If provided, the set must contain at least one item. Case you removed waf will retain the last known state and if omitted, the API applies the following defaults: `application/x-www-form-urlencoded`, `multipart/form-data`, `multipart/related`, `text/xml`, `application/xml`, `application/soap+xml`, `application/x-amf`, `application/json`, `application/octet-stream`, `application/csp-report`, `application/xss-auditor-report`, `text/plain`. -- `disabled_rule_collection_ids` (Set of String) Set of WAF Collection IDs explicitly disabled. Can be set to an empty set to clear previously set rules. Case you removed waf will retain the last known state. To view available rule collections, please consult the API documentation: https://docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections -- `disabled_rule_group_ids` (Set of String) Set of WAF Rule Group IDs explicitly disabled. Can be set to an empty set to clear previously set rules. Case you removed waf will retain the last known state. Precedence hierarchy: Groups override Collections. To view available rule groups, please consult the API documentation: https://docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections -- `disabled_rule_ids` (Set of String) Set of WAF rule IDs explicitly disabled. Can be set to an empty set to clear previously set rules. Case you removed waf will retain the last known state. Precedence hierarchy: Specific Rules override Groups. For example, an explicitly disabled Rule ID takes precedence over an enabled Group ID. To view available rules, please consult the API documentation: https://docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections -- `enabled_rule_collection_ids` (Set of String) Set of WAF Collection IDs explicitly enabled. Can be set to an empty set to clear previously set rules. Case you removed waf will retain the last known state. To view available rule collections, please consult the API documentation: https://docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections -- `enabled_rule_group_ids` (Set of String) Set of WAF Rule Group IDs explicitly enabled. Can be set to an empty set to clear previously set rules. Case you removed waf will retain the last known state. Precedence hierarchy: Groups override Collections. To view available rule groups, please consult the API documentation: https://docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections -- `enabled_rule_ids` (Set of String) Set of WAF rule IDs explicitly enabled. Can be set to an empty set to clear previously set rules. Case you removed waf will retain the last known state. Precedence hierarchy: Specific Rules override Groups. For example, an explicitly enabled Rule ID takes precedence over a disabled Group ID. To view available rules, please consult the API documentation: https://docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections -- `log_only_rule_collection_ids` (Set of String) Set of WAF Collection IDs explicitly marked as Log Only. Can be set to an empty set to clear previously set rules. Case you removed waf will retain the last known state. To view available rule collections, please consult the API documentation: https://docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections -- `log_only_rule_group_ids` (Set of String) Set of WAF Rule Group IDs explicitly marked as Log Only. Can be set to an empty set to clear previously set rules. Case you removed waf will retain the last known state. Precedence hierarchy: Groups override Collections. To view available rule groups, please consult the API documentation: https://docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections -- `log_only_rule_ids` (Set of String) Set of WAF rule IDs explicitly marked as Log Only. Can be set to an empty set to clear previously set rules. Case you removed waf will retain the last known state. Precedence hierarchy: Specific Rules override Groups. To view available rules, please consult the API documentation: https://docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections +- `disabled_rule_collection_ids` (Set of String) Set of WAF Collection IDs explicitly disabled. Set to an empty set or remove the attribute to clear previously set rules. To view available rule collections, please consult the API documentation: https://docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections +- `disabled_rule_group_ids` (Set of String) Set of WAF Rule Group IDs explicitly disabled. Set to an empty set or remove the attribute to clear previously set rules. Precedence hierarchy: Groups override Collections. To view available rule groups, please consult the API documentation: https://docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections +- `disabled_rule_ids` (Set of String) Set of WAF rule IDs explicitly disabled. Set to an empty set or remove the attribute to clear previously set rules. Precedence hierarchy: Specific Rules override Groups. For example, an explicitly disabled Rule ID takes precedence over an enabled Group ID. To view available rules, please consult the API documentation: https://docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections +- `enabled_rule_collection_ids` (Set of String) Set of WAF Collection IDs explicitly enabled. Set to an empty set to clear previously set rules. When the attribute is omitted, the server-managed set is left untouched (the API may populate defaults when the WAF is enabled). To view available rule collections, please consult the API documentation: https://docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections +- `enabled_rule_group_ids` (Set of String) Set of WAF Rule Group IDs explicitly enabled. Set to an empty set to clear previously set rules. When the attribute is omitted, the server-managed set is left untouched (the API may populate defaults when the WAF is enabled). Precedence hierarchy: Groups override Collections. To view available rule groups, please consult the API documentation: https://docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections +- `enabled_rule_ids` (Set of String) Set of WAF rule IDs explicitly enabled. Set to an empty set to clear previously set rules. When the attribute is omitted, the server-managed set is left untouched (the API may populate defaults when the WAF is enabled). Precedence hierarchy: Specific Rules override Groups. For example, an explicitly enabled Rule ID takes precedence over a disabled Group ID. To view available rules, please consult the API documentation: https://docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections +- `log_only_rule_collection_ids` (Set of String) Set of WAF Collection IDs explicitly marked as Log Only. Set to an empty set or remove the attribute to clear previously set rules. To view available rule collections, please consult the API documentation: https://docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections +- `log_only_rule_group_ids` (Set of String) Set of WAF Rule Group IDs explicitly marked as Log Only. Set to an empty set or remove the attribute to clear previously set rules. Precedence hierarchy: Groups override Collections. To view available rule groups, please consult the API documentation: https://docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections +- `log_only_rule_ids` (Set of String) Set of WAF rule IDs explicitly marked as Log Only. Set to an empty set or remove the attribute to clear previously set rules. Precedence hierarchy: Specific Rules override Groups. To view available rules, please consult the API documentation: https://docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections - `mode` (String) The operating mode of the WAF. 'ENABLED' actively blocks threats, 'LOG_ONLY' logs matches without blocking, and 'DISABLED' completely turns off inspection. Defaults to 'DISABLED'. - `paranoia_level` (String) Defines how aggressively the WAF should act on requests. Valid values are 'L1' to 'L4'. Case you removed waf will retain the last known state and if omitted, The API applies the following default 'L1'. - `type` (String) The tier of the WAF. Valid values are 'FREE' or 'PREMIUM'. Defaults to 'FREE'. diff --git a/docs/resources/cdn_distribution.md b/docs/resources/cdn_distribution.md index 5e5bad974..0e6c2c66d 100644 --- a/docs/resources/cdn_distribution.md +++ b/docs/resources/cdn_distribution.md @@ -252,15 +252,15 @@ Optional: - `allowed_http_methods` (Set of String) Restricts which HTTP methods the distribution accepts. If provided, the set must contain at least one item. Case you removed waf will retain the last known state and if omitted, the API applies the following defaults: `GET`, `HEAD`, `POST`, `PUT`, `DELETE`, `CONNECT`, `OPTIONS`, `TRACE`, `PATCH`. - `allowed_http_versions` (Set of String) Restricts which HTTP protocol versions are accepted. If provided, the set must contain at least one item. If omitted, the API applies the following defaults: `HTTP/1.0`, `HTTP/1.1`, `HTTP/2`, `HTTP/2.0`. - `allowed_request_content_types` (Set of String) Restricts which Content-Type headers are accepted in request bodies. If provided, the set must contain at least one item. Case you removed waf will retain the last known state and if omitted, the API applies the following defaults: `application/x-www-form-urlencoded`, `multipart/form-data`, `multipart/related`, `text/xml`, `application/xml`, `application/soap+xml`, `application/x-amf`, `application/json`, `application/octet-stream`, `application/csp-report`, `application/xss-auditor-report`, `text/plain`. -- `disabled_rule_collection_ids` (Set of String) Set of WAF Collection IDs explicitly disabled. Can be set to an empty set to clear previously set rules. Case you removed waf will retain the last known state. To view available rule collections, please consult the API documentation: https://docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections -- `disabled_rule_group_ids` (Set of String) Set of WAF Rule Group IDs explicitly disabled. Can be set to an empty set to clear previously set rules. Case you removed waf will retain the last known state. Precedence hierarchy: Groups override Collections. To view available rule groups, please consult the API documentation: https://docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections -- `disabled_rule_ids` (Set of String) Set of WAF rule IDs explicitly disabled. Can be set to an empty set to clear previously set rules. Case you removed waf will retain the last known state. Precedence hierarchy: Specific Rules override Groups. For example, an explicitly disabled Rule ID takes precedence over an enabled Group ID. To view available rules, please consult the API documentation: https://docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections -- `enabled_rule_collection_ids` (Set of String) Set of WAF Collection IDs explicitly enabled. Can be set to an empty set to clear previously set rules. Case you removed waf will retain the last known state. To view available rule collections, please consult the API documentation: https://docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections -- `enabled_rule_group_ids` (Set of String) Set of WAF Rule Group IDs explicitly enabled. Can be set to an empty set to clear previously set rules. Case you removed waf will retain the last known state. Precedence hierarchy: Groups override Collections. To view available rule groups, please consult the API documentation: https://docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections -- `enabled_rule_ids` (Set of String) Set of WAF rule IDs explicitly enabled. Can be set to an empty set to clear previously set rules. Case you removed waf will retain the last known state. Precedence hierarchy: Specific Rules override Groups. For example, an explicitly enabled Rule ID takes precedence over a disabled Group ID. To view available rules, please consult the API documentation: https://docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections -- `log_only_rule_collection_ids` (Set of String) Set of WAF Collection IDs explicitly marked as Log Only. Can be set to an empty set to clear previously set rules. Case you removed waf will retain the last known state. To view available rule collections, please consult the API documentation: https://docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections -- `log_only_rule_group_ids` (Set of String) Set of WAF Rule Group IDs explicitly marked as Log Only. Can be set to an empty set to clear previously set rules. Case you removed waf will retain the last known state. Precedence hierarchy: Groups override Collections. To view available rule groups, please consult the API documentation: https://docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections -- `log_only_rule_ids` (Set of String) Set of WAF rule IDs explicitly marked as Log Only. Can be set to an empty set to clear previously set rules. Case you removed waf will retain the last known state. Precedence hierarchy: Specific Rules override Groups. To view available rules, please consult the API documentation: https://docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections +- `disabled_rule_collection_ids` (Set of String) Set of WAF Collection IDs explicitly disabled. Set to an empty set or remove the attribute to clear previously set rules. To view available rule collections, please consult the API documentation: https://docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections +- `disabled_rule_group_ids` (Set of String) Set of WAF Rule Group IDs explicitly disabled. Set to an empty set or remove the attribute to clear previously set rules. Precedence hierarchy: Groups override Collections. To view available rule groups, please consult the API documentation: https://docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections +- `disabled_rule_ids` (Set of String) Set of WAF rule IDs explicitly disabled. Set to an empty set or remove the attribute to clear previously set rules. Precedence hierarchy: Specific Rules override Groups. For example, an explicitly disabled Rule ID takes precedence over an enabled Group ID. To view available rules, please consult the API documentation: https://docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections +- `enabled_rule_collection_ids` (Set of String) Set of WAF Collection IDs explicitly enabled. Set to an empty set to clear previously set rules. When the attribute is omitted, the server-managed set is left untouched (the API may populate defaults when the WAF is enabled). To view available rule collections, please consult the API documentation: https://docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections +- `enabled_rule_group_ids` (Set of String) Set of WAF Rule Group IDs explicitly enabled. Set to an empty set to clear previously set rules. When the attribute is omitted, the server-managed set is left untouched (the API may populate defaults when the WAF is enabled). Precedence hierarchy: Groups override Collections. To view available rule groups, please consult the API documentation: https://docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections +- `enabled_rule_ids` (Set of String) Set of WAF rule IDs explicitly enabled. Set to an empty set to clear previously set rules. When the attribute is omitted, the server-managed set is left untouched (the API may populate defaults when the WAF is enabled). Precedence hierarchy: Specific Rules override Groups. For example, an explicitly enabled Rule ID takes precedence over a disabled Group ID. To view available rules, please consult the API documentation: https://docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections +- `log_only_rule_collection_ids` (Set of String) Set of WAF Collection IDs explicitly marked as Log Only. Set to an empty set or remove the attribute to clear previously set rules. To view available rule collections, please consult the API documentation: https://docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections +- `log_only_rule_group_ids` (Set of String) Set of WAF Rule Group IDs explicitly marked as Log Only. Set to an empty set or remove the attribute to clear previously set rules. Precedence hierarchy: Groups override Collections. To view available rule groups, please consult the API documentation: https://docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections +- `log_only_rule_ids` (Set of String) Set of WAF rule IDs explicitly marked as Log Only. Set to an empty set or remove the attribute to clear previously set rules. Precedence hierarchy: Specific Rules override Groups. To view available rules, please consult the API documentation: https://docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections - `mode` (String) The operating mode of the WAF. 'ENABLED' actively blocks threats, 'LOG_ONLY' logs matches without blocking, and 'DISABLED' completely turns off inspection. Defaults to 'DISABLED'. - `paranoia_level` (String) Defines how aggressively the WAF should act on requests. Valid values are 'L1' to 'L4'. Case you removed waf will retain the last known state and if omitted, The API applies the following default 'L1'. - `type` (String) The tier of the WAF. Valid values are 'FREE' or 'PREMIUM'. Defaults to 'FREE'. diff --git a/stackit/internal/services/cdn/distribution/resource.go b/stackit/internal/services/cdn/distribution/resource.go index fbd41ff8d..9d6b1e037 100644 --- a/stackit/internal/services/cdn/distribution/resource.go +++ b/stackit/internal/services/cdn/distribution/resource.go @@ -40,6 +40,7 @@ import ( "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/features" cdnUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/cdn/utils" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils/planmodifiers/setplanmodifier" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" ) @@ -48,6 +49,7 @@ var ( _ resource.Resource = &distributionResource{} _ resource.ResourceWithConfigure = &distributionResource{} _ resource.ResourceWithImportState = &distributionResource{} + _ resource.ResourceWithModifyPlan = &distributionResource{} ) var schemaDescriptions = map[string]string{ @@ -96,15 +98,15 @@ var schemaDescriptions = map[string]string{ "waf_allowed_http_versions": "Restricts which HTTP protocol versions are accepted. If provided, the set must contain at least one item. If omitted, the API applies the following defaults: `HTTP/1.0`, `HTTP/1.1`, `HTTP/2`, `HTTP/2.0`.", "waf_allowed_request_content_types": "Restricts which Content-Type headers are accepted in request bodies. If provided, the set must contain at least one item. Case you removed waf will retain the last known state and if omitted, the API applies the following defaults: `application/x-www-form-urlencoded`, `multipart/form-data`, `multipart/related`, `text/xml`, `application/xml`, `application/soap+xml`, `application/x-amf`, `application/json`, `application/octet-stream`, `application/csp-report`, `application/xss-auditor-report`, `text/plain`.", "waf_allowed_http_methods": "Restricts which HTTP methods the distribution accepts. If provided, the set must contain at least one item. Case you removed waf will retain the last known state and if omitted, the API applies the following defaults: `GET`, `HEAD`, `POST`, `PUT`, `DELETE`, `CONNECT`, `OPTIONS`, `TRACE`, `PATCH`.", - "waf_enabled_rule_ids": "Set of WAF rule IDs explicitly enabled. Can be set to an empty set to clear previously set rules. Case you removed waf will retain the last known state. Precedence hierarchy: Specific Rules override Groups. For example, an explicitly enabled Rule ID takes precedence over a disabled Group ID. To view available rules, please consult the API documentation: https://docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections", - "waf_disabled_rule_ids": "Set of WAF rule IDs explicitly disabled. Can be set to an empty set to clear previously set rules. Case you removed waf will retain the last known state. Precedence hierarchy: Specific Rules override Groups. For example, an explicitly disabled Rule ID takes precedence over an enabled Group ID. To view available rules, please consult the API documentation: https://docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections", - "waf_log_only_rule_ids": "Set of WAF rule IDs explicitly marked as Log Only. Can be set to an empty set to clear previously set rules. Case you removed waf will retain the last known state. Precedence hierarchy: Specific Rules override Groups. To view available rules, please consult the API documentation: https://docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections", - "waf_enabled_rule_group_ids": "Set of WAF Rule Group IDs explicitly enabled. Can be set to an empty set to clear previously set rules. Case you removed waf will retain the last known state. Precedence hierarchy: Groups override Collections. To view available rule groups, please consult the API documentation: https://docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections", - "waf_disabled_rule_group_ids": "Set of WAF Rule Group IDs explicitly disabled. Can be set to an empty set to clear previously set rules. Case you removed waf will retain the last known state. Precedence hierarchy: Groups override Collections. To view available rule groups, please consult the API documentation: https://docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections", - "waf_log_only_rule_group_ids": "Set of WAF Rule Group IDs explicitly marked as Log Only. Can be set to an empty set to clear previously set rules. Case you removed waf will retain the last known state. Precedence hierarchy: Groups override Collections. To view available rule groups, please consult the API documentation: https://docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections", - "waf_enabled_rule_collection_ids": "Set of WAF Collection IDs explicitly enabled. Can be set to an empty set to clear previously set rules. Case you removed waf will retain the last known state. To view available rule collections, please consult the API documentation: https://docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections", - "waf_disabled_rule_collection_ids": "Set of WAF Collection IDs explicitly disabled. Can be set to an empty set to clear previously set rules. Case you removed waf will retain the last known state. To view available rule collections, please consult the API documentation: https://docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections", - "waf_log_only_rule_collection_ids": "Set of WAF Collection IDs explicitly marked as Log Only. Can be set to an empty set to clear previously set rules. Case you removed waf will retain the last known state. To view available rule collections, please consult the API documentation: https://docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections", + "waf_enabled_rule_ids": "Set of WAF rule IDs explicitly enabled. Set to an empty set to clear previously set rules. When the attribute is omitted, the server-managed set is left untouched (the API may populate defaults when the WAF is enabled). Precedence hierarchy: Specific Rules override Groups. For example, an explicitly enabled Rule ID takes precedence over a disabled Group ID. To view available rules, please consult the API documentation: https://docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections", + "waf_disabled_rule_ids": "Set of WAF rule IDs explicitly disabled. Set to an empty set or remove the attribute to clear previously set rules. Precedence hierarchy: Specific Rules override Groups. For example, an explicitly disabled Rule ID takes precedence over an enabled Group ID. To view available rules, please consult the API documentation: https://docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections", + "waf_log_only_rule_ids": "Set of WAF rule IDs explicitly marked as Log Only. Set to an empty set or remove the attribute to clear previously set rules. Precedence hierarchy: Specific Rules override Groups. To view available rules, please consult the API documentation: https://docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections", + "waf_enabled_rule_group_ids": "Set of WAF Rule Group IDs explicitly enabled. Set to an empty set to clear previously set rules. When the attribute is omitted, the server-managed set is left untouched (the API may populate defaults when the WAF is enabled). Precedence hierarchy: Groups override Collections. To view available rule groups, please consult the API documentation: https://docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections", + "waf_disabled_rule_group_ids": "Set of WAF Rule Group IDs explicitly disabled. Set to an empty set or remove the attribute to clear previously set rules. Precedence hierarchy: Groups override Collections. To view available rule groups, please consult the API documentation: https://docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections", + "waf_log_only_rule_group_ids": "Set of WAF Rule Group IDs explicitly marked as Log Only. Set to an empty set or remove the attribute to clear previously set rules. Precedence hierarchy: Groups override Collections. To view available rule groups, please consult the API documentation: https://docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections", + "waf_enabled_rule_collection_ids": "Set of WAF Collection IDs explicitly enabled. Set to an empty set to clear previously set rules. When the attribute is omitted, the server-managed set is left untouched (the API may populate defaults when the WAF is enabled). To view available rule collections, please consult the API documentation: https://docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections", + "waf_disabled_rule_collection_ids": "Set of WAF Collection IDs explicitly disabled. Set to an empty set or remove the attribute to clear previously set rules. To view available rule collections, please consult the API documentation: https://docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections", + "waf_log_only_rule_collection_ids": "Set of WAF Collection IDs explicitly marked as Log Only. Set to an empty set or remove the attribute to clear previously set rules. To view available rule collections, please consult the API documentation: https://docs.api.eu01.stackit.cloud/documentation/cdn/version/v1#tag/WAF/operation/ListWafCollections", "config_tls_config": "Configuration for TLS protocol versions. Note: Enabling older TLS versions (1.0, 1.1) is generally discouraged for security reasons.", "config_tls_enable_tls_10": "If set to true, the distribution will accept connections using TLS 1.0.", "config_tls_enable_tls_11": "If set to true, the distribution will accept connections using TLS 1.1.", @@ -583,12 +585,18 @@ func (r *distributionResource) Schema(_ context.Context, _ resource.SchemaReques Computed: true, ElementType: types.StringType, Description: schemaDescriptions["waf_disabled_rule_ids"], + PlanModifiers: []planmodifier.Set{ + setplanmodifier.EmptyOnRemoval(), + }, }, "log_only_rule_ids": schema.SetAttribute{ Optional: true, Computed: true, ElementType: types.StringType, Description: schemaDescriptions["waf_log_only_rule_ids"], + PlanModifiers: []planmodifier.Set{ + setplanmodifier.EmptyOnRemoval(), + }, }, "enabled_rule_group_ids": schema.SetAttribute{ Optional: true, @@ -601,12 +609,18 @@ func (r *distributionResource) Schema(_ context.Context, _ resource.SchemaReques Computed: true, ElementType: types.StringType, Description: schemaDescriptions["waf_disabled_rule_group_ids"], + PlanModifiers: []planmodifier.Set{ + setplanmodifier.EmptyOnRemoval(), + }, }, "log_only_rule_group_ids": schema.SetAttribute{ Optional: true, Computed: true, ElementType: types.StringType, Description: schemaDescriptions["waf_log_only_rule_group_ids"], + PlanModifiers: []planmodifier.Set{ + setplanmodifier.EmptyOnRemoval(), + }, }, "enabled_rule_collection_ids": schema.SetAttribute{ Optional: true, @@ -619,12 +633,18 @@ func (r *distributionResource) Schema(_ context.Context, _ resource.SchemaReques Computed: true, ElementType: types.StringType, Description: schemaDescriptions["waf_disabled_rule_collection_ids"], + PlanModifiers: []planmodifier.Set{ + setplanmodifier.EmptyOnRemoval(), + }, }, "log_only_rule_collection_ids": schema.SetAttribute{ Optional: true, Computed: true, ElementType: types.StringType, Description: schemaDescriptions["waf_log_only_rule_collection_ids"], + PlanModifiers: []planmodifier.Set{ + setplanmodifier.EmptyOnRemoval(), + }, }, }, }, @@ -1119,6 +1139,113 @@ func (r *distributionResource) Update(ctx context.Context, req resource.UpdateRe tflog.Info(ctx, "CDN distribution updated") } +// ModifyPlan implements resource.ResourceWithModifyPlan. +// +// The CDN API manages some attributes on the server side: the `updated_at` timestamp changes on +// every update, and WAF rule lists that are left unconfigured may be populated with defaults when +// the WAF is enabled. During an update these attributes must be planned as unknown +// "(known after apply)" so Terraform accepts whatever the server returns, instead of asserting a +// stale value and failing with "Provider produced inconsistent result after apply". +// +// The framework normally marks computed attributes unknown by itself, but only when it detects a +// diff before plan modifiers run. Since the EmptyOnRemoval set plan modifier can introduce the +// only diff (e.g. removing a WAF rule list), this resource-level step runs afterwards and marks +// the server-managed attributes unknown for every real update. +func (r *distributionResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { // nolint:gocritic,tfmodifyplan // function signature required by Terraform, distribution has no region + // Do nothing on resource creation; computed attributes are already unknown there. + if req.State.Raw.IsNull() { + return + } + + // Do nothing on resource destroy. + if req.Plan.Raw.IsNull() { + return + } + + var planModel, stateModel, configModel Model + resp.Diagnostics.Append(req.Plan.Get(ctx, &planModel)...) + resp.Diagnostics.Append(req.State.Get(ctx, &stateModel)...) + resp.Diagnostics.Append(req.Config.Get(ctx, &configModel)...) + if resp.Diagnostics.HasError() { + return + } + + // Do nothing when there is no update, to avoid perpetual diffs. + if planModel.UpdatedAt.Equal(stateModel.UpdatedAt) && planModel.Config.Equal(stateModel.Config) { + return + } + + // updated_at always changes on update; plan it as unknown. + planModel.UpdatedAt = types.StringUnknown() + + // WAF rule lists that are unconfigured (null in both config and plan) may be populated with + // server defaults during an update; plan them as unknown so the server value is accepted. + if !utils.IsUndefined(planModel.Config) && !utils.IsUndefined(configModel.Config) { + var planConfig, configConfig distributionConfig + resp.Diagnostics.Append(planModel.Config.As(ctx, &planConfig, basetypes.ObjectAsOptions{})...) + resp.Diagnostics.Append(configModel.Config.As(ctx, &configConfig, basetypes.ObjectAsOptions{})...) + if resp.Diagnostics.HasError() { + return + } + + if !utils.IsUndefined(planConfig.Waf) && !utils.IsUndefined(configConfig.Waf) { + var planWaf, configWaf wafConfig + resp.Diagnostics.Append(planConfig.Waf.As(ctx, &planWaf, basetypes.ObjectAsOptions{})...) + resp.Diagnostics.Append(configConfig.Waf.As(ctx, &configWaf, basetypes.ObjectAsOptions{})...) + if resp.Diagnostics.HasError() { + return + } + + markWafRuleListsUnknownIfUnconfigured(&planWaf, &configWaf) + + wafObj, diags := types.ObjectValueFrom(ctx, wafTypes, planWaf) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + planAttrs := planModel.Config.Attributes() + planAttrs["waf"] = wafObj + newConfig, diags := types.ObjectValue(planModel.Config.AttributeTypes(ctx), planAttrs) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + planModel.Config = newConfig + } + } + + resp.Diagnostics.Append(resp.Plan.Set(ctx, &planModel)...) +} + +// markWafRuleListsUnknownIfUnconfigured sets each WAF rule list in the plan model to unknown +// when it is unconfigured (null in the configuration and null in the plan), so that values +// managed by the server (e.g. defaults injected when the WAF is enabled) are accepted on apply. +// Lists with a configured value or an explicit empty-set plan (e.g. from EmptyOnRemoval) are left +// untouched. +func markWafRuleListsUnknownIfUnconfigured(planWaf, configWaf *wafConfig) { + ruleLists := []struct { + plan *types.Set + config *types.Set + }{ + {&planWaf.EnabledRuleIds, &configWaf.EnabledRuleIds}, + {&planWaf.DisabledRuleIds, &configWaf.DisabledRuleIds}, + {&planWaf.LogOnlyRuleIds, &configWaf.LogOnlyRuleIds}, + {&planWaf.EnabledRuleGroupIds, &configWaf.EnabledRuleGroupIds}, + {&planWaf.DisabledRuleGroupIds, &configWaf.DisabledRuleGroupIds}, + {&planWaf.LogOnlyRuleGroupIds, &configWaf.LogOnlyRuleGroupIds}, + {&planWaf.EnabledRuleCollectionIds, &configWaf.EnabledRuleCollectionIds}, + {&planWaf.DisabledRuleCollectionIds, &configWaf.DisabledRuleCollectionIds}, + {&planWaf.LogOnlyRuleCollectionIds, &configWaf.LogOnlyRuleCollectionIds}, + } + + for _, rl := range ruleLists { + if rl.config.IsNull() && rl.plan.IsNull() { + *rl.plan = types.SetUnknown(types.StringType) + } + } +} + func (r *distributionResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { // nolint:gocritic // function signature required by Terraform var model Model diags := req.State.Get(ctx, &model) @@ -1162,6 +1289,21 @@ func (r *distributionResource) ImportState(ctx context.Context, req resource.Imp tflog.Info(ctx, "CDN distribution state imported") } +// wafRuleListToSet converts an API WAF rule list into a Terraform set. The CDN API omits +// (returns nil for) rule-list fields that were never configured, and may also omit fields that +// were explicitly cleared. When the API omits a field, the value already present in the model +// (i.e. the plan/prior state) is preserved, so that an explicitly empty set configured by the +// user does not come back as null after apply. +func wafRuleListToSet(ctx context.Context, apiList []string, prior types.Set, diags *diag.Diagnostics) types.Set { + if apiList == nil { + if utils.IsUndefined(prior) { + return types.SetNull(types.StringType) + } + return prior + } + return conversion.StringListToSet(ctx, apiList, diags) +} + func mapFields(ctx context.Context, distribution *cdnSdk.Distribution, model *Model) error { if distribution == nil { return fmt.Errorf("response input is nil") @@ -1435,6 +1577,19 @@ func mapFields(ctx context.Context, distribution *cdnSdk.Distribution, model *Mo if distribution.Config.Waf.ParanoiaLevel != nil { pl = new(string(*distribution.Config.Waf.ParanoiaLevel)) } + + // The CDN API omits WAF rule-list fields that were never configured, and (depending on the + // WAF mode) may also omit fields that were explicitly cleared. To keep the Terraform state + // consistent with the plan (e.g. an explicitly empty set must not come back as null), we + // fall back to the value already present in the model whenever the API omits a field. + var priorWaf wafConfig + if !utils.IsUndefined(model.Config) { + var priorConfig distributionConfig + if d := model.Config.As(ctx, &priorConfig, basetypes.ObjectAsOptions{}); !d.HasError() && !utils.IsUndefined(priorConfig.Waf) { + _ = priorConfig.Waf.As(ctx, &priorWaf, basetypes.ObjectAsOptions{}) + } + } + wafObjAttrs := map[string]attr.Value{ "mode": types.StringValue(string(distribution.Config.Waf.Mode)), "type": types.StringValue(string(distribution.Config.Waf.Type)), @@ -1442,15 +1597,15 @@ func mapFields(ctx context.Context, distribution *cdnSdk.Distribution, model *Mo "allowed_http_versions": conversion.StringListToSet(ctx, distribution.Config.Waf.AllowedHttpVersions, &diags), "allowed_request_content_types": conversion.StringListToSet(ctx, distribution.Config.Waf.AllowedRequestContentTypes, &diags), "allowed_http_methods": conversion.StringListToSet(ctx, distribution.Config.Waf.AllowedHttpMethods, &diags), - "enabled_rule_ids": conversion.StringListToSet(ctx, distribution.Config.Waf.EnabledRuleIds, &diags), - "disabled_rule_ids": conversion.StringListToSet(ctx, distribution.Config.Waf.DisabledRuleIds, &diags), - "log_only_rule_ids": conversion.StringListToSet(ctx, distribution.Config.Waf.LogOnlyRuleIds, &diags), - "enabled_rule_group_ids": conversion.StringListToSet(ctx, distribution.Config.Waf.EnabledRuleGroupIds, &diags), - "disabled_rule_group_ids": conversion.StringListToSet(ctx, distribution.Config.Waf.DisabledRuleGroupIds, &diags), - "log_only_rule_group_ids": conversion.StringListToSet(ctx, distribution.Config.Waf.LogOnlyRuleGroupIds, &diags), - "enabled_rule_collection_ids": conversion.StringListToSet(ctx, distribution.Config.Waf.EnabledRuleCollectionIds, &diags), - "disabled_rule_collection_ids": conversion.StringListToSet(ctx, distribution.Config.Waf.DisabledRuleCollectionIds, &diags), - "log_only_rule_collection_ids": conversion.StringListToSet(ctx, distribution.Config.Waf.LogOnlyRuleCollectionIds, &diags), + "enabled_rule_ids": wafRuleListToSet(ctx, distribution.Config.Waf.EnabledRuleIds, priorWaf.EnabledRuleIds, &diags), + "disabled_rule_ids": wafRuleListToSet(ctx, distribution.Config.Waf.DisabledRuleIds, priorWaf.DisabledRuleIds, &diags), + "log_only_rule_ids": wafRuleListToSet(ctx, distribution.Config.Waf.LogOnlyRuleIds, priorWaf.LogOnlyRuleIds, &diags), + "enabled_rule_group_ids": wafRuleListToSet(ctx, distribution.Config.Waf.EnabledRuleGroupIds, priorWaf.EnabledRuleGroupIds, &diags), + "disabled_rule_group_ids": wafRuleListToSet(ctx, distribution.Config.Waf.DisabledRuleGroupIds, priorWaf.DisabledRuleGroupIds, &diags), + "log_only_rule_group_ids": wafRuleListToSet(ctx, distribution.Config.Waf.LogOnlyRuleGroupIds, priorWaf.LogOnlyRuleGroupIds, &diags), + "enabled_rule_collection_ids": wafRuleListToSet(ctx, distribution.Config.Waf.EnabledRuleCollectionIds, priorWaf.EnabledRuleCollectionIds, &diags), + "disabled_rule_collection_ids": wafRuleListToSet(ctx, distribution.Config.Waf.DisabledRuleCollectionIds, priorWaf.DisabledRuleCollectionIds, &diags), + "log_only_rule_collection_ids": wafRuleListToSet(ctx, distribution.Config.Waf.LogOnlyRuleCollectionIds, priorWaf.LogOnlyRuleCollectionIds, &diags), } if diags.HasError() { diff --git a/stackit/internal/services/cdn/distribution/resource_test.go b/stackit/internal/services/cdn/distribution/resource_test.go index a064b4d49..fa24e1c42 100644 --- a/stackit/internal/services/cdn/distribution/resource_test.go +++ b/stackit/internal/services/cdn/distribution/resource_test.go @@ -8,8 +8,11 @@ import ( "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/hashicorp/terraform-plugin-go/tftypes" cdnSdk "github.com/stackitcloud/stackit-sdk-go/services/cdn/v1api" ) @@ -1075,6 +1078,7 @@ func TestMapFields(t *testing.T) { types.StringValue("rule1"), types.StringValue("rule2"), }) + emptyWafSet := types.SetValueMust(types.StringType, []attr.Value{}) populatedWaf := types.ObjectValueMust(wafTypes, map[string]attr.Value{ "mode": types.StringValue("ENABLED"), "type": types.StringValue("PREMIUM"), @@ -1129,6 +1133,26 @@ func TestMapFields(t *testing.T) { "log_only_rule_collection_ids": types.SetNull(types.StringType), }) + // defaultWafEmptyRules is the WAF model produced by a plan in which the user explicitly set + // every rule list to an empty set (e.g. disabled_rule_ids = []) while the WAF stays disabled. + defaultWafEmptyRules := types.ObjectValueMust(wafTypes, map[string]attr.Value{ + "mode": types.StringValue("DISABLED"), + "type": types.StringValue("FREE"), + "paranoia_level": types.StringNull(), + "allowed_http_versions": types.SetNull(types.StringType), + "allowed_request_content_types": types.SetNull(types.StringType), + "allowed_http_methods": types.SetNull(types.StringType), + "enabled_rule_ids": emptyWafSet, + "disabled_rule_ids": emptyWafSet, + "log_only_rule_ids": emptyWafSet, + "enabled_rule_group_ids": emptyWafSet, + "disabled_rule_group_ids": emptyWafSet, + "log_only_rule_group_ids": emptyWafSet, + "enabled_rule_collection_ids": emptyWafSet, + "disabled_rule_collection_ids": emptyWafSet, + "log_only_rule_collection_ids": emptyWafSet, + }) + defaultTls := types.ObjectValueMust(tlsTypes, map[string]attr.Value{ "enable_tls_10": types.BoolValue(false), "enable_tls_11": types.BoolValue(false), @@ -1386,6 +1410,111 @@ func TestMapFields(t *testing.T) { }), IsValid: true, }, + // Regression test for https://github.com/stackitcloud/terraform-provider-stackit/issues/1630: + // when the API omits a WAF rule-list field (returns nil) but the model (plan) holds an + // explicitly empty set, the state must keep the empty set instead of becoming null. + "waf_rule_list_api_nil_model_empty_set": { + 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": defaultWafEmptyRules, + "tls": defaultTls, + "strip_response_cookies": types.BoolValue(false), + "forward_host_header": types.BoolValue(false), + }) + }), + // The API omits all rule-list fields (nil), as happens when the WAF is disabled. + Input: distributionFixture(func(d *cdnSdk.Distribution) { + d.Config.Waf = cdnSdk.WafConfig{ + Mode: cdnSdk.WAFMODE_DISABLED, + Type: cdnSdk.WAFTYPE_FREE, + } + }), + 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": defaultWafEmptyRules, + "tls": defaultTls, + "strip_response_cookies": types.BoolValue(false), + "forward_host_header": types.BoolValue(false), + }) + }), + IsValid: true, + }, + // When the API returns an explicit empty list, the state must be an empty set regardless + // of the prior model value. + "waf_rule_list_api_empty_list": { + 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": populatedWaf, + "tls": defaultTls, + "strip_response_cookies": types.BoolValue(false), + "forward_host_header": types.BoolValue(false), + }) + }), + Input: distributionFixture(func(d *cdnSdk.Distribution) { + d.Config.Waf = cdnSdk.WafConfig{ + Mode: cdnSdk.WAFMODE_ENABLED, + Type: cdnSdk.WAFTYPE_PREMIUM, + EnabledRuleIds: []string{}, + DisabledRuleIds: []string{}, + LogOnlyRuleIds: []string{}, + EnabledRuleGroupIds: []string{}, + DisabledRuleGroupIds: []string{}, + LogOnlyRuleGroupIds: []string{}, + EnabledRuleCollectionIds: []string{}, + DisabledRuleCollectionIds: []string{}, + LogOnlyRuleCollectionIds: []string{}, + AllowedHttpVersions: []string{"rule1", "rule2"}, + AllowedRequestContentTypes: []string{"rule1", "rule2"}, + AllowedHttpMethods: []string{"rule1", "rule2"}, + } + }), + Expected: expectedModel(func(m *Model) { + emptyRulesWaf := types.ObjectValueMust(wafTypes, map[string]attr.Value{ + "mode": types.StringValue("ENABLED"), + "type": types.StringValue("PREMIUM"), + "paranoia_level": types.StringNull(), + "allowed_http_versions": populatedWafSet, + "allowed_request_content_types": populatedWafSet, + "allowed_http_methods": populatedWafSet, + "enabled_rule_ids": emptyWafSet, + "disabled_rule_ids": emptyWafSet, + "log_only_rule_ids": emptyWafSet, + "enabled_rule_group_ids": emptyWafSet, + "disabled_rule_group_ids": emptyWafSet, + "log_only_rule_group_ids": emptyWafSet, + "enabled_rule_collection_ids": emptyWafSet, + "disabled_rule_collection_ids": emptyWafSet, + "log_only_rule_collection_ids": emptyWafSet, + }) + m.Config = createTestConfig(map[string]attr.Value{ + "backend": backend, + "regions": regionsFixture, + "optimizer": types.ObjectNull(optimizerTypes), + "blocked_countries": blockedCountriesFixture, + "redirects": types.ObjectNull(redirectsAttrTypes), + "waf": emptyRulesWaf, + "tls": defaultTls, + "strip_response_cookies": types.BoolValue(false), + "forward_host_header": types.BoolValue(false), + }) + }), + IsValid: true, + }, "happy_path_with_tls_and_strip_response_and_cookies_forward": { Expected: expectedModel(func(m *Model) { m.Config = createTestConfig(map[string]attr.Value{ @@ -1681,3 +1810,190 @@ func TestValidateCountryCode(t *testing.T) { }) } } + +// wafObjForModifyPlan builds a waf object value for ModifyPlan tests, allowing null sets. +func wafObjForModifyPlan(mode string, disabledIds, enabledCollectionIds types.Set) types.Object { + return types.ObjectValueMust(wafTypes, map[string]attr.Value{ + "mode": types.StringValue(mode), + "type": types.StringValue("FREE"), + "paranoia_level": types.StringNull(), + "allowed_http_versions": types.SetNull(types.StringType), + "allowed_request_content_types": types.SetNull(types.StringType), + "allowed_http_methods": types.SetNull(types.StringType), + "enabled_rule_ids": types.SetNull(types.StringType), + "disabled_rule_ids": disabledIds, + "log_only_rule_ids": types.SetNull(types.StringType), + "enabled_rule_group_ids": types.SetNull(types.StringType), + "disabled_rule_group_ids": types.SetNull(types.StringType), + "log_only_rule_group_ids": types.SetNull(types.StringType), + "enabled_rule_collection_ids": enabledCollectionIds, + "disabled_rule_collection_ids": types.SetNull(types.StringType), + "log_only_rule_collection_ids": types.SetNull(types.StringType), + }) +} + +func modifyPlanModel(updatedAt types.String, waf types.Object) *Model { + return &Model{ + ID: types.StringValue("test-project-id,test-distribution-id"), + DistributionId: types.StringValue("test-distribution-id"), + ProjectId: types.StringValue("test-project-id"), + Status: types.StringValue("ACTIVE"), + CreatedAt: types.StringValue("2026-09-04 09:00:00 +0000 UTC"), + UpdatedAt: updatedAt, + Errors: types.ListValueMust(types.StringType, []attr.Value{}), + Domains: types.ListValueMust(types.ObjectType{AttrTypes: domainTypes}, []attr.Value{}), + Config: configFixture(func(v map[string]attr.Value) { + v["backend"] = types.ObjectValueMust(backendTypes, map[string]attr.Value{ + "type": types.StringValue("http"), + "origin_url": types.StringValue("https://www.mycoolapp.com"), + "origin_request_headers": types.MapNull(types.StringType), + "geofencing": types.MapNull(geofencingTypes.ElemType), + "bucket_url": types.StringNull(), + "region": types.StringNull(), + "credentials": types.ObjectNull(backendCredentialsTypes), + }) + v["regions"] = types.ListValueMust(types.StringType, []attr.Value{types.StringValue("EU")}) + v["waf"] = waf + v["tls"] = types.ObjectNull(tlsTypes) + v["strip_response_cookies"] = types.BoolValue(false) + v["forward_host_header"] = types.BoolValue(false) + }), + } +} + +func modifyPlanRequest(ctx context.Context, schemaResp *resource.SchemaResponse, state, plan, config *Model) (resource.ModifyPlanRequest, *resource.ModifyPlanResponse) { + req := resource.ModifyPlanRequest{} + req.State = tfsdk.State{Schema: schemaResp.Schema, Raw: tftypes.NewValue(tftypes.DynamicPseudoType, nil)} + req.Plan = tfsdk.Plan{Schema: schemaResp.Schema, Raw: tftypes.NewValue(tftypes.DynamicPseudoType, nil)} + req.State.Set(ctx, state) + req.Plan.Set(ctx, plan) + + // tfsdk.Config has no Set method; marshal the config model via a scratch plan and reuse its Raw. + configScratch := tfsdk.Plan{Schema: schemaResp.Schema, Raw: tftypes.NewValue(tftypes.DynamicPseudoType, nil)} + configScratch.Set(ctx, config) + req.Config = tfsdk.Config{Schema: schemaResp.Schema, Raw: configScratch.Raw} + + resp := &resource.ModifyPlanResponse{} + resp.Plan = tfsdk.Plan{Schema: schemaResp.Schema, Raw: tftypes.NewValue(tftypes.DynamicPseudoType, nil)} + resp.Plan.Set(ctx, plan) + return req, resp +} + +// TestModifyPlan verifies that server-managed attributes (updated_at and unconfigured WAF rule +// lists) are planned as unknown during updates, and that no-op plans are left untouched to avoid +// perpetual diffs. Regression test for the inconsistent-result errors reported in +// https://github.com/stackitcloud/terraform-provider-stackit/issues/1630. +func TestModifyPlan(t *testing.T) { + ctx := context.Background() + r := &distributionResource{} + schemaResp := &resource.SchemaResponse{} + r.Schema(ctx, resource.SchemaRequest{}, schemaResp) + + tsOld := types.StringValue("2026-09-04 09:02:37 +0000 UTC") + + disabledPopulated := types.SetValueMust(types.StringType, []attr.Value{types.StringValue("@builtin/crs/request/942140")}) + disabledEmpty := types.SetValueMust(types.StringType, []attr.Value{}) + nullSet := types.SetNull(types.StringType) + + // helper to read a nested waf set from a plan model's config + getWafSet := func(t *testing.T, m Model, name string) types.Set { + t.Helper() + var cfg distributionConfig + diags := m.Config.As(ctx, &cfg, basetypes.ObjectAsOptions{}) + if diags.HasError() { + t.Fatalf("reading config: %v", diags) + } + var w wafConfig + diags = cfg.Waf.As(ctx, &w, basetypes.ObjectAsOptions{}) + if diags.HasError() { + t.Fatalf("reading waf: %v", diags) + } + switch name { + case "disabled_rule_ids": + return w.DisabledRuleIds + case "enabled_rule_collection_ids": + return w.EnabledRuleCollectionIds + } + t.Fatalf("unknown waf attr %q", name) + return types.SetNull(types.StringType) + } + + t.Run("update via waf removal: updated_at and unconfigured lists become unknown", func(t *testing.T) { + // state: disabled_rule_ids populated, enabled_rule_collection_ids null (never configured) + state := modifyPlanModel(tsOld, wafObjForModifyPlan("ENABLED", disabledPopulated, nullSet)) + // plan: EmptyOnRemoval already emptied disabled_rule_ids; enabled_rule_collection_ids null + plan := modifyPlanModel(tsOld, wafObjForModifyPlan("ENABLED", disabledEmpty, nullSet)) + // config: user removed disabled_rule_ids (null), enabled_rule_collection_ids null + config := modifyPlanModel(tsOld, wafObjForModifyPlan("ENABLED", nullSet, nullSet)) + + req, resp := modifyPlanRequest(ctx, schemaResp, state, plan, config) + r.ModifyPlan(ctx, req, resp) + if resp.Diagnostics.HasError() { + t.Fatalf("ModifyPlan diagnostics: %v", resp.Diagnostics) + } + + var got Model + resp.Plan.Get(ctx, &got) + + if !got.UpdatedAt.IsUnknown() { + t.Errorf("expected updated_at to be unknown, got %v", got.UpdatedAt) + } + if gotSet := getWafSet(t, got, "enabled_rule_collection_ids"); !gotSet.IsUnknown() { + t.Errorf("expected enabled_rule_collection_ids to be unknown, got %v", gotSet) + } + if gotSet := getWafSet(t, got, "disabled_rule_ids"); !gotSet.Equal(disabledEmpty) { + t.Errorf("expected disabled_rule_ids to remain empty set, got %v", gotSet) + } + }) + + t.Run("no-op plan: left untouched (no perpetual diff)", func(t *testing.T) { + state := modifyPlanModel(tsOld, wafObjForModifyPlan("ENABLED", disabledPopulated, nullSet)) + plan := modifyPlanModel(tsOld, wafObjForModifyPlan("ENABLED", disabledPopulated, nullSet)) + config := modifyPlanModel(tsOld, wafObjForModifyPlan("ENABLED", disabledPopulated, nullSet)) + + req, resp := modifyPlanRequest(ctx, schemaResp, state, plan, config) + r.ModifyPlan(ctx, req, resp) + if resp.Diagnostics.HasError() { + t.Fatalf("ModifyPlan diagnostics: %v", resp.Diagnostics) + } + + var got Model + resp.Plan.Get(ctx, &got) + + if !got.UpdatedAt.Equal(tsOld) { + t.Errorf("expected updated_at to remain %v, got %v", tsOld, got.UpdatedAt) + } + if gotSet := getWafSet(t, got, "enabled_rule_collection_ids"); !gotSet.IsNull() { + t.Errorf("expected enabled_rule_collection_ids to remain null, got %v", gotSet) + } + }) + + t.Run("create: skipped", func(t *testing.T) { + plan := modifyPlanModel(types.StringUnknown(), wafObjForModifyPlan("ENABLED", disabledPopulated, nullSet)) + config := modifyPlanModel(types.StringUnknown(), wafObjForModifyPlan("ENABLED", disabledPopulated, nullSet)) + + req := resource.ModifyPlanRequest{} + req.State = tfsdk.State{Schema: schemaResp.Schema, Raw: tftypes.NewValue(schemaResp.Schema.Type().TerraformType(ctx), nil)} // null state = create + req.Plan = tfsdk.Plan{Schema: schemaResp.Schema, Raw: tftypes.NewValue(tftypes.DynamicPseudoType, nil)} + req.Plan.Set(ctx, plan) + + configScratch := tfsdk.Plan{Schema: schemaResp.Schema, Raw: tftypes.NewValue(tftypes.DynamicPseudoType, nil)} + configScratch.Set(ctx, config) + req.Config = tfsdk.Config{Schema: schemaResp.Schema, Raw: configScratch.Raw} + + resp := &resource.ModifyPlanResponse{} + resp.Plan = tfsdk.Plan{Schema: schemaResp.Schema, Raw: tftypes.NewValue(tftypes.DynamicPseudoType, nil)} + resp.Plan.Set(ctx, plan) + + r.ModifyPlan(ctx, req, resp) + if resp.Diagnostics.HasError() { + t.Fatalf("ModifyPlan diagnostics: %v", resp.Diagnostics) + } + + var got Model + resp.Plan.Get(ctx, &got) + if !got.UpdatedAt.IsUnknown() { + t.Errorf("expected updated_at to remain unknown on create, got %v", got.UpdatedAt) + } + }) +} diff --git a/stackit/internal/utils/planmodifiers/setplanmodifier/empty_on_removal.go b/stackit/internal/utils/planmodifiers/setplanmodifier/empty_on_removal.go new file mode 100644 index 000000000..cde7b7b8d --- /dev/null +++ b/stackit/internal/utils/planmodifiers/setplanmodifier/empty_on_removal.go @@ -0,0 +1,68 @@ +package setplanmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// emptyOnRemovalModifier implements the plan modifier. +type emptyOnRemovalModifier struct{} + +func (m emptyOnRemovalModifier) Description(_ context.Context) string { + return "Plans an empty set when the attribute is removed from the configuration, so that previously applied values are cleared remotely." +} + +func (m emptyOnRemovalModifier) MarkdownDescription(ctx context.Context) string { + return m.Description(ctx) +} + +// PlanModifySet implements the plan modification logic. +// +// For Optional+Computed set attributes, Terraform's default behavior on update is to keep the +// prior state value when the attribute is removed (null) from the configuration. Depending on +// whether other changes exist in the plan, the value is either marked as unknown +// "(known after apply)" or silently carried over from the prior state. In both cases the +// attribute is never planned for removal, which prevents users from clearing previously +// configured values via Terraform. +// +// This modifier instead plans an empty set when the attribute is removed from the +// configuration, producing a diff that clears the remote values on apply. +func (m emptyOnRemovalModifier) PlanModifySet(ctx context.Context, req planmodifier.SetRequest, resp *planmodifier.SetResponse) { // nolint:gocritic // function signature required by Terraform + // Do nothing on resource creation; there is no prior value to clear. + if req.State.Raw.IsNull() { + return + } + + // Only act when the attribute was removed from the configuration. + if !req.ConfigValue.IsNull() { + return + } + + // Do nothing if there is an unknown configuration value, otherwise interpolation gets messed up. + if req.ConfigValue.IsUnknown() { + return + } + + // Do nothing when there is no prior value to clear; the empty plan would be a no-op diff. + if req.StateValue.IsNull() || req.StateValue.IsUnknown() { + return + } + + empty := types.SetValueMust(req.StateValue.ElementType(ctx), []attr.Value{}) + + // Do nothing when the plan already proposes an empty set. + if !req.PlanValue.IsUnknown() && req.PlanValue.Equal(empty) { + return + } + + resp.PlanValue = empty +} + +// EmptyOnRemoval returns a plan modifier that plans an empty set when the attribute is +// removed from the configuration, so that previously applied values are cleared remotely. +func EmptyOnRemoval() planmodifier.Set { + return emptyOnRemovalModifier{} +} diff --git a/stackit/internal/utils/planmodifiers/setplanmodifier/empty_on_removal_test.go b/stackit/internal/utils/planmodifiers/setplanmodifier/empty_on_removal_test.go new file mode 100644 index 000000000..e00e3a9f6 --- /dev/null +++ b/stackit/internal/utils/planmodifiers/setplanmodifier/empty_on_removal_test.go @@ -0,0 +1,161 @@ +package setplanmodifier + +import ( + "context" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +// setStateRaw builds a tfsdk.State whose Raw value is either null (resource creation) or a +// non-null object carrying the given attribute value (existing resource). +func setStateRaw(null bool, setValue tftypes.Value) tfsdk.State { + objectType := tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Set{ElementType: tftypes.String}, + }, + } + + if null { + return tfsdk.State{Raw: tftypes.NewValue(objectType, nil)} + } + + return tfsdk.State{Raw: tftypes.NewValue(objectType, map[string]tftypes.Value{ + "attr": setValue, + })} +} + +func TestEmptyOnRemovalModifier(t *testing.T) { + t.Parallel() + + elementType := types.StringType + + emptySet := types.SetValueMust(elementType, []attr.Value{}) + populatedSet := types.SetValueMust(elementType, []attr.Value{ + types.StringValue("rule1"), + }) + nullSet := types.SetNull(elementType) + unknownSet := types.SetUnknown(elementType) + + // Non-null raw state values for existing resources. + populatedRaw := tftypes.NewValue(tftypes.Set{ElementType: tftypes.String}, []tftypes.Value{ + tftypes.NewValue(tftypes.String, "rule1"), + }) + emptyRaw := tftypes.NewValue(tftypes.Set{ElementType: tftypes.String}, []tftypes.Value{}) + + tests := []struct { + description string + state tfsdk.State + configValue types.Set // the value provided by the user in the Terraform configuration + stateValue types.Set // the value stored in the TF state + planValue types.Set // the value Terraform's default plan proposes + expected types.Set // expected result + }{ + { + description: "plan empty set: config removed, plan unknown, state has values", + state: setStateRaw(false, populatedRaw), + configValue: nullSet, + stateValue: populatedSet, + planValue: unknownSet, + expected: emptySet, + }, + { + description: "plan empty set: config removed, plan unknown, state empty", + state: setStateRaw(false, emptyRaw), + configValue: nullSet, + stateValue: emptySet, + planValue: unknownSet, + expected: emptySet, + }, + { + // Regression test for the case where removing the attribute is the only change: + // Terraform's default plan silently carries over the prior state value (known, + // not unknown), which must still be turned into an empty set. + description: "plan empty set: config removed, plan carries over prior state value", + state: setStateRaw(false, populatedRaw), + configValue: nullSet, + stateValue: populatedSet, + planValue: populatedSet, + expected: emptySet, + }, + { + description: "do nothing: config removed, plan already an empty set", + state: setStateRaw(false, populatedRaw), + configValue: nullSet, + stateValue: populatedSet, + planValue: emptySet, + expected: emptySet, + }, + { + description: "do nothing: config has values", + state: setStateRaw(false, emptyRaw), + configValue: populatedSet, + stateValue: emptySet, + planValue: populatedSet, + expected: populatedSet, + }, + { + description: "do nothing: config empty set explicitly", + state: setStateRaw(false, populatedRaw), + configValue: emptySet, + stateValue: populatedSet, + planValue: emptySet, + expected: emptySet, + }, + { + description: "do nothing: config null, state null (nothing to clear)", + state: setStateRaw(false, emptyRaw), + configValue: nullSet, + stateValue: nullSet, + planValue: unknownSet, + expected: unknownSet, + }, + { + description: "do nothing: config null, state unknown", + state: setStateRaw(false, emptyRaw), + configValue: nullSet, + stateValue: unknownSet, + planValue: unknownSet, + expected: unknownSet, + }, + { + description: "do nothing on create: no prior state", + state: setStateRaw(true, tftypes.Value{}), + configValue: nullSet, + stateValue: nullSet, + planValue: unknownSet, + expected: unknownSet, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + t.Parallel() + ctx := context.Background() + + // set up the request representing Terraform Core's state/config + req := planmodifier.SetRequest{ + State: tt.state, + ConfigValue: tt.configValue, + StateValue: tt.stateValue, + PlanValue: tt.planValue, + } + + // set up the response representing Terraform Core's proposed plan + resp := planmodifier.SetResponse{ + PlanValue: tt.planValue, + } + + // execute the modifier + EmptyOnRemoval().PlanModifySet(ctx, req, &resp) + + if !resp.PlanValue.Equal(tt.expected) { + t.Errorf("Test %q failed.\nExpected plan: %s\nGot plan: %s", tt.description, tt.expected, resp.PlanValue) + } + }) + } +} diff --git a/waf-rule-lists-fix-notes.md b/waf-rule-lists-fix-notes.md new file mode 100644 index 000000000..6c0ecdc72 --- /dev/null +++ b/waf-rule-lists-fix-notes.md @@ -0,0 +1,118 @@ +# CDN Distribution WAF Rule Lists — Fix Review Guide + +**Issue:** [stackitcloud/terraform-provider-stackit#1630](https://github.com/stackitcloud/terraform-provider-stackit/issues/1630) +**Scope:** `stackit_cdn_distribution` resource, `config.waf.*` rule-list attributes +**Primary files touched:** + +- `stackit/internal/services/cdn/distribution/resource.go` +- `stackit/internal/services/cdn/distribution/resource_test.go` +- `stackit/internal/utils/planmodifiers/setplanmodifier/empty_on_removal.go` (new) +- `stackit/internal/utils/planmodifiers/setplanmodifier/empty_on_removal_test.go` (new) +- `docs/resources/cdn_distribution.md`, `docs/data-sources/cdn_distribution.md` (regenerated) + +--- + +## 1. The problem in a nutshell + +The nine WAF rule-list attributes (`enabled/disabled/log_only` × `rule/rule_group/rule_collection` `_ids`) are declared `Optional: true, Computed: true`. That schema combination is the root of every symptom in this issue: + +| # | Symptom | Trigger | +| --- | --------- | --------- | +| A | `terraform plan` reports "No changes" after a rule list is **removed** from the config | The framework carries the prior state value into the plan for unconfigured `Optional+Computed` attributes, so plan == state. | +| B | `Provider produced inconsistent result after apply`: `disabled_rule_ids: was cty.SetValEmpty(...), but now null` | The user works around (A) by setting `[]` explicitly; the apply clears the rules, but the server omits some (and only some!) of the fields if they are empty lists and the provider maps the absent field back to `null`, contradicting the planned `[]`. | +| C | Error for `updated_at` on the removal-triggered update | The server bumps the timestamp on every PATCH, but the plan had carried over the old, known timestamp. | +| D | Error for `enabled_rule_collection_ids: was null, but now [...]` | When the WAF is enabled, the server injects default rule collections. The plan held `null` for the unconfigured list while the apply returned the defaults. | + +The framework detail connecting (A), (C) and (D): the pass that marks null-config computed attributes as `(known after apply)` — `MarkComputedNilsAsUnknown` in `internal/fwserver/server_planresourcechange.go` (framework v1.19.0) — only runs **when the proposed new state already differs from prior state** (line 200 gate). When a removal is the *only* change, the proposed state equals prior state at that point, the pass is skipped, and every computed attribute keeps its prior state value as a *known* plan value. Plan modifiers run afterwards (line 293), so the diff created by our plan modifier comes too late to trigger the framework's own unknown-marking, which is why the `ModifyPlan` is needed. + +### Verified API behavior (drove the design) + +- `PATCH` with `"disabledRuleIds": []` clears the list; subsequent GET returns `[]`. This may not be true for other fields like `enabledRuleCollectionIds`, for which the server omits the field if configured as `[]`. +- Enabling the WAF (`mode = "ENABLED"`/`LOG_ONLY`) populates the `enabled_*` collections with server defaults **once**, when no user value exists. An explicit `[]` set by the user sticks and is not refilled. +- The `waf` config object is sparse when `mode = "DISABLED"`: rule-list fields are omitted from GET responses. +- The portal showing rules as "active" in the original report was a red herring: the WAF mode was `DISABLED`, so the portal showed its enable-feature page. Not a bug. + +--- + +## 2. Design decisions + +1. **Keep `Optional + Computed`.** The attributes genuinely have server-side defaults, so `Computed` is semantically correct. +2. **`enabled_*` vs. `disabled_*`/`log_only_*` behave differently on removal.** + - `enabled_*` lists are **server-defaulted** on WAF enablement. Removal must mean "stop managing, accept the server value" — explicit `[]` still clears them. We have no control over when the server decides to inject it's own values. + - `disabled_*` and `log_only_*` lists are never defaulted. Removal plans an empty set, i.e. actively clears the rules. This is the fix for symptom (A). +3. **The empty-vs-null ambiguity is resolved in favor of the plan.** Because the sparse-WAF GET response conflates "never configured" with "just cleared" (both absent), the provider preserves the value the plan/prior model carries when the API omits a field. +4. **Server-managed values are planned as unknown at the resource level**, not via per-attribute modifiers. The only reliable point to know "an update is happening" is after all attribute plan modifiers ran — a resource-level `ModifyPlan`. (An earlier draft per-attribute `updated_at` modifier was reverted for causing perpetual diffs.) + +--- + +## 3. Implementation walkthrough + +### 3.1 `setplanmodifier.EmptyOnRemoval()` — new plan modifier (symptom A) + +`stackit/internal/utils/planmodifiers/setplanmodifier/empty_on_removal.go` + +Plans an empty set when the attribute is removed from the configuration. Guard conditions: + +- `req.State.Raw.IsNull()` → resource creation: nothing to clear, API defaults must apply. +- `!req.ConfigValue.IsNull()` → attribute still configured: leave the user's value alone. +- `req.ConfigValue.IsUnknown()` → interpolation safety. +- `req.StateValue.IsNull() || req.StateValue.IsUnknown()` → no prior value to clear (also protects never-configured server-defaulted lists). +- Plan already an empty set → no-op (idempotence, avoids perpetual diffs). + +Critically, it fires both when the plan value is unknown (framework marked it) **and** when the plan value is a known carry-over of prior state — the latter being the exact "No changes" path of symptom (A). + +Applied in `resource.go` schema to the six non-server-defaulted lists only: `disabled_rule_ids`, `log_only_rule_ids`, `disabled_rule_group_ids`, `log_only_rule_group_ids`, `disabled_rule_collection_ids`, `log_only_rule_collection_ids`. The three `enabled_*` attributes have **no** plan modifier (see decision 2). + +### 3.2 `wafRuleListToSet()` — apply-time consistency (symptom B) + +`resource.go`, helper used by `mapFields`. + +``` +apiList != nil → use the API value (populated or explicit []) +apiList == nil → keep the value already in the model +``` + +`mapFields` decodes the incoming model's `waf` object (`priorWaf`) and uses it as the fallback for all nine lists. Because the model is plan-derived in Create/Update, an explicitly planned `[]` survives as `[]` instead of becoming `null` — eliminating the inconsistency of symptom (B). In Read, the model is state-derived, so refresh preserves server values rather than nulling them. + +### 3.3 `ModifyPlan` — resource-level unknown marking (symptoms C and D) + +`resource.go`, `func (r *distributionResource) ModifyPlan` (registered via `resource.ResourceWithModifyPlan`). + +Runs after all attribute plan modifiers, when "is this a real update?" is finally decidable: + +1. **Skip on create** (`req.State.Raw.IsNull()`) — computed attributes are unknown there anyway. +2. **Skip on destroy** (`req.Plan.Raw.IsNull()`). +3. **Skip on no-op** (`planModel.UpdatedAt.Equal(stateModel.UpdatedAt) && planModel.Config.Equal(stateModel.Config)`) — this is the anti-perpetual-diff guard; note the comparison happens after `EmptyOnRemoval`, so a removal-only change *is* detected as an update. +4. On a real update: + - `planModel.UpdatedAt = types.StringUnknown()` — the timestamp always changes on PATCH, so the plan must not assert the stale value (symptom C). + - `markWafRuleListsUnknownIfUnconfigured(&planWaf, &configWaf)` — each of the nine lists that is null in **both** config and plan is set to unknown, so server-injected defaults (symptom D) are accepted on apply. Lists with a configured value or an `EmptyOnRemoval`-produced `[]` are untouched. + +This replaces the framework's skipped `MarkComputedNilsAsUnknown` pass for exactly the attributes that need it, and composes with the framework's own pass when it does run (no-ops there). + +### 3.4 Documentation + +Attribute descriptions updated: + +- `disabled_*`/`log_only_*`: "Set to an empty set **or remove the attribute** to clear previously set rules." +- `enabled_*`: "Set to an empty set to clear. When omitted, the server-managed set is left untouched (the API may populate defaults when the WAF is enabled)." + +The old "retains the last known state" wording was removed. + +--- + +## 4. Tests + +- `empty_on_removal_test.go` — 9 cases: removal→empty (both unknown-plan and carried-over-known-plan paths), already-empty idempotence, configured values untouched, create/null-state guards. +- `TestMapFields` (`resource_test.go`) — two regression cases for symptom (B): API omits fields while the model holds explicit empty sets (state must stay empty), and API returns explicit empty lists (state must be empty regardless of prior populated model). +- `TestModifyPlan` (`resource_test.go`) — three scenarios: (1) update via WAF removal asserts `updated_at` and unconfigured `enabled_rule_collection_ids` become unknown while `disabled_rule_ids` stays `[]`; (2) no-op plan asserts nothing is touched (the anti-perpetual-diff guard); (3) create is skipped. + +Each guard was validated by temporarily deleting it and confirming the corresponding test fails. + +--- + +## 5. What to focus on when reviewing + +1. **The no-op guard in `ModifyPlan`** (`planModel.Config.Equal(stateModel.Config)`) is load-bearing. Removing it reintroduces perpetual diffs on `updated_at` and all unconfigured WAF lists. +2. **The asymmetry between `enabled_*` and the other six lists** is intentional and tied to verified API behavior (defaults injected once, explicit `[]` sticks). "Unifiying" them has consequences. This should be discussed in the team, as users may expect each field to behave exactly the same. +3. **`wafRuleListToSet` trades drift detection for consistency.** If someone modifies WAF lists out of band and the API then omits a field (disabled-WAF sparse response), the provider keeps the last known value instead of surfacing drift. Explicitly taken trade-off during the implementation. +4. **Not covered by this change:** the three `allowed_*` sets keep `SizeAtLeast(1)` and unchanged semantics (they can never be cleared); other always-changing computed attributes (`status`, `errors`, `domains`) were left alone because the wait handler normally returns them in their final state — they might share the theoretical symptom-(C) exposure but were not explicitly tested and did not manifest it during the waf tests.