Repository: grafana/terraform-provider-grafana

Issue:
parent_folder_uid isn't work as expected. grafana_folder resource
### Terraform Version
Terraform v1.3.10
on darwin_arm64
+ provider registry.terraform.io/grafana/grafana v2.6.1

### Affected Resource(s)
* grafana_folder

### Terraform Configuration Files
```hcl
resource "grafana_folder" "fld1" {
  title = "my-test-fld1"
}

resource "grafana_folder" "fld2" {
  title             = "my-test-fld2"
  parent_folder_uid = grafana_folder.fld1.uid
}

```

### Debug Output


### Expected Behavior
It should be possible to create subfolders in the Grafana Cloud instance
### Actual Behavior
A subfolder is created in the root, and during the next apply, it tries to reconfigure it each time tf apply is run

```hcl
  # grafana_folder.fld2 must be replaced
-/+ resource "grafana_folder" "fld2" {
      ~ id                           = "0:100" -> (known after apply)
      - org_id                       = "0" -> null
      + parent_folder_uid            = "dabaa340-ba9a-453c-a634-XXXXXXXXXXXXX" # forces replacement
      ~ uid                          = "f99ff261-6077-4847-8eac-YYYYYYYYYYYY" -> (known after apply)
      ~ url                          = "https://AAAAAAAAA.grafana.net/dashboards/f/f99ff261-6077-4847-8eac-ZZZZZZZZZZZZZZZ/my-test-fld2" -> (known after apply)
        # (2 unchanged attributes hidden)
    }
```

### Steps to Reproduce
Please list the steps required to reproduce the issue, for example:
1. `terraform apply`


### References
Are there any other GitHub issues (open or closed) or Pull Requests that should be linked here? For example:
- https://github.com/grafana/terraform-provider-grafana/pull/1129


Discussion:
@julienduchesne: It’s probably not enabled on your Grafana instance@m0ps: Thanks for the clarification. THe feature flag was enabled through a support request, and now it works as expected.@nmeser: Subfolder/Nested Folder working with provided configuration.
Grafana 11+ , Terraform Grafana Provider 4+

resource "grafana_folder" "fld1" {
  title = "my-test-fld1"
}

resource "grafana_folder" "fld2" {
  title             = "my-test-fld2"
  parent_folder_uid = grafana_folder.fld1.uid
}

