From 09d503e6ea1bc66e169176a508a882ece5fc2a3f Mon Sep 17 00:00:00 2001 From: hwigelsworth Date: Tue, 31 Mar 2026 20:22:50 -0400 Subject: [PATCH] add suppressIPDiff and canonicalizeIP helpers -- use in resourceVultrFirewallRule (vultr#714) --- vultr/resource_vultr_firewall_rule.go | 10 +- vultr/utility.go | 29 ++++++ vultr/utility_test.go | 129 ++++++++++++++++++++++++++ 3 files changed, 164 insertions(+), 4 deletions(-) create mode 100644 vultr/utility_test.go diff --git a/vultr/resource_vultr_firewall_rule.go b/vultr/resource_vultr_firewall_rule.go index 1e66fc15..f97fe05d 100644 --- a/vultr/resource_vultr_firewall_rule.go +++ b/vultr/resource_vultr_firewall_rule.go @@ -41,10 +41,12 @@ func resourceVultrFirewallRule() *schema.Resource { ValidateFunc: validation.StringInSlice([]string{"icmp", "tcp", "udp", "gre", "ah", "esp"}, false), }, "subnet": { - Type: schema.TypeString, - Required: true, - ForceNew: true, - ValidateFunc: validation.IsIPAddress, + Type: schema.TypeString, + Required: true, + ForceNew: true, + ValidateFunc: validation.IsIPAddress, + DiffSuppressFunc: suppressIPDiff, + StateFunc: canonicalizeIP, }, "subnet_size": { Type: schema.TypeInt, diff --git a/vultr/utility.go b/vultr/utility.go index 9b12c2f5..72cdf08d 100644 --- a/vultr/utility.go +++ b/vultr/utility.go @@ -1,6 +1,7 @@ package vultr import ( + "net" "strings" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" @@ -45,3 +46,31 @@ func diffSlice(x, y []string) []string { func IgnoreCase(k, old, new string, d *schema.ResourceData) bool { return strings.EqualFold(old, new) } + +// suppressIPDiff returns true when old and new are the same IP address +// just written differently (leading zeros, mixed case, etc). +// Handles both v4 and v6 transparently. +func suppressIPDiff(_, old, new string, _ *schema.ResourceData) bool { + oldIP := net.ParseIP(old) + newIP := net.ParseIP(new) + + // if either side doesn't parse, fall through to normal string compare + if oldIP == nil || newIP == nil { + return old == new + } + + return oldIP.Equal(newIP) +} + +// canonicalizeIP parses and re-renders an IP address to its canonical +// string form. For v6 this strips leading zeros and lowercases per +// RFC 5952, matching what the Vultr API returns. +// If it can't parse (bad input or empty string), just pass through. +func canonicalizeIP(val interface{}) string { + raw := val.(string) + ip := net.ParseIP(raw) + if ip == nil { + return raw + } + return ip.String() +} diff --git a/vultr/utility_test.go b/vultr/utility_test.go new file mode 100644 index 00000000..bd437ca6 --- /dev/null +++ b/vultr/utility_test.go @@ -0,0 +1,129 @@ +package vultr + +import ( + "testing" +) + +func TestSuppressIPDiff(t *testing.T) { + cases := []struct { + name string + old string + new string + suppress bool + }{ + // leading zero in v6 group + { + name: "v6 leading zero in group", + old: "2001:db8:1000:3b79:5400:5ff:fedf:fade", + new: "2001:db8:1000:3b79:5400:05ff:fedf:fade", + suppress: true, + }, + // same addr both sides -- no-op + { + name: "v6 identical", + old: "2001:db8::1", + new: "2001:db8::1", + suppress: true, + }, + // full expansion vs compressed -- same addr + { + name: "v6 expanded vs compressed", + old: "2001:db8:0:0:0:0:0:1", + new: "2001:db8::1", + suppress: true, + }, + // actually different addrs + { + name: "v6 different addrs", + old: "2001:db8::1", + new: "2001:db8::2", + suppress: false, + }, + // v4 sanity -- should be a noop but make sure it doesn't break + { + name: "v4 identical", + old: "10.0.0.1", + new: "10.0.0.1", + suppress: true, + }, + { + name: "v4 different", + old: "10.0.0.1", + new: "10.0.0.2", + suppress: false, + }, + // garbage in -- fall through to string compare + { + name: "unparseable falls through", + old: "not-an-ip", + new: "also-not-an-ip", + suppress: false, + }, + // mixed case hex -- same addr + { + name: "v6 mixed case", + old: "2001:db8:1000:3b79:5400:5FF:FEDF:FADE", + new: "2001:db8:1000:3b79:5400:5ff:fedf:fade", + suppress: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := suppressIPDiff("subnet", tc.old, tc.new, nil) + if got != tc.suppress { + t.Errorf("suppressIPDiff(%q, %q) = %v, want %v", + tc.old, tc.new, got, tc.suppress) + } + }) + } +} + +func TestCanonicalizeIP(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + // the actual bug case + { + name: "strips leading zero from v6 group", + in: "2001:db8:1000:3b79:5400:05ff:fedf:fade", + want: "2001:db8:1000:3b79:5400:5ff:fedf:fade", + }, + // already canonical -- passthrough + { + name: "already canonical v6", + in: "2001:db8::1", + want: "2001:db8::1", + }, + // v4 passthrough + { + name: "v4 passthrough", + in: "10.0.0.1", + want: "10.0.0.1", + }, + // full form gets compressed + { + name: "v6 full form compresses", + in: "2001:0db8:0000:0000:0000:0000:0000:0001", + want: "2001:db8::1", + }, + // bad input -- pass through unchanged + { + name: "bad input passthrough", + in: "not-an-ip", + want: "not-an-ip", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := canonicalizeIP(tc.in) + if got != tc.want { + t.Errorf("canonicalizeIP(%q) = %q, want %q", + tc.in, got, tc.want) + } + }) + } +}