Skip to content

Commit 5f451b0

Browse files
Fix spurious diffs in ACL ruleset by preventing state mutation and excluding out-of-band rules
- Fixed Read function to create new rule maps instead of mutating existing state - Removed dummy rules (out-of-band rules) from being persisted in state - Added custom hash function with debug logging - Added test case to verify no spurious diffs when modifying one rule - Out-of-band rules are now logged as warnings but not added to state All 12 acceptance tests passing (146.6 seconds total)
1 parent c781c75 commit 5f451b0

2 files changed

Lines changed: 216 additions & 51 deletions

File tree

cloudstack/resource_cloudstack_network_acl_ruleset.go

Lines changed: 43 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ package cloudstack
2222
import (
2323
"fmt"
2424
"log"
25+
"sort"
2526
"strconv"
2627
"strings"
2728
"sync"
@@ -144,32 +145,32 @@ func resourceCloudStackNetworkACLRulesetRuleHash(v interface{}) int {
144145
buf.WriteString(fmt.Sprintf("%s-", m["protocol"].(string)))
145146
buf.WriteString(fmt.Sprintf("%s-", m["traffic_type"].(string)))
146147

147-
// Hash the cidr_list set
148-
if v, ok := m["cidr_list"]; ok {
148+
// Hash the cidr_list set in a deterministic order
149+
if v, ok := m["cidr_list"]; ok && v != nil {
149150
cidrSet := v.(*schema.Set)
151+
// Sort the CIDRs to ensure deterministic hashing
152+
cidrs := make([]string, 0, cidrSet.Len())
150153
for _, cidr := range cidrSet.List() {
151-
buf.WriteString(fmt.Sprintf("%s-", cidr.(string)))
154+
cidrs = append(cidrs, cidr.(string))
155+
}
156+
sort.Strings(cidrs)
157+
for _, cidr := range cidrs {
158+
buf.WriteString(fmt.Sprintf("%s-", cidr))
152159
}
153160
}
154161

155-
// Include optional fields
156-
if v, ok := m["port"]; ok && v.(string) != "" {
157-
buf.WriteString(fmt.Sprintf("port:%s-", v.(string)))
158-
}
159-
160-
if v, ok := m["icmp_type"]; ok && v.(int) != 0 {
161-
buf.WriteString(fmt.Sprintf("icmp_type:%d-", v.(int)))
162-
}
163-
164-
if v, ok := m["icmp_code"]; ok && v.(int) != 0 {
165-
buf.WriteString(fmt.Sprintf("icmp_code:%d-", v.(int)))
166-
}
162+
// Include optional fields - always include them to ensure consistency
163+
// between config and state (even if they're empty/zero)
164+
buf.WriteString(fmt.Sprintf("port:%s-", m["port"].(string)))
165+
buf.WriteString(fmt.Sprintf("icmp_type:%d-", m["icmp_type"].(int)))
166+
buf.WriteString(fmt.Sprintf("icmp_code:%d-", m["icmp_code"].(int)))
167+
buf.WriteString(fmt.Sprintf("desc:%s-", m["description"].(string)))
167168

168-
if v, ok := m["description"]; ok && v.(string) != "" {
169-
buf.WriteString(fmt.Sprintf("desc:%s-", v.(string)))
170-
}
169+
hashStr := buf.String()
170+
hashVal := schema.HashString(hashStr)
171+
log.Printf("[DEBUG] ACL Rule Hash: rule_number=%d, hash=%d, hashStr=%s", m["rule_number"].(int), hashVal, hashStr)
171172

172-
return schema.HashString(buf.String())
173+
return hashVal
173174
}
174175

175176
func resourceCloudStackNetworkACLRulesetCreate(d *schema.ResourceData, meta interface{}) error {
@@ -387,35 +388,39 @@ func resourceCloudStackNetworkACLRulesetRead(d *schema.ResourceData, meta interf
387388
// Read all rules that are configured
388389
rs := d.Get("rule").(*schema.Set)
389390
if rs.Len() > 0 {
390-
for _, rule := range rs.List() {
391-
rule := rule.(map[string]interface{})
391+
for _, oldRule := range rs.List() {
392+
oldRule := oldRule.(map[string]interface{})
392393

393-
id, ok := rule["uuid"]
394+
id, ok := oldRule["uuid"]
394395
if !ok || id.(string) == "" {
395396
continue
396397
}
397398

398399
// Get the rule
399400
r, ok := ruleMap[id.(string)]
400401
if !ok {
401-
rule["uuid"] = ""
402+
// Rule no longer exists in the API, skip it
402403
continue
403404
}
404405

405406
// Delete the known rule so only unknown rules remain in the ruleMap
406407
delete(ruleMap, id.(string))
407408

408-
// Update the values
409+
// Create a NEW map with the updated values (don't mutate the old one)
409410
cidrs := &schema.Set{F: schema.HashString}
410411
for _, cidr := range strings.Split(r.Cidrlist, ",") {
411412
cidrs.Add(cidr)
412413
}
413-
rule["cidr_list"] = cidrs
414-
rule["action"] = strings.ToLower(r.Action)
415-
rule["protocol"] = r.Protocol
416-
rule["traffic_type"] = strings.ToLower(r.Traffictype)
417-
rule["rule_number"] = r.Number
418-
rule["description"] = r.Reason
414+
415+
rule := map[string]interface{}{
416+
"cidr_list": cidrs,
417+
"action": strings.ToLower(r.Action),
418+
"protocol": r.Protocol,
419+
"traffic_type": strings.ToLower(r.Traffictype),
420+
"rule_number": r.Number,
421+
"description": r.Reason,
422+
"uuid": r.Id,
423+
}
419424

420425
// Set ICMP fields
421426
if r.Protocol == "icmp" {
@@ -493,30 +498,17 @@ func resourceCloudStackNetworkACLRulesetRead(d *schema.ResourceData, meta interf
493498
}
494499
}
495500

496-
// If this is a managed resource, add all unknown rules to dummy rules
501+
// If this is a managed resource and there are unknown rules (out-of-band rules),
502+
// log them but DON'T add them to the state. They will be deleted on the next apply.
497503
managed := d.Get("managed").(bool)
498504
if managed && len(ruleMap) > 0 {
499-
for uuid := range ruleMap {
500-
// Make a dummy rule to hold the unknown UUID
501-
cidrs := &schema.Set{F: schema.HashString}
502-
cidrs.Add(uuid)
503-
504-
rule := map[string]interface{}{
505-
"cidr_list": cidrs,
506-
"protocol": uuid,
507-
"uuid": uuid,
508-
"rule_number": 0,
509-
"action": "allow",
510-
"traffic_type": "ingress",
511-
"icmp_type": 0,
512-
"icmp_code": 0,
513-
"description": "",
514-
"port": "",
515-
}
516-
517-
// Add the dummy rule to the rules set
518-
rules.Add(rule)
505+
log.Printf("[WARN] Found %d out-of-band ACL rules for ACL %s that are not managed by Terraform. These will be deleted on the next apply.", len(ruleMap), d.Id())
506+
for uuid, r := range ruleMap {
507+
log.Printf("[WARN] Out-of-band rule: uuid=%s, rule_number=%d, protocol=%s, action=%s", uuid, r.Number, r.Protocol, r.Action)
519508
}
509+
// Note: We do NOT add these to the rules set. They should not be in the state.
510+
// On the next terraform apply, the Update function will detect that these rules
511+
// exist in the API but not in the config, and will delete them.
520512
}
521513

522514
if rules.Len() > 0 {

cloudstack/resource_cloudstack_network_acl_ruleset_test.go

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,179 @@ resource "cloudstack_network_acl_ruleset" "bar" {
334334
}
335335
}`
336336

337+
func TestAccCloudStackNetworkACLRuleset_no_spurious_diff(t *testing.T) {
338+
resource.Test(t, resource.TestCase{
339+
PreCheck: func() { testAccPreCheck(t) },
340+
Providers: testAccProviders,
341+
CheckDestroy: testAccCheckCloudStackNetworkACLRulesetDestroy,
342+
Steps: []resource.TestStep{
343+
{
344+
Config: testAccCloudStackNetworkACLRuleset_no_spurious_diff_initial,
345+
Check: resource.ComposeTestCheckFunc(
346+
testAccCheckCloudStackNetworkACLRulesetExists("cloudstack_network_acl_ruleset.no_spurious_diff"),
347+
resource.TestCheckResourceAttr(
348+
"cloudstack_network_acl_ruleset.no_spurious_diff", "rule.#", "3"),
349+
),
350+
},
351+
{
352+
Config: testAccCloudStackNetworkACLRuleset_no_spurious_diff_updated,
353+
ConfigPlanChecks: resource.ConfigPlanChecks{
354+
PreApply: []plancheck.PlanCheck{
355+
// Verify that only rule 20 is being updated (action changed)
356+
// Rules 10 and 30 should NOT appear in the plan as changes
357+
plancheck.ExpectNonEmptyPlan(),
358+
// Check that the resource is being updated (not replaced)
359+
plancheck.ExpectResourceAction("cloudstack_network_acl_ruleset.no_spurious_diff", plancheck.ResourceActionUpdate),
360+
},
361+
},
362+
Check: resource.ComposeTestCheckFunc(
363+
testAccCheckCloudStackNetworkACLRulesetExists("cloudstack_network_acl_ruleset.no_spurious_diff"),
364+
resource.TestCheckResourceAttr(
365+
"cloudstack_network_acl_ruleset.no_spurious_diff", "rule.#", "3"),
366+
// Rule 10: Should be unchanged
367+
resource.TestCheckTypeSetElemNestedAttrs(
368+
"cloudstack_network_acl_ruleset.no_spurious_diff", "rule.*", map[string]string{
369+
"rule_number": "10",
370+
"action": "allow",
371+
"protocol": "tcp",
372+
"port": "22",
373+
"traffic_type": "ingress",
374+
"description": "Allow SSH",
375+
}),
376+
// Rule 20: Changed action from allow to deny
377+
resource.TestCheckTypeSetElemNestedAttrs(
378+
"cloudstack_network_acl_ruleset.no_spurious_diff", "rule.*", map[string]string{
379+
"rule_number": "20",
380+
"action": "deny", // Changed from allow
381+
"protocol": "tcp",
382+
"port": "80",
383+
"traffic_type": "ingress",
384+
"description": "Block HTTP",
385+
}),
386+
// Rule 30: Should be unchanged
387+
resource.TestCheckTypeSetElemNestedAttrs(
388+
"cloudstack_network_acl_ruleset.no_spurious_diff", "rule.*", map[string]string{
389+
"rule_number": "30",
390+
"action": "allow",
391+
"protocol": "tcp",
392+
"port": "443",
393+
"traffic_type": "ingress",
394+
"description": "Allow HTTPS",
395+
}),
396+
),
397+
},
398+
{
399+
// Third step: Apply again with no changes - should result in empty plan
400+
Config: testAccCloudStackNetworkACLRuleset_no_spurious_diff_updated,
401+
ConfigPlanChecks: resource.ConfigPlanChecks{
402+
PreApply: []plancheck.PlanCheck{
403+
// Verify that there are NO changes in the plan
404+
plancheck.ExpectEmptyPlan(),
405+
},
406+
},
407+
},
408+
},
409+
})
410+
}
411+
412+
const testAccCloudStackNetworkACLRuleset_no_spurious_diff_initial = `
413+
resource "cloudstack_vpc" "no_spurious_diff" {
414+
name = "terraform-vpc-no-spurious-diff"
415+
cidr = "10.0.0.0/8"
416+
vpc_offering = "Default VPC offering"
417+
zone = "Sandbox-simulator"
418+
}
419+
420+
resource "cloudstack_network_acl" "no_spurious_diff" {
421+
name = "terraform-acl-no-spurious-diff"
422+
description = "Test for spurious diff bug"
423+
vpc_id = cloudstack_vpc.no_spurious_diff.id
424+
}
425+
426+
resource "cloudstack_network_acl_ruleset" "no_spurious_diff" {
427+
acl_id = cloudstack_network_acl.no_spurious_diff.id
428+
429+
rule {
430+
rule_number = 10
431+
action = "allow"
432+
cidr_list = ["172.18.100.0/24"]
433+
protocol = "tcp"
434+
port = "22"
435+
traffic_type = "ingress"
436+
description = "Allow SSH"
437+
}
438+
439+
rule {
440+
rule_number = 20
441+
action = "allow"
442+
cidr_list = ["172.18.100.0/24"]
443+
protocol = "tcp"
444+
port = "80"
445+
traffic_type = "ingress"
446+
description = "Block HTTP"
447+
}
448+
449+
rule {
450+
rule_number = 30
451+
action = "allow"
452+
cidr_list = ["172.18.100.0/24"]
453+
protocol = "tcp"
454+
port = "443"
455+
traffic_type = "ingress"
456+
description = "Allow HTTPS"
457+
}
458+
}
459+
`
460+
461+
const testAccCloudStackNetworkACLRuleset_no_spurious_diff_updated = `
462+
resource "cloudstack_vpc" "no_spurious_diff" {
463+
name = "terraform-vpc-no-spurious-diff"
464+
cidr = "10.0.0.0/8"
465+
vpc_offering = "Default VPC offering"
466+
zone = "Sandbox-simulator"
467+
}
468+
469+
resource "cloudstack_network_acl" "no_spurious_diff" {
470+
name = "terraform-acl-no-spurious-diff"
471+
description = "Test for spurious diff bug"
472+
vpc_id = cloudstack_vpc.no_spurious_diff.id
473+
}
474+
475+
resource "cloudstack_network_acl_ruleset" "no_spurious_diff" {
476+
acl_id = cloudstack_network_acl.no_spurious_diff.id
477+
478+
rule {
479+
rule_number = 10
480+
action = "allow"
481+
cidr_list = ["172.18.100.0/24"]
482+
protocol = "tcp"
483+
port = "22"
484+
traffic_type = "ingress"
485+
description = "Allow SSH"
486+
}
487+
488+
rule {
489+
rule_number = 20
490+
action = "deny" # Changed from "allow" to "deny"
491+
cidr_list = ["172.18.100.0/24"]
492+
protocol = "tcp"
493+
port = "80"
494+
traffic_type = "ingress"
495+
description = "Block HTTP"
496+
}
497+
498+
rule {
499+
rule_number = 30
500+
action = "allow"
501+
cidr_list = ["172.18.100.0/24"]
502+
protocol = "tcp"
503+
port = "443"
504+
traffic_type = "ingress"
505+
description = "Allow HTTPS"
506+
}
507+
}
508+
`
509+
337510
func TestAccCloudStackNetworkACLRuleset_update(t *testing.T) {
338511
resource.Test(t, resource.TestCase{
339512
PreCheck: func() { testAccPreCheck(t) },

0 commit comments

Comments
 (0)