Solution patch:
diff --git a/docs/resources/folder.md b/docs/resources/folder.md
index cec486c6..11c51bbf 100644
--- a/docs/resources/folder.md
+++ b/docs/resources/folder.md
@@ -44,7 +44,7 @@ resource "grafana_folder" "test_folder_with_uid" {
 ### Optional
 
 - `org_id` (String) The Organization ID. If not set, the Org ID defined in the provider block will be used.
-- `parent_folder_uid` (String) The uid of the parent folder. If set, the folder will be nested. If not set, the folder will be created in the root folder.
+- `parent_folder_uid` (String) The uid of the parent folder. If set, the folder will be nested. If not set, the folder will be created in the root folder. Note: This requires the nestedFolders feature flag to be enabled on your Grafana instance.
 - `prevent_destroy_if_not_empty` (Boolean) Prevent deletion of the folder if it is not empty (contains dashboards or alert rules). Defaults to `false`.
 - `uid` (String) Unique identifier.
 
diff --git a/docs/resources/notification_policy.md b/docs/resources/notification_policy.md
index e0a8be40..79281c07 100644
--- a/docs/resources/notification_policy.md
+++ b/docs/resources/notification_policy.md
@@ -55,6 +55,16 @@ resource "grafana_notification_policy" "my_notification_policy" {
       match = "="
       value = "myvalue"
     }
+    matcher {
+      label = "alertname"
+      match = "="
+      value = "CPU Usage"
+    }
+    matcher {
+      label = "Name"
+      match = "=~"
+      value = "host.*|host-b.*"
+    }
     contact_point = grafana_contact_point.a_contact_point.name
     continue      = true
     mute_timings  = [grafana_mute_timing.a_mute_timing.name]
@@ -118,7 +128,7 @@ Optional:
 - `group_by` (List of String) A list of alert labels to group alerts into notifications by. Use the special label `...` to group alerts by all labels, effectively disabling grouping. Required for root policy only. If empty, the parent grouping is used.
 - `group_interval` (String) Minimum time interval between two notifications for the same group. Default is 5 minutes.
 - `group_wait` (String) Time to wait to buffer alerts of the same group before sending a notification. Default is 30 seconds.
-- `matcher` (Block List) Describes which labels this rule should match. When multiple matchers are supplied, an alert must match ALL matchers to be accepted by this policy. When no matchers are supplied, the rule will match all alert instances. (see [below for nested schema](#nestedblock--policy--matcher))
+- `matcher` (Block Set) Describes which labels this rule should match. When multiple matchers are supplied, an alert must match ALL matchers to be accepted by this policy. When no matchers are supplied, the rule will match all alert instances. (see [below for nested schema](#nestedblock--policy--matcher))
 - `mute_timings` (List of String) A list of mute timing names to apply to alerts that match this policy.
 - `policy` (Block List) Routing rules for specific label sets. (see [below for nested schema](#nestedblock--policy--policy))
 - `repeat_interval` (String) Minimum time interval for re-sending a notification if an alert is still firing. Default is 4 hours.
@@ -146,7 +156,7 @@ Optional:
 - `group_by` (List of String) A list of alert labels to group alerts into notifications by. Use the special label `...` to group alerts by all labels, effectively disabling grouping. Required for root policy only. If empty, the parent grouping is used.
 - `group_interval` (String) Minimum time interval between two notifications for the same group. Default is 5 minutes.
 - `group_wait` (String) Time to wait to buffer alerts of the same group before sending a notification. Default is 30 seconds.
-- `matcher` (Block List) Describes which labels this rule should match. When multiple matchers are supplied, an alert must match ALL matchers to be accepted by this policy. When no matchers are supplied, the rule will match all alert instances. (see [below for nested schema](#nestedblock--policy--policy--matcher))
+- `matcher` (Block Set) Describes which labels this rule should match. When multiple matchers are supplied, an alert must match ALL matchers to be accepted by this policy. When no matchers are supplied, the rule will match all alert instances. (see [below for nested schema](#nestedblock--policy--policy--matcher))
 - `mute_timings` (List of String) A list of mute timing names to apply to alerts that match this policy.
 - `policy` (Block List) Routing rules for specific label sets. (see [below for nested schema](#nestedblock--policy--policy--policy))
 - `repeat_interval` (String) Minimum time interval for re-sending a notification if an alert is still firing. Default is 4 hours.
@@ -174,7 +184,7 @@ Optional:
 - `group_by` (List of String) A list of alert labels to group alerts into notifications by. Use the special label `...` to group alerts by all labels, effectively disabling grouping. Required for root policy only. If empty, the parent grouping is used.
 - `group_interval` (String) Minimum time interval between two notifications for the same group. Default is 5 minutes.
 - `group_wait` (String) Time to wait to buffer alerts of the same group before sending a notification. Default is 30 seconds.
-- `matcher` (Block List) Describes which labels this rule should match. When multiple matchers are supplied, an alert must match ALL matchers to be accepted by this policy. When no matchers are supplied, the rule will match all alert instances. (see [below for nested schema](#nestedblock--policy--policy--policy--matcher))
+- `matcher` (Block Set) Describes which labels this rule should match. When multiple matchers are supplied, an alert must match ALL matchers to be accepted by this policy. When no matchers are supplied, the rule will match all alert instances. (see [below for nested schema](#nestedblock--policy--policy--policy--matcher))
 - `mute_timings` (List of String) A list of mute timing names to apply to alerts that match this policy.
 - `policy` (Block List) Routing rules for specific label sets. (see [below for nested schema](#nestedblock--policy--policy--policy--policy))
 - `repeat_interval` (String) Minimum time interval for re-sending a notification if an alert is still firing. Default is 4 hours.
@@ -202,7 +212,7 @@ Optional:
 - `continue` (Boolean) Whether to continue matching subsequent rules if an alert matches the current rule. Otherwise, the rule will be 'consumed' by the first policy to match it.
 - `group_interval` (String) Minimum time interval between two notifications for the same group. Default is 5 minutes.
 - `group_wait` (String) Time to wait to buffer alerts of the same group before sending a notification. Default is 30 seconds.
-- `matcher` (Block List) Describes which labels this rule should match. When multiple matchers are supplied, an alert must match ALL matchers to be accepted by this policy. When no matchers are supplied, the rule will match all alert instances. (see [below for nested schema](#nestedblock--policy--policy--policy--policy--matcher))
+- `matcher` (Block Set) Describes which labels this rule should match. When multiple matchers are supplied, an alert must match ALL matchers to be accepted by this policy. When no matchers are supplied, the rule will match all alert instances. (see [below for nested schema](#nestedblock--policy--policy--policy--policy--matcher))
 - `mute_timings` (List of String) A list of mute timing names to apply to alerts that match this policy.
 - `repeat_interval` (String) Minimum time interval for re-sending a notification if an alert is still firing. Default is 4 hours.
 
diff --git a/examples/resources/grafana_notification_policy/resource.tf b/examples/resources/grafana_notification_policy/resource.tf
index b7a60062..adb5cfa2 100644
--- a/examples/resources/grafana_notification_policy/resource.tf
+++ b/examples/resources/grafana_notification_policy/resource.tf
@@ -30,6 +30,16 @@ resource "grafana_notification_policy" "my_notification_policy" {
       match = "="
       value = "myvalue"
     }
+    matcher {
+      label = "alertname"
+      match = "="
+      value = "CPU Usage"
+    }
+    matcher {
+      label = "Name"
+      match = "=~"
+      value = "host.*|host-b.*"
+    }
     contact_point = grafana_contact_point.a_contact_point.name
     continue      = true
     mute_timings  = [grafana_mute_timing.a_mute_timing.name]
diff --git a/internal/resources/grafana/resource_alerting_notification_policy.go b/internal/resources/grafana/resource_alerting_notification_policy.go
index b2af99cc..99c52ce4 100644
--- a/internal/resources/grafana/resource_alerting_notification_policy.go
+++ b/internal/resources/grafana/resource_alerting_notification_policy.go
@@ -105,7 +105,7 @@ func policySchema(depth uint) *schema.Resource {
 				},
 			},
 			"matcher": {
-				Type:        schema.TypeList,
+				Type:        schema.TypeSet,
 				Optional:    true,
 				Description: "Describes which labels this rule should match. When multiple matchers are supplied, an alert must match ALL matchers to be accepted by this policy. When no matchers are supplied, the rule will match all alert instances.",
 				Elem: &schema.Resource{
@@ -341,7 +341,7 @@ func unpackSpecificPolicy(p interface{}) (gapi.SpecificPolicy, error) {
 	}
 
 	if v, ok := json["matcher"]; ok && v != nil {
-		ms := v.([]interface{})
+		ms := v.(*schema.Set).List()
 		matchers := make([]gapi.Matcher, 0, len(ms))
 		for _, m := range ms {
 			matcher, err := unpackPolicyMatcher(m)
diff --git a/internal/resources/grafana/resource_folder.go b/internal/resources/grafana/resource_folder.go
index 2f92522b..3ab65c06 100644
--- a/internal/resources/grafana/resource_folder.go
+++ b/internal/resources/grafana/resource_folder.go
@@ -56,10 +56,13 @@ func ResourceFolder() *schema.Resource {
 				Description: "Prevent deletion of the folder if it is not empty (contains dashboards or alert rules).",
 			},
 			"parent_folder_uid": {
-				Type:        schema.TypeString,
-				Optional:    true,
-				ForceNew:    true,
-				Description: "The uid of the parent folder. If set, the folder will be nested. If not set, the folder will be created in the root folder.",
+				Type:     schema.TypeString,
+				Optional: true,
+				ForceNew: true,
+				Description: "The uid of the parent folder. " +
+					"If set, the folder will be nested. " +
+					"If not set, the folder will be created in the root folder. " +
+					"Note: This requires the nestedFolders feature flag to be enabled on your Grafana instance.",
 			},
 		},
 	}



Test patch:
diff --git a/internal/resources/grafana/data_source_dashboards_test.go b/internal/resources/grafana/data_source_dashboards_test.go
index 3d8eae23..64ffcd1f 100644
--- a/internal/resources/grafana/data_source_dashboards_test.go
+++ b/internal/resources/grafana/data_source_dashboards_test.go
@@ -10,7 +10,8 @@ import (
 func TestAccDataSourceDashboardsAllAndByFolderID(t *testing.T) {
 	testutils.CheckOSSTestsEnabled(t)
 
-	resource.ParallelTest(t, resource.TestCase{
+	// Do not use parallel tests here because it tests a listing datasource on the default org
+	resource.Test(t, resource.TestCase{
 		ProviderFactories: testutils.ProviderFactories,
 		Steps: []resource.TestStep{
 			{
diff --git a/internal/resources/grafana/resource_alerting_notification_policy_test.go b/internal/resources/grafana/resource_alerting_notification_policy_test.go
index 13e5b402..f0521faa 100644
--- a/internal/resources/grafana/resource_alerting_notification_policy_test.go
+++ b/internal/resources/grafana/resource_alerting_notification_policy_test.go
@@ -35,9 +35,16 @@ func TestAccNotificationPolicy_basic(t *testing.T) {
 					// nested
 					resource.TestCheckResourceAttr("grafana_notification_policy.my_notification_policy", "policy.#", "2"),
 					resource.TestCheckResourceAttr("grafana_notification_policy.my_notification_policy", "policy.0.contact_point", "A Contact Point"),
-					resource.TestCheckResourceAttr("grafana_notification_policy.my_notification_policy", "policy.0.matcher.0.label", "mylabel"),
-					resource.TestCheckResourceAttr("grafana_notification_policy.my_notification_policy", "policy.0.matcher.0.match", "="),
-					resource.TestCheckResourceAttr("grafana_notification_policy.my_notification_policy", "policy.0.matcher.0.value", "myvalue"),
+					// Matchers are reordered by the API
+					resource.TestCheckResourceAttr("grafana_notification_policy.my_notification_policy", "policy.0.matcher.0.label", "Name"),
+					resource.TestCheckResourceAttr("grafana_notification_policy.my_notification_policy", "policy.0.matcher.0.match", "=~"),
+					resource.TestCheckResourceAttr("grafana_notification_policy.my_notification_policy", "policy.0.matcher.0.value", "host.*|host-b.*"),
+					resource.TestCheckResourceAttr("grafana_notification_policy.my_notification_policy", "policy.0.matcher.1.label", "alertname"),
+					resource.TestCheckResourceAttr("grafana_notification_policy.my_notification_policy", "policy.0.matcher.1.match", "="),
+					resource.TestCheckResourceAttr("grafana_notification_policy.my_notification_policy", "policy.0.matcher.1.value", "CPU Usage"),
+					resource.TestCheckResourceAttr("grafana_notification_policy.my_notification_policy", "policy.0.matcher.2.label", "mylabel"),
+					resource.TestCheckResourceAttr("grafana_notification_policy.my_notification_policy", "policy.0.matcher.2.match", "="),
+					resource.TestCheckResourceAttr("grafana_notification_policy.my_notification_policy", "policy.0.matcher.2.value", "myvalue"),
 					resource.TestCheckNoResourceAttr("grafana_notification_policy.my_notification_policy", "policy.0.group_by"),
 					resource.TestCheckResourceAttr("grafana_notification_policy.my_notification_policy", "policy.0.continue", "true"),
 					resource.TestCheckResourceAttr("grafana_notification_policy.my_notification_policy", "policy.0.mute_timings.0", "Some Mute Timing"),
diff --git a/internal/resources/grafana/resource_role_test.go b/internal/resources/grafana/resource_role_test.go
index 8a81e8c1..eb5f850d 100644
--- a/internal/resources/grafana/resource_role_test.go
+++ b/internal/resources/grafana/resource_role_test.go
@@ -131,13 +131,10 @@ func TestAccRole_inOrg(t *testing.T) {
 
 	resource.ParallelTest(t, resource.TestCase{
 		ProviderFactories: testutils.ProviderFactories,
-		CheckDestroy: resource.ComposeTestCheckFunc(
-			roleCheckExists.destroyed(&role, &org),
-			orgCheckExists.destroyed(&org, nil),
-		),
+		CheckDestroy:      orgCheckExists.destroyed(&org, nil),
 		Steps: []resource.TestStep{
 			{
-				Config: roleInOrg(name, true),
+				Config: roleInOrg(name),
 				Check: resource.ComposeTestCheckFunc(
 					roleCheckExists.exists("grafana_role.test", &role),
 					orgCheckExists.exists("grafana_organization.test", &org),
@@ -153,9 +150,9 @@ func TestAccRole_inOrg(t *testing.T) {
 					resource.TestCheckResourceAttr("grafana_role.test", "hidden", "false"),
 				),
 			},
-			// Test destroying role within org
+			// Test destroying role within org. Org keeps existing but role is gone.
 			{
-				Config: roleInOrg(name, false),
+				Config: testutils.WithoutResource(t, roleInOrg(name), "grafana_role.test"),
 				Check: resource.ComposeTestCheckFunc(
 					roleCheckExists.destroyed(&role, &org),
 					orgCheckExists.exists("grafana_organization.test", &org),
@@ -165,14 +162,12 @@ func TestAccRole_inOrg(t *testing.T) {
 	})
 }
 
-func roleInOrg(name string, roleExists bool) string {
+func roleInOrg(name string) string {
 	def := fmt.Sprintf(`
 resource "grafana_organization" "test" {
 	name = "%s"
-}`, name)
+}
 
-	if roleExists {
-		def += fmt.Sprintf(`
 resource "grafana_role" "test" {
 	org_id = grafana_organization.test.id
 	name  = "%[1]s"
@@ -184,7 +179,6 @@ resource "grafana_role" "test" {
 	hidden = false
 	auto_increment_version = true
 }`, name)
-	}
 
 	return def
 }
diff --git a/internal/resources/grafana/resource_team_test.go b/internal/resources/grafana/resource_team_test.go
index 5d6f9859..bb31a4ab 100644
--- a/internal/resources/grafana/resource_team_test.go
+++ b/internal/resources/grafana/resource_team_test.go
@@ -290,7 +290,7 @@ func TestAccResourceTeam_InOrg(t *testing.T) {
 
 	resource.ParallelTest(t, resource.TestCase{
 		ProviderFactories: testutils.ProviderFactories,
-		CheckDestroy:      teamCheckExists.destroyed(&team, &org),
+		CheckDestroy:      orgCheckExists.destroyed(&org, nil),
 		Steps: []resource.TestStep{
 			{
 				Config: testAccTeamInOrganization(name),
@@ -303,6 +303,14 @@ func TestAccResourceTeam_InOrg(t *testing.T) {
 					checkResourceIsInOrg("grafana_team.test", "grafana_organization.test"),
 				),
 			},
+			// Test destroying team within org. Org keeps existing but team is gone.
+			{
+				Config: testutils.WithoutResource(t, testAccTeamInOrganization(name), "grafana_team.test"),
+				Check: resource.ComposeTestCheckFunc(
+					teamCheckExists.destroyed(&team, &org),
+					orgCheckExists.exists("grafana_organization.test", &org),
+				),
+			},
 		},
 	})
 }
diff --git a/internal/testutils/resources.go b/internal/testutils/resources.go
new file mode 100644
index 00000000..dc22e572
--- /dev/null
+++ b/internal/testutils/resources.go
@@ -0,0 +1,25 @@
+package testutils
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/hashicorp/hcl/v2"
+	"github.com/hashicorp/hcl/v2/hclwrite"
+)
+
+// WithoutResource removes a resource from a Terraform configuration.
+func WithoutResource(t *testing.T, tfCode string, resourceName string) string {
+	tfConfig, err := hclwrite.ParseConfig([]byte(tfCode), "", hcl.Pos{Line: 1, Column: 1})
+	if err != nil {
+		t.Fatalf("failed to parse HCL: %v", err)
+	}
+
+	block := tfConfig.Body().FirstMatchingBlock("resource", strings.Split(resourceName, "."))
+	if block == nil {
+		t.Fatalf("failed to find resource %q", resourceName)
+	}
+	tfConfig.Body().RemoveBlock(block)
+
+	return string(tfConfig.Bytes())
+}
diff --git a/internal/testutils/resources_test.go b/internal/testutils/resources_test.go
new file mode 100644
index 00000000..e3707705
--- /dev/null
+++ b/internal/testutils/resources_test.go
@@ -0,0 +1,63 @@
+package testutils
+
+import "testing"
+
+func TestWithoutResource(t *testing.T) {
+	t.Parallel()
+
+	input := `resource "grafana_organization" "test" {
+		name = "test"
+	}
+	
+	resource "grafana_folder" "test" {
+		org_id = grafana_organization.test.id
+		title = "test"
+	}
+	
+	resource "grafana_rule_group" "test" {
+		org_id = grafana_organization.test.id
+		name             = "test"
+		folder_uid       = grafana_folder.test.uid
+		interval_seconds = 360
+		rule {
+			name           = "My Alert Rule 1"
+			for            = "2m"
+			condition      = "B"
+			no_data_state  = "NoData"
+			exec_err_state = "Alerting"
+			is_paused = false
+			data {
+				ref_id     = "A"
+				query_type = ""
+				relative_time_range {
+					from = 600
+					to   = 0
+				}
+				datasource_uid = "PD8C576611E62080A"
+				model = jsonencode({
+					hide          = false
+					intervalMs    = 1000
+					maxDataPoints = 43200
+					refId         = "A"
+				})
+			}
+		}
+	}`
+
+	expected := `resource "grafana_organization" "test" {
+  name = "test"
+}
+
+resource "grafana_folder" "test" {
+  org_id = grafana_organization.test.id
+  title  = "test"
+}
+
+`
+
+	actual := WithoutResource(t, input, "grafana_rule_group.test")
+
+	if actual != expected {
+		t.Errorf("expected:\n%q\n\nactual:\n%q", expected, actual)
+	}
+}