Copilot commented on code in PR #282:
URL: 
https://github.com/apache/cloudstack-terraform-provider/pull/282#discussion_r3240432639


##########
cloudstack/resource_cloudstack_network.go:
##########
@@ -306,6 +378,13 @@ func resourceCloudStackNetworkRead(d *schema.ResourceData, 
meta interface{}) err
        d.Set("network_domain", n.Networkdomain)
        d.Set("vpc_id", n.Vpcid)
 
+       // Always set IPv6 fields to detect drift when IPv6 is removed 
server-side
+       d.Set("ip6cidr", n.Ip6cidr)
+       d.Set("ip6gateway", n.Ip6gateway)
+
+       // Note: CloudStack API may not return startipv6 and endipv6 fields
+       // These are typically only set during network creation
+

Review Comment:
   The Read function only refreshes `ip6cidr` and `ip6gateway` from the API, 
but never refreshes `startipv6` and `endipv6`. The inline comment acknowledges 
this, but the consequence is that drift in the IPv6 IP range will go 
undetected, and any out-of-band changes (or differences between user-supplied 
values and what CloudStack actually stored) will never be reconciled into 
state. Since these schema fields are `Computed: true`, Terraform also has no 
way to populate them on initial import. Consider attempting to read the 
network's IPv6 range (e.g., via `listVlanIpRanges` / `listIpv6FirewallRules` or 
whichever CloudStack API surfaces it) and setting these fields, or at minimum 
documenting that drift detection is intentionally not supported.
   



##########
cloudstack/resource_cloudstack_network_test.go:
##########
@@ -165,6 +175,90 @@ func TestAccCloudStackNetwork_importProject(t *testing.T) {
        })
 }
 
