This is an automated email from the ASF dual-hosted git repository.
sureshanaparti pushed a commit to branch main
in repository
https://gitbox.apache.org/repos/asf/cloudstack-terraform-provider.git
The following commit(s) were added to refs/heads/main by this push:
new 7a9e48b Fix role_permission description edits and authoritative drift
detection (#350)
7a9e48b is described below
commit 7a9e48bccf03f4e78881560203c5d09c6baabf9d
Author: Vishesh <[email protected]>
AuthorDate: Tue Sep 15 18:03:09 2026 +0530
Fix role_permission description edits and authoritative drift detection
(#350)
---
cloudstack/resource_cloudstack_role_permission.go | 144 ++++++++++++-------
.../resource_cloudstack_role_permission_test.go | 152 +++++++++++++++++++++
2 files changed, 248 insertions(+), 48 deletions(-)
diff --git a/cloudstack/resource_cloudstack_role_permission.go
b/cloudstack/resource_cloudstack_role_permission.go
index c6f73d0..2a9a436 100644
--- a/cloudstack/resource_cloudstack_role_permission.go
+++ b/cloudstack/resource_cloudstack_role_permission.go
@@ -20,6 +20,7 @@
package cloudstack
import (
+ "context"
"fmt"
"log"
"sync"
@@ -44,6 +45,20 @@ func resourceCloudStackRolePermission() *schema.Resource {
Read: resourceCloudStackRolePermissionRead,
Update: resourceCloudStackRolePermissionUpdate,
Delete: resourceCloudStackRolePermissionDelete,
+ // Duplicate rules are rejected at plan time; by Update the
invalid list is already
+ // in state. Rules still unknown at plan time are skipped (they
read as "").
+ CustomizeDiff: func(_ context.Context, d *schema.ResourceDiff,
_ interface{}) error {
+ if !d.NewValueKnown("permission") {
+ return nil
+ }
+ permissions :=
rolePermissionSpecs(d.Get("permission").([]interface{}))
+ for i := range permissions {
+ if
!d.NewValueKnown(fmt.Sprintf("permission.%d.rule", i)) {
+ permissions[i].Rule = ""
+ }
+ }
+ return validateUniqueRolePermissionRules(permissions)
+ },
Schema: map[string]*schema.Schema{
"role_id": {
Type: schema.TypeString,
@@ -138,7 +153,7 @@ func resourceCloudStackRolePermissionRead(d
*schema.ResourceData, meta interface
// Keep managed permissions in the order returned by CloudStack.
Otherwise an
// out-of-band reorder is hidden by refresh and Terraform cannot
restore the
// order declared in the configuration.
- readPermissions := make([]interface{}, 0,
len(used)+len(missingPermissions))
+ readPermissions := make([]interface{}, 0,
len(rolePermissions)+len(missingPermissions))
for _, rp := range rolePermissions {
if !used[rp.Id] {
continue
@@ -150,6 +165,23 @@ func resourceCloudStackRolePermissionRead(d
*schema.ResourceData, meta interface
Description: rp.Description,
}))
}
+
+ // When authoritative, undeclared permissions must appear in state or
no diff is
+ // produced and Update never runs. They carry no id: an id marks a
managed permission,
+ // and reconcile deletes formerly managed ones when authoritative is
switched off.
+ if d.Get("authoritative").(bool) {
+ for _, rp := range rolePermissions {
+ if used[rp.Id] {
+ continue
+ }
+ readPermissions = append(readPermissions,
rolePermissionState(rolePermissionSpec{
+ Rule: rp.Rule,
+ Permission: rp.Permission,
+ Description: rp.Description,
+ }))
+ }
+ }
+
readPermissions = append(readPermissions, missingPermissions...)
if err := d.Set("permission", readPermissions); err != nil {
@@ -205,24 +237,14 @@ func resourceCloudStackRolePermissionDelete(d
*schema.ResourceData, meta interfa
return nil
}
- rolePermissionsByID := make(map[string]*cloudstack.RolePermission)
- for _, rp := range rolePermissions {
- rolePermissionsByID[rp.Id] = rp
- }
-
- used := make(map[string]bool)
- for _, permission := range
rolePermissionSpecs(d.Get("permission").([]interface{})) {
- ruleID := permission.ID
- if ruleID == "" {
- if rp := findMatchingRolePermission(rolePermissions,
permission, used); rp != nil {
- ruleID = rp.Id
- }
- }
- if ruleID == "" || rolePermissionsByID[ruleID] == nil {
+ // Not authoritative: remove only the permissions this resource manages
and
+ // leave anything added outside Terraform in place.
+ desiredPermissions :=
rolePermissionSpecs(d.Get("permission").([]interface{}))
+ for _, rp := range matchCloudStackRolePermissions(rolePermissions,
desiredPermissions) {
+ if rp == nil {
continue
}
- used[ruleID] = true
- if err := deleteCloudStackRolePermission(cs, ruleID); err !=
nil {
+ if err := deleteCloudStackRolePermission(cs, rp.Id); err != nil
{
return err
}
}
@@ -234,6 +256,11 @@ func reconcileCloudStackRolePermissions(d
*schema.ResourceData, meta interface{}
cs := meta.(*cloudstack.CloudStackClient)
roleID := d.Get("role_id").(string)
+ desiredPermissions :=
rolePermissionSpecs(d.Get("permission").([]interface{}))
+ if err := validateUniqueRolePermissionRules(desiredPermissions); err !=
nil {
+ return err
+ }
+
rolePermissions, err := listCloudStackRolePermissions(cs, roleID)
if err != nil {
return fmt.Errorf("Error listing Role Permissions: %s", err)
@@ -246,9 +273,26 @@ func reconcileCloudStackRolePermissions(d
*schema.ResourceData, meta interface{}
managedIDs := make([]string, 0)
managedIDSet := make(map[string]bool)
- desiredPermissions :=
rolePermissionSpecs(d.Get("permission").([]interface{}))
+ deleted := make(map[string]bool)
matchedPermissions := matchCloudStackRolePermissions(rolePermissions,
desiredPermissions)
+ // Descriptions cannot be updated in place, so a changed one is
recreated. Delete
+ // first: creating the replacement while the old one exists fails with
"Rule already exists".
+ for i, desired := range desiredPermissions {
+ rp := matchedPermissions[i]
+ if rp == nil || rp.Description == desired.Description {
+ continue
+ }
+
+ if err := deleteCloudStackRolePermission(cs, rp.Id); err != nil
{
+ return err
+ }
+
+ deleted[rp.Id] = true
+ delete(rolePermissionsByID, rp.Id)
+ matchedPermissions[i] = nil
+ }
+
for i, desired := range desiredPermissions {
rp := matchedPermissions[i]
if rp == nil {
@@ -275,16 +319,17 @@ func reconcileCloudStackRolePermissions(d
*schema.ResourceData, meta interface{}
if d.Get("authoritative").(bool) {
for _, rp := range rolePermissions {
- if managedIDSet[rp.Id] {
+ if managedIDSet[rp.Id] || deleted[rp.Id] {
continue
}
if err := deleteCloudStackRolePermission(cs, rp.Id);
err != nil {
return err
}
+ deleted[rp.Id] = true
}
} else {
for oldID := range oldManagedIDs {
- if managedIDSet[oldID] {
+ if managedIDSet[oldID] || deleted[oldID] {
continue
}
if rolePermissionsByID[oldID] == nil {
@@ -293,6 +338,7 @@ func reconcileCloudStackRolePermissions(d
*schema.ResourceData, meta interface{}
if err := deleteCloudStackRolePermission(cs, oldID);
err != nil {
return err
}
+ deleted[oldID] = true
}
}
@@ -412,48 +458,50 @@ func rolePermissionState(permission rolePermissionSpec)
map[string]interface{} {
}
}
-func findMatchingRolePermission(rolePermissions []*cloudstack.RolePermission,
desired rolePermissionSpec, used map[string]bool) *cloudstack.RolePermission {
- for _, rp := range rolePermissions {
- if used[rp.Id] {
- continue
- }
- if rp.Rule == desired.Rule && rp.Description ==
desired.Description {
- return rp
- }
- }
-
- return nil
-}
-
+// matchCloudStackRolePermissions pairs each desired permission with the
existing one for
+// the same rule. A rule may appear once per role, so it is the permission's
identity.
func matchCloudStackRolePermissions(rolePermissions
[]*cloudstack.RolePermission, desiredPermissions []rolePermissionSpec)
[]*cloudstack.RolePermission {
- permissionsByID := make(map[string]*cloudstack.RolePermission,
len(rolePermissions))
+ permissionsByRule := make(map[string]*cloudstack.RolePermission,
len(rolePermissions))
for _, rp := range rolePermissions {
- permissionsByID[rp.Id] = rp
+ if _, ok := permissionsByRule[rp.Rule]; !ok {
+ permissionsByRule[rp.Rule] = rp
+ }
}
matchedPermissions := make([]*cloudstack.RolePermission,
len(desiredPermissions))
- used := make(map[string]bool)
+ used := make(map[string]bool, len(desiredPermissions))
for i, desired := range desiredPermissions {
- var rp *cloudstack.RolePermission
- if desired.ID != "" {
- candidate := permissionsByID[desired.ID]
- if candidate != nil && !used[candidate.Id] &&
candidate.Rule == desired.Rule && candidate.Description == desired.Description {
- rp = candidate
- }
- }
- if rp == nil {
- rp = findMatchingRolePermission(rolePermissions,
desired, used)
- }
- if rp != nil {
- used[rp.Id] = true
+ rp := permissionsByRule[desired.Rule]
+ if rp == nil || used[rp.Id] {
+ continue
}
+
+ used[rp.Id] = true
matchedPermissions[i] = rp
}
return matchedPermissions
}
+// validateUniqueRolePermissionRules rejects a rule listed more than once.
+func validateUniqueRolePermissionRules(permissions []rolePermissionSpec) error
{
+ seen := make(map[string]int, len(permissions))
+ for i, permission := range permissions {
+ if permission.Rule == "" {
+ continue
+ }
+ if first, ok := seen[permission.Rule]; ok {
+ return fmt.Errorf(
+ "duplicate rule %q in permission entries %d and
%d: a rule may appear at most once per role",
+ permission.Rule, first+1, i+1)
+ }
+ seen[permission.Rule] = i
+ }
+
+ return nil
+}
+
func rolePermissionLock(roleID string) *sync.Mutex {
lock, _ := rolePermissionLocks.LoadOrStore(roleID, &sync.Mutex{})
return lock.(*sync.Mutex)
diff --git a/cloudstack/resource_cloudstack_role_permission_test.go
b/cloudstack/resource_cloudstack_role_permission_test.go
index 6196a05..6d8606e 100644
--- a/cloudstack/resource_cloudstack_role_permission_test.go
+++ b/cloudstack/resource_cloudstack_role_permission_test.go
@@ -26,6 +26,8 @@ import (
"github.com/apache/cloudstack-go/v2/cloudstack"
"github.com/hashicorp/terraform-plugin-testing/helper/resource"
"github.com/hashicorp/terraform-plugin-testing/terraform"
+ "regexp"
+ "strings"
)
func TestAccCloudStackRolePermission_basic(t *testing.T) {
@@ -645,3 +647,153 @@ resource "cloudstack_role_permission" "foo" {
}
}
`
+
+func TestMatchCloudStackRolePermissions_descriptionChangeMatchesByRule(t
*testing.T) {
+ rolePermissions := []*cloudstack.RolePermission{
+ {Id: "list-id", Rule: "listVirtualMachines", Permission:
"allow", Description: "old"},
+ {Id: "deploy-id", Rule: "deployVirtualMachine", Permission:
"deny", Description: "no deploy"},
+ }
+ desiredPermissions := []rolePermissionSpec{
+ {ID: "list-id", Rule: "listVirtualMachines", Permission:
"allow", Description: "new"},
+ {ID: "deploy-id", Rule: "deployVirtualMachine", Permission:
"allow", Description: "no deploy"},
+ }
+
+ matchedPermissions := matchCloudStackRolePermissions(rolePermissions,
desiredPermissions)
+ assertRolePermissionIDs(t, matchedPermissions, []string{"list-id",
"deploy-id"})
+}
+
+func TestValidateUniqueRolePermissionRules(t *testing.T) {
+ if err := validateUniqueRolePermissionRules([]rolePermissionSpec{
+ {Rule: "listVirtualMachines"}, {Rule: "listVolumes"},
+ }); err != nil {
+ t.Fatalf("unexpected error for unique rules: %s", err)
+ }
+
+ if err := validateUniqueRolePermissionRules([]rolePermissionSpec{
+ {Rule: ""}, {Rule: "listVolumes"}, {Rule: ""},
+ }); err != nil {
+ t.Fatalf("unexpected error for unknown (empty) rules: %s", err)
+ }
+
+ err := validateUniqueRolePermissionRules([]rolePermissionSpec{
+ {Rule: "listVirtualMachines"}, {Rule: "listVolumes"}, {Rule:
"listVirtualMachines"},
+ })
+ if err == nil {
+ t.Fatal("expected an error for a duplicated rule")
+ }
+ for _, want := range []string{`"listVirtualMachines"`, "entries 1 and
3"} {
+ if !strings.Contains(err.Error(), want) {
+ t.Fatalf("error %q should mention %s", err, want)
+ }
+ }
+}
+
+func TestAccCloudStackRolePermission_descriptionChange(t *testing.T) {
+ resource.Test(t, resource.TestCase{
+ PreCheck: func() { testAccPreCheck(t) },
+ Providers: testAccProviders,
+ CheckDestroy: testAccCheckCloudStackRolePermissionDestroy,
+ Steps: []resource.TestStep{
+ {
+ Config: testAccCloudStackRolePermission_basic,
+ Check: resource.ComposeTestCheckFunc(
+
testAccCheckCloudStackRolePermissionExists("cloudstack_role_permission.foo"),
+
resource.TestCheckResourceAttr("cloudstack_role_permission.foo",
"permission.0.description", "terraform test role permission"),
+ ),
+ },
+ {
+ Config:
testAccCloudStackRolePermission_descriptionChanged,
+ Check: resource.ComposeTestCheckFunc(
+
testAccCheckCloudStackRolePermissionExists("cloudstack_role_permission.foo"),
+
testAccCheckCloudStackRolePermissionOrder("cloudstack_role_permission.foo",
[]string{"listVirtualMachines"}),
+
resource.TestCheckResourceAttr("cloudstack_role_permission.foo",
"permission.0.description", "terraform test role permission (updated)"),
+ ),
+ },
+ },
+ })
+}
+
+// Unlike _authoritative, the config is identical in both steps, so only the
refresh
+// can detect the externally added permission.
+func TestAccCloudStackRolePermission_authoritativeDrift(t *testing.T) {
+ var externalRuleID string
+
+ resource.Test(t, resource.TestCase{
+ PreCheck: func() { testAccPreCheck(t) },
+ Providers: testAccProviders,
+ CheckDestroy: testAccCheckCloudStackRolePermissionDestroy,
+ Steps: []resource.TestStep{
+ {
+ Config:
testAccCloudStackRolePermission_authoritative,
+ Check: resource.ComposeTestCheckFunc(
+
testAccCheckCloudStackRolePermissionExists("cloudstack_role_permission.foo"),
+
testAccCreateCloudStackRolePermission("cloudstack_role_permission.foo",
"listVirtualMachines", "allow", "external role permission", &externalRuleID),
+ ),
+ // The Check adds a permission out of band; the
post-step refresh seeing it
+ // as drift is the behaviour under test.
+ ExpectNonEmptyPlan: true,
+ },
+ {
+ Config:
testAccCloudStackRolePermission_authoritative,
+ Check: resource.ComposeTestCheckFunc(
+
testAccCheckCloudStackRolePermissionExists("cloudstack_role_permission.foo"),
+
testAccCheckCloudStackRolePermissionRuleMissing("cloudstack_role_permission.foo",
&externalRuleID),
+
testAccCheckCloudStackRolePermissionOrder("cloudstack_role_permission.foo",
[]string{"listZones"}),
+ ),
+ },
+ },
+ })
+}
+
+const testAccCloudStackRolePermission_descriptionChanged = `
+resource "cloudstack_role" "foo" {
+ name = "terraform-role"
+ type = "User"
+}
+
+resource "cloudstack_role_permission" "foo" {
+ role_id = cloudstack_role.foo.id
+
+ permission {
+ rule = "listVirtualMachines"
+ permission = "allow"
+ description = "terraform test role permission (updated)"
+ }
+}
+`
+
+func TestAccCloudStackRolePermission_duplicateRuleRejected(t *testing.T) {
+ resource.Test(t, resource.TestCase{
+ PreCheck: func() { testAccPreCheck(t) },
+ Providers: testAccProviders,
+ CheckDestroy: testAccCheckCloudStackRolePermissionDestroy,
+ Steps: []resource.TestStep{
+ {
+ Config:
testAccCloudStackRolePermission_duplicateRule,
+ PlanOnly: true,
+ ExpectError: regexp.MustCompile(`duplicate rule
"listVirtualMachines" in permission entries 1 and 2`),
+ },
+ },
+ })
+}
+
+const testAccCloudStackRolePermission_duplicateRule = `
+resource "cloudstack_role" "foo" {
+ name = "terraform-role"
+ type = "User"
+}
+
+resource "cloudstack_role_permission" "foo" {
+ role_id = cloudstack_role.foo.id
+
+ permission {
+ rule = "listVirtualMachines"
+ permission = "allow"
+ }
+
+ permission {
+ rule = "listVirtualMachines"
+ permission = "deny"
+ }
+}
+`