Repository: incident-io/terraform-provider-incident
Pull request #210: Use `Set` to model schedule rotations
Author: login: edds
Created: 2025-05-27T16:18:15Z | Merged: 2025-05-29T16:06:14Z

The order isn't important, using a set will allow us to return them in a different order as they are defined without causing a large plan diff.

Fixes #202

Linked issue #202: Use a set for schedule rotations in terraform, not a list
When exporting a complex schedule, the UI exports rotations in the schedule in a different order to how your DB stores them. This leads to very large terraform plan diffs when looking at larger schedules.

I also noted that sometimes the export functionality would output the days of the week in a different order to what is stored in the DB leading to diffs here too.

I am unsure if this is a bug in the provider itself or an issue with the export feature in the UI.

Steps to reproduce:

1. Take a large complex schedule
2. Use the "Copy this schedule to Terraform" feature in incident
3. Import the resource to TF state
4. Run `terraform plan`
5. The `rotations` array will likely be out of order showing quite an alarming diff
Linked issue comments: user: login: leonore
created_at: 2025-05-20T09:30:59Z
body: Hey @therealdwright 👋 The order of the rotations should not matter. Our terraform model is using a list for rotations here, which is why it's screaming out a diff - we'll be making a change to our provider to use a set. Thanks for the report!
author_association: CONTRIBUTOR

