diff --git a/vultr/resource_vultr_firewall_rule.go b/vultr/resource_vultr_firewall_rule.go index 5c371f27..11a7c5a8 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 c699bef0..2c2d6737 100644 --- a/vultr/utility.go +++ b/vultr/utility.go @@ -3,6 +3,7 @@ package vultr import ( "encoding/json" "fmt" + "net" "net/http" "strings" @@ -70,3 +71,31 @@ func checkIsMissing(e error, missingMsg string) (bool, error) { 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) + } + }) + } +}