Skip to content
Open
169 changes: 169 additions & 0 deletions cloudstack/resource_cloudstack_network.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,13 +78,46 @@ 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,
Computed: true,
ForceNew: true,
},

"ip6gateway": {
Type: schema.TypeString,
Optional: true,
Computed: true,
ForceNew: true,
},

"startip": {
Type: schema.TypeString,
Optional: true,
Expand All @@ -99,6 +132,20 @@ func resourceCloudStackNetwork() *schema.Resource {
ForceNew: true,
},

"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,
Expand Down Expand Up @@ -209,6 +256,31 @@ func resourceCloudStackNetworkCreate(d *schema.ResourceData, meta interface{}) e
p.SetEndip(endip)
}

// 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))
Expand Down Expand Up @@ -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

if n.Aclid == "" {
n.Aclid = none
}
Expand Down Expand Up @@ -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)
} 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
}
161 changes: 161 additions & 0 deletions cloudstack/resource_cloudstack_network_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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.")
}
}

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 {
Expand Down Expand Up @@ -244,6 +338,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 testAccCheckCloudStackNetworkDestroy(s *terraform.State) error {
cs := testAccProvider.Meta().(*cloudstack.CloudStackClient)

Expand Down Expand Up @@ -377,3 +499,42 @@ resource "cloudstack_network" "foo" {
acl_id = cloudstack_network_acl.bar.id
zone = cloudstack_vpc.foo.zone
}`

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"
}`
Loading
Loading