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 b17357b Add IPv6 support to cloudstack_network resource (#282)
b17357b is described below
commit b17357bb1a230658bf055d0608dff11f82303e3f
Author: Brad House - Nexthop <[email protected]>
AuthorDate: Mon Aug 17 05:23:36 2026 -0400
Add IPv6 support to cloudstack_network resource (#282)
* Add IPv6 support to cloudstack_network resource
This commit adds comprehensive IPv6 support to the cloudstack_network
resource,
allowing users to configure IPv6 CIDR blocks, gateways, and IP ranges for
CloudStack networks.
## New Features
### Schema Fields
- ip6cidr: IPv6 CIDR block for the network (e.g., "2001:db8::/64")
- ip6gateway: IPv6 gateway address (optional, defaults to network address +
1)
- startipv6: Starting IPv6 address for the IP range (optional)
- endipv6: Ending IPv6 address for the IP range (optional)
### Implementation Details
#### Network Creation (resourceCloudStackNetworkCreate)
- Added IPv6 CIDR parsing and validation using parseCIDRv6() helper
- Automatically calculates IPv6 gateway (defaults to network address + 1,
e.g., 2001:db8::1)
- Automatically generates IPv6 IP range when specifyiprange is enabled
- Properly sets IPv6 parameters on CloudStack API calls
#### Network Read (resourceCloudStackNetworkRead)
- Reads IPv6 CIDR and gateway from CloudStack API
- Only sets IPv6 fields in state when they have non-empty values
- Prevents unwanted plan diffs when IPv6 is not configured
#### Helper Function: parseCIDRv6
- Parses IPv6 CIDR notation using Go's net.ParseCIDR
- Calculates default gateway (network address + 1, e.g., prefix::1)
- Generates start IP (network address + 2)
- Generates end IP (last address in CIDR range using bitwise operations)
- Supports custom gateway and IP range specification
## Test Coverage
### Acceptance Tests (3 new tests)
- TestAccCloudStackNetwork_ipv6: Basic IPv6 network with ip6cidr
- TestAccCloudStackNetwork_ipv6_vpc: IPv6 network within a VPC
- TestAccCloudStackNetwork_ipv6_custom_gateway: IPv6 with custom gateway
Note: These tests skip gracefully on CloudStack simulator (error 4350)
because
the simulator only supports IPv6 with advanced shared network offerings.
Tests
will work correctly on real CloudStack environments with proper IPv6
support.
### Unit Tests (5 new tests in resource_cloudstack_network_unit_test.go)
- TestParseCIDRv6_DefaultGateway: Verifies default gateway calculation
(network + 1)
- TestParseCIDRv6_CustomGateway: Tests custom gateway specification
- TestParseCIDRv6_WithIPRange: Tests automatic IP range generation
- TestParseCIDRv6_CustomIPRange: Tests custom start/end IP specification
- TestParseCIDRv6_SmallerPrefix: Tests different prefix lengths (/48, /64)
All unit tests pass and validate the IPv6 CIDR parsing logic independently
of the CloudStack API.
## Documentation
### Updated website/docs/r/network.html.markdown
- Added IPv6 usage example showing ip6cidr configuration
- Added ip6gateway to exported attributes reference with clear default
behavior
- Added gateway to exported attributes reference for completeness
### Test Documentation
- Added comments explaining IPv6 test limitations with simulator
- Referenced unit tests for developers wanting to verify IPv6 logic
## Usage Example
```hcl
resource "cloudstack_network" "ipv6" {
name = "test-network-ipv6"
cidr = "10.0.0.0/16"
ip6cidr = "2001:db8::/64"
network_offering = "Default Network"
zone = "zone-1"
}
```
The above example will create a network with:
- IPv4: 10.0.0.0/16
- IPv6: 2001:db8::/64
- IPv6 Gateway: 2001:db8::1 (automatically calculated)
## Verification
- Build: Clean (no compilation errors)
- Vet: Clean (no warnings)
- Unit Tests: 5/5 passing
- Acceptance Tests: 6/6 passing (existing), 3/3 skipping appropriately
(IPv6)
- All existing network tests continue to pass without regression
* Remove unnecessary 'none' check from ip6cidr validation
The ip6cidr field does not have a default value of 'none' (unlike aclid),
so checking for != none is unnecessary and could cause confusion if a user
actually tries to set ip6cidr = "none". The GetOk() check is sufficient
to determine if the field has been set.
* Add IPv6 validation to parseCIDRv6 to prevent IPv4 CIDR panics
The parseCIDRv6 function now explicitly validates that the provided CIDR
is IPv6, not IPv4. Previously, net.ParseCIDR() would accept IPv4 CIDRs,
and To16() would return a non-nil value (IPv4-mapped IPv6), but the mask
would only be 4 bytes. This caused a panic when the code tried to index
ipnet.Mask[i] assuming a 16-byte mask.
The fix adds two validation checks:
1. ip.To4() == nil (ensures it's not IPv4)
2. len(ipnet.Mask) == net.IPv6len (ensures 16-byte mask)
Added unit test TestParseCIDRv6_RejectsIPv4 to verify the validation.
* Add prefix length validation for IPv6 gateway and IP range defaults
The code previously assumed it could always use network+1 for gateway and
network+2 for start IP, but this fails for very small prefixes:
- /128 (1 address): Cannot accommodate gateway
- /127 (2 addresses): Can accommodate gateway but not start/end IP range
Added validation to ensure:
- When specifyiprange is false: minimum /127 (2 addresses for gateway)
- When specifyiprange is true: minimum /126 (4 addresses for gateway +
range)
Added comprehensive unit tests for edge cases:
- TestParseCIDRv6_Prefix128_NoIPRange: Rejects /128 (too small)
- TestParseCIDRv6_Prefix127_NoIPRange: Accepts /127 without IP range
- TestParseCIDRv6_Prefix127_WithIPRange: Rejects /127 with IP range
- TestParseCIDRv6_Prefix126_WithIPRange: Accepts /126 with IP range
* Make IPv6 acceptance tests conditionally skip based on environment
IPv6 acceptance tests are now conditionally skipped instead of being
unconditionally skipped. The tests will:
1. Skip on localhost/127.0.0.1 (assumed to be simulator) by default
2. Run on other API URLs (assumed to be real CloudStack)
3. Can be force-enabled via CLOUDSTACK_ENABLE_IPV6_TESTS=true env var
This allows the tests to run on real CloudStack environments with IPv6
support while still skipping on the simulator where IPv6 is not supported
for isolated networks.
Added testAccPreCheckIPv6Support() helper function that checks:
- Standard testAccPreCheck() requirements
- CLOUDSTACK_ENABLE_IPV6_TESTS environment variable for override
- API URL to detect simulator (localhost/127.0.0.1)
* Always set IPv6 fields in Read to properly detect drift
* Fix unsafe error string comparison in unit tests
* normalize ipv6 address
* Fix undefined 'no' variable in IPv6 network creation
Co-authored-by: Suresh Kumar Anaparti <[email protected]>
Co-authored-by: Manoj Kumar <[email protected]>
---
cloudstack/resource_cloudstack_network.go | 207 ++++++++++++-
cloudstack/resource_cloudstack_network_test.go | 161 ++++++++++
.../resource_cloudstack_network_unit_test.go | 326 +++++++++++++++++++++
website/docs/r/network.html.markdown | 34 +++
4 files changed, 722 insertions(+), 6 deletions(-)
diff --git a/cloudstack/resource_cloudstack_network.go
b/cloudstack/resource_cloudstack_network.go
index c38f9c2..3a92dcc 100644
--- a/cloudstack/resource_cloudstack_network.go
+++ b/cloudstack/resource_cloudstack_network.go
@@ -79,6 +79,32 @@ func resourceCloudStackNetwork() *schema.Resource {
ForceNew: true,
},
+ "ip6cidr": {
+ Type: schema.TypeString,
+ Optional: true,
+ ForceNew: true,
+ StateFunc: func(v interface{}) string {
+ s, ok := v.(string)
+ if !ok {
+ return ""
+ }
+
+ // Leave empty value unchanged.
+ if s == "" {
+ return s
+ }
+
+ // Parse and canonicalize the IPv6
CIDR. If parsing fails,
+ // return the original string so
invalid input is not altered.
+ _, ipnet, err := net.ParseCIDR(s)
+ if err != nil {
+ return s
+ }
+
+ return ipnet.String()
+ },
+ },
+
"gateway": {
Type: schema.TypeString,
Optional: true,
@@ -87,6 +113,13 @@ func resourceCloudStackNetwork() *schema.Resource {
RequiredWith: []string{"cidr"},
},
+ "ip6gateway": {
+ Type: schema.TypeString,
+ Optional: true,
+ Computed: true,
+ ForceNew: true,
+ },
+
"startip": {
Type: schema.TypeString,
Optional: true,
@@ -103,6 +136,20 @@ func resourceCloudStackNetwork() *schema.Resource {
RequiredWith: []string{"cidr"},
},
+ "startipv6": {
+ Type: schema.TypeString,
+ Optional: true,
+ Computed: true,
+ ForceNew: true,
+ },
+
+ "endipv6": {
+ Type: schema.TypeString,
+ Optional: true,
+ Computed: true,
+ ForceNew: true,
+ },
+
"network_domain": {
Type: schema.TypeString,
Optional: true,
@@ -188,13 +235,13 @@ func resourceCloudStackNetworkCreate(d
*schema.ResourceData, meta interface{}) e
p.SetDisplaytext(name)
}
- if _, ok := d.GetOk("cidr"); ok {
- // Get the network offering to check if it supports specifying
IP ranges
- no, _, err :=
cs.NetworkOffering.GetNetworkOfferingByID(networkofferingid)
- if err != nil {
- return err
- }
+ // Get the network offering to check if it supports specifying IP ranges
+ no, _, err :=
cs.NetworkOffering.GetNetworkOfferingByID(networkofferingid)
+ if err != nil {
+ return err
+ }
+ if _, ok := d.GetOk("cidr"); ok {
m, err := parseCIDR(d, no.Specifyipranges)
if err != nil {
return err
@@ -215,6 +262,31 @@ func resourceCloudStackNetworkCreate(d
*schema.ResourceData, meta interface{}) e
}
}
+ // IPv6 support
+ if ip6cidr, ok := d.GetOk("ip6cidr"); ok {
+ m6, err := parseCIDRv6(d, no.Specifyipranges)
+ if err != nil {
+ return err
+ }
+
+ p.SetIp6cidr(ip6cidr.(string))
+
+ // Only set the start IPv6 if we have one
+ if startipv6, ok := m6["startipv6"]; ok {
+ p.SetStartipv6(startipv6)
+ }
+
+ // Only set the ipv6 gateway if we have one
+ if ip6gateway, ok := m6["ip6gateway"]; ok {
+ p.SetIp6gateway(ip6gateway)
+ }
+
+ // Only set the end IPv6 if we have one
+ if endipv6, ok := m6["endipv6"]; ok {
+ p.SetEndipv6(endipv6)
+ }
+ }
+
// Set the network domain if we have one
if networkDomain, ok := d.GetOk("network_domain"); ok {
p.SetNetworkdomain(networkDomain.(string))
@@ -339,6 +411,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
+
if n.Aclid == "" {
n.Aclid = none
}
@@ -504,3 +583,119 @@ 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
+}
+
+// validateIPv6InCIDR verifies that a user-supplied IPv6 address parses and
falls
+// within the given network. It returns a clear local error instead of letting
an
+// out-of-subnet value surface as an opaque CloudStack API failure at apply
time.
+func validateIPv6InCIDR(field, value string, ipnet *net.IPNet) error {
+ ip := net.ParseIP(value)
+ if ip == nil {
+ return fmt.Errorf("%s %q is not a valid IP address", field,
value)
+ }
+ if !ipnet.Contains(ip) {
+ return fmt.Errorf("%s %q is not within ip6cidr %s", field,
value, ipnet.String())
+ }
+ return nil
+}
+
+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 {
+ gw := gateway.(string)
+ if err := validateIPv6InCIDR("ip6gateway", gw, ipnet); err !=
nil {
+ return nil, err
+ }
+ m["ip6gateway"] = gw
+ } 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 {
+ start := startipv6.(string)
+ if err := validateIPv6InCIDR("startipv6", start, ipnet); err !=
nil {
+ return nil, err
+ }
+ m["startipv6"] = start
+ } 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 {
+ end := endip.(string)
+ if err := validateIPv6InCIDR("endipv6", end, ipnet); err != nil
{
+ return nil, err
+ }
+ m["endipv6"] = end
+ } else if specifyiprange {
+ ip16 := ipnet.IP.To16()
+ if ip16 == nil {
+ return nil, fmt.Errorf("cidr not valid for ipv6")
+ }
+
+ last := make(net.IP, len(ip16))
+ copy(last, ip16)
+
+ for i := range ip16 {
+ // Perform bitwise OR with the inverse of the mask
+ last[i] |= ^ipnet.Mask[i]
+ }
+ m["endipv6"] = last.String()
+ }
+
+ return m, nil
+}
diff --git a/cloudstack/resource_cloudstack_network_test.go
b/cloudstack/resource_cloudstack_network_test.go
index 22451bc..044ac88 100644
--- a/cloudstack/resource_cloudstack_network_test.go
+++ b/cloudstack/resource_cloudstack_network_test.go
@@ -17,10 +17,20 @@
// under the License.
//
+// NOTE: IPv6 acceptance tests (TestAccCloudStackNetwork_ipv6*) are
conditionally
+// skipped when running against the CloudStack simulator because the simulator
+// only supports IPv6 with advanced shared network offerings. These tests will
+// run on real CloudStack environments with proper IPv6 support. Set the
environment
+// variable CLOUDSTACK_ENABLE_IPV6_TESTS=true to force-enable IPv6 tests.
+// Unit tests for the IPv6 CIDR parsing logic are available in
+// resource_cloudstack_network_unit_test.go and do not require a CloudStack
instance.
+
package cloudstack
import (
"fmt"
+ "os"
+ "strings"
"testing"
"github.com/apache/cloudstack-go/v2/cloudstack"
@@ -189,6 +199,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.")
+ }
+}
+
+func TestAccCloudStackNetwork_ipv6(t *testing.T) {
+ var network cloudstack.Network
+
+ resource.Test(t, resource.TestCase{
+ PreCheck: func() { testAccPreCheckIPv6Support(t) },
+ Providers: testAccProviders,
+ CheckDestroy: testAccCheckCloudStackNetworkDestroy,
+ Steps: []resource.TestStep{
+ {
+ Config: testAccCloudStackNetwork_ipv6,
+ Check: resource.ComposeTestCheckFunc(
+ testAccCheckCloudStackNetworkExists(
+ "cloudstack_network.foo",
&network),
+
testAccCheckCloudStackNetworkIPv6Attributes(&network),
+ resource.TestCheckResourceAttr(
+ "cloudstack_network.foo",
"ip6cidr", "2001:db8::/64"),
+ ),
+ },
+ },
+ })
+}
+
+func TestAccCloudStackNetwork_ipv6_vpc(t *testing.T) {
+ var network cloudstack.Network
+
+ resource.Test(t, resource.TestCase{
+ PreCheck: func() { testAccPreCheckIPv6Support(t) },
+ Providers: testAccProviders,
+ CheckDestroy: testAccCheckCloudStackNetworkDestroy,
+ Steps: []resource.TestStep{
+ {
+ Config: testAccCloudStackNetwork_ipv6_vpc,
+ Check: resource.ComposeTestCheckFunc(
+ testAccCheckCloudStackNetworkExists(
+ "cloudstack_network.foo",
&network),
+ resource.TestCheckResourceAttr(
+ "cloudstack_network.foo",
"ip6cidr", "2001:db8:1::/64"),
+ ),
+ },
+ },
+ })
+}
+
+func TestAccCloudStackNetwork_ipv6_custom_gateway(t *testing.T) {
+ var network cloudstack.Network
+
+ resource.Test(t, resource.TestCase{
+ PreCheck: func() { testAccPreCheckIPv6Support(t) },
+ Providers: testAccProviders,
+ CheckDestroy: testAccCheckCloudStackNetworkDestroy,
+ Steps: []resource.TestStep{
+ {
+ Config:
testAccCloudStackNetwork_ipv6_custom_gateway,
+ Check: resource.ComposeTestCheckFunc(
+ testAccCheckCloudStackNetworkExists(
+ "cloudstack_network.foo",
&network),
+ resource.TestCheckResourceAttr(
+ "cloudstack_network.foo",
"ip6cidr", "2001:db8:2::/64"),
+ resource.TestCheckResourceAttr(
+ "cloudstack_network.foo",
"ip6gateway", "2001:db8:2::1"),
+ ),
+ },
+ },
+ })
+}
+
func testAccCheckCloudStackNetworkExists(
n string, network *cloudstack.Network) resource.TestCheckFunc {
return func(s *terraform.State) error {
@@ -268,6 +362,34 @@ func testAccCheckCloudStackNetworkVPCAttributes(
}
}
+func testAccCheckCloudStackNetworkIPv6Attributes(
+ network *cloudstack.Network) resource.TestCheckFunc {
+ return func(s *terraform.State) error {
+
+ if network.Name != "terraform-network-ipv6" {
+ return fmt.Errorf("Bad name: %s", network.Name)
+ }
+
+ if network.Displaytext != "terraform-network-ipv6" {
+ return fmt.Errorf("Bad display name: %s",
network.Displaytext)
+ }
+
+ if network.Cidr != "10.1.2.0/24" {
+ return fmt.Errorf("Bad CIDR: %s", network.Cidr)
+ }
+
+ if network.Ip6cidr != "2001:db8::/64" {
+ return fmt.Errorf("Bad IPv6 CIDR: %s", network.Ip6cidr)
+ }
+
+ if network.Networkofferingname !=
"DefaultIsolatedNetworkOfferingWithSourceNatService" {
+ return fmt.Errorf("Bad network offering: %s",
network.Networkofferingname)
+ }
+
+ return nil
+ }
+}
+
func testAccCheckCloudStackNetworkProjectInherited(
network *cloudstack.Network) resource.TestCheckFunc {
return func(s *terraform.State) error {
@@ -470,6 +592,45 @@ resource "cloudstack_network" "isolated_no_cidr" {
zone = "Sandbox-simulator"
}`
+const testAccCloudStackNetwork_ipv6 = `
+resource "cloudstack_network" "foo" {
+ name = "terraform-network-ipv6"
+ display_text = "terraform-network-ipv6"
+ cidr = "10.1.2.0/24"
+ ip6cidr = "2001:db8::/64"
+ network_offering = "DefaultIsolatedNetworkOfferingWithSourceNatService"
+ zone = "Sandbox-simulator"
+}`
+
+const testAccCloudStackNetwork_ipv6_vpc = `
+resource "cloudstack_vpc" "foo" {
+ name = "terraform-vpc-ipv6"
+ cidr = "10.0.0.0/8"
+ vpc_offering = "Default VPC offering"
+ zone = "Sandbox-simulator"
+}
+
+resource "cloudstack_network" "foo" {
+ name = "terraform-network-ipv6"
+ display_text = "terraform-network-ipv6"
+ cidr = "10.1.1.0/24"
+ ip6cidr = "2001:db8:1::/64"
+ network_offering = "DefaultIsolatedNetworkOfferingForVpcNetworks"
+ vpc_id = cloudstack_vpc.foo.id
+ zone = cloudstack_vpc.foo.zone
+}`
+
+const testAccCloudStackNetwork_ipv6_custom_gateway = `
+resource "cloudstack_network" "foo" {
+ name = "terraform-network-ipv6-custom"
+ display_text = "terraform-network-ipv6-custom"
+ cidr = "10.1.3.0/24"
+ ip6cidr = "2001:db8:2::/64"
+ ip6gateway = "2001:db8:2::1"
+ network_offering = "DefaultIsolatedNetworkOfferingWithSourceNatService"
+ zone = "Sandbox-simulator"
+}`
+
const testAccCloudStackNetwork_vpcProjectInheritance = `
resource "cloudstack_vpc" "foo" {
name = "terraform-vpc"
diff --git a/cloudstack/resource_cloudstack_network_unit_test.go
b/cloudstack/resource_cloudstack_network_unit_test.go
new file mode 100644
index 0000000..78896ab
--- /dev/null
+++ b/cloudstack/resource_cloudstack_network_unit_test.go
@@ -0,0 +1,326 @@
+//
+// 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 (
+ "net"
+ "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 TestAddToIPv6_CarryAcrossBytes(t *testing.T) {
+ // Exercise addToIPv6 directly with addresses that force a carry from
the
+ // low byte into higher bytes (which a masked network address never
would).
+ cases := []struct {
+ name string
+ input string
+ offset uint64
+ expected string
+ }{
+ {"carry into next byte", "2001:db8::ff", 1, "2001:db8::100"},
+ {"carry across two bytes", "2001:db8::ffff", 1,
"2001:db8::1:0"},
+ {"offset of two with carry", "2001:db8::ff", 2,
"2001:db8::101"},
+ {"no carry", "2001:db8::", 1, "2001:db8::1"},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ got := addToIPv6(net.ParseIP(tc.input),
tc.offset).String()
+ if got != tc.expected {
+ t.Errorf("addToIPv6(%s, %d) = %s, expected %s",
tc.input, tc.offset, got, tc.expected)
+ }
+ })
+ }
+}
diff --git a/website/docs/r/network.html.markdown
b/website/docs/r/network.html.markdown
index 6585ebd..72f6d56 100644
--- a/website/docs/r/network.html.markdown
+++ b/website/docs/r/network.html.markdown
@@ -23,6 +23,18 @@ resource "cloudstack_network" "default" {
}
```
+With IPv6 support:
+
+```hcl
+resource "cloudstack_network" "ipv6" {
+ name = "test-network-ipv6"
+ cidr = "10.0.0.0/16"
+ ip6cidr = "2001:db8::/64"
+ network_offering = "Default Network"
+ zone = "zone-1"
+}
+```
+
VPC network with automatic project inheritance:
```hcl
@@ -64,6 +76,26 @@ 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. Must be a valid
+ IPv6 CIDR (IPv4 CIDRs are rejected). The prefix must be at least `/127` (or
+ `/126` when the network offering has `specifyipranges` enabled) so that the
+ gateway and, when applicable, the IP range can be derived. Changing this
+ forces a new resource to be created.
+
+* `ip6gateway` - (Optional) IPv6 Gateway that will be provided to the instances
+ in this network. Must fall within `ip6cidr`. 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. Must fall within `ip6cidr`. Only applied when the network offering
+ has `specifyipranges` enabled; in that case it defaults to the second
+ available IP in the range (otherwise it is not sent to the API).
+
+* `endipv6` - (Optional) End of the IPv6 block that will be available on the
+ network. Must fall within `ip6cidr`. Only applied when the network offering
+ has `specifyipranges` enabled; in that case it defaults to the last
+ available IP in the range (otherwise it is not sent to the API).
+
* `network_domain` - (Optional) DNS domain for the network.
* `network_offering` - (Required) The name or ID of the network offering to use
@@ -104,6 +136,8 @@ The following attributes are exported:
* `id` - The ID of the network.
* `display_text` - The display text of the network.
+* `gateway` - The IPv4 gateway of the network.
+* `ip6gateway` - The IPv6 gateway of the network.
* `network_domain` - DNS domain for the network.
* `source_nat_ip_address` - The associated source NAT IP.
* `source_nat_ip_id` - The ID of the associated source NAT IP.