Diff:
diff --git a/docs/resources/schedule.md b/docs/resources/schedule.md
index 4e0d2cdd..14167de3 100644
--- a/docs/resources/schedule.md
+++ b/docs/resources/schedule.md
@@ -120,7 +120,7 @@ resource "incident_schedule" "primary_on_call" {
 ### Required
 
 - `name` (String) Human readable name synced from external provider
-- `rotations` (Attributes List) (see [below for nested schema](#nestedatt--rotations))
+- `rotations` (Attributes Set) (see [below for nested schema](#nestedatt--rotations))
 - `timezone` (String)
 
 ### Optional
@@ -139,7 +139,7 @@ Required:
 
 - `id` (String) Unique internal ID of the rotation
 - `name` (String) Human readable name synced from external provider
-- `versions` (Attributes List) (see [below for nested schema](#nestedatt--rotations--versions))
+- `versions` (Attributes Set) (see [below for nested schema](#nestedatt--rotations--versions))
 
 <a id="nestedatt--rotations--versions"></a>
 ### Nested Schema for `rotations.versions`
diff --git a/internal/provider/incident_schedule_resource.go b/internal/provider/incident_schedule_resource.go
index 5774864e..0d1766a5 100644
--- a/internal/provider/incident_schedule_resource.go
+++ b/internal/provider/incident_schedule_resource.go
@@ -76,7 +76,7 @@ func (r *IncidentScheduleResource) Schema(ctx context.Context, req resource.Sche
 				},
 				Optional: true,
 			},
-			"rotations": schema.ListNestedAttribute{
+			"rotations": schema.SetNestedAttribute{
 				NestedObject: schema.NestedAttributeObject{
 					Attributes: map[string]schema.Attribute{
 						"id": schema.StringAttribute{
@@ -87,7 +87,7 @@ func (r *IncidentScheduleResource) Schema(ctx context.Context, req resource.Sche
 							Required:            true,
 							MarkdownDescription: apischema.Docstring("ScheduleRotationV2", "name"),
 						},
-						"versions": schema.ListNestedAttribute{
+						"versions": schema.SetNestedAttribute{
 							Required: true,
 							NestedObject: schema.NestedAttributeObject{
 								Attributes: map[string]schema.Attribute{
diff --git a/internal/provider/incident_schedule_resource_test.go b/internal/provider/incident_schedule_resource_test.go
index 70a654bc..f2b41122 100644
--- a/internal/provider/incident_schedule_resource_test.go
+++ b/internal/provider/incident_schedule_resource_test.go
@@ -5,10 +5,12 @@ import (
 	"reflect"
 	"regexp"
 	"strconv"
+	"strings"
 	"testing"
 	"time"
 
 	"github.com/hashicorp/terraform-plugin-testing/helper/resource"
+	"github.com/hashicorp/terraform-plugin-testing/terraform"
 	"github.com/samber/lo"
 
 	"github.com/incident-io/terraform-provider-incident/internal/client"
@@ -244,11 +246,10 @@ func TestAccIncidentScheduleResourceRotationUpdates(t *testing.T) {
 					resource.TestCheckResourceAttr(
 						"incident_schedule.example", "rotations.0.versions.#", "2",
 					),
-					resource.TestCheckResourceAttr(
-						"incident_schedule.example", "rotations.0.versions.1.layers.0.name", "Layer Two",
-					),
-					resource.TestCheckResourceAttr(
-						"incident_schedule.example", "rotations.0.versions.1.working_intervals.0.weekday", "monday",
+					testValueExistsInSet("incident_schedule.example", "rotations.0.versions.*.layers.0.name", "Layer One"),
+					testValueExistsInSet("incident_schedule.example", "rotations.0.versions.*.layers.0.name", "Layer Two"),
+					testValueExistsInSet(
+						"incident_schedule.example", "rotations.0.versions.*.working_intervals.0.weekday", "monday",
 					),
 				),
 			},
@@ -256,6 +257,42 @@ func TestAccIncidentScheduleResourceRotationUpdates(t *testing.T) {
 	})
 }
 
+// Test that a value exists in a set, use '*' to replace the element indexing,
+// this is different from the provided helpers as the set can be anywhere in
+// the attribute path, rather than needing to be the last element.
+func testValueExistsInSet(resourceName string, attr string, value string) resource.TestCheckFunc {
+	return func(s *terraform.State) error {
+		rs, ok := s.RootModule().Resources[resourceName]
+		if !ok {
+			return fmt.Errorf("Not found: %s", resourceName)
+		}
+		is := rs.Primary
+		if is == nil {
+			return fmt.Errorf("No primary instance: %s in %s", resourceName, s.RootModule().Path)
+		}
+
+		attrParts := strings.Split(attr, ".")
+
+		for stateKey, stateValue := range is.Attributes {
+			stateKeyParts := strings.Split(stateKey, ".")
+
+			if len(stateKeyParts) != len(attrParts) {
+				continue
+			}
+
+			for i := range attrParts {
+				if attrParts[i] != stateKeyParts[i] && attrParts[i] != "*" {
+					break
+				}
+				if i == len(attrParts)-1 && stateValue == value {
+					return nil
+				}
+			}
+		}
+		return fmt.Errorf("%s not found in %s", value, attr)
+	}
+}
+
 func TestAccIncidentScheduleResourceHolidayConfig(t *testing.T) {
 	resource.Test(t, resource.TestCase{
 		PreCheck:                 func() { testAccPreCheck(t) },


Reviews:
user: login: leonore
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-27T16:22:53Z
user: login: edds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-28T08:06:52Z
user: login: leonore
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-28T12:04:57Z

Review comments:
diff_hunk: @@ -76,7 +76,7 @@ func (r *IncidentScheduleResource) Schema(ctx context.Context, req resource.Sche
 				},
 				Optional: true,
 			},
-			"rotations": schema.ListNestedAttribute{
+			"rotations": schema.SetNestedAttribute{
 				NestedObject: schema.NestedAttributeObject{
 					Attributes: map[string]schema.Attribute{
 						"id": schema.StringAttribute{
path: internal/provider/incident_schedule_resource.go
user: login: leonore
body: I think you'll need the `versions` below to be a set as well!
created_at: 2025-05-27T16:22:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -76,7 +76,7 @@ func (r *IncidentScheduleResource) Schema(ctx context.Context, req resource.Sche
 				},
 				Optional: true,
 			},
-			"rotations": schema.ListNestedAttribute{
+			"rotations": schema.SetNestedAttribute{
 				NestedObject: schema.NestedAttributeObject{
 					Attributes: map[string]schema.Attribute{
 						"id": schema.StringAttribute{
path: internal/provider/incident_schedule_resource.go
user: login: edds
body: Good shout, I've made that change also :+1: 
created_at: 2025-05-28T08:06:52Z
author_association: CONTRIBUTOR

PR comments:
user: login: rorymalcolm
created_at: 2025-05-27T16:25:26Z
body: qq: Will we need a plan modifier here if we're changing the underlying type? I'm unsure
author_association: CONTRIBUTOR
user: login: edds
created_at: 2025-05-28T08:11:10Z
body: @rorymalcolm my understanding (and from testing locally) is no. In the terraform state lists and sets are stored as the same data structure, so it should cleanly map over for any consumers. 
author_association: CONTRIBUTOR
user: login: rorymalcolm
created_at: 2025-05-28T08:12:01Z
body: > @rorymalcolm my understanding (and from testing locally) is no. In the terraform state lists and sets are stored as the same data structure, so it should cleanly map over for any consumers.

Ah nice one, thanks for checking!
author_association: CONTRIBUTOR