Repository: rancher/terraform-provider-rancher2

Issue:
[release/v13] fix: support exceptions in diff suppression
Backport #1808 to release/v13 for #1809

Copied from PR:

<!--- If there is no user issue related to this then you should remove the next line --->
Addresses  https://github.com/rancher/terraform-provider-rancher2/issues/1807 


<!--- Add labels (eg. release/v13) for each release branch to target --->
<!--- Labels need to be added before PR is created for automation to run smoothly! --->

## Description

Please see https://github.com/rancher/terraform-provider-rancher2/issues/1807 for details. 

This PR adds support for exceptions when computing the diff of Rancher-managed labels and annotations. 

## Testing

Following the same steps provided in the issue, I can confirm that Terraform detects the removal of the annotation, and after applying the changes, the annotation on the cluster is set to its default value by rancher-webhook. 


Output: 
```
Terraform will perform the following actions:

  # rancher2_cluster.my-imported-cluster-tf will be updated in-place
  ~ resource "rancher2_cluster" "my-imported-cluster-tf" {
      ~ annotations                = {
          - "rancher.io/imported-cluster-version-management"              = "false" -> null
            # (9 unchanged elements hidden)
        }
        id                         = "c-zcwc9"
        name                       = "jiaqi-imported-cluster-tf"
        # (13 unchanged attributes hidden)

        # (2 unchanged blocks hidden)
    }

Plan: 0 to add, 1 to change, 0 to destroy.
...
```

Rancher side: 
<img width="1008" height="621" alt="Screenshot 2025-11-14 at 11 55 09 AM" src="https://github.com/user-attachments/assets/feb34c84-36e1-4c41-936b-0edcd9ff4b6f" />




 


