diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fee09cd6..324b1efd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,7 @@ alias = [ ### Changes +- Fix `elasticstack_kibana_action_connector` failing with "inconsistent result after apply" when config contains null values ([#1524](https://github.com/elastic/terraform-provider-elasticstack/pull/1524)) - Add `host_name_format` to `elasticstack_fleet_agent_policy` to configure host name format (hostname or FQDN) ([#1312](https://github.com/elastic/terraform-provider-elasticstack/pull/1312)) - Create `elasticstack_kibana_prebuilt_rule` resource ([#1296](https://github.com/elastic/terraform-provider-elasticstack/pull/1296)) - Add `required_versions` to `elasticstack_fleet_agent_policy` ([#1436](https://github.com/elastic/terraform-provider-elasticstack/pull/1436)) diff --git a/internal/kibana/connectors/acc_test.go b/internal/kibana/connectors/acc_test.go index 367fe6597..5817cb4b5 100644 --- a/internal/kibana/connectors/acc_test.go +++ b/internal/kibana/connectors/acc_test.go @@ -91,7 +91,7 @@ func TestAccResourceKibanaConnectorCasesWebhook(t *testing.T) { resource.TestMatchResourceAttr("elasticstack_kibana_action_connector.test", "config", regexp.MustCompile(`\"getIncidentResponseExternalTitleKey\":\"title\"`)), resource.TestMatchResourceAttr("elasticstack_kibana_action_connector.test", "config", regexp.MustCompile(`\"getIncidentUrl\":\"https://www\.elastic\.co/\"`)), resource.TestMatchResourceAttr("elasticstack_kibana_action_connector.test", "config", regexp.MustCompile(`\"updateIncidentJson\":\"{}\"`)), - resource.TestMatchResourceAttr("elasticstack_kibana_action_connector.test", "config", regexp.MustCompile(`\"updateIncidentUrl\":\"https://www.elastic\.co/\"`)), + resource.TestMatchResourceAttr("elasticstack_kibana_action_connector.test", "config", regexp.MustCompile(`\"updateIncidentUrl\":\"https://www\.elastic\.co/\"`)), resource.TestMatchResourceAttr("elasticstack_kibana_action_connector.test", "config", regexp.MustCompile(`\"viewIncidentUrl\":\"https://www\.elastic\.co/\"`)), resource.TestMatchResourceAttr("elasticstack_kibana_action_connector.test", "secrets", regexp.MustCompile(`\"user\":\"user1\"`)), @@ -119,6 +119,26 @@ func TestAccResourceKibanaConnectorCasesWebhook(t *testing.T) { resource.TestMatchResourceAttr("elasticstack_kibana_action_connector.test", "secrets", regexp.MustCompile(`\"password\":\"password2\"`)), ), }, + { + SkipFunc: versionutils.CheckIfVersionIsUnsupported(tc.minVersion), + ConfigDirectory: acctest.NamedTestCaseDirectory("null_headers"), + ConfigVariables: vars, + Check: resource.ComposeTestCheckFunc( + testCommonAttributes(connectorName, ".cases-webhook"), + + resource.TestMatchResourceAttr("elasticstack_kibana_action_connector.test", "config", regexp.MustCompile(`\"createIncidentJson\":\"{}\"`)), + resource.TestMatchResourceAttr("elasticstack_kibana_action_connector.test", "config", regexp.MustCompile(`\"createIncidentResponseKey\":\"key\"`)), + resource.TestMatchResourceAttr("elasticstack_kibana_action_connector.test", "config", regexp.MustCompile(`\"createIncidentUrl\":\"https://www\.elastic\.co/\"`)), + resource.TestMatchResourceAttr("elasticstack_kibana_action_connector.test", "config", regexp.MustCompile(`\"getIncidentResponseExternalTitleKey\":\"title\"`)), + resource.TestMatchResourceAttr("elasticstack_kibana_action_connector.test", "config", regexp.MustCompile(`\"getIncidentUrl\":\"https://www\.elastic\.co/\"`)), + resource.TestMatchResourceAttr("elasticstack_kibana_action_connector.test", "config", regexp.MustCompile(`\"updateIncidentJson\":\"{}\"`)), + resource.TestMatchResourceAttr("elasticstack_kibana_action_connector.test", "config", regexp.MustCompile(`\"updateIncidentUrl\":\"https://www\.elastic\.co/\"`)), + resource.TestMatchResourceAttr("elasticstack_kibana_action_connector.test", "config", regexp.MustCompile(`\"viewIncidentUrl\":\"https://www\.elastic\.co/\"`)), + resource.TestMatchResourceAttr("elasticstack_kibana_action_connector.test", "config", regexp.MustCompile(`\"headers\":null`)), + resource.TestMatchResourceAttr("elasticstack_kibana_action_connector.test", "secrets", regexp.MustCompile(`\"user\":\"user1\"`)), + resource.TestMatchResourceAttr("elasticstack_kibana_action_connector.test", "secrets", regexp.MustCompile(`\"password\":\"password1\"`)), + ), + }, }, }) }) diff --git a/internal/kibana/connectors/config_value.go b/internal/kibana/connectors/config_value.go index baafc3fa1..14a684712 100644 --- a/internal/kibana/connectors/config_value.go +++ b/internal/kibana/connectors/config_value.go @@ -67,6 +67,7 @@ func (v ConfigValue) SanitizedValue() (string, diag.Diagnostics) { } delete(unsanitizedMap, connectorTypeIDKey) + removeNulls(unsanitizedMap) sanitizedValue, err := json.Marshal(unsanitizedMap) if err != nil { diags.AddError("Failed to marshal sanitized config value", err.Error()) @@ -76,6 +77,21 @@ func (v ConfigValue) SanitizedValue() (string, diag.Diagnostics) { return string(sanitizedValue), diags } +// removeNulls recursively removes all null values from the map +func removeNulls(m map[string]interface{}) { + for key, value := range m { + if value == nil { + delete(m, key) + continue + } + + if nestedMap, ok := value.(map[string]interface{}); ok { + removeNulls(nestedMap) + continue + } + } +} + // StringSemanticEquals returns true if the given config object value is semantically equal to the current config object value. // The comparison will ignore any default values present in one value, but unset in the other. func (v ConfigValue) StringSemanticEquals(ctx context.Context, newValuable basetypes.StringValuable) (bool, diag.Diagnostics) { diff --git a/internal/kibana/connectors/config_value_test.go b/internal/kibana/connectors/config_value_test.go index b651ad2bf..84ee3ca25 100644 --- a/internal/kibana/connectors/config_value_test.go +++ b/internal/kibana/connectors/config_value_test.go @@ -125,6 +125,47 @@ func TestConfigValue_SanitizedValue(t *testing.T) { expectError: true, errorContains: "Failed to unmarshal config value", }, + { + name: "JSON with null values gets sanitized - top level", + configValue: ConfigValue{ + Normalized: jsontypes.NewNormalizedValue(`{"key": "value", "nullField": null, "another": "field"}`), + }, + expectedResult: `{"another":"field","key":"value"}`, + expectError: false, + }, + { + name: "JSON with null values gets sanitized - nested", + configValue: ConfigValue{ + Normalized: jsontypes.NewNormalizedValue(`{"key": "value", "nested": {"field": "value", "nullField": null}}`), + }, + expectedResult: `{"key":"value","nested":{"field":"value"}}`, + expectError: false, + }, + { + name: "JSON with null values gets sanitized - mixed", + configValue: ConfigValue{ + Normalized: jsontypes.NewNormalizedValue(`{"key": "value", "nullTop": null, "nested": {"field": "value", "nullNested": null}, "another": null}`), + }, + expectedResult: `{"key":"value","nested":{"field":"value"}}`, + expectError: false, + }, + { + name: "JSON with only null values results in empty object", + configValue: ConfigValue{ + Normalized: jsontypes.NewNormalizedValue(`{"nullField1": null, "nullField2": null}`), + }, + expectedResult: `{}`, + expectError: false, + }, + { + name: "JSON with null and connector type ID gets both sanitized", + configValue: ConfigValue{ + Normalized: jsontypes.NewNormalizedValue(`{"key": "value", "nullField": null, "__tf_provider_connector_type_id": "test-connector"}`), + connectorTypeID: "test-connector", + }, + expectedResult: `{"key":"value"}`, + expectError: false, + }, } for _, tt := range tests { diff --git a/internal/kibana/connectors/testdata/TestAccResourceKibanaConnectorCasesWebhook/with_empty_connector_id/null_headers/connector.tf b/internal/kibana/connectors/testdata/TestAccResourceKibanaConnectorCasesWebhook/with_empty_connector_id/null_headers/connector.tf new file mode 100644 index 000000000..ba9320cd5 --- /dev/null +++ b/internal/kibana/connectors/testdata/TestAccResourceKibanaConnectorCasesWebhook/with_empty_connector_id/null_headers/connector.tf @@ -0,0 +1,24 @@ +variable "connector_name" { + description = "The connector name" + type = string +} + +resource "elasticstack_kibana_action_connector" "test" { + name = var.connector_name + config = jsonencode({ + createIncidentJson = "{}" + createIncidentResponseKey = "key" + createIncidentUrl = "https://www.elastic.co/" + getIncidentResponseExternalTitleKey = "title" + getIncidentUrl = "https://www.elastic.co/" + headers = null + updateIncidentJson = "{}" + updateIncidentUrl = "https://www.elastic.co/" + viewIncidentUrl = "https://www.elastic.co/" + }) + secrets = jsonencode({ + user = "user1" + password = "password1" + }) + connector_type_id = ".cases-webhook" +} diff --git a/internal/kibana/connectors/testdata/TestAccResourceKibanaConnectorCasesWebhook/with_predefined_connector_id/null_headers/connector.tf b/internal/kibana/connectors/testdata/TestAccResourceKibanaConnectorCasesWebhook/with_predefined_connector_id/null_headers/connector.tf new file mode 100644 index 000000000..17b13f9cf --- /dev/null +++ b/internal/kibana/connectors/testdata/TestAccResourceKibanaConnectorCasesWebhook/with_predefined_connector_id/null_headers/connector.tf @@ -0,0 +1,30 @@ +variable "connector_name" { + description = "The connector name" + type = string +} + +variable "connector_id" { + description = "Connector ID" + type = string +} + +resource "elasticstack_kibana_action_connector" "test" { + name = var.connector_name + connector_id = var.connector_id + config = jsonencode({ + createIncidentJson = "{}" + createIncidentResponseKey = "key" + createIncidentUrl = "https://www.elastic.co/" + getIncidentResponseExternalTitleKey = "title" + getIncidentUrl = "https://www.elastic.co/" + headers = null + updateIncidentJson = "{}" + updateIncidentUrl = "https://www.elastic.co/" + viewIncidentUrl = "https://www.elastic.co/" + }) + secrets = jsonencode({ + user = "user1" + password = "password1" + }) + connector_type_id = ".cases-webhook" +}