+// testAccPreCheckIPv6Support checks if IPv6 tests should run.
+// IPv6 tests are skipped on the CloudStack simulator unless explicitly enabled
+// via the CLOUDSTACK_ENABLE_IPV6_TESTS environment variable.
+func testAccPreCheckIPv6Support(t *testing.T) {
+       testAccPreCheck(t)
+
+       // Allow explicit override to enable IPv6 tests
+       if os.Getenv("CLOUDSTACK_ENABLE_IPV6_TESTS") == "true" {
+               return
+       }
+
+       // Try to detect if we're running on the simulator by checking the API 
URL
+       apiURL := os.Getenv("CLOUDSTACK_API_URL")
+       if strings.Contains(apiURL, "localhost") || strings.Contains(apiURL, 
"127.0.0.1") {
+               t.Skip("Skipping IPv6 test: CloudStack simulator does not 
support IPv6 for isolated networks. Set CLOUDSTACK_ENABLE_IPV6_TESTS=true to 
force-enable.")
+       }

Review Comment:
   The simulator detection here is fragile: it relies on the literal substrings 
`localhost` or `127.0.0.1` appearing in `CLOUDSTACK_API_URL`. Real 
(non-simulator) CloudStack deployments accessed via an SSH tunnel, a reverse 
proxy, or any other `localhost`/`127.0.0.1` URL will incorrectly skip these 
tests, and conversely a simulator reachable via a hostname or different 
loopback (e.g. `::1`) will run them and likely fail. Consider gating on an 
explicit opt-in env var (`CLOUDSTACK_ENABLE_IPV6_TESTS`) without the URL-based 
heuristic, or by querying CloudStack for IPv6 capability/zone support before 
running the test.



##########
cloudstack/resource_cloudstack_network.go:
##########
@@ -471,3 +550,93 @@ func parseCIDR(d *schema.ResourceData, specifyiprange 
bool) (map[string]string,
 
        return m, nil
 }
+
+// addToIPv6 adds an integer offset to an IPv6 address with proper carry 
across all bytes.
+// Returns a new net.IP with the result.
+func addToIPv6(ip net.IP, offset uint64) net.IP {
+       result := make(net.IP, len(ip))
+       copy(result, ip)
+
+       carry := offset
+       // Start from the least significant byte (rightmost) and work backwards
+       for i := len(result) - 1; i >= 0 && carry > 0; i-- {
+               sum := uint64(result[i]) + carry
+               result[i] = byte(sum & 0xff)
+               carry = sum >> 8
+       }
+
+       return result
+}
+
+func parseCIDRv6(d *schema.ResourceData, specifyiprange bool) 
(map[string]string, error) {
+       m := make(map[string]string, 4)
+
+       cidr := d.Get("ip6cidr").(string)
+       ip, ipnet, err := net.ParseCIDR(cidr)
+       if err != nil {
+               return nil, fmt.Errorf("Unable to parse cidr %s: %s", cidr, err)
+       }
+
+       // Validate that this is actually an IPv6 CIDR
+       if ip.To4() != nil {
+               return nil, fmt.Errorf("ip6cidr must be an IPv6 CIDR, got IPv4: 
%s", cidr)
+       }
+       if len(ipnet.Mask) != net.IPv6len {
+               return nil, fmt.Errorf("ip6cidr must be an IPv6 CIDR with 
16-byte mask, got %d bytes: %s", len(ipnet.Mask), cidr)
+       }
+
+       // Validate prefix length to ensure we have enough addresses for 
gateway/start/end
+       ones, _ := ipnet.Mask.Size()
+       if specifyiprange {
+               // When specifyiprange is true, we need at least 3 addresses:
+               // - gateway (network + 1)
+               // - start IP (network + 2)
+               // - end IP (network + 3 or more)
+               // This requires a /126 or larger prefix (4 addresses minimum)
+               if ones > 126 {
+                       return nil, fmt.Errorf("ip6cidr prefix /%d is too small 
for automatic IP range generation; minimum is /126 (4 addresses)", ones)
+               }
+       } else {
+               // When specifyiprange is false, we only need the gateway 
(network + 1)
+               // This requires a /127 or larger prefix (2 addresses minimum)
+               if ones > 127 {
+                       return nil, fmt.Errorf("ip6cidr prefix /%d is too small 
for automatic gateway generation; minimum is /127 (2 addresses)", ones)
+               }
+       }
+
+       if gateway, ok := d.GetOk("ip6gateway"); ok {
+               m["ip6gateway"] = gateway.(string)
+       } else {
+               // Default gateway to network address + 1 (e.g., 2001:db8::1)
+               gwip := addToIPv6(ipnet.IP, 1)
+               m["ip6gateway"] = gwip.String()
+       }
+
+       if startipv6, ok := d.GetOk("startipv6"); ok {
+               m["startipv6"] = startipv6.(string)
+       } else if specifyiprange {
+               // Default start IP to network address + 2
+               startip := addToIPv6(ipnet.IP, 2)
+               m["startipv6"] = startip.String()
+       }
+
+       if endip, ok := d.GetOk("endipv6"); ok {
+               m["endipv6"] = endip.(string)

Review Comment:
   User-supplied `ip6gateway`, `startipv6`, and `endipv6` are passed through to 
the CloudStack API without verifying that they fall within the `ip6cidr` 
subnet. If a user provides values outside the CIDR (e.g., a typo or wrong 
prefix), the failure will only surface as an opaque CloudStack API error. 
Consider validating with `ipnet.Contains(net.ParseIP(...))` and returning a 
clear local error before invoking the API, matching the spirit of the new 
prefix-length validation above.



##########
website/docs/r/network.html.markdown:
##########
@@ -43,6 +55,19 @@ The following arguments are supported:
 * `endip` - (Optional) End of the IP block that will be available on the
     network. Defaults to the last available IP in the range.
 
+* `ip6cidr` - (Optional) The IPv6 CIDR block for the network.  Changing this
+    forces a new resource to be created.

Review Comment:
   The new prefix-length validation (`/127` minimum without IP range, `/126` 
minimum with IP range) and the rejection of IPv4 CIDRs are not mentioned in the 
user-facing documentation for `ip6cidr`. Users who pass a `/128` or an IPv4 
string will see only a local error message at apply time with no documented 
constraint. Consider documenting the allowed prefix range and that the value 
must be an IPv6 CIDR.
   



##########
website/docs/r/network.html.markdown:
##########
@@ -43,6 +55,19 @@ The following arguments are supported:
 * `endip` - (Optional) End of the IP block that will be available on the
     network. Defaults to the last available IP in the range.
 
+* `ip6cidr` - (Optional) The IPv6 CIDR block for the network.  Changing this
+    forces a new resource to be created.
+
+* `ip6gateway` - (Optional) IPv6 Gateway that will be provided to the instances
+    in this network. Defaults to the second address in the subnet (network 
address + 1,
+    e.g., 2001:db8::1 for 2001:db8::/64).
+
+* `startipv6` - (Optional) Start of the IPv6 block that will be available on 
the
+    network. Defaults to the second available IP in the range.
+
+* `endipv6` - (Optional) End of the IPv6 block that will be available on the
+    network. Defaults to the last available IP in the range.

Review Comment:
   The documentation for `startipv6` and `endipv6` states they "Default to the 
second/last available IP in the range," but the implementation only assigns 
these defaults when the network offering has `specifyipranges` enabled. 
Otherwise, no default is computed and these attributes are simply not sent to 
the API (and will be empty in state). Please clarify in the docs that the 
default is conditional on the network offering's `specifyipranges` setting, 
similar to the behavior of `startip`/`endip`.
   



##########
cloudstack/resource_cloudstack_network_unit_test.go:
##########
@@ -0,0 +1,321 @@
+//
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+
+package cloudstack
+
+import (
+       "strings"
+       "testing"
+
+       "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
+)
+
+func TestParseCIDRv6_DefaultGateway(t *testing.T) {
+       d := schema.TestResourceDataRaw(t, resourceCloudStackNetwork().Schema, 
map[string]interface{}{
+               "ip6cidr": "2001:db8::/64",
+       })
+
+       result, err := parseCIDRv6(d, false)
+       if err != nil {
+               t.Fatalf("parseCIDRv6 failed: %v", err)
+       }
+
+       // Default gateway should be network address + 1
+       expectedGateway := "2001:db8::1"
+       if result["ip6gateway"] != expectedGateway {
+               t.Errorf("Expected gateway %s, got %s", expectedGateway, 
result["ip6gateway"])
+       }
+
+       // When specifyiprange is false, startipv6 and endipv6 should not be set
+       if _, ok := result["startipv6"]; ok {
+               t.Errorf("startipv6 should not be set when specifyiprange is 
false")
+       }
+       if _, ok := result["endipv6"]; ok {
+               t.Errorf("endipv6 should not be set when specifyiprange is 
false")
+       }
+}
+
+func TestParseCIDRv6_CustomGateway(t *testing.T) {
+       d := schema.TestResourceDataRaw(t, resourceCloudStackNetwork().Schema, 
map[string]interface{}{
+               "ip6cidr":    "2001:db8::/64",
+               "ip6gateway": "2001:db8::1",
+       })
+
+       result, err := parseCIDRv6(d, false)
+       if err != nil {
+               t.Fatalf("parseCIDRv6 failed: %v", err)
+       }
+
+       expectedGateway := "2001:db8::1"
+       if result["ip6gateway"] != expectedGateway {
+               t.Errorf("Expected gateway %s, got %s", expectedGateway, 
result["ip6gateway"])
+       }
+}
+
+func TestParseCIDRv6_WithIPRange(t *testing.T) {
+       d := schema.TestResourceDataRaw(t, resourceCloudStackNetwork().Schema, 
map[string]interface{}{
+               "ip6cidr": "2001:db8::/64",
+       })
+
+       result, err := parseCIDRv6(d, true)
+       if err != nil {
+               t.Fatalf("parseCIDRv6 failed: %v", err)
+       }
+
+       // Check gateway (should be network address + 1)
+       expectedGateway := "2001:db8::1"
+       if result["ip6gateway"] != expectedGateway {
+               t.Errorf("Expected gateway %s, got %s", expectedGateway, 
result["ip6gateway"])
+       }
+
+       // Check start IP (should be network address + 2)
+       expectedStartIP := "2001:db8::2"
+       if result["startipv6"] != expectedStartIP {
+               t.Errorf("Expected start IP %s, got %s", expectedStartIP, 
result["startipv6"])
+       }
+
+       // Check end IP (should be the last address in the /64 range)
+       expectedEndIP := "2001:db8::ffff:ffff:ffff:ffff"
+       if result["endipv6"] != expectedEndIP {
+               t.Errorf("Expected end IP %s, got %s", expectedEndIP, 
result["endipv6"])
+       }
+}
+
+func TestParseCIDRv6_CustomIPRange(t *testing.T) {
+       d := schema.TestResourceDataRaw(t, resourceCloudStackNetwork().Schema, 
map[string]interface{}{
+               "ip6cidr":   "2001:db8:1::/64",
+               "startipv6": "2001:db8:1::100",
+               "endipv6":   "2001:db8:1::200",
+       })
+
+       result, err := parseCIDRv6(d, true)
+       if err != nil {
+               t.Fatalf("parseCIDRv6 failed: %v", err)
+       }
+
+       // Check that custom values are used
+       if result["startipv6"] != "2001:db8:1::100" {
+               t.Errorf("Expected custom start IP 2001:db8:1::100, got %s", 
result["startipv6"])
+       }
+       if result["endipv6"] != "2001:db8:1::200" {
+               t.Errorf("Expected custom end IP 2001:db8:1::200, got %s", 
result["endipv6"])
+       }
+}
+
+func TestParseCIDRv6_SmallerPrefix(t *testing.T) {
+       d := schema.TestResourceDataRaw(t, resourceCloudStackNetwork().Schema, 
map[string]interface{}{
+               "ip6cidr": "2001:db8::/48",
+       })
+
+       result, err := parseCIDRv6(d, true)
+       if err != nil {
+               t.Fatalf("parseCIDRv6 failed: %v", err)
+       }
+
+       // For a /48, the end IP should have the last 80 bits set to 1
+       expectedEndIP := "2001:db8:0:ffff:ffff:ffff:ffff:ffff"
+       if result["endipv6"] != expectedEndIP {
+               t.Errorf("Expected end IP %s, got %s", expectedEndIP, 
result["endipv6"])
+       }
+}
+
+func TestParseCIDRv6_RejectsIPv4(t *testing.T) {
+       d := schema.TestResourceDataRaw(t, resourceCloudStackNetwork().Schema, 
map[string]interface{}{
+               "ip6cidr": "10.0.0.0/24",
+       })
+
+       _, err := parseCIDRv6(d, false)
+       if err == nil {
+               t.Fatal("parseCIDRv6 should reject IPv4 CIDR")
+       }
+
+       expectedError := "ip6cidr must be an IPv6 CIDR, got IPv4"
+       if !strings.HasPrefix(err.Error(), expectedError) {
+               t.Errorf("Expected error message to start with '%s', got '%s'", 
expectedError, err.Error())
+       }
+}
+
+func TestParseCIDRv6_Prefix128_NoIPRange(t *testing.T) {
+       // /128 is a single address - should fail even without IP range
+       d := schema.TestResourceDataRaw(t, resourceCloudStackNetwork().Schema, 
map[string]interface{}{
+               "ip6cidr": "2001:db8::1/128",
+       })
+
+       _, err := parseCIDRv6(d, false)
+       if err == nil {
+               t.Fatal("parseCIDRv6 should reject /128 prefix (single 
address)")
+       }
+
+       expectedError := "ip6cidr prefix /128 is too small"
+       if !strings.HasPrefix(err.Error(), expectedError) {
+               t.Errorf("Expected error message to start with '%s', got '%s'", 
expectedError, err.Error())
+       }
+}
+
+func TestParseCIDRv6_Prefix127_NoIPRange(t *testing.T) {
+       // /127 has 2 addresses - should work without IP range (only needs 
gateway)
+       d := schema.TestResourceDataRaw(t, resourceCloudStackNetwork().Schema, 
map[string]interface{}{
+               "ip6cidr": "2001:db8::/127",
+       })
+
+       result, err := parseCIDRv6(d, false)
+       if err != nil {
+               t.Fatalf("parseCIDRv6 should accept /127 prefix without IP 
range: %v", err)
+       }
+
+       // Should have gateway
+       if _, ok := result["ip6gateway"]; !ok {
+               t.Error("Expected ip6gateway to be set")
+       }
+
+       // Should not have start/end IP
+       if _, ok := result["startipv6"]; ok {
+               t.Error("startipv6 should not be set when specifyiprange is 
false")
+       }
+}
+
+func TestParseCIDRv6_Prefix127_WithIPRange(t *testing.T) {
+       // /127 has only 2 addresses - should fail with IP range (needs 3+ 
addresses)
+       d := schema.TestResourceDataRaw(t, resourceCloudStackNetwork().Schema, 
map[string]interface{}{
+               "ip6cidr": "2001:db8::/127",
+       })
+
+       _, err := parseCIDRv6(d, true)
+       if err == nil {
+               t.Fatal("parseCIDRv6 should reject /127 prefix with IP range 
(only 2 addresses)")
+       }
+
+       expectedError := "ip6cidr prefix /127 is too small for automatic IP 
range generation"
+       if !strings.HasPrefix(err.Error(), expectedError) {
+               t.Errorf("Expected error message to start with '%s', got '%s'", 
expectedError, err.Error())
+       }
+}
+
+func TestParseCIDRv6_Prefix126_WithIPRange(t *testing.T) {
+       // /126 has 4 addresses - should work with IP range
+       d := schema.TestResourceDataRaw(t, resourceCloudStackNetwork().Schema, 
map[string]interface{}{
+               "ip6cidr": "2001:db8::/126",
+       })
+
+       result, err := parseCIDRv6(d, true)
+       if err != nil {
+               t.Fatalf("parseCIDRv6 should accept /126 prefix with IP range: 
%v", err)
+       }
+
+       // Should have gateway, start, and end
+       if _, ok := result["ip6gateway"]; !ok {
+               t.Error("Expected ip6gateway to be set")
+       }
+       if _, ok := result["startipv6"]; !ok {
+               t.Error("Expected startipv6 to be set")
+       }
+       if _, ok := result["endipv6"]; !ok {
+               t.Error("Expected endipv6 to be set")
+       }
+
+       // Verify the end IP is correct for /126 (last 2 bits set to 1)
+       expectedEndIP := "2001:db8::3"
+       if result["endipv6"] != expectedEndIP {
+               t.Errorf("Expected end IP %s for /126, got %s", expectedEndIP, 
result["endipv6"])
+       }
+}
+
+func TestParseCIDRv6_NonZeroNetworkAddress(t *testing.T) {
+       // Test with a CIDR where the network address doesn't end in ::0
+       // This tests the fix for proper IPv6 address arithmetic with carry
+       d := schema.TestResourceDataRaw(t, resourceCloudStackNetwork().Schema, 
map[string]interface{}{
+               "ip6cidr": "2001:db8::4/126",
+       })
+
+       result, err := parseCIDRv6(d, true)
+       if err != nil {
+               t.Fatalf("parseCIDRv6 failed: %v", err)
+       }
+
+       // For 2001:db8::4/126, the network is 2001:db8::4
+       // Gateway should be network + 1 = 2001:db8::5
+       expectedGateway := "2001:db8::5"
+       if result["ip6gateway"] != expectedGateway {
+               t.Errorf("Expected gateway %s, got %s", expectedGateway, 
result["ip6gateway"])
+       }
+
+       // Start IP should be network + 2 = 2001:db8::6
+       expectedStartIP := "2001:db8::6"
+       if result["startipv6"] != expectedStartIP {
+               t.Errorf("Expected start IP %s, got %s", expectedStartIP, 
result["startipv6"])
+       }
+
+       // End IP should be network + 3 = 2001:db8::7 (last address in /126)
+       expectedEndIP := "2001:db8::7"
+       if result["endipv6"] != expectedEndIP {
+               t.Errorf("Expected end IP %s, got %s", expectedEndIP, 
result["endipv6"])
+       }
+}
+
+func TestParseCIDRv6_NonAlignedPrefix(t *testing.T) {
+       // Test with a /124 prefix where network address has non-zero low-order 
bits
+       d := schema.TestResourceDataRaw(t, resourceCloudStackNetwork().Schema, 
map[string]interface{}{
+               "ip6cidr": "2001:db8::f0/124",
+       })
+
+       result, err := parseCIDRv6(d, true)
+       if err != nil {
+               t.Fatalf("parseCIDRv6 failed: %v", err)
+       }
+
+       // For 2001:db8::f0/124, the network is 2001:db8::f0
+       // Gateway should be network + 1 = 2001:db8::f1
+       expectedGateway := "2001:db8::f1"
+       if result["ip6gateway"] != expectedGateway {
+               t.Errorf("Expected gateway %s, got %s", expectedGateway, 
result["ip6gateway"])
+       }
+
+       // Start IP should be network + 2 = 2001:db8::f2
+       expectedStartIP := "2001:db8::f2"
+       if result["startipv6"] != expectedStartIP {
+               t.Errorf("Expected start IP %s, got %s", expectedStartIP, 
result["startipv6"])
+       }
+
+       // End IP should be 2001:db8::ff (last address in /124)
+       expectedEndIP := "2001:db8::ff"
+       if result["endipv6"] != expectedEndIP {
+               t.Errorf("Expected end IP %s, got %s", expectedEndIP, 
result["endipv6"])
+       }
+}
+
+func TestParseCIDRv6_CarryAcrossBytes(t *testing.T) {
+       // Test carry across byte boundaries
+       // Use a network address ending in ::ff to test carry to next byte
+       d := schema.TestResourceDataRaw(t, resourceCloudStackNetwork().Schema, 
map[string]interface{}{
+               "ip6cidr": "2001:db8::ff/120",
+       })
+
+       result, err := parseCIDRv6(d, true)
+       if err != nil {
+               t.Fatalf("parseCIDRv6 failed: %v", err)
+       }
+
+       // For 2001:db8::ff/120, network is 2001:db8::0 (masked)
+       // But let's test with an address that will actually carry
+       // Gateway should be network + 1
+       expectedGateway := "2001:db8::1"
+       if result["ip6gateway"] != expectedGateway {
+               t.Errorf("Expected gateway %s, got %s", expectedGateway, 
result["ip6gateway"])
+       }
+}

Review Comment:
   This test name and comment describe testing "carry across byte boundaries," 
but the CIDR `2001:db8::ff/120` is masked to network `2001:db8::00`, so the 
resulting `gateway = network + 1 = ::1` never exercises any carry. The body 
comment even admits this ("But let's test with an address that will actually 
carry"). To actually verify `addToIPv6`'s carry behavior, use a CIDR whose 
masked network address ends in a byte boundary that causes a real carry when 
adding 1 or 2 (e.g., a `/120` whose unmasked input wouldn't help — instead 
construct a unit test that calls `addToIPv6` directly with an address ending in 
`...:ff` and offset 1, or use a CIDR like `2001:db8::ff00/120` if testing 
through the wrapper).



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to