Discussion:
@github-actions: New Release Candidate Available for Validation: [v13.0.0-rc.12](https://github.com/rancher/terraform-provider-rancher2/releases/tag/v13.0.0-rc.12)

@github-actions: New Release Candidate Available for Validation: [v13.0.0-rc.13](https://github.com/rancher/terraform-provider-rancher2/releases/tag/v13.0.0-rc.13)

@github-actions: New Release Candidate Available for Validation: [v13.0.0-rc.14](https://github.com/rancher/terraform-provider-rancher2/releases/tag/v13.0.0-rc.14)

@github-actions: New Release Candidate Available for Validation: [v13.0.0-rc.15](https://github.com/rancher/terraform-provider-rancher2/releases/tag/v13.0.0-rc.15)

@Josh-Diamond: Ticket #1823 - Test Results - ✅ 
---

Verified on Rancher `v2.13.0` with Rancher2 `v13.0.0-rc.15`:

1. Fresh install of Rancher `v2.13.0`
2. Create a standalone RKE2 cluster (to later import)
3. Run the following `main.tf` to create a cluster object to then import cluster created in step 2
```terraform
resource "rancher2_cluster" "my-imported-cluster-tf" {
  name = "my-imported-cluster-tf"
  annotations = {
    "rancher.io/imported-cluster-version-management" = "false"
    "another.annotation/key"  = "another-value"
  }
}

output "kubectl-command" {
  value = ["${rancher2_cluster.my-imported-cluster-tf.cluster_registration_token.0.command}"]
}

output "insecure-kubectl-command" {
  value = ["${rancher2_cluster.my-imported-cluster-tf.cluster_registration_token.0.insecure_command}"]
}
```
4. Verified - cluster object successfully creates and import command is provided as a tf output
5. Run import command against cluster created in step 2
6. Verified - cluster successfully imports with expected annotations
7. Remove the following annotation and re-run `terraform apply`:
`"rancher.io/imported-cluster-version-management" = "false" `
8. Verified - terraform detects a change and removes the annotation
9. Verified - annotation is removed in Rancher UI as well, as expected

Screenshot:
<img width="1125" height="403" alt="Image" src="https://github.com/user-attachments/assets/ed573288-6b7a-43b8-92ed-1f3bb599acbf" />

Solution patch:
diff --git a/rancher2/schema_common.go b/rancher2/schema_common.go
index f777818f..a75d3085 100644
--- a/rancher2/schema_common.go
+++ b/rancher2/schema_common.go
@@ -11,35 +11,49 @@ const (
 	commonAnnotationLabelRancher = "rancher.io/"
 )
 
+// exceptions is a list of annotation/label keys that should not be suppressed.
+var exceptions = []string{
+	"rancher.io/imported-cluster-version-management",
+}
+
+// suppressFunc is a DiffSuppressFunc that prevents Terraform from removing Rancher-managed annotations or labels.
+// It also ignores changes to a predefined set of Rancher-managed annotation/label keys.
+//
+// Thought it is not recommended, users can still add annotations or labels whose keys contain Rancher-managed keys,
+// but they won't be able to remove them once added.
+// Note: Terraform prefixes the key `k` with either "annotations." or "labels."
+var suppressFunc = func(k, old, new string, d *schema.ResourceData) bool {
+	for _, exception := range exceptions {
+		// Explicitly check if the key ends with the exception
+		if strings.HasSuffix(k, exception) {
+			return false
+		}
+	}
+
+	if (strings.Contains(k, commonAnnotationLabelCattle) || strings.Contains(k, commonAnnotationLabelRancher)) && new == "" {
+		return true
+	}
+
+	return false
+}
+
 // Schemas
 
 func commonAnnotationLabelFields() map[string]*schema.Schema {
 	s := map[string]*schema.Schema{
 		"annotations": {
-			Type:        schema.TypeMap,
-			Optional:    true,
-			Computed:    true,
-			Description: "Annotations of the resource",
-			DiffSuppressFunc: func(k, old, new string, d *schema.ResourceData) bool {
-				// Suppressing diff for annotations containing cattle.io/
-				if (strings.Contains(k, commonAnnotationLabelCattle) || strings.Contains(k, commonAnnotationLabelRancher)) && new == "" {
-					return true
-				}
-				return false
-			},
+			Type:             schema.TypeMap,
+			Optional:         true,
+			Computed:         true,
+			Description:      "Annotations of the resource",
+			DiffSuppressFunc: suppressFunc,
 		},
 		"labels": {
-			Type:        schema.TypeMap,
-			Optional:    true,
-			Computed:    true,
-			Description: "Labels of the resource",
-			DiffSuppressFunc: func(k, old, new string, d *schema.ResourceData) bool {
-				// Suppressing diff for labels containing cattle.io/
-				if (strings.Contains(k, commonAnnotationLabelCattle) || strings.Contains(k, commonAnnotationLabelRancher)) && new == "" {
-					return true
-				}
-				return false
-			},
+			Type:             schema.TypeMap,
+			Optional:         true,
+			Computed:         true,
+			Description:      "Labels of the resource",
+			DiffSuppressFunc: suppressFunc,
 		},
 	}
 	return s



Test patch:
diff --git a/rancher2/schema_common_test.go b/rancher2/schema_common_test.go
new file mode 100644
index 00000000..216f987e
--- /dev/null
+++ b/rancher2/schema_common_test.go
@@ -0,0 +1,94 @@
+package rancher2
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestSupressFunc(t *testing.T) {
+	cases := []struct {
+		Name           string
+		K              string
+		Old            string
+		New            string
+		ExpectedResult bool
+	}{
+		{
+			Name:           "Exception key change should not be suppressed",
+			K:              "annotations.rancher.io/imported-cluster-version-management",
+			Old:            "true",
+			New:            "false",
+			ExpectedResult: false,
+		},
+		{
+			Name:           "Exception key removal should not be suppressed",
+			K:              "labels.rancher.io/imported-cluster-version-management",
+			Old:            "false",
+			New:            "",
+			ExpectedResult: false,
+		},
+		{
+			Name:           "Exception key but not as suffix should not be suppressed",
+			K:              "labels.rancher.io/imported-cluster-version-management-new",
+			Old:            "old_val",
+			New:            "new_val",
+			ExpectedResult: false,
+		},
+		{
+			Name:           "Rancher.io annotation/label removal should be suppressed",
+			K:              "annotations.rancher.io/creator",
+			Old:            "user-123",
+			New:            "",
+			ExpectedResult: true,
+		},
+		{
+			Name:           "Cattle.io annotation/label removal should be suppressed",
+			K:              "annotations.cattle.io/some-state",
+			Old:            "some-val",
+			New:            "",
+			ExpectedResult: true,
+		},
+		{
+			Name:           "Cattle.io annotation/label addition should not be suppressed",
+			K:              "annotations.cattle.io/some-state",
+			Old:            "",
+			New:            "some_val",
+			ExpectedResult: false,
+		},
+		{
+			Name:           "User-managed change to a rancher.io annotation should not be suppressed",
+			K:              "annotations.rancher.io/creator",
+			Old:            "user-123",
+			New:            "user-456",
+			ExpectedResult: false,
+		},
+		{Name: "User-managed change to a cattle.io annotation should not be suppressed",
+			K:              "annotations.cattle.io/creator",
+			Old:            "user-123",
+			New:            "user-456",
+			ExpectedResult: false,
+		},
+		{
+			Name:           "Non-Rancher annotation should not be suppressed",
+			K:              "annotations.my-custom-annotation",
+			Old:            "old-val",
+			New:            "new-val",
+			ExpectedResult: false,
+		},
+		{
+			Name:           "Non-Rancher annotation removal should not be suppressed",
+			K:              "annotations.my-custom-annotation",
+			Old:            "old-val",
+			New:            "",
+			ExpectedResult: false,
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.Name, func(t *testing.T) {
+			result := suppressFunc(tc.K, tc.Old, tc.New, nil)
+			assert.Equal(t, tc.ExpectedResult, result)
+		})
+	}
+}