From a35b88731552a1b866ffa47484ce4be8094b6353 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Diogo=20Ferr=C3=A3o?= Date: Thu, 18 Jul 2024 15:05:35 +0100 Subject: [PATCH] Onboard Argus alert configs (#449) * Onboard Argus alert config receivers (#439) * initial schema and to payload * finish receiver resource implementation (schema, topayload and mapfields) * fix toUpdate payload lists * fix resource and datasource on alert config attr removal, add testing * fix linting and testing * initial test map fields * improve testing, remove logging * rename vars in acc testing * refactor mapAlertConfig * improve mock alert config, fix testing * make the mock alert config receivers match the default * generate docs * Onboard Argus Alert Config global configuration (#446) * initial implementation * initial map fields * extend datasource, finish resource impl and extend acc testing * remove unmapped fields * add all attributes back * remove commented inhibit rules * generate docs, fix testing * address PR comments * Onboard Alert Config route (#447) * Initial implementation * add schema to datasource, improve acc testing * fix linting, generate docs * address PR comments * fix and improve acceptance tests * update test titles (comments) * address acceptance comments (#452) * Onboard Argus Alert Config child routes in Route (#463) * initial schema and map fields implementation * initial working solution * improve implementation, generate docs * fix description, add more unit testing * address PR comments * add unit and acc testing, fix datasource schema, fix plan_id mapping * add checks to acceptance testing * update acceptance tests --- docs/data-sources/argus_instance.md | 95 ++ docs/resources/argus_instance.md | 108 ++ stackit/internal/conversion/conversion.go | 17 + .../internal/conversion/conversion_test.go | 82 ++ .../internal/services/argus/argus_acc_test.go | 313 +++- .../services/argus/instance/datasource.go | 175 +++ .../services/argus/instance/resource.go | 1285 +++++++++++++++++ .../services/argus/instance/resource_test.go | 1015 +++++++++++++ 8 files changed, 3081 insertions(+), 9 deletions(-) create mode 100644 stackit/internal/conversion/conversion_test.go diff --git a/docs/data-sources/argus_instance.md b/docs/data-sources/argus_instance.md index 4a1076ce..4787ec31 100644 --- a/docs/data-sources/argus_instance.md +++ b/docs/data-sources/argus_instance.md @@ -30,6 +30,7 @@ data "stackit_argus_instance" "example" { ### Read-Only - `acl` (Set of String) The access control list for this instance. Each entry is an IP address range that is permitted to access, in CIDR notation. +- `alert_config` (Attributes) Alert configuration for the instance. (see [below for nested schema](#nestedatt--alert_config)) - `alerting_url` (String) Specifies Alerting URL. - `dashboard_url` (String) Specifies Argus instance dashboard URL. - `grafana_initial_admin_password` (String, Sensitive) Specifies an initial Grafana admin password. @@ -54,3 +55,97 @@ data "stackit_argus_instance" "example" { - `plan_name` (String) Specifies the Argus plan. E.g. `Monitoring-Medium-EU01`. - `targets_url` (String) Specifies Targets URL. - `zipkin_spans_url` (String) + + +### Nested Schema for `alert_config` + +Read-Only: + +- `global` (Attributes) Global configuration for the alerts. (see [below for nested schema](#nestedatt--alert_config--global)) +- `receivers` (Attributes List) List of alert receivers. (see [below for nested schema](#nestedatt--alert_config--receivers)) +- `route` (Attributes) The route for the alert. (see [below for nested schema](#nestedatt--alert_config--route)) + + +### Nested Schema for `alert_config.global` + +Read-Only: + +- `opsgenie_api_key` (String, Sensitive) The API key for OpsGenie. +- `opsgenie_api_url` (String) The host to send OpsGenie API requests to. Must be a valid URL +- `resolve_timeout` (String) The default value used by alertmanager if the alert does not include EndsAt. After this time passes, it can declare the alert as resolved if it has not been updated. This has no impact on alerts from Prometheus, as they always include EndsAt. +- `smtp_auth_identity` (String) SMTP authentication information. Must be a valid email address +- `smtp_auth_password` (String, Sensitive) SMTP Auth using LOGIN and PLAIN. +- `smtp_auth_username` (String) SMTP Auth using CRAM-MD5, LOGIN and PLAIN. If empty, Alertmanager doesn't authenticate to the SMTP server. +- `smtp_from` (String) The default SMTP From header field. Must be a valid email address +- `smtp_smart_host` (String) The default SMTP smarthost used for sending emails, including port number. Port number usually is 25, or 587 for SMTP over TLS (sometimes referred to as STARTTLS). + + + +### Nested Schema for `alert_config.receivers` + +Read-Only: + +- `email_configs` (Attributes List) List of email configurations. (see [below for nested schema](#nestedatt--alert_config--receivers--email_configs)) +- `name` (String) Name of the receiver. +- `opsgenie_configs` (Attributes List) List of OpsGenie configurations. (see [below for nested schema](#nestedatt--alert_config--receivers--opsgenie_configs)) +- `webhooks_configs` (Attributes List) List of Webhooks configurations. (see [below for nested schema](#nestedatt--alert_config--receivers--webhooks_configs)) + + +### Nested Schema for `alert_config.receivers.email_configs` + +Read-Only: + +- `auth_identity` (String) SMTP authentication information. Must be a valid email address +- `auth_password` (String) SMTP authentication password. +- `auth_username` (String) SMTP authentication username. +- `from` (String) The sender email address. Must be a valid email address +- `smart_host` (String) The SMTP host through which emails are sent. +- `to` (String) The email address to send notifications to. Must be a valid email address + + + +### Nested Schema for `alert_config.receivers.opsgenie_configs` + +Read-Only: + +- `api_key` (String) The API key for OpsGenie. +- `api_url` (String) The host to send OpsGenie API requests to. Must be a valid URL +- `tags` (String) Comma separated list of tags attached to the notifications. + + + +### Nested Schema for `alert_config.receivers.webhooks_configs` + +Read-Only: + +- `ms_teams` (Boolean) Microsoft Teams webhooks require special handling, set this to true if the webhook is for Microsoft Teams. +- `url` (String) The endpoint to send HTTP POST requests to. Must be a valid URL + + + + +### Nested Schema for `alert_config.route` + +Read-Only: + +- `group_by` (List of String) The labels by which incoming alerts are grouped together. For example, multiple alerts coming in for cluster=A and alertname=LatencyHigh would be batched into a single group. To aggregate by all possible labels use the special value '...' as the sole label name, for example: group_by: ['...']. This effectively disables aggregation entirely, passing through all alerts as-is. This is unlikely to be what you want, unless you have a very low alert volume or your upstream notification system performs its own grouping. +- `group_interval` (String) How long to wait before sending a notification about new alerts that are added to a group of alerts for which an initial notification has already been sent. (Usually ~5m or more.) +- `group_wait` (String) How long to initially wait to send a notification for a group of alerts. Allows to wait for an inhibiting alert to arrive or collect more initial alerts for the same group. (Usually ~0s to few minutes.) . +- `match` (Map of String) A set of equality matchers an alert has to fulfill to match the node. +- `match_regex` (Map of String) A set of regex-matchers an alert has to fulfill to match the node. +- `receiver` (String) The name of the receiver to route the alerts to. +- `repeat_interval` (String) How long to wait before sending a notification again if it has already been sent successfully for an alert. (Usually ~3h or more). +- `routes` (Attributes List) List of child routes. (see [below for nested schema](#nestedatt--alert_config--route--routes)) + + +### Nested Schema for `alert_config.route.routes` + +Read-Only: + +- `group_by` (List of String) The labels by which incoming alerts are grouped together. For example, multiple alerts coming in for cluster=A and alertname=LatencyHigh would be batched into a single group. To aggregate by all possible labels use the special value '...' as the sole label name, for example: group_by: ['...']. This effectively disables aggregation entirely, passing through all alerts as-is. This is unlikely to be what you want, unless you have a very low alert volume or your upstream notification system performs its own grouping. +- `group_interval` (String) How long to wait before sending a notification about new alerts that are added to a group of alerts for which an initial notification has already been sent. (Usually ~5m or more.) +- `group_wait` (String) How long to initially wait to send a notification for a group of alerts. Allows to wait for an inhibiting alert to arrive or collect more initial alerts for the same group. (Usually ~0s to few minutes.) +- `match` (Map of String) A set of equality matchers an alert has to fulfill to match the node. +- `match_regex` (Map of String) A set of regex-matchers an alert has to fulfill to match the node. +- `receiver` (String) The name of the receiver to route the alerts to. +- `repeat_interval` (String) How long to wait before sending a notification again if it has already been sent successfully for an alert. (Usually ~3h or more). diff --git a/docs/resources/argus_instance.md b/docs/resources/argus_instance.md index 3cb4c82d..acbfb441 100644 --- a/docs/resources/argus_instance.md +++ b/docs/resources/argus_instance.md @@ -36,6 +36,7 @@ resource "stackit_argus_instance" "example" { ### Optional - `acl` (Set of String) The access control list for this instance. Each entry is an IP address range that is permitted to access, in CIDR notation. +- `alert_config` (Attributes) Alert configuration for the instance. (see [below for nested schema](#nestedatt--alert_config)) - `metrics_retention_days` (Number) Specifies for how many days the raw metrics are kept. - `metrics_retention_days_1h_downsampling` (Number) Specifies for how many days the 1h downsampled metrics are kept. must be less than the value of the 5m downsampling retention. Default is set to `0` (disabled). - `metrics_retention_days_5m_downsampling` (Number) Specifies for how many days the 5m downsampled metrics are kept. must be less than the value of the general retention. Default is set to `0` (disabled). @@ -62,3 +63,110 @@ resource "stackit_argus_instance" "example" { - `plan_id` (String) The Argus plan ID. - `targets_url` (String) Specifies Targets URL. - `zipkin_spans_url` (String) + + +### Nested Schema for `alert_config` + +Required: + +- `receivers` (Attributes List) List of alert receivers. (see [below for nested schema](#nestedatt--alert_config--receivers)) +- `route` (Attributes) Route configuration for the alerts. (see [below for nested schema](#nestedatt--alert_config--route)) + +Optional: + +- `global` (Attributes) Global configuration for the alerts. (see [below for nested schema](#nestedatt--alert_config--global)) + + +### Nested Schema for `alert_config.receivers` + +Required: + +- `name` (String) Name of the receiver. + +Optional: + +- `email_configs` (Attributes List) List of email configurations. (see [below for nested schema](#nestedatt--alert_config--receivers--email_configs)) +- `opsgenie_configs` (Attributes List) List of OpsGenie configurations. (see [below for nested schema](#nestedatt--alert_config--receivers--opsgenie_configs)) +- `webhooks_configs` (Attributes List) List of Webhooks configurations. (see [below for nested schema](#nestedatt--alert_config--receivers--webhooks_configs)) + + +### Nested Schema for `alert_config.receivers.email_configs` + +Optional: + +- `auth_identity` (String) SMTP authentication information. Must be a valid email address +- `auth_password` (String) SMTP authentication password. +- `auth_username` (String) SMTP authentication username. +- `from` (String) The sender email address. Must be a valid email address +- `smart_host` (String) The SMTP host through which emails are sent. +- `to` (String) The email address to send notifications to. Must be a valid email address + + + +### Nested Schema for `alert_config.receivers.opsgenie_configs` + +Optional: + +- `api_key` (String) The API key for OpsGenie. +- `api_url` (String) The host to send OpsGenie API requests to. Must be a valid URL +- `tags` (String) Comma separated list of tags attached to the notifications. + + + +### Nested Schema for `alert_config.receivers.webhooks_configs` + +Optional: + +- `ms_teams` (Boolean) Microsoft Teams webhooks require special handling, set this to true if the webhook is for Microsoft Teams. +- `url` (String) The endpoint to send HTTP POST requests to. Must be a valid URL + + + + +### Nested Schema for `alert_config.route` + +Required: + +- `receiver` (String) The name of the receiver to route the alerts to. + +Optional: + +- `group_by` (List of String) The labels by which incoming alerts are grouped together. For example, multiple alerts coming in for cluster=A and alertname=LatencyHigh would be batched into a single group. To aggregate by all possible labels use the special value '...' as the sole label name, for example: group_by: ['...']. This effectively disables aggregation entirely, passing through all alerts as-is. This is unlikely to be what you want, unless you have a very low alert volume or your upstream notification system performs its own grouping. +- `group_interval` (String) How long to wait before sending a notification about new alerts that are added to a group of alerts for which an initial notification has already been sent. (Usually ~5m or more.) +- `group_wait` (String) How long to initially wait to send a notification for a group of alerts. Allows to wait for an inhibiting alert to arrive or collect more initial alerts for the same group. (Usually ~0s to few minutes.) +- `match` (Map of String) A set of equality matchers an alert has to fulfill to match the node. +- `match_regex` (Map of String) A set of regex-matchers an alert has to fulfill to match the node. +- `repeat_interval` (String) How long to wait before sending a notification again if it has already been sent successfully for an alert. (Usually ~3h or more). +- `routes` (Attributes List) List of child routes. (see [below for nested schema](#nestedatt--alert_config--route--routes)) + + +### Nested Schema for `alert_config.route.routes` + +Required: + +- `receiver` (String) The name of the receiver to route the alerts to. + +Optional: + +- `group_by` (List of String) The labels by which incoming alerts are grouped together. For example, multiple alerts coming in for cluster=A and alertname=LatencyHigh would be batched into a single group. To aggregate by all possible labels use the special value '...' as the sole label name, for example: group_by: ['...']. This effectively disables aggregation entirely, passing through all alerts as-is. This is unlikely to be what you want, unless you have a very low alert volume or your upstream notification system performs its own grouping. +- `group_interval` (String) How long to wait before sending a notification about new alerts that are added to a group of alerts for which an initial notification has already been sent. (Usually ~5m or more.) +- `group_wait` (String) How long to initially wait to send a notification for a group of alerts. Allows to wait for an inhibiting alert to arrive or collect more initial alerts for the same group. (Usually ~0s to few minutes.) +- `match` (Map of String) A set of equality matchers an alert has to fulfill to match the node. +- `match_regex` (Map of String) A set of regex-matchers an alert has to fulfill to match the node. +- `repeat_interval` (String) How long to wait before sending a notification again if it has already been sent successfully for an alert. (Usually ~3h or more). + + + + +### Nested Schema for `alert_config.global` + +Optional: + +- `opsgenie_api_key` (String, Sensitive) The API key for OpsGenie. +- `opsgenie_api_url` (String) The host to send OpsGenie API requests to. Must be a valid URL +- `resolve_timeout` (String) The default value used by alertmanager if the alert does not include EndsAt. After this time passes, it can declare the alert as resolved if it has not been updated. This has no impact on alerts from Prometheus, as they always include EndsAt. +- `smtp_auth_identity` (String) SMTP authentication information. Must be a valid email address +- `smtp_auth_password` (String, Sensitive) SMTP Auth using LOGIN and PLAIN. +- `smtp_auth_username` (String) SMTP Auth using CRAM-MD5, LOGIN and PLAIN. If empty, Alertmanager doesn't authenticate to the SMTP server. +- `smtp_from` (String) The default SMTP From header field. Must be a valid email address +- `smtp_smart_host` (String) The default SMTP smarthost used for sending emails, including port number in format `host:port` (eg. `smtp.example.com:587`). Port number usually is 25, or 587 for SMTP over TLS (sometimes referred to as STARTTLS). diff --git a/stackit/internal/conversion/conversion.go b/stackit/internal/conversion/conversion.go index 56df3c77..ccc017c3 100644 --- a/stackit/internal/conversion/conversion.go +++ b/stackit/internal/conversion/conversion.go @@ -7,6 +7,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" ) func ToString(ctx context.Context, v attr.Value) (string, error) { @@ -58,6 +59,22 @@ func ToTerraformStringMap(ctx context.Context, m map[string]string) (basetypes.M return res, nil } +// ToStringInterfaceMap converts a basetypes.MapValue of Strings to a map[string]interface{}. +func ToStringInterfaceMap(ctx context.Context, m basetypes.MapValue) (map[string]interface{}, error) { + labels := map[string]string{} + diags := m.ElementsAs(ctx, &labels, false) + if diags.HasError() { + return nil, fmt.Errorf("converting from MapValue: %w", core.DiagsToError(diags)) + } + + interfaceMap := make(map[string]interface{}, len(labels)) + for k, v := range labels { + interfaceMap[k] = v + } + + return interfaceMap, nil +} + // StringValueToPointer converts basetypes.StringValue to a pointer to string. // It returns nil if the value is null or unknown. func StringValueToPointer(s basetypes.StringValue) *string { diff --git a/stackit/internal/conversion/conversion_test.go b/stackit/internal/conversion/conversion_test.go new file mode 100644 index 00000000..5662e7d3 --- /dev/null +++ b/stackit/internal/conversion/conversion_test.go @@ -0,0 +1,82 @@ +package conversion + +import ( + "context" + "reflect" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" +) + +func TestFromTerraformStringMapToInterfaceMap(t *testing.T) { + type args struct { + ctx context.Context + m basetypes.MapValue + } + tests := []struct { + name string + args args + want map[string]interface{} + wantErr bool + }{ + { + name: "base", + args: args{ + ctx: context.Background(), + m: types.MapValueMust(types.StringType, map[string]attr.Value{ + "key": types.StringValue("value"), + "key2": types.StringValue("value2"), + "key3": types.StringValue("value3"), + }), + }, + want: map[string]interface{}{ + "key": "value", + "key2": "value2", + "key3": "value3", + }, + wantErr: false, + }, + { + name: "empty", + args: args{ + ctx: context.Background(), + m: types.MapValueMust(types.StringType, map[string]attr.Value{}), + }, + want: map[string]interface{}{}, + wantErr: false, + }, + { + name: "nil", + args: args{ + ctx: context.Background(), + m: types.MapNull(types.StringType), + }, + want: map[string]interface{}{}, + wantErr: false, + }, + { + name: "invalid type map (non-string)", + args: args{ + ctx: context.Background(), + m: types.MapValueMust(types.Int64Type, map[string]attr.Value{ + "key": types.Int64Value(1), + }), + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ToStringInterfaceMap(tt.args.ctx, tt.args.m) + if (err != nil) != tt.wantErr { + t.Errorf("FromTerraformStringMapToInterfaceMap() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("FromTerraformStringMapToInterfaceMap() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/stackit/internal/services/argus/argus_acc_test.go b/stackit/internal/services/argus/argus_acc_test.go index 4c5839da..76d1dd42 100644 --- a/stackit/internal/services/argus/argus_acc_test.go +++ b/stackit/internal/services/argus/argus_acc_test.go @@ -45,11 +45,121 @@ var credentialResource = map[string]string{ "project_id": testutil.ProjectId, } -func instanceResourceConfig(acl, metricsRetentionDays, metricsRetentionDays1hDownsampling, metricsRetentionDays5mDownsampling *string, instanceName, planName string) string { +func buildAlertConfigReceivers(hasOpsGenie, hasEmail, hasWebhook bool) string { + if !hasOpsGenie && !hasEmail && !hasWebhook { + return "" + } + + receivers := "[" + + if hasOpsGenie { + receivers += ` + { + name = "OpsGenieReceiverInfo" + opsgenie_configs = [ + { + tags = "iam,argus-alert" + api_key = "example-api-key" + } + ] + }, +` + } + + if hasEmail { + receivers += ` + { + name = "EmailReceiverInfo" + email_configs = [ + { + to = "me@example.com" + }, + ] + }, +` + } + + if hasWebhook { + receivers += ` + { + name = "WebhookReceiverInfo" + webhooks_configs = [ + { + url = "https://example.com" + ms_teams = true + }, + ] + }, +` + } + + return receivers + "]" +} + +func buildAlertConfigRoute(childRoutes bool) string { + route := `{ + receiver = "OpsGenieReceiverInfo" + group_by = ["alertname"] + group_interval = "10m" + group_wait = "1m" + repeat_interval = "1h"` + + if childRoutes { + route += ` + routes = [ + { + match = { + severity = "critical" + } + receiver = "OpsGenieReceiverInfo" + }, + { + match = { + severity = "warning" + } + receiver = "WebhookReceiverInfo" + } + ]` + } + + return route + "\n}" +} + +func buildAlertConfigGlobal(includeEmailOptions bool) string { + defaultOptions := `{ + resolve_timeout = "5m" + opsgenie_api_key = "example-api-key" + opsgenie_api_url = "https://api.eu.opsgenie.com"` + + if !includeEmailOptions { + return defaultOptions + "\n}" + } + return defaultOptions + ` + smtp_smart_host = "smtp.example.com:587" + smtp_from = "me@example.com" +}` +} + +func buildAlertConfig(receivers, route, global string) *string { + if receivers == "" && route == "" && global == "" { + return nil + } + returnStr := fmt.Sprintf(` + alert_config = { + receivers = %s, + route = %s, + global = %s + } + `, receivers, route, global) + return &returnStr +} + +func instanceResourceConfig(acl, metricsRetentionDays, metricsRetentionDays1hDownsampling, metricsRetentionDays5mDownsampling, alertConfig *string, instanceName, planName string) string { var aclStr string var metricsRetentionDaysStr string var metricsRetentionDays1hDownsamplingStr string var metricsRetentionDays5mDownsamplingStr string + var alertConfigStr string if acl != nil { aclStr = fmt.Sprintf("acl = %s", *acl) @@ -67,7 +177,11 @@ func instanceResourceConfig(acl, metricsRetentionDays, metricsRetentionDays1hDow metricsRetentionDays5mDownsamplingStr = fmt.Sprintf("metrics_retention_days_5m_downsampling = %s", *metricsRetentionDays5mDownsampling) } - optionalsStr := strings.Join([]string{aclStr, metricsRetentionDaysStr, metricsRetentionDays1hDownsamplingStr, metricsRetentionDays5mDownsamplingStr}, "\n") + if alertConfig != nil { + alertConfigStr = *alertConfig + } + + optionalsStr := strings.Join([]string{aclStr, metricsRetentionDaysStr, metricsRetentionDays1hDownsamplingStr, metricsRetentionDays5mDownsamplingStr, alertConfigStr}, "\n") return fmt.Sprintf(` resource "stackit_argus_instance" "instance" { @@ -114,10 +228,16 @@ func credentialResourceConfig() string { }` } -func resourceConfig(acl, metricsRetentionDays, metricsRetentionDays1hDownsampling, metricsRetentionDays5mDownsampling *string, instanceName, planName, target, saml2EnableUrlParameters string) string { +func resourceConfig(acl, metricsRetentionDays, metricsRetentionDays1hDownsampling, metricsRetentionDays5mDownsampling, alertConfig *string, instanceName, planName, target, saml2EnableUrlParameters string) string { return fmt.Sprintf("%s\n\n%s\n\n%s\n\n%s", testutil.ArgusProviderConfig(), - instanceResourceConfig(acl, metricsRetentionDays, metricsRetentionDays1hDownsampling, metricsRetentionDays5mDownsampling, instanceName, planName), + instanceResourceConfig(acl, + metricsRetentionDays, + metricsRetentionDays1hDownsampling, + metricsRetentionDays5mDownsampling, + alertConfig, + instanceName, + planName), scrapeConfigResourceConfig(target, saml2EnableUrlParameters), credentialResourceConfig(), ) @@ -140,6 +260,7 @@ func TestAccResource(t *testing.T) { utils.Ptr(instanceResource["metrics_retention_days"]), utils.Ptr(instanceResource["metrics_retention_days_1h_downsampling"]), utils.Ptr(instanceResource["metrics_retention_days_5m_downsampling"]), + buildAlertConfig(buildAlertConfigReceivers(true, false, true), buildAlertConfigRoute(false), buildAlertConfigGlobal(false)), instanceResource["name"], instanceResource["plan_name"], scrapeConfigResource["urls"], @@ -171,6 +292,33 @@ func TestAccResource(t *testing.T) { resource.TestCheckResourceAttrSet("stackit_argus_instance.instance", "otlp_traces_url"), resource.TestCheckResourceAttrSet("stackit_argus_instance.instance", "zipkin_spans_url"), + // Alert Config + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.receivers.#", "2"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.route.group_by.#", "1"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.route.group_by.0", "alertname"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.route.group_interval", "10m"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.route.group_wait", "1m"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.route.repeat_interval", "1h"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.global.resolve_timeout", "5m"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.global.opsgenie_api_key", "example-api-key"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.global.opsgenie_api_url", "https://api.eu.opsgenie.com"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.route.receiver", "OpsGenieReceiverInfo"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.route.group_by.#", "1"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.route.group_by.0", "alertname"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.route.group_interval", "10m"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.route.group_wait", "1m"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.route.repeat_interval", "1h"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.route.routes.#", "0"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.receivers.0.name", "OpsGenieReceiverInfo"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.receivers.0.opsgenie_configs.#", "1"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.receivers.0.opsgenie_configs.0.tags", "iam,argus-alert"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.receivers.0.opsgenie_configs.0.api_key", + "example-api-key"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.receivers.1.name", "WebhookReceiverInfo"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.receivers.1.webhooks_configs.#", "1"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.receivers.1.webhooks_configs.0.url", "https://example.com"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.receivers.1.webhooks_configs.0.ms_teams", "true"), + // ACL resource.TestCheckResourceAttr("stackit_argus_instance.instance", "acl.#", "2"), resource.TestCheckResourceAttr("stackit_argus_instance.instance", "acl.0", instanceResource["acl-0"]), @@ -203,13 +351,126 @@ func TestAccResource(t *testing.T) { resource.TestCheckResourceAttrSet("stackit_argus_credential.credential", "password"), ), }, - // Creation without ACL and partial metrics retention days + // Update Alert Config with complete Receiver (email, webhook and opsgenie configs), global options and Route with child routes + { + Config: resourceConfig( + utils.Ptr(fmt.Sprintf( + "[%q, %q, %q]", + instanceResource["acl-0"], + instanceResource["acl-1"], + instanceResource["acl-1"], + )), + utils.Ptr(instanceResource["metrics_retention_days"]), + utils.Ptr(instanceResource["metrics_retention_days_1h_downsampling"]), + utils.Ptr(instanceResource["metrics_retention_days_5m_downsampling"]), + buildAlertConfig(buildAlertConfigReceivers(true, true, true), buildAlertConfigRoute(true), buildAlertConfigGlobal(true)), + instanceResource["name"], + instanceResource["plan_name"], + scrapeConfigResource["urls"], + scrapeConfigResource["saml2_enable_url_parameters"], + ), + Check: resource.ComposeAggregateTestCheckFunc( + // Instance data + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "project_id", instanceResource["project_id"]), + resource.TestCheckResourceAttrSet("stackit_argus_instance.instance", "instance_id"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "name", instanceResource["name"]), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "plan_name", instanceResource["plan_name"]), + resource.TestCheckResourceAttrSet("stackit_argus_instance.instance", "dashboard_url"), + resource.TestCheckResourceAttrSet("stackit_argus_instance.instance", "is_updatable"), + resource.TestCheckResourceAttrSet("stackit_argus_instance.instance", "grafana_public_read_access"), + resource.TestCheckResourceAttrSet("stackit_argus_instance.instance", "grafana_url"), + resource.TestCheckResourceAttrSet("stackit_argus_instance.instance", "grafana_initial_admin_user"), + resource.TestCheckResourceAttrSet("stackit_argus_instance.instance", "grafana_initial_admin_password"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "metrics_retention_days", instanceResource["metrics_retention_days"]), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "metrics_retention_days_5m_downsampling", instanceResource["metrics_retention_days_5m_downsampling"]), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "metrics_retention_days_1h_downsampling", instanceResource["metrics_retention_days_1h_downsampling"]), + resource.TestCheckResourceAttrSet("stackit_argus_instance.instance", "metrics_url"), + resource.TestCheckResourceAttrSet("stackit_argus_instance.instance", "metrics_push_url"), + resource.TestCheckResourceAttrSet("stackit_argus_instance.instance", "targets_url"), + resource.TestCheckResourceAttrSet("stackit_argus_instance.instance", "alerting_url"), + resource.TestCheckResourceAttrSet("stackit_argus_instance.instance", "logs_url"), + resource.TestCheckResourceAttrSet("stackit_argus_instance.instance", "logs_push_url"), + resource.TestCheckResourceAttrSet("stackit_argus_instance.instance", "jaeger_traces_url"), + resource.TestCheckResourceAttrSet("stackit_argus_instance.instance", "jaeger_ui_url"), + resource.TestCheckResourceAttrSet("stackit_argus_instance.instance", "otlp_traces_url"), + resource.TestCheckResourceAttrSet("stackit_argus_instance.instance", "zipkin_spans_url"), + + // Alert Config + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.receivers.#", "3"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.route.group_by.#", "1"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.route.group_by.0", "alertname"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.route.group_interval", "10m"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.route.group_wait", "1m"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.route.repeat_interval", "1h"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.global.resolve_timeout", "5m"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.global.opsgenie_api_key", "example-api-key"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.global.opsgenie_api_url", "https://api.eu.opsgenie.com"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.global.smtp_smart_host", "smtp.example.com:587"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.global.smtp_from", "me@example.com"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.route.receiver", "OpsGenieReceiverInfo"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.route.group_by.#", "1"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.route.group_by.0", "alertname"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.route.group_interval", "10m"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.route.group_wait", "1m"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.route.repeat_interval", "1h"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.route.routes.#", "2"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.route.routes.0.match.severity", "critical"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.route.routes.0.receiver", "OpsGenieReceiverInfo"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.route.routes.1.match.severity", "warning"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.route.routes.1.receiver", "WebhookReceiverInfo"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.receivers.0.name", "OpsGenieReceiverInfo"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.receivers.0.opsgenie_configs.#", "1"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.receivers.0.opsgenie_configs.0.tags", "iam,argus-alert"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.receivers.0.opsgenie_configs.0.api_key", + "example-api-key"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.receivers.1.name", "EmailReceiverInfo"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.receivers.1.email_configs.#", "1"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.receivers.1.email_configs.0.to", "me@example.com"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.receivers.2.name", "WebhookReceiverInfo"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.receivers.2.webhooks_configs.#", "1"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.receivers.2.webhooks_configs.0.url", "https://example.com"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.receivers.2.webhooks_configs.0.ms_teams", "true"), + + // ACL + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "acl.#", "2"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "acl.0", instanceResource["acl-0"]), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "acl.1", instanceResource["acl-1"]), + + // scrape config data + resource.TestCheckResourceAttrPair( + "stackit_argus_instance.instance", "project_id", + "stackit_argus_scrapeconfig.scrapeconfig", "project_id", + ), + resource.TestCheckResourceAttrPair( + "stackit_argus_instance.instance", "instance_id", + "stackit_argus_scrapeconfig.scrapeconfig", "instance_id", + ), + resource.TestCheckResourceAttr("stackit_argus_scrapeconfig.scrapeconfig", "name", scrapeConfigResource["name"]), + resource.TestCheckResourceAttr("stackit_argus_scrapeconfig.scrapeconfig", "targets.0.urls.#", "2"), + resource.TestCheckResourceAttr("stackit_argus_scrapeconfig.scrapeconfig", "metrics_path", scrapeConfigResource["metrics_path"]), + resource.TestCheckResourceAttr("stackit_argus_scrapeconfig.scrapeconfig", "scheme", scrapeConfigResource["scheme"]), + resource.TestCheckResourceAttr("stackit_argus_scrapeconfig.scrapeconfig", "scrape_interval", scrapeConfigResource["scrape_interval"]), + resource.TestCheckResourceAttr("stackit_argus_scrapeconfig.scrapeconfig", "sample_limit", scrapeConfigResource["sample_limit"]), + resource.TestCheckResourceAttr("stackit_argus_scrapeconfig.scrapeconfig", "saml2.enable_url_parameters", scrapeConfigResource["saml2_enable_url_parameters"]), + + // credentials + resource.TestCheckResourceAttr("stackit_argus_credential.credential", "project_id", credentialResource["project_id"]), + resource.TestCheckResourceAttrPair( + "stackit_argus_instance.instance", "instance_id", + "stackit_argus_credential.credential", "instance_id", + ), + resource.TestCheckResourceAttrSet("stackit_argus_credential.credential", "username"), + resource.TestCheckResourceAttrSet("stackit_argus_credential.credential", "password"), + ), + }, + // Update without ACL, partial metrics retention days and NO alert configs { Config: resourceConfig( nil, nil, utils.Ptr(instanceResource["metrics_retention_days_1h_downsampling"]), nil, + nil, instanceResource["name"], instanceResource["plan_name"], scrapeConfigResource["urls"], @@ -271,13 +532,14 @@ func TestAccResource(t *testing.T) { resource.TestCheckResourceAttrSet("stackit_argus_credential.credential", "password"), ), }, - // Creation with empty ACL + // Update with empty ACL, NO metrics retention days and NO alert configs { Config: resourceConfig( utils.Ptr("[]"), nil, nil, nil, + nil, instanceResource["name"], instanceResource["plan_name"], scrapeConfigResource["urls"], @@ -339,8 +601,8 @@ func TestAccResource(t *testing.T) { resource.TestCheckResourceAttrSet("stackit_argus_credential.credential", "password"), ), }, + // Data source { - // Data source Config: fmt.Sprintf(` %s @@ -364,6 +626,7 @@ func TestAccResource(t *testing.T) { utils.Ptr(instanceResource["metrics_retention_days"]), utils.Ptr(instanceResource["metrics_retention_days_1h_downsampling"]), utils.Ptr(instanceResource["metrics_retention_days_5m_downsampling"]), + buildAlertConfig(buildAlertConfigReceivers(true, false, true), buildAlertConfigRoute(true), buildAlertConfigGlobal(false)), instanceResource["name"], instanceResource["plan_name"], scrapeConfigResource["urls"], @@ -409,8 +672,7 @@ func TestAccResource(t *testing.T) { resource.TestCheckResourceAttr("data.stackit_argus_scrapeconfig.scrapeconfig", "saml2.enable_url_parameters", scrapeConfigResource["saml2_enable_url_parameters"]), ), }, - - // Import + // Import 1 { ResourceName: "stackit_argus_instance.instance", ImportStateIdFunc: func(s *terraform.State) (string, error) { @@ -428,6 +690,7 @@ func TestAccResource(t *testing.T) { ImportState: true, ImportStateVerify: true, }, + // Import 2 { ResourceName: "stackit_argus_scrapeconfig.scrapeconfig", ImportStateIdFunc: func(s *terraform.State) (string, error) { @@ -459,6 +722,7 @@ func TestAccResource(t *testing.T) { utils.Ptr(instanceResource["metrics_retention_days"]), utils.Ptr(instanceResource["metrics_retention_days_1h_downsampling"]), utils.Ptr(instanceResource["metrics_retention_days_5m_downsampling"]), + buildAlertConfig(buildAlertConfigReceivers(true, false, true), buildAlertConfigRoute(true), buildAlertConfigGlobal(false)), fmt.Sprintf("%s-new", instanceResource["name"]), instanceResource["new_plan_name"], "", @@ -474,6 +738,37 @@ func TestAccResource(t *testing.T) { resource.TestCheckResourceAttr("stackit_argus_instance.instance", "acl.0", instanceResource["acl-0"]), resource.TestCheckResourceAttr("stackit_argus_instance.instance", "acl.1", instanceResource["acl-1-updated"]), + // Alert Config + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.route.group_by.#", "1"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.route.group_by.0", "alertname"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.route.group_interval", "10m"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.route.group_wait", "1m"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.route.repeat_interval", "1h"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.global.resolve_timeout", "5m"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.global.opsgenie_api_key", "example-api-key"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.global.opsgenie_api_url", "https://api.eu.opsgenie.com"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.route.receiver", "OpsGenieReceiverInfo"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.route.group_by.#", "1"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.route.group_by.0", "alertname"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.route.group_interval", "10m"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.route.group_wait", "1m"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.route.repeat_interval", "1h"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.route.routes.#", "2"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.route.routes.0.match.severity", "critical"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.route.routes.0.receiver", "OpsGenieReceiverInfo"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.route.routes.1.match.severity", "warning"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.route.routes.1.receiver", "WebhookReceiverInfo"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.receivers.#", "2"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.receivers.0.name", "OpsGenieReceiverInfo"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.receivers.0.opsgenie_configs.#", "1"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.receivers.0.opsgenie_configs.0.tags", "iam,argus-alert"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.receivers.0.opsgenie_configs.0.api_key", + "example-api-key"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.receivers.1.name", "WebhookReceiverInfo"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.receivers.1.webhooks_configs.#", "1"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.receivers.1.webhooks_configs.0.url", "https://example.com"), + resource.TestCheckResourceAttr("stackit_argus_instance.instance", "alert_config.receivers.1.webhooks_configs.0.ms_teams", "true"), + // Scrape Config resource.TestCheckResourceAttr("stackit_argus_scrapeconfig.scrapeconfig", "name", scrapeConfigResource["name"]), resource.TestCheckResourceAttr("stackit_argus_scrapeconfig.scrapeconfig", "targets.#", "0"), diff --git a/stackit/internal/services/argus/instance/datasource.go b/stackit/internal/services/argus/instance/datasource.go index 5143b05a..36d3809c 100644 --- a/stackit/internal/services/argus/instance/datasource.go +++ b/stackit/internal/services/argus/instance/datasource.go @@ -204,6 +204,170 @@ func (d *instanceDataSource) Schema(_ context.Context, _ datasource.SchemaReques ElementType: types.StringType, Computed: true, }, + "alert_config": schema.SingleNestedAttribute{ + Description: "Alert configuration for the instance.", + Computed: true, + Attributes: map[string]schema.Attribute{ + "receivers": schema.ListNestedAttribute{ + Description: "List of alert receivers.", + Computed: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "name": schema.StringAttribute{ + Description: "Name of the receiver.", + Computed: true, + }, + "email_configs": schema.ListNestedAttribute{ + Description: "List of email configurations.", + Computed: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "auth_identity": schema.StringAttribute{ + Description: "SMTP authentication information. Must be a valid email address", + Computed: true, + }, + "auth_password": schema.StringAttribute{ + Description: "SMTP authentication password.", + Computed: true, + }, + "auth_username": schema.StringAttribute{ + Description: "SMTP authentication username.", + Computed: true, + }, + "from": schema.StringAttribute{ + Description: "The sender email address. Must be a valid email address", + Computed: true, + }, + "smart_host": schema.StringAttribute{ + Description: "The SMTP host through which emails are sent.", + Computed: true, + }, + "to": schema.StringAttribute{ + Description: "The email address to send notifications to. Must be a valid email address", + Computed: true, + }, + }, + }, + }, + "opsgenie_configs": schema.ListNestedAttribute{ + Description: "List of OpsGenie configurations.", + Computed: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "api_key": schema.StringAttribute{ + Description: "The API key for OpsGenie.", + Computed: true, + }, + "api_url": schema.StringAttribute{ + Description: "The host to send OpsGenie API requests to. Must be a valid URL", + Computed: true, + }, + "tags": schema.StringAttribute{ + Description: "Comma separated list of tags attached to the notifications.", + Computed: true, + }, + }, + }, + }, + "webhooks_configs": schema.ListNestedAttribute{ + Description: "List of Webhooks configurations.", + Computed: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "url": schema.StringAttribute{ + Description: "The endpoint to send HTTP POST requests to. Must be a valid URL", + Computed: true, + }, + "ms_teams": schema.BoolAttribute{ + Description: "Microsoft Teams webhooks require special handling, set this to true if the webhook is for Microsoft Teams.", + Computed: true, + }, + }, + }, + }, + }, + }, + }, + "route": schema.SingleNestedAttribute{ + Description: "The route for the alert.", + Computed: true, + Attributes: map[string]schema.Attribute{ + "group_by": schema.ListAttribute{ + Description: "The labels by which incoming alerts are grouped together. For example, multiple alerts coming in for cluster=A and alertname=LatencyHigh would be batched into a single group. To aggregate by all possible labels use the special value '...' as the sole label name, for example: group_by: ['...']. This effectively disables aggregation entirely, passing through all alerts as-is. This is unlikely to be what you want, unless you have a very low alert volume or your upstream notification system performs its own grouping.", + Computed: true, + ElementType: types.StringType, + }, + "group_interval": schema.StringAttribute{ + Description: "How long to wait before sending a notification about new alerts that are added to a group of alerts for which an initial notification has already been sent. (Usually ~5m or more.)", + Computed: true, + }, + "group_wait": schema.StringAttribute{ + Description: "How long to initially wait to send a notification for a group of alerts. Allows to wait for an inhibiting alert to arrive or collect more initial alerts for the same group. (Usually ~0s to few minutes.) .", + Computed: true, + }, + "match": schema.MapAttribute{ + Description: "A set of equality matchers an alert has to fulfill to match the node.", + Computed: true, + ElementType: types.StringType, + }, + "match_regex": schema.MapAttribute{ + Description: "A set of regex-matchers an alert has to fulfill to match the node.", + Computed: true, + ElementType: types.StringType, + }, + "receiver": schema.StringAttribute{ + Description: "The name of the receiver to route the alerts to.", + Computed: true, + }, + "repeat_interval": schema.StringAttribute{ + Description: "How long to wait before sending a notification again if it has already been sent successfully for an alert. (Usually ~3h or more).", + Computed: true, + }, + "routes": getDatasourceRouteNestedObject(), + }, + }, + "global": schema.SingleNestedAttribute{ + Description: "Global configuration for the alerts.", + Computed: true, + Attributes: map[string]schema.Attribute{ + "opsgenie_api_key": schema.StringAttribute{ + Description: "The API key for OpsGenie.", + Computed: true, + Sensitive: true, + }, + "opsgenie_api_url": schema.StringAttribute{ + Description: "The host to send OpsGenie API requests to. Must be a valid URL", + Computed: true, + }, + "resolve_timeout": schema.StringAttribute{ + Description: "The default value used by alertmanager if the alert does not include EndsAt. After this time passes, it can declare the alert as resolved if it has not been updated. This has no impact on alerts from Prometheus, as they always include EndsAt.", + Computed: true, + }, + "smtp_auth_identity": schema.StringAttribute{ + Description: "SMTP authentication information. Must be a valid email address", + Computed: true, + }, + "smtp_auth_password": schema.StringAttribute{ + Description: "SMTP Auth using LOGIN and PLAIN.", + Computed: true, + Sensitive: true, + }, + "smtp_auth_username": schema.StringAttribute{ + Description: "SMTP Auth using CRAM-MD5, LOGIN and PLAIN. If empty, Alertmanager doesn't authenticate to the SMTP server.", + Computed: true, + }, + "smtp_from": schema.StringAttribute{ + Description: "The default SMTP From header field. Must be a valid email address", + Computed: true, + }, + "smtp_smart_host": schema.StringAttribute{ + Description: "The default SMTP smarthost used for sending emails, including port number. Port number usually is 25, or 587 for SMTP over TLS (sometimes referred to as STARTTLS).", + Computed: true, + }, + }, + }, + }, + }, }, } } @@ -239,6 +403,12 @@ func (d *instanceDataSource) Read(ctx context.Context, req datasource.ReadReques return } + alertConfigResp, err := d.client.GetAlertConfigs(ctx, instanceId, projectId).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading instance", fmt.Sprintf("Calling API to get alert config: %v", err)) + return + } + // Map response body to schema err = mapFields(ctx, instanceResp, &model) if err != nil { @@ -250,6 +420,11 @@ func (d *instanceDataSource) Read(ctx context.Context, req datasource.ReadReques core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading instance", fmt.Sprintf("Processing API response for the ACL: %v", err)) return } + err = mapAlertConfigField(ctx, alertConfigResp, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating instance", fmt.Sprintf("Processing API response for the alert config: %v", err)) + return + } diags = resp.State.Set(ctx, model) resp.Diagnostics.Append(diags...) diff --git a/stackit/internal/services/argus/instance/resource.go b/stackit/internal/services/argus/instance/resource.go index 445a1455..34e4833c 100644 --- a/stackit/internal/services/argus/instance/resource.go +++ b/stackit/internal/services/argus/instance/resource.go @@ -7,17 +7,20 @@ import ( "strconv" "strings" + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/mapplanmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/stackitcloud/stackit-sdk-go/core/config" "github.com/stackitcloud/stackit-sdk-go/core/oapierror" @@ -29,6 +32,10 @@ import ( "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" ) +// Currently, due to incorrect types in the API, the maximum recursion level for child routes is set to 1. +// Once this is fixed, the value should be set to 10. +const childRouteMaxRecursionLevel = 1 + // Ensure the implementation satisfies the expected interfaces. var ( _ resource.Resource = &instanceResource{} @@ -64,6 +71,255 @@ type Model struct { OtlpTracesURL types.String `tfsdk:"otlp_traces_url"` ZipkinSpansURL types.String `tfsdk:"zipkin_spans_url"` ACL types.Set `tfsdk:"acl"` + AlertConfig types.Object `tfsdk:"alert_config"` +} + +// Struct corresponding to Model.AlertConfig +type alertConfigModel struct { + GlobalConfiguration types.Object `tfsdk:"global"` + Receivers types.List `tfsdk:"receivers"` + Route types.Object `tfsdk:"route"` +} + +var alertConfigTypes = map[string]attr.Type{ + "receivers": types.ListType{ElemType: types.ObjectType{AttrTypes: receiversTypes}}, + "route": types.ObjectType{AttrTypes: routeTypes}, + "global": types.ObjectType{AttrTypes: globalConfigurationTypes}, +} + +// Struct corresponding to Model.AlertConfig.global +type globalConfigurationModel struct { + OpsgenieApiKey types.String `tfsdk:"opsgenie_api_key"` + OpsgenieApiUrl types.String `tfsdk:"opsgenie_api_url"` + ResolveTimeout types.String `tfsdk:"resolve_timeout"` + SmtpAuthIdentity types.String `tfsdk:"smtp_auth_identity"` + SmtpAuthPassword types.String `tfsdk:"smtp_auth_password"` + SmtpAuthUsername types.String `tfsdk:"smtp_auth_username"` + SmtpFrom types.String `tfsdk:"smtp_from"` + SmtpSmartHost types.String `tfsdk:"smtp_smart_host"` +} + +var globalConfigurationTypes = map[string]attr.Type{ + "opsgenie_api_key": types.StringType, + "opsgenie_api_url": types.StringType, + "resolve_timeout": types.StringType, + "smtp_auth_identity": types.StringType, + "smtp_auth_password": types.StringType, + "smtp_auth_username": types.StringType, + "smtp_from": types.StringType, + "smtp_smart_host": types.StringType, +} + +// Struct corresponding to Model.AlertConfig.route +type routeModel struct { + GroupBy types.List `tfsdk:"group_by"` + GroupInterval types.String `tfsdk:"group_interval"` + GroupWait types.String `tfsdk:"group_wait"` + Match types.Map `tfsdk:"match"` + MatchRegex types.Map `tfsdk:"match_regex"` + Receiver types.String `tfsdk:"receiver"` + RepeatInterval types.String `tfsdk:"repeat_interval"` + Routes types.List `tfsdk:"routes"` +} + +// Struct corresponding to Model.AlertConfig.route but without the recursive routes field +// This is used to map the last level of recursion of the routes field +type routeModelNoRoutes struct { + GroupBy types.List `tfsdk:"group_by"` + GroupInterval types.String `tfsdk:"group_interval"` + GroupWait types.String `tfsdk:"group_wait"` + Match types.Map `tfsdk:"match"` + MatchRegex types.Map `tfsdk:"match_regex"` + Receiver types.String `tfsdk:"receiver"` + RepeatInterval types.String `tfsdk:"repeat_interval"` +} + +var routeTypes = map[string]attr.Type{ + "group_by": types.ListType{ElemType: types.StringType}, + "group_interval": types.StringType, + "group_wait": types.StringType, + "match": types.MapType{ElemType: types.StringType}, + "match_regex": types.MapType{ElemType: types.StringType}, + "receiver": types.StringType, + "repeat_interval": types.StringType, + "routes": types.ListType{ElemType: getRouteListType()}, +} + +// Struct corresponding to Model.AlertConfig.receivers +type receiversModel struct { + Name types.String `tfsdk:"name"` + EmailConfigs types.List `tfsdk:"email_configs"` + OpsGenieConfigs types.List `tfsdk:"opsgenie_configs"` + WebHooksConfigs types.List `tfsdk:"webhooks_configs"` +} + +var receiversTypes = map[string]attr.Type{ + "name": types.StringType, + "email_configs": types.ListType{ElemType: types.ObjectType{AttrTypes: emailConfigsTypes}}, + "opsgenie_configs": types.ListType{ElemType: types.ObjectType{AttrTypes: opsgenieConfigsTypes}}, + "webhooks_configs": types.ListType{ElemType: types.ObjectType{AttrTypes: webHooksConfigsTypes}}, +} + +// Struct corresponding to Model.AlertConfig.receivers.emailConfigs +type emailConfigsModel struct { + AuthIdentity types.String `tfsdk:"auth_identity"` + AuthPassword types.String `tfsdk:"auth_password"` + AuthUsername types.String `tfsdk:"auth_username"` + From types.String `tfsdk:"from"` + Smarthost types.String `tfsdk:"smart_host"` + To types.String `tfsdk:"to"` +} + +var emailConfigsTypes = map[string]attr.Type{ + "auth_identity": types.StringType, + "auth_password": types.StringType, + "auth_username": types.StringType, + "from": types.StringType, + "smart_host": types.StringType, + "to": types.StringType, +} + +// Struct corresponding to Model.AlertConfig.receivers.opsGenieConfigs +type opsgenieConfigsModel struct { + ApiKey types.String `tfsdk:"api_key"` + ApiUrl types.String `tfsdk:"api_url"` + Tags types.String `tfsdk:"tags"` +} + +var opsgenieConfigsTypes = map[string]attr.Type{ + "api_key": types.StringType, + "api_url": types.StringType, + "tags": types.StringType, +} + +// Struct corresponding to Model.AlertConfig.receivers.webHooksConfigs +type webHooksConfigsModel struct { + Url types.String `tfsdk:"url"` + MsTeams types.Bool `tfsdk:"ms_teams"` +} + +var webHooksConfigsTypes = map[string]attr.Type{ + "url": types.StringType, + "ms_teams": types.BoolType, +} + +var routeDescriptions = map[string]string{ + "group_by": "The labels by which incoming alerts are grouped together. For example, multiple alerts coming in for cluster=A and alertname=LatencyHigh would be batched into a single group. To aggregate by all possible labels use the special value '...' as the sole label name, for example: group_by: ['...']. This effectively disables aggregation entirely, passing through all alerts as-is. This is unlikely to be what you want, unless you have a very low alert volume or your upstream notification system performs its own grouping.", + "group_interval": "How long to wait before sending a notification about new alerts that are added to a group of alerts for which an initial notification has already been sent. (Usually ~5m or more.)", + "group_wait": "How long to initially wait to send a notification for a group of alerts. Allows to wait for an inhibiting alert to arrive or collect more initial alerts for the same group. (Usually ~0s to few minutes.)", + "match": "A set of equality matchers an alert has to fulfill to match the node.", + "match_regex": "A set of regex-matchers an alert has to fulfill to match the node.", + "receiver": "The name of the receiver to route the alerts to.", + "repeat_interval": "How long to wait before sending a notification again if it has already been sent successfully for an alert. (Usually ~3h or more).", + "routes": "List of child routes.", +} + +// getRouteListType is a helper function to return the route list attribute type. +func getRouteListType() types.ObjectType { + return getRouteListTypeAux(1, childRouteMaxRecursionLevel) +} + +// getRouteListTypeAux returns the type of the route list attribute with the given level of child routes recursion. +// The level is used to determine the current depth of the nested object. +// The limit is used to determine the maximum depth of the nested object. +// The level should be lower or equal to the limit, if higher, the function will produce a stack overflow. +func getRouteListTypeAux(level, limit int) types.ObjectType { + attributeTypes := map[string]attr.Type{ + "group_by": types.ListType{ElemType: types.StringType}, + "group_interval": types.StringType, + "group_wait": types.StringType, + "match": types.MapType{ElemType: types.StringType}, + "match_regex": types.MapType{ElemType: types.StringType}, + "receiver": types.StringType, + "repeat_interval": types.StringType, + } + + if level != limit { + attributeTypes["routes"] = types.ListType{ElemType: getRouteListTypeAux(level+1, limit)} + } + + return types.ObjectType{AttrTypes: attributeTypes} +} + +func getRouteNestedObject() schema.ListNestedAttribute { + return getRouteNestedObjectAux(false, 1, childRouteMaxRecursionLevel) +} + +func getDatasourceRouteNestedObject() schema.ListNestedAttribute { + return getRouteNestedObjectAux(true, 1, childRouteMaxRecursionLevel) +} + +// getRouteNestedObjectAux returns the nested object for the route attribute with the given level of child routes recursion. +// The isDatasource is used to determine if the route is used in a datasource schema or not. If it is a datasource, all fields are computed. +// The level is used to determine the current depth of the nested object. +// The limit is used to determine the maximum depth of the nested object. +// The level should be lower or equal to the limit, if higher, the function will produce a stack overflow. +func getRouteNestedObjectAux(isDatasource bool, level, limit int) schema.ListNestedAttribute { + attributesMap := map[string]schema.Attribute{ + "group_by": schema.ListAttribute{ + Description: routeDescriptions["group_by"], + Optional: !isDatasource, + Computed: isDatasource, + ElementType: types.StringType, + }, + "group_interval": schema.StringAttribute{ + Description: routeDescriptions["group_interval"], + Optional: !isDatasource, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "group_wait": schema.StringAttribute{ + Description: routeDescriptions["group_wait"], + Optional: !isDatasource, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "match": schema.MapAttribute{ + Description: routeDescriptions["match"], + Optional: !isDatasource, + Computed: isDatasource, + ElementType: types.StringType, + }, + "match_regex": schema.MapAttribute{ + Description: routeDescriptions["match_regex"], + Optional: !isDatasource, + Computed: isDatasource, + ElementType: types.StringType, + }, + "receiver": schema.StringAttribute{ + Description: routeDescriptions["receiver"], + Required: !isDatasource, + Computed: isDatasource, + }, + "repeat_interval": schema.StringAttribute{ + Description: routeDescriptions["repeat_interval"], + Optional: !isDatasource, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + } + + if level != limit { + attributesMap["routes"] = getRouteNestedObjectAux(isDatasource, level+1, limit) + } + + return schema.ListNestedAttribute{ + Description: routeDescriptions["routes"], + Optional: !isDatasource, + Computed: isDatasource, + Validators: []validator.List{ + listvalidator.SizeAtLeast(1), + }, + NestedObject: schema.NestedAttributeObject{ + Attributes: attributesMap, + }, + } } // NewInstanceResource is a helper function to simplify the provider implementation. @@ -186,27 +442,45 @@ func (r *instanceResource) Schema(_ context.Context, _ resource.SchemaRequest, r "dashboard_url": schema.StringAttribute{ Description: "Specifies Argus instance dashboard URL.", Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, }, "is_updatable": schema.BoolAttribute{ Description: "Specifies if the instance can be updated.", Computed: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.UseStateForUnknown(), + }, }, "grafana_public_read_access": schema.BoolAttribute{ Description: "If true, anyone can access Grafana dashboards without logging in.", Computed: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.UseStateForUnknown(), + }, }, "grafana_url": schema.StringAttribute{ Description: "Specifies Grafana URL.", Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, }, "grafana_initial_admin_user": schema.StringAttribute{ Description: "Specifies an initial Grafana admin username.", Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, }, "grafana_initial_admin_password": schema.StringAttribute{ Description: "Specifies an initial Grafana admin password.", Computed: true, Sensitive: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, }, "metrics_retention_days": schema.Int64Attribute{ Description: "Specifies for how many days the raw metrics are kept.", @@ -226,38 +500,68 @@ func (r *instanceResource) Schema(_ context.Context, _ resource.SchemaRequest, r "metrics_url": schema.StringAttribute{ Description: "Specifies metrics URL.", Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, }, "metrics_push_url": schema.StringAttribute{ Description: "Specifies URL for pushing metrics.", Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, }, "targets_url": schema.StringAttribute{ Description: "Specifies Targets URL.", Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, }, "alerting_url": schema.StringAttribute{ Description: "Specifies Alerting URL.", Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, }, "logs_url": schema.StringAttribute{ Description: "Specifies Logs URL.", Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, }, "logs_push_url": schema.StringAttribute{ Description: "Specifies URL for pushing logs.", Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, }, "jaeger_traces_url": schema.StringAttribute{ Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, }, "jaeger_ui_url": schema.StringAttribute{ Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, }, "otlp_traces_url": schema.StringAttribute{ Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, }, "zipkin_spans_url": schema.StringAttribute{ Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, }, "acl": schema.SetAttribute{ Description: "The access control list for this instance. Each entry is an IP address range that is permitted to access, in CIDR notation.", @@ -269,6 +573,200 @@ func (r *instanceResource) Schema(_ context.Context, _ resource.SchemaRequest, r ), }, }, + "alert_config": schema.SingleNestedAttribute{ + Description: "Alert configuration for the instance.", + Optional: true, + Attributes: map[string]schema.Attribute{ + "receivers": schema.ListNestedAttribute{ + Description: "List of alert receivers.", + Required: true, + Validators: []validator.List{ + listvalidator.SizeAtLeast(1), + }, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "name": schema.StringAttribute{ + Description: "Name of the receiver.", + Required: true, + }, + "email_configs": schema.ListNestedAttribute{ + Description: "List of email configurations.", + Optional: true, + Validators: []validator.List{ + listvalidator.SizeAtLeast(1), + }, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "auth_identity": schema.StringAttribute{ + Description: "SMTP authentication information. Must be a valid email address", + Optional: true, + }, + "auth_password": schema.StringAttribute{ + Description: "SMTP authentication password.", + Optional: true, + }, + "auth_username": schema.StringAttribute{ + Description: "SMTP authentication username.", + Optional: true, + }, + "from": schema.StringAttribute{ + Description: "The sender email address. Must be a valid email address", + Optional: true, + }, + "smart_host": schema.StringAttribute{ + Description: "The SMTP host through which emails are sent.", + Optional: true, + }, + "to": schema.StringAttribute{ + Description: "The email address to send notifications to. Must be a valid email address", + Optional: true, + }, + }, + }, + }, + "opsgenie_configs": schema.ListNestedAttribute{ + Description: "List of OpsGenie configurations.", + Optional: true, + Validators: []validator.List{ + listvalidator.SizeAtLeast(1), + }, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "api_key": schema.StringAttribute{ + Description: "The API key for OpsGenie.", + Optional: true, + }, + "api_url": schema.StringAttribute{ + Description: "The host to send OpsGenie API requests to. Must be a valid URL", + Optional: true, + }, + "tags": schema.StringAttribute{ + Description: "Comma separated list of tags attached to the notifications.", + Optional: true, + }, + }, + }, + }, + "webhooks_configs": schema.ListNestedAttribute{ + Description: "List of Webhooks configurations.", + Optional: true, + Validators: []validator.List{ + listvalidator.SizeAtLeast(1), + }, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "url": schema.StringAttribute{ + Description: "The endpoint to send HTTP POST requests to. Must be a valid URL", + Optional: true, + }, + "ms_teams": schema.BoolAttribute{ + Description: "Microsoft Teams webhooks require special handling, set this to true if the webhook is for Microsoft Teams.", + Optional: true, + }, + }, + }, + }, + }, + }, + }, + "route": schema.SingleNestedAttribute{ + Description: "Route configuration for the alerts.", + Required: true, + Attributes: map[string]schema.Attribute{ + "group_by": schema.ListAttribute{ + Description: routeDescriptions["group_by"], + Optional: true, + ElementType: types.StringType, + }, + "group_interval": schema.StringAttribute{ + Description: routeDescriptions["group_interval"], + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "group_wait": schema.StringAttribute{ + Description: routeDescriptions["group_wait"], + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "match": schema.MapAttribute{ + Description: routeDescriptions["match"], + Optional: true, + ElementType: types.StringType, + }, + "match_regex": schema.MapAttribute{ + Description: routeDescriptions["match_regex"], + Optional: true, + ElementType: types.StringType, + }, + "receiver": schema.StringAttribute{ + Description: routeDescriptions["receiver"], + Required: true, + }, + "repeat_interval": schema.StringAttribute{ + Description: routeDescriptions["repeat_interval"], + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "routes": getRouteNestedObject(), + }, + }, + "global": schema.SingleNestedAttribute{ + Description: "Global configuration for the alerts.", + Optional: true, + Computed: true, + Attributes: map[string]schema.Attribute{ + "opsgenie_api_key": schema.StringAttribute{ + Description: "The API key for OpsGenie.", + Optional: true, + Sensitive: true, + }, + "opsgenie_api_url": schema.StringAttribute{ + Description: "The host to send OpsGenie API requests to. Must be a valid URL", + Optional: true, + }, + "resolve_timeout": schema.StringAttribute{ + Description: "The default value used by alertmanager if the alert does not include EndsAt. After this time passes, it can declare the alert as resolved if it has not been updated. This has no impact on alerts from Prometheus, as they always include EndsAt.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "smtp_auth_identity": schema.StringAttribute{ + Description: "SMTP authentication information. Must be a valid email address", + Optional: true, + }, + "smtp_auth_password": schema.StringAttribute{ + Description: "SMTP Auth using LOGIN and PLAIN.", + Optional: true, + Sensitive: true, + }, + "smtp_auth_username": schema.StringAttribute{ + Description: "SMTP Auth using CRAM-MD5, LOGIN and PLAIN. If empty, Alertmanager doesn't authenticate to the SMTP server.", + Optional: true, + }, + "smtp_from": schema.StringAttribute{ + Description: "The default SMTP From header field. Must be a valid email address", + Optional: true, + Computed: true, + }, + "smtp_smart_host": schema.StringAttribute{ + Description: "The default SMTP smarthost used for sending emails, including port number in format `host:port` (eg. `smtp.example.com:587`). Port number usually is 25, or 587 for SMTP over TLS (sometimes referred to as STARTTLS).", + Optional: true, + }, + }, + }, + }, + }, }, } } @@ -296,6 +794,15 @@ func (r *instanceResource) Create(ctx context.Context, req resource.CreateReques metricsRetentionDays5mDownsampling := conversion.Int64ValueToPointer(model.MetricsRetentionDays5mDownsampling) metricsRetentionDays1hDownsampling := conversion.Int64ValueToPointer(model.MetricsRetentionDays1hDownsampling) + alertConfig := alertConfigModel{} + if !(model.AlertConfig.IsNull() || model.AlertConfig.IsUnknown()) { + diags = model.AlertConfig.As(ctx, &alertConfig, basetypes.ObjectAsOptions{}) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + } + projectId := model.ProjectId.ValueString() ctx = tflog.SetField(ctx, "project_id", projectId) @@ -405,6 +912,50 @@ func (r *instanceResource) Create(ctx context.Context, req resource.CreateReques return } + // Alert Config + if model.AlertConfig.IsUnknown() || model.AlertConfig.IsNull() { + alertConfig, err = getMockAlertConfig(ctx) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating instance", fmt.Sprintf("Getting mock alert config: %v", err)) + return + } + } + + alertConfigPayload, err := toUpdateAlertConfigPayload(ctx, &alertConfig) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating instance", fmt.Sprintf("Building alert config payload: %v", err)) + return + } + + if alertConfigPayload != nil { + _, err = r.client.UpdateAlertConfigs(ctx, *instanceId, projectId).UpdateAlertConfigsPayload(*alertConfigPayload).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating instance", fmt.Sprintf("Setting alert config: %v", err)) + return + } + } + + // Get alert config after update + alertConfigResp, err := r.client.GetAlertConfigs(ctx, *instanceId, projectId).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating instance", fmt.Sprintf("Getting alert config: %v", err)) + return + } + + // Map response body to schema + err = mapAlertConfigField(ctx, alertConfigResp, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating instance", fmt.Sprintf("Processing API response for the alert config: %v", err)) + return + } + + // Set state to fully populated data + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "Argus instance created") } @@ -448,6 +999,12 @@ func (r *instanceResource) Read(ctx context.Context, req resource.ReadRequest, r return } + alertConfigResp, err := r.client.GetAlertConfigs(ctx, instanceId, projectId).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading instance", fmt.Sprintf("Calling API to get alert config: %v", err)) + return + } + // Map response body to schema err = mapFields(ctx, instanceResp, &model) if err != nil { @@ -469,6 +1026,13 @@ func (r *instanceResource) Read(ctx context.Context, req resource.ReadRequest, r return } + // Map response body to schema + err = mapAlertConfigField(ctx, alertConfigResp, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating instance", fmt.Sprintf("Processing API response for the alert config: %v", err)) + return + } + // Set state to fully populated data diags = resp.State.Set(ctx, model) resp.Diagnostics.Append(diags...) @@ -504,6 +1068,15 @@ func (r *instanceResource) Update(ctx context.Context, req resource.UpdateReques metricsRetentionDays5mDownsampling := conversion.Int64ValueToPointer(model.MetricsRetentionDays5mDownsampling) metricsRetentionDays1hDownsampling := conversion.Int64ValueToPointer(model.MetricsRetentionDays1hDownsampling) + alertConfig := alertConfigModel{} + if !(model.AlertConfig.IsNull() || model.AlertConfig.IsUnknown()) { + diags = model.AlertConfig.As(ctx, &alertConfig, basetypes.ObjectAsOptions{}) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + } + err := r.loadPlanId(ctx, &model) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating instance", fmt.Sprintf("Loading service plan: %v", err)) @@ -606,6 +1179,50 @@ func (r *instanceResource) Update(ctx context.Context, req resource.UpdateReques return } + // Alert Config + if model.AlertConfig.IsUnknown() || model.AlertConfig.IsNull() { + alertConfig, err = getMockAlertConfig(ctx) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating instance", fmt.Sprintf("Getting mock alert config: %v", err)) + return + } + } + + alertConfigPayload, err := toUpdateAlertConfigPayload(ctx, &alertConfig) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating instance", fmt.Sprintf("Building alert config payload: %v", err)) + return + } + + if alertConfigPayload != nil { + _, err = r.client.UpdateAlertConfigs(ctx, instanceId, projectId).UpdateAlertConfigsPayload(*alertConfigPayload).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating instance", fmt.Sprintf("Setting alert config: %v", err)) + return + } + } + + // Get updated alert config + alertConfigResp, err := r.client.GetAlertConfigs(ctx, instanceId, projectId).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating instance", fmt.Sprintf("Calling API to get alert config: %v", err)) + return + } + + // Map response body to schema + err = mapAlertConfigField(ctx, alertConfigResp, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating instance", fmt.Sprintf("Processing API response for the alert config: %v", err)) + return + } + + // Set state to fully populated data + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "Argus instance updated") } @@ -784,6 +1401,432 @@ func mapMetricsRetentionField(r *argus.GetMetricsStorageRetentionResponse, model return nil } +func mapAlertConfigField(ctx context.Context, resp *argus.GetAlertConfigsResponse, model *Model) error { + if resp == nil || resp.Data == nil { + model.AlertConfig = types.ObjectNull(alertConfigTypes) + return nil + } + + if model == nil { + return fmt.Errorf("nil model") + } + + var alertConfigTF *alertConfigModel + if !(model.AlertConfig.IsNull() || model.AlertConfig.IsUnknown()) { + alertConfigTF = &alertConfigModel{} + diags := model.AlertConfig.As(ctx, &alertConfigTF, basetypes.ObjectAsOptions{}) + if diags.HasError() { + return fmt.Errorf("mapping alert config: %w", core.DiagsToError(diags)) + } + } + + respReceivers := resp.Data.Receivers + respRoute := resp.Data.Route + respGlobalConfigs := resp.Data.Global + + receiversList, err := mapReceiversToAttributes(ctx, respReceivers) + if err != nil { + return fmt.Errorf("mapping alert config receivers: %w", err) + } + + route, err := mapRouteToAttributes(ctx, respRoute) + if err != nil { + return fmt.Errorf("mapping alert config route: %w", err) + } + + var globalConfigModel *globalConfigurationModel + if alertConfigTF != nil && !alertConfigTF.GlobalConfiguration.IsNull() && !alertConfigTF.GlobalConfiguration.IsUnknown() { + globalConfigModel = &globalConfigurationModel{} + diags := alertConfigTF.GlobalConfiguration.As(ctx, globalConfigModel, basetypes.ObjectAsOptions{}) + if diags.HasError() { + return fmt.Errorf("mapping alert config: %w", core.DiagsToError(diags)) + } + } + + globalConfig, err := mapGlobalConfigToAttributes(respGlobalConfigs, globalConfigModel) + if err != nil { + return fmt.Errorf("mapping alert config global config: %w", err) + } + + alertConfig, diags := types.ObjectValue(alertConfigTypes, map[string]attr.Value{ + "receivers": receiversList, + "route": route, + "global": globalConfig, + }) + if diags.HasError() { + return fmt.Errorf("converting alert config to TF type: %w", core.DiagsToError(diags)) + } + + // Check if the alert config is equal to the mock alert config + // This is done because the Alert Config cannot be removed from the instance, but can be unset by the user in the Terraform configuration + // If the alert config is equal to the mock alert config, we will map the Alert Config to an empty object in the Terraform state + // This is done to avoid inconsistent applies or non-empty plans after applying + mockAlertConfig, err := getMockAlertConfig(ctx) + if err != nil { + return fmt.Errorf("getting mock alert config: %w", err) + } + modelMockAlertConfig, diags := types.ObjectValueFrom(ctx, alertConfigTypes, mockAlertConfig) + if diags.HasError() { + return fmt.Errorf("converting mock alert config to TF type: %w", core.DiagsToError(diags)) + } + if alertConfig.Equal(modelMockAlertConfig) { + alertConfig = types.ObjectNull(alertConfigTypes) + } + + model.AlertConfig = alertConfig + return nil +} + +// getMockAlertConfig returns a default alert config to be set in the instance if the alert config is unset in the Terraform configuration +// +// This is done because the Alert Config cannot be removed from the instance, but can be unset by the user in the Terraform configuration. +// So, we set the Alert Config in the instance to our mock configuration and +// map the Alert Config to an empty object in the Terraform state if it matches the mock alert config +func getMockAlertConfig(ctx context.Context) (alertConfigModel, error) { + mockEmailConfig, diags := types.ObjectValue(emailConfigsTypes, map[string]attr.Value{ + "to": types.StringValue("123@gmail.com"), + "smart_host": types.StringValue("smtp.gmail.com:587"), + "from": types.StringValue("xxxx@gmail.com"), + "auth_username": types.StringValue("xxxx@gmail.com"), + "auth_password": types.StringValue("xxxxxxxxx"), + "auth_identity": types.StringValue("xxxx@gmail.com"), + }) + if diags.HasError() { + return alertConfigModel{}, fmt.Errorf("mapping email config: %w", core.DiagsToError(diags)) + } + + mockEmailConfigs, diags := types.ListValueFrom(ctx, types.ObjectType{AttrTypes: emailConfigsTypes}, []attr.Value{ + mockEmailConfig, + }) + if diags.HasError() { + return alertConfigModel{}, fmt.Errorf("mapping email configs: %w", core.DiagsToError(diags)) + } + + mockReceiver, diags := types.ObjectValue(receiversTypes, map[string]attr.Value{ + "name": types.StringValue("email-me"), + "email_configs": mockEmailConfigs, + "opsgenie_configs": types.ListNull(types.ObjectType{AttrTypes: opsgenieConfigsTypes}), + "webhooks_configs": types.ListNull(types.ObjectType{AttrTypes: webHooksConfigsTypes}), + }) + if diags.HasError() { + return alertConfigModel{}, fmt.Errorf("mapping receiver: %w", core.DiagsToError(diags)) + } + + mockReceivers, diags := types.ListValueFrom(ctx, types.ObjectType{AttrTypes: receiversTypes}, []attr.Value{ + mockReceiver, + }) + if diags.HasError() { + return alertConfigModel{}, fmt.Errorf("mapping receivers: %w", core.DiagsToError(diags)) + } + + mockGroupByList, diags := types.ListValueFrom(ctx, types.StringType, []attr.Value{ + types.StringValue("job"), + }) + if diags.HasError() { + return alertConfigModel{}, fmt.Errorf("mapping group by list: %w", core.DiagsToError(diags)) + } + + mockRoute, diags := types.ObjectValue(routeTypes, map[string]attr.Value{ + "receiver": types.StringValue("email-me"), + "group_by": mockGroupByList, + "group_wait": types.StringValue("30s"), + "group_interval": types.StringValue("5m"), + "repeat_interval": types.StringValue("4h"), + "match": types.MapNull(types.StringType), + "match_regex": types.MapNull(types.StringType), + "routes": types.ListNull(getRouteListType()), + }) + if diags.HasError() { + return alertConfigModel{}, fmt.Errorf("mapping route: %w", core.DiagsToError(diags)) + } + + mockGlobalConfig, diags := types.ObjectValue(globalConfigurationTypes, map[string]attr.Value{ + "opsgenie_api_key": types.StringNull(), + "opsgenie_api_url": types.StringNull(), + "resolve_timeout": types.StringValue("5m"), + "smtp_auth_identity": types.StringNull(), + "smtp_auth_password": types.StringNull(), + "smtp_auth_username": types.StringNull(), + "smtp_from": types.StringValue("argus@argus.stackit.cloud"), + "smtp_smart_host": types.StringNull(), + }) + if diags.HasError() { + return alertConfigModel{}, fmt.Errorf("mapping global config: %w", core.DiagsToError(diags)) + } + + return alertConfigModel{ + Receivers: mockReceivers, + Route: mockRoute, + GlobalConfiguration: mockGlobalConfig, + }, nil +} + +func mapGlobalConfigToAttributes(respGlobalConfigs *argus.Global, globalConfigsTF *globalConfigurationModel) (basetypes.ObjectValue, error) { + if respGlobalConfigs == nil { + return types.ObjectNull(globalConfigurationTypes), nil + } + + // This bypass is needed because these values are not returned in the API GET response + smtpSmartHost := respGlobalConfigs.SmtpSmarthost + smtpAuthIdentity := respGlobalConfigs.SmtpAuthIdentity + smtpAuthPassword := respGlobalConfigs.SmtpAuthPassword + smtpAuthUsername := respGlobalConfigs.SmtpAuthUsername + if globalConfigsTF != nil { + if respGlobalConfigs.SmtpSmarthost == nil && + !globalConfigsTF.SmtpSmartHost.IsNull() && !globalConfigsTF.SmtpSmartHost.IsUnknown() { + smtpSmartHost = utils.Ptr(globalConfigsTF.SmtpSmartHost.ValueString()) + } + if respGlobalConfigs.SmtpAuthIdentity == nil && + !globalConfigsTF.SmtpAuthIdentity.IsNull() && !globalConfigsTF.SmtpAuthIdentity.IsUnknown() { + smtpAuthIdentity = utils.Ptr(globalConfigsTF.SmtpAuthIdentity.ValueString()) + } + if respGlobalConfigs.SmtpAuthPassword == nil && + !globalConfigsTF.SmtpAuthPassword.IsNull() && !globalConfigsTF.SmtpAuthPassword.IsUnknown() { + smtpAuthPassword = utils.Ptr(globalConfigsTF.SmtpAuthPassword.ValueString()) + } + if respGlobalConfigs.SmtpAuthUsername == nil && + !globalConfigsTF.SmtpAuthUsername.IsNull() && !globalConfigsTF.SmtpAuthUsername.IsUnknown() { + smtpAuthUsername = utils.Ptr(globalConfigsTF.SmtpAuthUsername.ValueString()) + } + } + + globalConfigObject, diags := types.ObjectValue(globalConfigurationTypes, map[string]attr.Value{ + "opsgenie_api_key": types.StringPointerValue(respGlobalConfigs.OpsgenieApiKey), + "opsgenie_api_url": types.StringPointerValue(respGlobalConfigs.OpsgenieApiUrl), + "resolve_timeout": types.StringPointerValue(respGlobalConfigs.ResolveTimeout), + "smtp_from": types.StringPointerValue(respGlobalConfigs.SmtpFrom), + "smtp_auth_identity": types.StringPointerValue(smtpAuthIdentity), + "smtp_auth_password": types.StringPointerValue(smtpAuthPassword), + "smtp_auth_username": types.StringPointerValue(smtpAuthUsername), + "smtp_smart_host": types.StringPointerValue(smtpSmartHost), + }) + if diags.HasError() { + return types.ObjectNull(globalConfigurationTypes), fmt.Errorf("mapping global config: %w", core.DiagsToError(diags)) + } + + return globalConfigObject, nil +} + +func mapReceiversToAttributes(ctx context.Context, respReceivers *[]argus.Receivers) (basetypes.ListValue, error) { + if respReceivers == nil { + return types.ListNull(types.ObjectType{AttrTypes: receiversTypes}), nil + } + receiversList := []attr.Value{} + emptyList, diags := types.ListValueFrom(ctx, types.ObjectType{AttrTypes: receiversTypes}, []attr.Value{}) + if diags.HasError() { + // Should not happen + return emptyList, fmt.Errorf("mapping empty list: %w", core.DiagsToError(diags)) + } + + if len(*respReceivers) == 0 { + return emptyList, nil + } + + for i := range *respReceivers { + receiver := (*respReceivers)[i] + + emailConfigList := []attr.Value{} + if receiver.EmailConfigs != nil { + for _, emailConfig := range *receiver.EmailConfigs { + emailConfigMap := map[string]attr.Value{ + "auth_identity": types.StringPointerValue(emailConfig.AuthIdentity), + "auth_password": types.StringPointerValue(emailConfig.AuthPassword), + "auth_username": types.StringPointerValue(emailConfig.AuthUsername), + "from": types.StringPointerValue(emailConfig.From), + "smart_host": types.StringPointerValue(emailConfig.Smarthost), + "to": types.StringPointerValue(emailConfig.To), + } + emailConfigModel, diags := types.ObjectValue(emailConfigsTypes, emailConfigMap) + if diags.HasError() { + return emptyList, fmt.Errorf("mapping email config: %w", core.DiagsToError(diags)) + } + emailConfigList = append(emailConfigList, emailConfigModel) + } + } + + opsgenieConfigList := []attr.Value{} + if receiver.OpsgenieConfigs != nil { + for _, opsgenieConfig := range *receiver.OpsgenieConfigs { + opsGenieConfigMap := map[string]attr.Value{ + "api_key": types.StringPointerValue(opsgenieConfig.ApiKey), + "api_url": types.StringPointerValue(opsgenieConfig.ApiUrl), + "tags": types.StringPointerValue(opsgenieConfig.Tags), + } + opsGenieConfigModel, diags := types.ObjectValue(opsgenieConfigsTypes, opsGenieConfigMap) + if diags.HasError() { + return emptyList, fmt.Errorf("mapping opsgenie config: %w", core.DiagsToError(diags)) + } + opsgenieConfigList = append(opsgenieConfigList, opsGenieConfigModel) + } + } + + webhooksConfigList := []attr.Value{} + if receiver.WebHookConfigs != nil { + for _, webhookConfig := range *receiver.WebHookConfigs { + webHookConfigsMap := map[string]attr.Value{ + "url": types.StringPointerValue(webhookConfig.Url), + "ms_teams": types.BoolPointerValue(webhookConfig.MsTeams), + } + webHookConfigsModel, diags := types.ObjectValue(webHooksConfigsTypes, webHookConfigsMap) + if diags.HasError() { + return emptyList, fmt.Errorf("mapping webhooks config: %w", core.DiagsToError(diags)) + } + webhooksConfigList = append(webhooksConfigList, webHookConfigsModel) + } + } + + if receiver.Name == nil { + return emptyList, fmt.Errorf("receiver name is nil") + } + + var emailConfigs basetypes.ListValue + if len(emailConfigList) == 0 { + emailConfigs = types.ListNull(types.ObjectType{AttrTypes: emailConfigsTypes}) + } else { + emailConfigs, diags = types.ListValueFrom(ctx, types.ObjectType{AttrTypes: emailConfigsTypes}, emailConfigList) + if diags.HasError() { + return emptyList, fmt.Errorf("mapping email configs: %w", core.DiagsToError(diags)) + } + } + + var opsGenieConfigs basetypes.ListValue + if len(opsgenieConfigList) == 0 { + opsGenieConfigs = types.ListNull(types.ObjectType{AttrTypes: opsgenieConfigsTypes}) + } else { + opsGenieConfigs, diags = types.ListValueFrom(ctx, types.ObjectType{AttrTypes: opsgenieConfigsTypes}, opsgenieConfigList) + if diags.HasError() { + return emptyList, fmt.Errorf("mapping opsgenie configs: %w", core.DiagsToError(diags)) + } + } + + var webHooksConfigs basetypes.ListValue + if len(webhooksConfigList) == 0 { + webHooksConfigs = types.ListNull(types.ObjectType{AttrTypes: webHooksConfigsTypes}) + } else { + webHooksConfigs, diags = types.ListValueFrom(ctx, types.ObjectType{AttrTypes: webHooksConfigsTypes}, webhooksConfigList) + if diags.HasError() { + return emptyList, fmt.Errorf("mapping webhooks configs: %w", core.DiagsToError(diags)) + } + } + + receiverMap := map[string]attr.Value{ + "name": types.StringPointerValue(receiver.Name), + "email_configs": emailConfigs, + "opsgenie_configs": opsGenieConfigs, + "webhooks_configs": webHooksConfigs, + } + + receiversModel, diags := types.ObjectValue(receiversTypes, receiverMap) + if diags.HasError() { + return emptyList, fmt.Errorf("mapping receiver: %w", core.DiagsToError(diags)) + } + + receiversList = append(receiversList, receiversModel) + } + + returnReceiversList, diags := types.ListValueFrom(ctx, types.ObjectType{AttrTypes: receiversTypes}, receiversList) + if diags.HasError() { + return emptyList, fmt.Errorf("mapping receivers list: %w", core.DiagsToError(diags)) + } + return returnReceiversList, nil +} + +func mapRouteToAttributes(ctx context.Context, route *argus.Route) (attr.Value, error) { + if route == nil { + return types.ObjectNull(routeTypes), nil + } + + groupByModel, diags := types.ListValueFrom(ctx, types.StringType, route.GroupBy) + if diags.HasError() { + return types.ObjectNull(routeTypes), fmt.Errorf("mapping group by: %w", core.DiagsToError(diags)) + } + + matchModel, diags := types.MapValueFrom(ctx, types.StringType, route.Match) + if diags.HasError() { + return types.ObjectNull(routeTypes), fmt.Errorf("mapping match: %w", core.DiagsToError(diags)) + } + + matchRegexModel, diags := types.MapValueFrom(ctx, types.StringType, route.MatchRe) + if diags.HasError() { + return types.ObjectNull(routeTypes), fmt.Errorf("mapping match regex: %w", core.DiagsToError(diags)) + } + + childRoutes, err := mapChildRoutesToAttributes(ctx, route.Routes) + if err != nil { + return types.ObjectNull(routeTypes), fmt.Errorf("mapping child routes: %w", err) + } + + routeMap := map[string]attr.Value{ + "group_by": groupByModel, + "group_interval": types.StringPointerValue(route.GroupInterval), + "group_wait": types.StringPointerValue(route.GroupWait), + "match": matchModel, + "match_regex": matchRegexModel, + "receiver": types.StringPointerValue(route.Receiver), + "repeat_interval": types.StringPointerValue(route.RepeatInterval), + "routes": childRoutes, + } + + routeModel, diags := types.ObjectValue(routeTypes, routeMap) + if diags.HasError() { + return types.ObjectNull(routeTypes), fmt.Errorf("converting route to TF types: %w", core.DiagsToError(diags)) + } + + return routeModel, nil +} + +// mapChildRoutesToAttributes maps the child routes to the Terraform attributes +// This should be a recursive function to handle nested child routes +// However, the API does not currently have the correct type for the child routes +// In the future, the current implementation should be the final case of the recursive function +func mapChildRoutesToAttributes(ctx context.Context, routes *[]argus.RouteSerializer) (basetypes.ListValue, error) { + nullList := types.ListNull(getRouteListType()) + if routes == nil { + return nullList, nil + } + + routesList := []attr.Value{} + for _, route := range *routes { + groupByModel, diags := types.ListValueFrom(ctx, types.StringType, route.GroupBy) + if diags.HasError() { + return nullList, fmt.Errorf("mapping group by: %w", core.DiagsToError(diags)) + } + + matchModel, diags := types.MapValueFrom(ctx, types.StringType, route.Match) + if diags.HasError() { + return nullList, fmt.Errorf("mapping match: %w", core.DiagsToError(diags)) + } + + matchRegexModel, diags := types.MapValueFrom(ctx, types.StringType, route.MatchRe) + if diags.HasError() { + return nullList, fmt.Errorf("mapping match regex: %w", core.DiagsToError(diags)) + } + + routeMap := map[string]attr.Value{ + "group_by": groupByModel, + "group_interval": types.StringPointerValue(route.GroupInterval), + "group_wait": types.StringPointerValue(route.GroupWait), + "match": matchModel, + "match_regex": matchRegexModel, + "receiver": types.StringPointerValue(route.Receiver), + "repeat_interval": types.StringPointerValue(route.RepeatInterval), + } + + routeModel, diags := types.ObjectValue(getRouteListType().AttrTypes, routeMap) + if diags.HasError() { + return types.ListNull(getRouteListType()), fmt.Errorf("converting child route to TF types: %w", core.DiagsToError(diags)) + } + + routesList = append(routesList, routeModel) + } + + returnRoutesList, diags := types.ListValueFrom(ctx, getRouteListType(), routesList) + if diags.HasError() { + return nullList, fmt.Errorf("mapping child routes list: %w", core.DiagsToError(diags)) + } + return returnRoutesList, nil +} + func toCreatePayload(model *Model) (*argus.CreateInstancePayload, error) { if model == nil { return nil, fmt.Errorf("nil model") @@ -863,6 +1906,248 @@ func toUpdatePayload(model *Model) (*argus.UpdateInstancePayload, error) { }, nil } +func toUpdateAlertConfigPayload(ctx context.Context, model *alertConfigModel) (*argus.UpdateAlertConfigsPayload, error) { + if model == nil { + return nil, fmt.Errorf("nil model") + } + if model.Receivers.IsNull() || model.Receivers.IsUnknown() { + return nil, fmt.Errorf("receivers in the model are null or unknown") + } + + if model.Route.IsNull() || model.Route.IsUnknown() { + return nil, fmt.Errorf("route in the model is null or unknown") + } + + var err error + + payload := argus.UpdateAlertConfigsPayload{} + + payload.Receivers, err = toReceiverPayload(ctx, model) + if err != nil { + return nil, fmt.Errorf("mapping receivers: %w", err) + } + + routeTF := routeModel{} + diags := model.Route.As(ctx, &routeTF, basetypes.ObjectAsOptions{}) + if diags.HasError() { + return nil, fmt.Errorf("mapping route: %w", core.DiagsToError(diags)) + } + + payload.Route, err = toRoutePayload(ctx, &routeTF) + if err != nil { + return nil, fmt.Errorf("mapping route: %w", err) + } + + if !model.GlobalConfiguration.IsNull() && !model.GlobalConfiguration.IsUnknown() { + payload.Global, err = toGlobalConfigPayload(ctx, model) + if err != nil { + return nil, fmt.Errorf("mapping global: %w", err) + } + } + + return &payload, nil +} + +func toReceiverPayload(ctx context.Context, model *alertConfigModel) (*[]argus.UpdateAlertConfigsPayloadReceiversInner, error) { + if model == nil { + return nil, fmt.Errorf("nil model") + } + receiversModel := []receiversModel{} + diags := model.Receivers.ElementsAs(ctx, &receiversModel, false) + if diags.HasError() { + return nil, fmt.Errorf("mapping receivers: %w", core.DiagsToError(diags)) + } + + receivers := []argus.UpdateAlertConfigsPayloadReceiversInner{} + + for i := range receiversModel { + receiver := receiversModel[i] + receiverPayload := argus.UpdateAlertConfigsPayloadReceiversInner{ + Name: conversion.StringValueToPointer(receiver.Name), + } + + if !receiver.EmailConfigs.IsNull() && !receiver.EmailConfigs.IsUnknown() { + emailConfigs := []emailConfigsModel{} + diags := receiver.EmailConfigs.ElementsAs(ctx, &emailConfigs, false) + if diags.HasError() { + return nil, fmt.Errorf("mapping email configs: %w", core.DiagsToError(diags)) + } + payloadEmailConfigs := []argus.CreateAlertConfigReceiverPayloadEmailConfigsInner{} + for i := range emailConfigs { + emailConfig := emailConfigs[i] + payloadEmailConfigs = append(payloadEmailConfigs, argus.CreateAlertConfigReceiverPayloadEmailConfigsInner{ + AuthIdentity: conversion.StringValueToPointer(emailConfig.AuthIdentity), + AuthPassword: conversion.StringValueToPointer(emailConfig.AuthPassword), + AuthUsername: conversion.StringValueToPointer(emailConfig.AuthUsername), + From: conversion.StringValueToPointer(emailConfig.From), + Smarthost: conversion.StringValueToPointer(emailConfig.Smarthost), + To: conversion.StringValueToPointer(emailConfig.To), + }) + } + receiverPayload.EmailConfigs = &payloadEmailConfigs + } + + if !receiver.OpsGenieConfigs.IsNull() && !receiver.OpsGenieConfigs.IsUnknown() { + opsgenieConfigs := []opsgenieConfigsModel{} + diags := receiver.OpsGenieConfigs.ElementsAs(ctx, &opsgenieConfigs, false) + if diags.HasError() { + return nil, fmt.Errorf("mapping opsgenie configs: %w", core.DiagsToError(diags)) + } + payloadOpsGenieConfigs := []argus.CreateAlertConfigReceiverPayloadOpsgenieConfigsInner{} + for i := range opsgenieConfigs { + opsgenieConfig := opsgenieConfigs[i] + payloadOpsGenieConfigs = append(payloadOpsGenieConfigs, argus.CreateAlertConfigReceiverPayloadOpsgenieConfigsInner{ + ApiKey: conversion.StringValueToPointer(opsgenieConfig.ApiKey), + ApiUrl: conversion.StringValueToPointer(opsgenieConfig.ApiUrl), + Tags: conversion.StringValueToPointer(opsgenieConfig.Tags), + }) + } + receiverPayload.OpsgenieConfigs = &payloadOpsGenieConfigs + } + + if !receiver.WebHooksConfigs.IsNull() && !receiver.WebHooksConfigs.IsUnknown() { + receiverWebHooksConfigs := []webHooksConfigsModel{} + diags := receiver.WebHooksConfigs.ElementsAs(ctx, &receiverWebHooksConfigs, false) + if diags.HasError() { + return nil, fmt.Errorf("mapping webhooks configs: %w", core.DiagsToError(diags)) + } + payloadWebHooksConfigs := []argus.CreateAlertConfigReceiverPayloadWebHookConfigsInner{} + for i := range receiverWebHooksConfigs { + webHooksConfig := receiverWebHooksConfigs[i] + payloadWebHooksConfigs = append(payloadWebHooksConfigs, argus.CreateAlertConfigReceiverPayloadWebHookConfigsInner{ + Url: conversion.StringValueToPointer(webHooksConfig.Url), + MsTeams: conversion.BoolValueToPointer(webHooksConfig.MsTeams), + }) + } + receiverPayload.WebHookConfigs = &payloadWebHooksConfigs + } + + receivers = append(receivers, receiverPayload) + } + return &receivers, nil +} + +func toRoutePayload(ctx context.Context, routeTF *routeModel) (*argus.UpdateAlertConfigsPayloadRoute, error) { + if routeTF == nil { + return nil, fmt.Errorf("nil route model") + } + + var groupByPayload *[]string + var matchPayload *map[string]interface{} + var matchRegexPayload *map[string]interface{} + var childRoutesPayload *[]argus.CreateAlertConfigRoutePayloadRoutesInner + + if !routeTF.GroupBy.IsNull() && !routeTF.GroupBy.IsUnknown() { + groupByPayload = &[]string{} + diags := routeTF.GroupBy.ElementsAs(ctx, groupByPayload, false) + if diags.HasError() { + return nil, fmt.Errorf("mapping group by: %w", core.DiagsToError(diags)) + } + } + + if !routeTF.Match.IsNull() && !routeTF.Match.IsUnknown() { + matchMap, err := conversion.ToStringInterfaceMap(ctx, routeTF.Match) + if err != nil { + return nil, fmt.Errorf("mapping match: %w", err) + } + matchPayload = &matchMap + } + + if !routeTF.MatchRegex.IsNull() && !routeTF.MatchRegex.IsUnknown() { + matchRegexMap, err := conversion.ToStringInterfaceMap(ctx, routeTF.MatchRegex) + if err != nil { + return nil, fmt.Errorf("mapping match regex: %w", err) + } + matchRegexPayload = &matchRegexMap + } + + if !routeTF.Routes.IsNull() && !routeTF.Routes.IsUnknown() { + childRoutes := []routeModel{} + diags := routeTF.Routes.ElementsAs(ctx, &childRoutes, false) + if diags.HasError() { + // If there is an error, we will try to map the child routes as if they are the last child routes + // This is done because the last child routes in the recursion have a different structure (don't have the `routes` fields) + // and need to be unpacked to a different struct (routeModelNoRoutes) + lastChildRoutes := []routeModelNoRoutes{} + diags = routeTF.Routes.ElementsAs(ctx, &lastChildRoutes, true) + if diags.HasError() { + return nil, fmt.Errorf("mapping child routes: %w", core.DiagsToError(diags)) + } + for i := range lastChildRoutes { + childRoute := routeModel{ + GroupBy: lastChildRoutes[i].GroupBy, + GroupInterval: lastChildRoutes[i].GroupInterval, + GroupWait: lastChildRoutes[i].GroupWait, + Match: lastChildRoutes[i].Match, + MatchRegex: lastChildRoutes[i].MatchRegex, + Receiver: lastChildRoutes[i].Receiver, + RepeatInterval: lastChildRoutes[i].RepeatInterval, + Routes: types.ListNull(getRouteListType()), + } + childRoutes = append(childRoutes, childRoute) + } + } + + childRoutesList := []argus.CreateAlertConfigRoutePayloadRoutesInner{} + for i := range childRoutes { + childRoute := childRoutes[i] + childRoutePayload, err := toRoutePayload(ctx, &childRoute) + if err != nil { + return nil, fmt.Errorf("mapping child route: %w", err) + } + childRoutesList = append(childRoutesList, *toChildRoutePayload(childRoutePayload)) + } + + childRoutesPayload = &childRoutesList + } + + return &argus.UpdateAlertConfigsPayloadRoute{ + GroupBy: groupByPayload, + GroupInterval: conversion.StringValueToPointer(routeTF.GroupInterval), + GroupWait: conversion.StringValueToPointer(routeTF.GroupWait), + Match: matchPayload, + MatchRe: matchRegexPayload, + Receiver: conversion.StringValueToPointer(routeTF.Receiver), + RepeatInterval: conversion.StringValueToPointer(routeTF.RepeatInterval), + Routes: childRoutesPayload, + }, nil +} + +func toChildRoutePayload(in *argus.UpdateAlertConfigsPayloadRoute) *argus.CreateAlertConfigRoutePayloadRoutesInner { + if in == nil { + return nil + } + return &argus.CreateAlertConfigRoutePayloadRoutesInner{ + GroupBy: in.GroupBy, + GroupInterval: in.GroupInterval, + GroupWait: in.GroupWait, + Match: in.Match, + MatchRe: in.MatchRe, + Receiver: in.Receiver, + RepeatInterval: in.RepeatInterval, + // Routes not currently supported + } +} + +func toGlobalConfigPayload(ctx context.Context, model *alertConfigModel) (*argus.UpdateAlertConfigsPayloadGlobal, error) { + globalConfigModel := globalConfigurationModel{} + diags := model.GlobalConfiguration.As(ctx, &globalConfigModel, basetypes.ObjectAsOptions{}) + if diags.HasError() { + return nil, fmt.Errorf("mapping global configuration: %w", core.DiagsToError(diags)) + } + + return &argus.UpdateAlertConfigsPayloadGlobal{ + OpsgenieApiKey: conversion.StringValueToPointer(globalConfigModel.OpsgenieApiKey), + OpsgenieApiUrl: conversion.StringValueToPointer(globalConfigModel.OpsgenieApiUrl), + ResolveTimeout: conversion.StringValueToPointer(globalConfigModel.ResolveTimeout), + SmtpAuthIdentity: conversion.StringValueToPointer(globalConfigModel.SmtpAuthIdentity), + SmtpAuthPassword: conversion.StringValueToPointer(globalConfigModel.SmtpAuthPassword), + SmtpAuthUsername: conversion.StringValueToPointer(globalConfigModel.SmtpAuthUsername), + SmtpFrom: conversion.StringValueToPointer(globalConfigModel.SmtpFrom), + SmtpSmarthost: conversion.StringValueToPointer(globalConfigModel.SmtpSmartHost), + }, nil +} + func (r *instanceResource) loadPlanId(ctx context.Context, model *Model) error { projectId := model.ProjectId.ValueString() res, err := r.client.ListPlans(ctx, projectId).Execute() diff --git a/stackit/internal/services/argus/instance/resource_test.go b/stackit/internal/services/argus/instance/resource_test.go index 62c223ae..504a9c70 100644 --- a/stackit/internal/services/argus/instance/resource_test.go +++ b/stackit/internal/services/argus/instance/resource_test.go @@ -5,13 +5,326 @@ import ( "testing" "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework/types/basetypes" "github.com/stackitcloud/stackit-sdk-go/core/utils" "github.com/stackitcloud/stackit-sdk-go/services/argus" ) +func fixtureEmailConfigsModel() basetypes.ListValue { + return types.ListValueMust(types.ObjectType{AttrTypes: emailConfigsTypes}, []attr.Value{ + types.ObjectValueMust(emailConfigsTypes, map[string]attr.Value{ + "auth_identity": types.StringValue("identity"), + "auth_password": types.StringValue("password"), + "auth_username": types.StringValue("username"), + "from": types.StringValue("notification@example.com"), + "smart_host": types.StringValue("smtp.example.com"), + "to": types.StringValue("me@example.com"), + }), + }) +} + +func fixtureOpsGenieConfigsModel() basetypes.ListValue { + return types.ListValueMust(types.ObjectType{AttrTypes: opsgenieConfigsTypes}, []attr.Value{ + types.ObjectValueMust(opsgenieConfigsTypes, map[string]attr.Value{ + "api_key": types.StringValue("key"), + "tags": types.StringValue("tag"), + "api_url": types.StringValue("ops.example.com"), + }), + }) +} + +func fixtureWebHooksConfigsModel() basetypes.ListValue { + return types.ListValueMust(types.ObjectType{AttrTypes: webHooksConfigsTypes}, []attr.Value{ + types.ObjectValueMust(webHooksConfigsTypes, map[string]attr.Value{ + "url": types.StringValue("http://example.com"), + "ms_teams": types.BoolValue(true), + }), + }) +} + +func fixtureReceiverModel(emailConfigs, opsGenieConfigs, webHooksConfigs basetypes.ListValue) basetypes.ObjectValue { + return types.ObjectValueMust(receiversTypes, map[string]attr.Value{ + "name": types.StringValue("name"), + "email_configs": emailConfigs, + "opsgenie_configs": opsGenieConfigs, + "webhooks_configs": webHooksConfigs, + }) +} + +func fixtureRouteModel() basetypes.ObjectValue { + return types.ObjectValueMust(routeTypes, map[string]attr.Value{ + "group_by": types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("label1"), + types.StringValue("label2"), + }), + "group_interval": types.StringValue("1m"), + "group_wait": types.StringValue("1m"), + "match": types.MapValueMust(types.StringType, map[string]attr.Value{"key": types.StringValue("value")}), + "match_regex": types.MapValueMust(types.StringType, map[string]attr.Value{"key": types.StringValue("value")}), + "receiver": types.StringValue("name"), + "repeat_interval": types.StringValue("1m"), + // "routes": types.ListNull(getRouteListType()), + "routes": types.ListValueMust(getRouteListType(), []attr.Value{ + types.ObjectValueMust(getRouteListType().AttrTypes, map[string]attr.Value{ + "group_by": types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("label1"), + types.StringValue("label2"), + }), + "group_interval": types.StringValue("1m"), + "group_wait": types.StringValue("1m"), + "match": types.MapValueMust(types.StringType, map[string]attr.Value{"key": types.StringValue("value")}), + "match_regex": types.MapValueMust(types.StringType, map[string]attr.Value{"key": types.StringValue("value")}), + "receiver": types.StringValue("name"), + "repeat_interval": types.StringValue("1m"), + }), + }), + }) +} + +func fixtureNullRouteModel() basetypes.ObjectValue { + return types.ObjectValueMust(routeTypes, map[string]attr.Value{ + "group_by": types.ListNull(types.StringType), + "group_interval": types.StringNull(), + "group_wait": types.StringNull(), + "match": types.MapNull(types.StringType), + "match_regex": types.MapNull(types.StringType), + "receiver": types.StringNull(), + "repeat_interval": types.StringNull(), + "routes": types.ListNull(getRouteListType()), + }) +} + +func fixtureGlobalConfigModel() basetypes.ObjectValue { + return types.ObjectValueMust(globalConfigurationTypes, map[string]attr.Value{ + "opsgenie_api_key": types.StringValue("key"), + "opsgenie_api_url": types.StringValue("ops.example.com"), + "resolve_timeout": types.StringValue("1m"), + "smtp_auth_identity": types.StringValue("identity"), + "smtp_auth_username": types.StringValue("username"), + "smtp_auth_password": types.StringValue("password"), + "smtp_from": types.StringValue("me@example.com"), + "smtp_smart_host": types.StringValue("smtp.example.com:25"), + }) +} + +func fixtureNullGlobalConfigModel() basetypes.ObjectValue { + return types.ObjectValueMust(globalConfigurationTypes, map[string]attr.Value{ + "opsgenie_api_key": types.StringNull(), + "opsgenie_api_url": types.StringNull(), + "resolve_timeout": types.StringNull(), + "smtp_auth_identity": types.StringNull(), + "smtp_auth_username": types.StringNull(), + "smtp_auth_password": types.StringNull(), + "smtp_from": types.StringNull(), + "smtp_smart_host": types.StringNull(), + }) +} + +func fixtureEmailConfigsPayload() argus.CreateAlertConfigReceiverPayloadEmailConfigsInner { + return argus.CreateAlertConfigReceiverPayloadEmailConfigsInner{ + AuthIdentity: utils.Ptr("identity"), + AuthPassword: utils.Ptr("password"), + AuthUsername: utils.Ptr("username"), + From: utils.Ptr("notification@example.com"), + Smarthost: utils.Ptr("smtp.example.com"), + To: utils.Ptr("me@example.com"), + } +} + +func fixtureOpsGenieConfigsPayload() argus.CreateAlertConfigReceiverPayloadOpsgenieConfigsInner { + return argus.CreateAlertConfigReceiverPayloadOpsgenieConfigsInner{ + ApiKey: utils.Ptr("key"), + Tags: utils.Ptr("tag"), + ApiUrl: utils.Ptr("ops.example.com"), + } +} + +func fixtureWebHooksConfigsPayload() argus.CreateAlertConfigReceiverPayloadWebHookConfigsInner { + return argus.CreateAlertConfigReceiverPayloadWebHookConfigsInner{ + Url: utils.Ptr("http://example.com"), + MsTeams: utils.Ptr(true), + } +} + +func fixtureReceiverPayload(emailConfigs *[]argus.CreateAlertConfigReceiverPayloadEmailConfigsInner, opsGenieConfigs *[]argus.CreateAlertConfigReceiverPayloadOpsgenieConfigsInner, webHooksConfigs *[]argus.CreateAlertConfigReceiverPayloadWebHookConfigsInner) argus.UpdateAlertConfigsPayloadReceiversInner { + return argus.UpdateAlertConfigsPayloadReceiversInner{ + EmailConfigs: emailConfigs, + Name: utils.Ptr("name"), + OpsgenieConfigs: opsGenieConfigs, + WebHookConfigs: webHooksConfigs, + } +} + +func fixtureRoutePayload() *argus.UpdateAlertConfigsPayloadRoute { + return &argus.UpdateAlertConfigsPayloadRoute{ + GroupBy: utils.Ptr([]string{"label1", "label2"}), + GroupInterval: utils.Ptr("1m"), + GroupWait: utils.Ptr("1m"), + Match: &map[string]interface{}{"key": "value"}, + MatchRe: &map[string]interface{}{"key": "value"}, + Receiver: utils.Ptr("name"), + RepeatInterval: utils.Ptr("1m"), + Routes: &[]argus.CreateAlertConfigRoutePayloadRoutesInner{ + { + GroupBy: utils.Ptr([]string{"label1", "label2"}), + GroupInterval: utils.Ptr("1m"), + GroupWait: utils.Ptr("1m"), + Match: &map[string]interface{}{"key": "value"}, + MatchRe: &map[string]interface{}{"key": "value"}, + Receiver: utils.Ptr("name"), + RepeatInterval: utils.Ptr("1m"), + }, + }, + } +} + +func fixtureGlobalConfigPayload() *argus.UpdateAlertConfigsPayloadGlobal { + return &argus.UpdateAlertConfigsPayloadGlobal{ + OpsgenieApiKey: utils.Ptr("key"), + OpsgenieApiUrl: utils.Ptr("ops.example.com"), + ResolveTimeout: utils.Ptr("1m"), + SmtpAuthIdentity: utils.Ptr("identity"), + SmtpAuthUsername: utils.Ptr("username"), + SmtpAuthPassword: utils.Ptr("password"), + SmtpFrom: utils.Ptr("me@example.com"), + SmtpSmarthost: utils.Ptr("smtp.example.com:25"), + } +} + +func fixtureReceiverResponse(emailConfigs *[]argus.EmailConfig, opsGenieConfigs *[]argus.OpsgenieConfig, webhookConfigs *[]argus.WebHook) argus.Receivers { + return argus.Receivers{ + Name: utils.Ptr("name"), + EmailConfigs: emailConfigs, + OpsgenieConfigs: opsGenieConfigs, + WebHookConfigs: webhookConfigs, + } +} + +func fixtureEmailConfigsResponse() argus.EmailConfig { + return argus.EmailConfig{ + AuthIdentity: utils.Ptr("identity"), + AuthPassword: utils.Ptr("password"), + AuthUsername: utils.Ptr("username"), + From: utils.Ptr("notification@example.com"), + Smarthost: utils.Ptr("smtp.example.com"), + To: utils.Ptr("me@example.com"), + } +} + +func fixtureOpsGenieConfigsResponse() argus.OpsgenieConfig { + return argus.OpsgenieConfig{ + ApiKey: utils.Ptr("key"), + Tags: utils.Ptr("tag"), + ApiUrl: utils.Ptr("ops.example.com"), + } +} + +func fixtureWebHooksConfigsResponse() argus.WebHook { + return argus.WebHook{ + Url: utils.Ptr("http://example.com"), + MsTeams: utils.Ptr(true), + } +} + +func fixtureRouteResponse() *argus.Route { + return &argus.Route{ + GroupBy: utils.Ptr([]string{"label1", "label2"}), + GroupInterval: utils.Ptr("1m"), + GroupWait: utils.Ptr("1m"), + Match: &map[string]string{"key": "value"}, + MatchRe: &map[string]string{"key": "value"}, + Receiver: utils.Ptr("name"), + RepeatInterval: utils.Ptr("1m"), + Routes: &[]argus.RouteSerializer{ + { + GroupBy: utils.Ptr([]string{"label1", "label2"}), + GroupInterval: utils.Ptr("1m"), + GroupWait: utils.Ptr("1m"), + Match: &map[string]string{"key": "value"}, + MatchRe: &map[string]string{"key": "value"}, + Receiver: utils.Ptr("name"), + RepeatInterval: utils.Ptr("1m"), + }, + }, + } +} + +func fixtureGlobalConfigResponse() *argus.Global { + return &argus.Global{ + OpsgenieApiKey: utils.Ptr("key"), + OpsgenieApiUrl: utils.Ptr("ops.example.com"), + ResolveTimeout: utils.Ptr("1m"), + SmtpAuthIdentity: utils.Ptr("identity"), + SmtpAuthUsername: utils.Ptr("username"), + SmtpAuthPassword: utils.Ptr("password"), + SmtpFrom: utils.Ptr("me@example.com"), + SmtpSmarthost: utils.Ptr("smtp.example.com:25"), + } +} + +func fixtureRouteAttributeSchema(route *schema.ListNestedAttribute, isDatasource bool) map[string]schema.Attribute { + attributeMap := map[string]schema.Attribute{ + "group_by": schema.ListAttribute{ + Description: routeDescriptions["group_by"], + Optional: !isDatasource, + Computed: isDatasource, + ElementType: types.StringType, + }, + "group_interval": schema.StringAttribute{ + Description: routeDescriptions["group_interval"], + Optional: !isDatasource, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "group_wait": schema.StringAttribute{ + Description: routeDescriptions["group_wait"], + Optional: !isDatasource, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "match": schema.MapAttribute{ + Description: routeDescriptions["match"], + Optional: !isDatasource, + Computed: isDatasource, + ElementType: types.StringType, + }, + "match_regex": schema.MapAttribute{ + Description: routeDescriptions["match_regex"], + Optional: !isDatasource, + Computed: isDatasource, + ElementType: types.StringType, + }, + "receiver": schema.StringAttribute{ + Description: routeDescriptions["receiver"], + Required: !isDatasource, + Computed: isDatasource, + }, + "repeat_interval": schema.StringAttribute{ + Description: routeDescriptions["repeat_interval"], + Optional: !isDatasource, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + } + if route != nil { + attributeMap["routes"] = *route + } + return attributeMap +} + func TestMapFields(t *testing.T) { tests := []struct { description string @@ -271,6 +584,356 @@ func TestMapFields(t *testing.T) { } } +func TestMapAlertConfigField(t *testing.T) { + tests := []struct { + description string + alertConfigResp *argus.GetAlertConfigsResponse + expected Model + isValid bool + }{ + { + description: "basic_ok", + alertConfigResp: &argus.GetAlertConfigsResponse{ + Data: &argus.Alert{ + Receivers: &[]argus.Receivers{ + fixtureReceiverResponse( + &[]argus.EmailConfig{ + fixtureEmailConfigsResponse(), + }, + &[]argus.OpsgenieConfig{ + fixtureOpsGenieConfigsResponse(), + }, + &[]argus.WebHook{ + fixtureWebHooksConfigsResponse(), + }, + ), + }, + Route: fixtureRouteResponse(), + Global: fixtureGlobalConfigResponse(), + }, + }, + expected: Model{ + ACL: types.SetNull(types.StringType), + Parameters: types.MapNull(types.StringType), + AlertConfig: types.ObjectValueMust(alertConfigTypes, map[string]attr.Value{ + "receivers": types.ListValueMust(types.ObjectType{AttrTypes: receiversTypes}, []attr.Value{ + fixtureReceiverModel( + fixtureEmailConfigsModel(), + fixtureOpsGenieConfigsModel(), + fixtureWebHooksConfigsModel(), + ), + }), + "route": fixtureRouteModel(), + "global": fixtureGlobalConfigModel(), + }), + }, + isValid: true, + }, + { + description: "receivers only emailconfigs", + alertConfigResp: &argus.GetAlertConfigsResponse{ + Data: &argus.Alert{ + Receivers: &[]argus.Receivers{ + fixtureReceiverResponse( + &[]argus.EmailConfig{ + fixtureEmailConfigsResponse(), + }, + nil, + nil, + ), + }, + Route: fixtureRouteResponse(), + }, + }, + expected: Model{ + ACL: types.SetNull(types.StringType), + Parameters: types.MapNull(types.StringType), + AlertConfig: types.ObjectValueMust(alertConfigTypes, map[string]attr.Value{ + "receivers": types.ListValueMust(types.ObjectType{AttrTypes: receiversTypes}, []attr.Value{ + fixtureReceiverModel( + fixtureEmailConfigsModel(), + types.ListNull(types.ObjectType{AttrTypes: opsgenieConfigsTypes}), + types.ListNull(types.ObjectType{AttrTypes: webHooksConfigsTypes}), + ), + }), + "route": fixtureRouteModel(), + "global": types.ObjectNull(globalConfigurationTypes), + }), + }, + isValid: true, + }, + { + description: "receivers only opsgenieconfigs", + alertConfigResp: &argus.GetAlertConfigsResponse{ + Data: &argus.Alert{ + Receivers: &[]argus.Receivers{ + fixtureReceiverResponse( + nil, + &[]argus.OpsgenieConfig{ + fixtureOpsGenieConfigsResponse(), + }, + nil, + ), + }, + Route: fixtureRouteResponse(), + }, + }, + expected: Model{ + ACL: types.SetNull(types.StringType), + Parameters: types.MapNull(types.StringType), + AlertConfig: types.ObjectValueMust(alertConfigTypes, map[string]attr.Value{ + "receivers": types.ListValueMust(types.ObjectType{AttrTypes: receiversTypes}, []attr.Value{ + fixtureReceiverModel( + types.ListNull(types.ObjectType{AttrTypes: emailConfigsTypes}), + fixtureOpsGenieConfigsModel(), + types.ListNull(types.ObjectType{AttrTypes: webHooksConfigsTypes}), + ), + }), + "route": fixtureRouteModel(), + "global": types.ObjectNull(globalConfigurationTypes), + }), + }, + isValid: true, + }, + { + description: "receivers only webhooksconfigs", + alertConfigResp: &argus.GetAlertConfigsResponse{ + Data: &argus.Alert{ + Receivers: &[]argus.Receivers{ + fixtureReceiverResponse( + nil, + nil, + &[]argus.WebHook{ + fixtureWebHooksConfigsResponse(), + }, + ), + }, + Route: fixtureRouteResponse(), + }, + }, + expected: Model{ + ACL: types.SetNull(types.StringType), + Parameters: types.MapNull(types.StringType), + AlertConfig: types.ObjectValueMust(alertConfigTypes, map[string]attr.Value{ + "receivers": types.ListValueMust(types.ObjectType{AttrTypes: receiversTypes}, []attr.Value{ + fixtureReceiverModel( + types.ListNull(types.ObjectType{AttrTypes: emailConfigsTypes}), + types.ListNull(types.ObjectType{AttrTypes: opsgenieConfigsTypes}), + fixtureWebHooksConfigsModel(), + ), + }), + "route": fixtureRouteModel(), + "global": types.ObjectNull(globalConfigurationTypes), + }), + }, + isValid: true, + }, + { + description: "no receivers, no routes", + alertConfigResp: &argus.GetAlertConfigsResponse{ + Data: &argus.Alert{ + Receivers: &[]argus.Receivers{}, + Route: &argus.Route{}, + }, + }, + expected: Model{ + ACL: types.SetNull(types.StringType), + Parameters: types.MapNull(types.StringType), + AlertConfig: types.ObjectValueMust(alertConfigTypes, map[string]attr.Value{ + "receivers": types.ListValueMust(types.ObjectType{AttrTypes: receiversTypes}, []attr.Value{}), + "route": fixtureNullRouteModel(), + "global": types.ObjectNull(globalConfigurationTypes), + }), + }, + isValid: true, + }, + { + description: "no receivers, default routes", + alertConfigResp: &argus.GetAlertConfigsResponse{ + Data: &argus.Alert{ + Receivers: &[]argus.Receivers{}, + Route: fixtureRouteResponse(), + }, + }, + expected: Model{ + ACL: types.SetNull(types.StringType), + Parameters: types.MapNull(types.StringType), + AlertConfig: types.ObjectValueMust(alertConfigTypes, map[string]attr.Value{ + "receivers": types.ListValueMust(types.ObjectType{AttrTypes: receiversTypes}, []attr.Value{}), + "route": fixtureRouteModel(), + "global": types.ObjectNull(globalConfigurationTypes), + }), + }, + isValid: true, + }, + { + description: "default receivers, no routes", + alertConfigResp: &argus.GetAlertConfigsResponse{ + Data: &argus.Alert{ + Receivers: &[]argus.Receivers{ + fixtureReceiverResponse( + &[]argus.EmailConfig{ + fixtureEmailConfigsResponse(), + }, + &[]argus.OpsgenieConfig{ + fixtureOpsGenieConfigsResponse(), + }, + &[]argus.WebHook{ + fixtureWebHooksConfigsResponse(), + }, + ), + }, + Route: &argus.Route{}, + }, + }, + expected: Model{ + ACL: types.SetNull(types.StringType), + Parameters: types.MapNull(types.StringType), + AlertConfig: types.ObjectValueMust(alertConfigTypes, map[string]attr.Value{ + "receivers": types.ListValueMust(types.ObjectType{AttrTypes: receiversTypes}, []attr.Value{ + fixtureReceiverModel( + fixtureEmailConfigsModel(), + fixtureOpsGenieConfigsModel(), + fixtureWebHooksConfigsModel(), + ), + }), + "route": fixtureNullRouteModel(), + "global": types.ObjectNull(globalConfigurationTypes), + }), + }, + isValid: true, + }, + { + description: "nil receivers", + alertConfigResp: &argus.GetAlertConfigsResponse{ + Data: &argus.Alert{ + Receivers: nil, + Route: fixtureRouteResponse(), + }, + }, + expected: Model{ + ACL: types.SetNull(types.StringType), + Parameters: types.MapNull(types.StringType), + AlertConfig: types.ObjectValueMust(alertConfigTypes, map[string]attr.Value{ + "receivers": types.ListNull(types.ObjectType{AttrTypes: receiversTypes}), + "route": fixtureRouteModel(), + "global": types.ObjectNull(globalConfigurationTypes), + }), + }, + isValid: true, + }, + { + description: "nil route", + alertConfigResp: &argus.GetAlertConfigsResponse{ + Data: &argus.Alert{ + Receivers: &[]argus.Receivers{ + fixtureReceiverResponse( + &[]argus.EmailConfig{ + fixtureEmailConfigsResponse(), + }, + &[]argus.OpsgenieConfig{ + fixtureOpsGenieConfigsResponse(), + }, + &[]argus.WebHook{ + fixtureWebHooksConfigsResponse(), + }, + ), + }, + Route: nil, + }, + }, + expected: Model{ + ACL: types.SetNull(types.StringType), + Parameters: types.MapNull(types.StringType), + AlertConfig: types.ObjectValueMust(alertConfigTypes, map[string]attr.Value{ + "receivers": types.ListValueMust(types.ObjectType{AttrTypes: receiversTypes}, []attr.Value{ + fixtureReceiverModel( + fixtureEmailConfigsModel(), + fixtureOpsGenieConfigsModel(), + fixtureWebHooksConfigsModel(), + ), + }), + "route": types.ObjectNull(routeTypes), + "global": types.ObjectNull(globalConfigurationTypes), + }), + }, + isValid: true, + }, + { + description: "empty global options", + alertConfigResp: &argus.GetAlertConfigsResponse{ + Data: &argus.Alert{ + Receivers: &[]argus.Receivers{ + fixtureReceiverResponse( + &[]argus.EmailConfig{ + fixtureEmailConfigsResponse(), + }, + &[]argus.OpsgenieConfig{ + fixtureOpsGenieConfigsResponse(), + }, + &[]argus.WebHook{ + fixtureWebHooksConfigsResponse(), + }, + ), + }, + Route: fixtureRouteResponse(), + Global: &argus.Global{}, + }, + }, + expected: Model{ + ACL: types.SetNull(types.StringType), + Parameters: types.MapNull(types.StringType), + AlertConfig: types.ObjectValueMust(alertConfigTypes, map[string]attr.Value{ + "receivers": types.ListValueMust(types.ObjectType{AttrTypes: receiversTypes}, []attr.Value{ + fixtureReceiverModel( + fixtureEmailConfigsModel(), + fixtureOpsGenieConfigsModel(), + fixtureWebHooksConfigsModel(), + ), + }), + "route": fixtureRouteModel(), + "global": fixtureNullGlobalConfigModel(), + }), + }, + isValid: true, + }, + { + description: "nil resp", + alertConfigResp: nil, + expected: Model{ + ACL: types.SetNull(types.StringType), + Parameters: types.MapNull(types.StringType), + AlertConfig: types.ObjectNull(receiversTypes), + }, + isValid: true, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + state := &Model{ + ProjectId: tt.expected.ProjectId, + ACL: types.SetNull(types.StringType), + Parameters: types.MapNull(types.StringType), + } + err := mapAlertConfigField(context.Background(), tt.alertConfigResp, state) + if !tt.isValid && err == nil { + t.Fatalf("Should have failed") + } + if tt.isValid && err != nil { + t.Fatalf("Should not have failed: %v", err) + } + + if tt.isValid { + diff := cmp.Diff(state.AlertConfig, tt.expected.AlertConfig) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + } + }) + } +} + func TestToCreatePayload(t *testing.T) { tests := []struct { description string @@ -522,6 +1185,358 @@ func TestToUpdateMetricsStorageRetentionPayload(t *testing.T) { } } +func TestToUpdateAlertConfigPayload(t *testing.T) { + tests := []struct { + description string + input alertConfigModel + expected *argus.UpdateAlertConfigsPayload + isValid bool + }{ + { + description: "base", + input: alertConfigModel{ + Receivers: types.ListValueMust(types.ObjectType{AttrTypes: receiversTypes}, []attr.Value{ + fixtureReceiverModel( + fixtureEmailConfigsModel(), + fixtureOpsGenieConfigsModel(), + fixtureWebHooksConfigsModel(), + ), + }), + Route: fixtureRouteModel(), + GlobalConfiguration: fixtureGlobalConfigModel(), + }, + expected: &argus.UpdateAlertConfigsPayload{ + Receivers: &[]argus.UpdateAlertConfigsPayloadReceiversInner{ + fixtureReceiverPayload( + &[]argus.CreateAlertConfigReceiverPayloadEmailConfigsInner{fixtureEmailConfigsPayload()}, + &[]argus.CreateAlertConfigReceiverPayloadOpsgenieConfigsInner{fixtureOpsGenieConfigsPayload()}, + &[]argus.CreateAlertConfigReceiverPayloadWebHookConfigsInner{fixtureWebHooksConfigsPayload()}, + ), + }, + Route: fixtureRoutePayload(), + Global: fixtureGlobalConfigPayload(), + }, + isValid: true, + }, + { + description: "receivers only emailconfigs", + input: alertConfigModel{ + Receivers: types.ListValueMust(types.ObjectType{AttrTypes: receiversTypes}, []attr.Value{ + fixtureReceiverModel( + fixtureEmailConfigsModel(), + types.ListNull(types.ObjectType{AttrTypes: opsgenieConfigsTypes}), + types.ListNull(types.ObjectType{AttrTypes: webHooksConfigsTypes}), + ), + }), + Route: fixtureRouteModel(), + }, + expected: &argus.UpdateAlertConfigsPayload{ + Receivers: &[]argus.UpdateAlertConfigsPayloadReceiversInner{ + fixtureReceiverPayload( + &[]argus.CreateAlertConfigReceiverPayloadEmailConfigsInner{fixtureEmailConfigsPayload()}, + nil, + nil, + ), + }, + Route: fixtureRoutePayload(), + }, + isValid: true, + }, + { + description: "receivers only opsgenieconfigs", + input: alertConfigModel{ + Receivers: types.ListValueMust(types.ObjectType{AttrTypes: receiversTypes}, []attr.Value{ + fixtureReceiverModel( + types.ListNull(types.ObjectType{AttrTypes: emailConfigsTypes}), + fixtureOpsGenieConfigsModel(), + types.ListNull(types.ObjectType{AttrTypes: webHooksConfigsTypes}), + ), + }), + Route: fixtureRouteModel(), + }, + expected: &argus.UpdateAlertConfigsPayload{ + Receivers: &[]argus.UpdateAlertConfigsPayloadReceiversInner{ + fixtureReceiverPayload( + nil, + &[]argus.CreateAlertConfigReceiverPayloadOpsgenieConfigsInner{fixtureOpsGenieConfigsPayload()}, + nil, + ), + }, + Route: fixtureRoutePayload(), + }, + isValid: true, + }, + { + description: "multiple receivers", + input: alertConfigModel{ + Receivers: types.ListValueMust(types.ObjectType{AttrTypes: receiversTypes}, []attr.Value{ + fixtureReceiverModel( + fixtureEmailConfigsModel(), + fixtureOpsGenieConfigsModel(), + fixtureWebHooksConfigsModel(), + ), + fixtureReceiverModel( + fixtureEmailConfigsModel(), + fixtureOpsGenieConfigsModel(), + fixtureWebHooksConfigsModel(), + ), + }), + Route: fixtureRouteModel(), + }, + expected: &argus.UpdateAlertConfigsPayload{ + Receivers: &[]argus.UpdateAlertConfigsPayloadReceiversInner{ + fixtureReceiverPayload( + &[]argus.CreateAlertConfigReceiverPayloadEmailConfigsInner{fixtureEmailConfigsPayload()}, + &[]argus.CreateAlertConfigReceiverPayloadOpsgenieConfigsInner{fixtureOpsGenieConfigsPayload()}, + &[]argus.CreateAlertConfigReceiverPayloadWebHookConfigsInner{fixtureWebHooksConfigsPayload()}, + ), + fixtureReceiverPayload( + &[]argus.CreateAlertConfigReceiverPayloadEmailConfigsInner{fixtureEmailConfigsPayload()}, + &[]argus.CreateAlertConfigReceiverPayloadOpsgenieConfigsInner{fixtureOpsGenieConfigsPayload()}, + &[]argus.CreateAlertConfigReceiverPayloadWebHookConfigsInner{fixtureWebHooksConfigsPayload()}, + ), + }, + Route: fixtureRoutePayload(), + }, + isValid: true, + }, + { + description: "empty global options", + input: alertConfigModel{ + Receivers: types.ListValueMust(types.ObjectType{AttrTypes: receiversTypes}, []attr.Value{ + fixtureReceiverModel( + fixtureEmailConfigsModel(), + fixtureOpsGenieConfigsModel(), + fixtureWebHooksConfigsModel(), + ), + }), + Route: fixtureRouteModel(), + GlobalConfiguration: fixtureNullGlobalConfigModel(), + }, + expected: &argus.UpdateAlertConfigsPayload{ + Receivers: &[]argus.UpdateAlertConfigsPayloadReceiversInner{ + fixtureReceiverPayload( + &[]argus.CreateAlertConfigReceiverPayloadEmailConfigsInner{fixtureEmailConfigsPayload()}, + &[]argus.CreateAlertConfigReceiverPayloadOpsgenieConfigsInner{fixtureOpsGenieConfigsPayload()}, + &[]argus.CreateAlertConfigReceiverPayloadWebHookConfigsInner{fixtureWebHooksConfigsPayload()}, + ), + }, + Route: fixtureRoutePayload(), + Global: &argus.UpdateAlertConfigsPayloadGlobal{}, + }, + isValid: true, + }, + { + description: "empty alert config", + input: alertConfigModel{}, + isValid: false, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + output, err := toUpdateAlertConfigPayload(context.Background(), &tt.input) + if !tt.isValid && err == nil { + t.Fatalf("Should have failed") + } + if tt.isValid && err != nil { + t.Fatalf("Should not have failed: %v", err) + } + if tt.isValid { + diff := cmp.Diff(output, tt.expected) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + } + }) + } +} + +func TestGetRouteNestedObjectAux(t *testing.T) { + tests := []struct { + description string + startingLevel int + recursionLimit int + isDatasource bool + expected schema.ListNestedAttribute + }{ + { + "no recursion, resource", + 1, + 1, + false, + schema.ListNestedAttribute{ + Description: routeDescriptions["routes"], + Optional: true, + Validators: []validator.List{ + listvalidator.SizeAtLeast(1), + }, + NestedObject: schema.NestedAttributeObject{ + Attributes: fixtureRouteAttributeSchema(nil, false), + }, + }, + }, + { + "recursion 1, resource", + 1, + 2, + false, + schema.ListNestedAttribute{ + Description: routeDescriptions["routes"], + Optional: true, + Validators: []validator.List{ + listvalidator.SizeAtLeast(1), + }, + NestedObject: schema.NestedAttributeObject{ + Attributes: fixtureRouteAttributeSchema( + &schema.ListNestedAttribute{ + Description: routeDescriptions["routes"], + Optional: true, + Validators: []validator.List{ + listvalidator.SizeAtLeast(1), + }, + NestedObject: schema.NestedAttributeObject{ + Attributes: fixtureRouteAttributeSchema(nil, false), + }, + }, + false, + ), + }, + }, + }, + { + "no recursion,datasource", + 1, + 1, + true, + schema.ListNestedAttribute{ + Description: routeDescriptions["routes"], + Computed: true, + Validators: []validator.List{ + listvalidator.SizeAtLeast(1), + }, + NestedObject: schema.NestedAttributeObject{ + Attributes: fixtureRouteAttributeSchema(nil, true), + }, + }, + }, + { + "recursion 1, datasource", + 1, + 2, + true, + schema.ListNestedAttribute{ + Description: routeDescriptions["routes"], + Computed: true, + Validators: []validator.List{ + listvalidator.SizeAtLeast(1), + }, + NestedObject: schema.NestedAttributeObject{ + Attributes: fixtureRouteAttributeSchema( + &schema.ListNestedAttribute{ + Description: routeDescriptions["routes"], + Computed: true, + Validators: []validator.List{ + listvalidator.SizeAtLeast(1), + }, + NestedObject: schema.NestedAttributeObject{ + Attributes: fixtureRouteAttributeSchema(nil, true), + }, + }, + true, + ), + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + output := getRouteNestedObjectAux(tt.isDatasource, tt.startingLevel, tt.recursionLimit) + diff := cmp.Diff(output, tt.expected) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestGetRouteListTypeAux(t *testing.T) { + tests := []struct { + description string + startingLevel int + recursionLimit int + expected types.ObjectType + }{ + { + "no recursion", + 1, + 1, + types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "group_by": types.ListType{ElemType: types.StringType}, + "group_interval": types.StringType, + "group_wait": types.StringType, + "match": types.MapType{ElemType: types.StringType}, + "match_regex": types.MapType{ElemType: types.StringType}, + "receiver": types.StringType, + "repeat_interval": types.StringType, + }, + }, + }, + { + "recursion 1", + 1, + 2, + types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "group_by": types.ListType{ElemType: types.StringType}, + "group_interval": types.StringType, + "group_wait": types.StringType, + "match": types.MapType{ElemType: types.StringType}, + "match_regex": types.MapType{ElemType: types.StringType}, + "receiver": types.StringType, + "repeat_interval": types.StringType, + "routes": types.ListType{ElemType: types.ObjectType{AttrTypes: map[string]attr.Type{ + "group_by": types.ListType{ElemType: types.StringType}, + "group_interval": types.StringType, + "group_wait": types.StringType, + "match": types.MapType{ElemType: types.StringType}, + "match_regex": types.MapType{ElemType: types.StringType}, + "receiver": types.StringType, + "repeat_interval": types.StringType, + }}}, + }, + }, + }, + { + "recursion 2", + 2, + 2, + types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "group_by": types.ListType{ElemType: types.StringType}, + "group_interval": types.StringType, + "group_wait": types.StringType, + "match": types.MapType{ElemType: types.StringType}, + "match_regex": types.MapType{ElemType: types.StringType}, + "receiver": types.StringType, + "repeat_interval": types.StringType, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + output := getRouteListTypeAux(tt.startingLevel, tt.recursionLimit) + diff := cmp.Diff(output, tt.expected) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + func makeTestMap(t *testing.T) basetypes.MapValue { p := make(map[string]attr.Value, 1) p["key"] = types.StringValue("value")