Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .changelog/3882.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
```release-note:bug
resource/mongodbatlas_alert_configuration: Fixes issue preventing updates from notification types supporting `interval_min` (e.g., MICROSOFT_TEAMS) to types that don't support it (PAGER_DUTY, OPS_GENIE, VICTOR_OPS)
```
10 changes: 0 additions & 10 deletions internal/service/alertconfiguration/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package alertconfiguration

import (
"fmt"
"strings"

"go.mongodb.org/atlas-sdk/v20250312009/admin"

Expand All @@ -14,15 +13,6 @@ import (
func NewNotificationList(list []TfNotificationModel) (*[]admin.AlertsNotificationRootForGroup, error) {
notifications := make([]admin.AlertsNotificationRootForGroup, len(list))

for i := range list {
if !list[i].IntervalMin.IsNull() && list[i].IntervalMin.ValueInt64() > 0 {
typeName := list[i].TypeName.ValueString()
if strings.EqualFold(typeName, pagerDuty) || strings.EqualFold(typeName, opsGenie) || strings.EqualFold(typeName, victorOps) {
return nil, fmt.Errorf(`'interval_min' must not be set if type_name is 'PAGER_DUTY', 'OPS_GENIE' or 'VICTOR_OPS'`)
}
}
}
Comment on lines 14 to -24
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

q: When value is unset in the config, doesnt the IntervalMin value appear as unknown in the plan? Would expect validation is not ecountered, probably missing something.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am thinking the same, especially since we are not using UseStateForUnknown.
However, no reason to have it here as we are doing the validation in the schema.


for i := range list {
n := &list[i]
notifications[i] = admin.AlertsNotificationRootForGroup{
Expand Down
3 changes: 3 additions & 0 deletions internal/service/alertconfiguration/resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,9 @@ func (r *alertConfigurationRS) Schema(ctx context.Context, req resource.SchemaRe
"interval_min": schema.Int64Attribute{
Optional: true,
Computed: true,
Validators: []validator.Int64{
ValidIntervalMin(),
},
Comment on lines +293 to +295
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

q: If intervalMin is send in a request using incorrect type does the API return any error or simply ignores? Just thinking if we can avoid this validation from our side.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question! According to the validator's documentation comment (lines 17-20 in validator_interval_min.go), the API doesn't actually receive interval_min when the notification type doesn't support it - the nested_type in the API payload omits the field entirely, so it's never sent to the API. This means API-level validation cannot catch this error.

I think it would be misleading if we don't raise an error if the user sets it for a type that doesn't support it

},
"mobile_number": schema.StringAttribute{
Optional: true,
Expand Down
94 changes: 94 additions & 0 deletions internal/service/alertconfiguration/resource_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -570,6 +570,50 @@ func TestAccConfigRSAlertConfiguration_withVictorOps(t *testing.T) {
})
}

func TestAccConfigRSAlertConfiguration_updateNotificationTypeFromTeamsToPagerDuty(t *testing.T) {
// This test reproduces issue #3869: updating from MICROSOFT_TEAMS with interval_min
// to PAGER_DUTY should work without requiring deletion and recreation
var (
projectID = acc.ProjectIDExecution(t)
teamsWebhookURL = "https://outlook.office.com/webhook/11111111-1111-1111-1111-111111111111@22222222-2222-2222-2222-222222222222/IncomingWebhook/33333333333333333333333333333333/44444444-4444-4444-4444-444444444444"
pagerDutyKey = dummy32CharKey
)

resource.ParallelTest(t, resource.TestCase{
PreCheck: func() { acc.PreCheckBasic(t) },
ProtoV6ProviderFactories: acc.TestAccProviderV6Factories,
CheckDestroy: checkDestroy(),
Steps: []resource.TestStep{
{
// Verify that explicitly setting interval_min with PAGER_DUTY fails at schema validation level
Config: configWithPagerDutyAndIntervalMin(projectID, pagerDutyKey, true),
ExpectError: regexp.MustCompile(`(?s).*'interval_min'.*must not be set.*PAGER_DUTY`),
},
{
Config: configWithTeamsNotificationAndIntervalMin(projectID, teamsWebhookURL, true),
Check: resource.ComposeAggregateTestCheckFunc(
checkExists(resourceName),
resource.TestCheckResourceAttr(resourceName, "project_id", projectID),
resource.TestCheckResourceAttr(resourceName, "notification.#", "1"),
resource.TestCheckResourceAttr(resourceName, "notification.0.type_name", "MICROSOFT_TEAMS"),
resource.TestCheckResourceAttr(resourceName, "notification.0.interval_min", "30"),
),
},
{
Config: configWithPagerDutyNotification(projectID, pagerDutyKey, true),
Check: resource.ComposeAggregateTestCheckFunc(
checkExists(resourceName),
resource.TestCheckResourceAttr(resourceName, "project_id", projectID),
resource.TestCheckResourceAttr(resourceName, "notification.#", "1"),
resource.TestCheckResourceAttr(resourceName, "notification.0.type_name", "PAGER_DUTY"),
// interval_min should not be set for PAGER_DUTY
resource.TestCheckNoResourceAttr(resourceName, "notification.0.interval_min"),
),
},
},
})
}

func TestAccConfigRSAlertConfiguration_withSeverityOverride(t *testing.T) {
var (
projectID = acc.ProjectIDExecution(t)
Expand Down Expand Up @@ -992,6 +1036,56 @@ func configWithVictorOps(projectID, apiKey string, enabled bool) string {
`, projectID, apiKey, enabled)
}

func configWithTeamsNotificationAndIntervalMin(projectID, webhookURL string, enabled bool) string {
return fmt.Sprintf(`
resource "mongodbatlas_alert_configuration" "test" {
project_id = %[1]q
enabled = %[3]t
event_type = "NO_PRIMARY"

notification {
type_name = "MICROSOFT_TEAMS"
microsoft_teams_webhook_url = %[2]q
interval_min = 30
delay_min = 0
}
}
`, projectID, webhookURL, enabled)
}

func configWithPagerDutyNotification(projectID, serviceKey string, enabled bool) string {
return fmt.Sprintf(`
resource "mongodbatlas_alert_configuration" "test" {
project_id = %[1]q
enabled = %[3]t
event_type = "NO_PRIMARY"

notification {
type_name = "PAGER_DUTY"
service_key = %[2]q
delay_min = 0
}
}
`, projectID, serviceKey, enabled)
}

func configWithPagerDutyAndIntervalMin(projectID, serviceKey string, enabled bool) string {
return fmt.Sprintf(`
resource "mongodbatlas_alert_configuration" "test" {
project_id = %[1]q
enabled = %[3]t
event_type = "NO_PRIMARY"

notification {
type_name = "PAGER_DUTY"
service_key = %[2]q
interval_min = 30
delay_min = 0
}
}
`, projectID, serviceKey, enabled)
}

func configWithEmptyMetricThresholdConfig(projectID string, enabled bool) string {
return fmt.Sprintf(`
resource "mongodbatlas_alert_configuration" "test" {
Expand Down
86 changes: 86 additions & 0 deletions internal/service/alertconfiguration/validator_interval_min.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package alertconfiguration

import (
"context"
"fmt"
"strconv"
"strings"

"github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag"
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
)

// IntervalMinValidator validates that interval_min is not set for notification types
// that don't support it (PAGER_DUTY, OPS_GENIE, VICTOR_OPS).
//
// API-level validation cannot catch this error because when the notification type
// doesn't support interval_min, the nested_type in the API payload omits the field,
// so it's never sent to the API. Client-side validation ensures users get immediate
// feedback during Terraform's plan phase.
type IntervalMinValidator struct{}

func (v IntervalMinValidator) Description(_ context.Context) string {
return "'interval_min' must not be set if type_name is 'PAGER_DUTY', 'OPS_GENIE' or 'VICTOR_OPS'"
}

func (v IntervalMinValidator) MarkdownDescription(ctx context.Context) string {
return v.Description(ctx)
}

func (v IntervalMinValidator) ValidateInt64(ctx context.Context, req validator.Int64Request, resp *validator.Int64Response) {
// If the value is unknown/null/0, there is nothing to validate.
if req.ConfigValue.ValueInt64() <= 0 {
return
}

// Parse the path to find which notification index we're validating
// Path format: notification[0].interval_min
pathStr := req.Path.String()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

relying on path string format looks a little risky but I guess we would catch this quickly if it changes in a future version

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed - parsing the path string format (e.g., notification[0].interval_min) is a bit fragile, but it's a necessary workaround given the validator's constraints. The validator only receives the attribute value and path, not the full notification context, so we need to extract the notification index from the path to check the corresponding type_name.

If the path format changes in a future Terraform version, the validator will gracefully skip validation (lines 52-55) when it can't parse the index, so it won't break functionality - it just won't validate in that case. We'd likely catch this during testing since the validator is covered by tests.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be able to simplify the parsing and de-risk with:

	notificationPath := req.Path.ParentPath() // <- This would give notification[0]

	var notification TfNotificationModel
	diags := req.Config.GetAttribute(ctx, notificationPath, &notification) // <- This should give you the notification at index 0 directly, removing the need to parse the index.
	if diags.HasError() {
		// If we can't read the notification, skip validation (might be unknown during plan)
		return
	}

Haven't tested it so may be missing something.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great suggestion! 9bbec77

notificationIndex := -1

// Extract the index from the path (e.g., "notification[0]" -> 0)
if idxStart := strings.Index(pathStr, "["); idxStart != -1 {
if idxEnd := strings.Index(pathStr[idxStart:], "]"); idxEnd != -1 {
idxStr := pathStr[idxStart+1 : idxStart+idxEnd]
if idx, err := strconv.Atoi(idxStr); err == nil {
notificationIndex = idx
}
}
}

// If we couldn't parse the index, skip validation
if notificationIndex < 0 {
return
}

// Get the entire notification list from config
var notifications []TfNotificationModel
diags := req.Config.GetAttribute(ctx, path.Root("notification"), &notifications)
if diags.HasError() {
// If we can't read notifications, skip validation (might be unknown during plan)
return
}

// Check if we have the notification at the parsed index
if notificationIndex >= len(notifications) {
return
}

notification := notifications[notificationIndex]
typeNameValue := notification.TypeName.ValueString()
// Check if the type_name is one of the unsupported types
if strings.EqualFold(typeNameValue, pagerDuty) ||
strings.EqualFold(typeNameValue, opsGenie) ||
strings.EqualFold(typeNameValue, victorOps) {
resp.Diagnostics.Append(validatordiag.InvalidAttributeValueDiagnostic(
req.Path,
v.Description(ctx),
fmt.Sprintf("%d", req.ConfigValue.ValueInt64()),
))
}
}

func ValidIntervalMin() validator.Int64 {
return IntervalMinValidator{}
}
Loading