Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/acceptance.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ permissions:

env:
CLOUDSTACK_API_URL: http://localhost:8080/client/api
CLOUDSTACK_VERSIONS: "['4.19.0.1', '4.19.1.3', '4.19.2.0', '4.19.3.0', '4.20.1.0']"
CLOUDSTACK_VERSIONS: "['4.19.0.1', '4.19.1.3', '4.19.2.0', '4.19.3.0', '4.20.1.0', '4.22.0.0']"

jobs:
prepare-matrix:
Expand Down
89 changes: 89 additions & 0 deletions cloudstack/provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,14 @@ package cloudstack

import (
"context"
"fmt"
"os"
"regexp"
"strconv"
"strings"
"testing"

"github.com/apache/cloudstack-go/v2/cloudstack"
"github.com/hashicorp/terraform-plugin-framework/providerserver"
"github.com/hashicorp/terraform-plugin-go/tfprotov6"
"github.com/hashicorp/terraform-plugin-mux/tf5to6server"
Expand Down Expand Up @@ -145,3 +149,88 @@ func testAccPreCheck(t *testing.T) {
t.Fatal("CLOUDSTACK_SECRET_KEY must be set for acceptance tests")
}
}

// parseCloudStackVersion parses a CloudStack version string (e.g., "4.22.0.0")
// and returns a numeric value for comparison (e.g., 4.22 -> 4022).
// The numeric value is calculated as: major * 1000 + minor.
// Returns 0 if the version string cannot be parsed.
func parseCloudStackVersion(version string) int {
parts := strings.Split(version, ".")
if len(parts) < 2 {
return 0
}

major := 0
minor := 0

// Parse major version - extract first numeric part
majorStr := regexp.MustCompile(`^\d+`).FindString(parts[0])
if majorStr != "" {
major, _ = strconv.Atoi(majorStr)
}

// Parse minor version - extract first numeric part
minorStr := regexp.MustCompile(`^\d+`).FindString(parts[1])
if minorStr != "" {
minor, _ = strconv.Atoi(minorStr)
}

return major*1000 + minor
}

// getCloudStackVersion retrieves the CloudStack version from the API.
// Returns the version string and any error encountered.
func getCloudStackVersion(cs *cloudstack.CloudStackClient) (string, error) {
p := cs.Configuration.NewListCapabilitiesParams()
caps, err := cs.Configuration.ListCapabilities(p)
if err != nil {
return "", err
}

if caps != nil && caps.Capabilities != nil && caps.Capabilities.Cloudstackversion != "" {
return caps.Capabilities.Cloudstackversion, nil
}

return "", fmt.Errorf("unable to determine CloudStack version")
}

// requireMinimumCloudStackVersion checks if the CloudStack version meets the minimum requirement.
// If the version is below the minimum, it skips the test with an appropriate message.
// The minVersion parameter should be in the format returned by parseCloudStackVersion (e.g., 4022 for 4.22.0).
func requireMinimumCloudStackVersion(t *testing.T, cs *cloudstack.CloudStackClient, minVersion int, featureName string) {
version, err := getCloudStackVersion(cs)
if err != nil {
t.Skipf("Unable to check CloudStack version: %v", err)
return
}

versionNum := parseCloudStackVersion(version)
if versionNum < minVersion {
// Convert minVersion back to readable format (e.g., 4022 -> "4.22")
major := minVersion / 1000
minor := minVersion % 1000
t.Skipf("%s not supported in CloudStack version %s (requires %d.%d+)", featureName, version, major, minor)
}
}

// testAccPreCheckStaticRouteNexthop checks if the CloudStack version supports
// the nexthop parameter for static routes (requires 4.22.0+)
func testAccPreCheckStaticRouteNexthop(t *testing.T) {
testAccPreCheck(t)

// Create a CloudStack client to check version
config := Config{
APIURL: os.Getenv("CLOUDSTACK_API_URL"),
APIKey: os.Getenv("CLOUDSTACK_API_KEY"),
SecretKey: os.Getenv("CLOUDSTACK_SECRET_KEY"),
Timeout: 900,
}

cs, err := config.NewClient()
if err != nil {
t.Fatalf("Failed to create CloudStack client: %v", err)
}

const minVersionNum = 4022 // 4.22.0
requireMinimumCloudStackVersion(t, cs, minVersionNum, "Static route nexthop parameter")
}
79 changes: 74 additions & 5 deletions cloudstack/resource_cloudstack_static_route.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,26 @@ func resourceCloudStackStaticRoute() *schema.Resource {
},

"gateway_id": {
Type: schema.TypeString,
Required: true,
ForceNew: true,
Type: schema.TypeString,
Optional: true,
ForceNew: true,
ConflictsWith: []string{"nexthop", "vpc_id"},
},

"nexthop": {
Type: schema.TypeString,
Optional: true,
ForceNew: true,
ConflictsWith: []string{"gateway_id"},
RequiredWith: []string{"vpc_id"},
},

"vpc_id": {
Type: schema.TypeString,
Optional: true,
ForceNew: true,
ConflictsWith: []string{"gateway_id"},
RequiredWith: []string{"nexthop"},
},
},
}
Expand All @@ -53,16 +70,30 @@ func resourceCloudStackStaticRoute() *schema.Resource {
func resourceCloudStackStaticRouteCreate(d *schema.ResourceData, meta interface{}) error {
cs := meta.(*cloudstack.CloudStackClient)

// Verify that required parameters are set
if err := verifyStaticRouteParams(d); err != nil {
return err
}

// Create a new parameter struct
p := cs.VPC.NewCreateStaticRouteParams(
d.Get("cidr").(string),
)

// Set either gateway_id or nexthop+vpc_id (they are mutually exclusive)
if v, ok := d.GetOk("gateway_id"); ok {
p.SetGatewayid(v.(string))
}

// Create the new private gateway
if v, ok := d.GetOk("nexthop"); ok {
p.SetNexthop(v.(string))
}

if v, ok := d.GetOk("vpc_id"); ok {
p.SetVpcid(v.(string))
}

// Create the new static route
r, err := cs.VPC.CreateStaticRoute(p)
if err != nil {
return fmt.Errorf("Error creating static route for %s: %s", d.Get("cidr").(string), err)
Expand All @@ -76,7 +107,7 @@ func resourceCloudStackStaticRouteCreate(d *schema.ResourceData, meta interface{
func resourceCloudStackStaticRouteRead(d *schema.ResourceData, meta interface{}) error {
cs := meta.(*cloudstack.CloudStackClient)

// Get the virtual machine details
// Get the static route details
r, count, err := cs.VPC.GetStaticRouteByID(d.Id())
if err != nil {
if count == 0 {
Expand All @@ -90,6 +121,19 @@ func resourceCloudStackStaticRouteRead(d *schema.ResourceData, meta interface{})

d.Set("cidr", r.Cidr)

// Set gateway_id if it's not empty (indicates this route uses a gateway)
if r.Vpcgatewayid != "" {
d.Set("gateway_id", r.Vpcgatewayid)
}

// Set nexthop and vpc_id if nexthop is not empty (indicates this route uses nexthop)
if r.Nexthop != "" {
d.Set("nexthop", r.Nexthop)
if r.Vpcid != "" {
d.Set("vpc_id", r.Vpcid)
}
}

return nil
}

Expand All @@ -114,3 +158,28 @@ func resourceCloudStackStaticRouteDelete(d *schema.ResourceData, meta interface{

return nil
}

func verifyStaticRouteParams(d *schema.ResourceData) error {
_, hasGatewayID := d.GetOk("gateway_id")
_, hasNexthop := d.GetOk("nexthop")
_, hasVpcID := d.GetOk("vpc_id")

// Check that either gateway_id or (nexthop + vpc_id) is provided
if !hasGatewayID && !hasNexthop {
return fmt.Errorf(
"You must supply either 'gateway_id' or 'nexthop' (with 'vpc_id')")
}

// Check that nexthop and vpc_id are used together
if hasNexthop && !hasVpcID {
return fmt.Errorf(
"You must supply 'vpc_id' when using 'nexthop'")
}

if hasVpcID && !hasNexthop {
return fmt.Errorf(
"You must supply 'nexthop' when using 'vpc_id'")
}

return nil
}
106 changes: 106 additions & 0 deletions cloudstack/resource_cloudstack_static_route_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ package cloudstack

import (
"fmt"
"regexp"
"testing"

"github.com/apache/cloudstack-go/v2/cloudstack"
Expand All @@ -42,6 +43,35 @@ func TestAccCloudStackStaticRoute_basic(t *testing.T) {
testAccCheckCloudStackStaticRouteExists(
"cloudstack_static_route.foo", &staticroute),
testAccCheckCloudStackStaticRouteAttributes(&staticroute),
resource.TestCheckResourceAttr(
"cloudstack_static_route.foo", "cidr", "172.16.0.0/16"),
),
},
},
})
}

func TestAccCloudStackStaticRoute_nexthop(t *testing.T) {
var staticroute cloudstack.StaticRoute

resource.Test(t, resource.TestCase{
PreCheck: func() { testAccPreCheckStaticRouteNexthop(t) },
Providers: testAccProviders,
CheckDestroy: testAccCheckCloudStackStaticRouteDestroy,
Steps: []resource.TestStep{
{
Config: testAccCloudStackStaticRoute_nexthop,
Check: resource.ComposeTestCheckFunc(
testAccCheckCloudStackStaticRouteExists(
"cloudstack_static_route.bar", &staticroute),
testAccCheckCloudStackStaticRouteNexthopAttributes(&staticroute),
resource.TestCheckResourceAttr(
"cloudstack_static_route.bar", "cidr", "192.168.0.0/16"),
resource.TestCheckResourceAttr(
"cloudstack_static_route.bar", "nexthop", "10.1.1.1"),
resource.TestCheckResourceAttrPair(
"cloudstack_static_route.bar", "vpc_id",
"cloudstack_vpc.bar", "id"),
),
},
},
Expand Down Expand Up @@ -89,6 +119,22 @@ func testAccCheckCloudStackStaticRouteAttributes(
}
}

func testAccCheckCloudStackStaticRouteNexthopAttributes(
staticroute *cloudstack.StaticRoute) resource.TestCheckFunc {
return func(s *terraform.State) error {

if staticroute.Cidr != "192.168.0.0/16" {
return fmt.Errorf("Bad CIDR: %s", staticroute.Cidr)
}

if staticroute.Nexthop != "10.1.1.1" {
return fmt.Errorf("Bad nexthop: %s", staticroute.Nexthop)
}

return nil
}
}

func testAccCheckCloudStackStaticRouteDestroy(s *terraform.State) error {
cs := testAccProvider.Meta().(*cloudstack.CloudStackClient)

Expand Down Expand Up @@ -136,3 +182,63 @@ resource "cloudstack_static_route" "foo" {
cidr = "172.16.0.0/16"
gateway_id = cloudstack_private_gateway.foo.id
}`

const testAccCloudStackStaticRoute_nexthop = `
resource "cloudstack_vpc" "bar" {
name = "terraform-vpc-nexthop"
cidr = "10.0.0.0/8"
vpc_offering = "Default VPC offering"
zone = "Sandbox-simulator"
}

resource "cloudstack_static_route" "bar" {
cidr = "192.168.0.0/16"
nexthop = "10.1.1.1"
vpc_id = cloudstack_vpc.bar.id
}`

// Test validation errors
func TestAccCloudStackStaticRoute_validation(t *testing.T) {
resource.Test(t, resource.TestCase{
PreCheck: func() { testAccPreCheck(t) },
Providers: testAccProviders,
Steps: []resource.TestStep{
{
Config: testAccCloudStackStaticRoute_noParameters,
ExpectError: regexp.MustCompile(`You must supply either 'gateway_id' or 'nexthop'`),
},
{
Config: testAccCloudStackStaticRoute_nexthopWithoutVpc,
ExpectError: regexp.MustCompile(`all of .nexthop,vpc_id. must be specified`),
},
{
Config: testAccCloudStackStaticRoute_vpcWithoutNexthop,
ExpectError: regexp.MustCompile(`all of .nexthop,vpc_id. must be specified`),
},
},
})
}

const testAccCloudStackStaticRoute_noParameters = `
resource "cloudstack_static_route" "invalid" {
cidr = "192.168.0.0/16"
}`

const testAccCloudStackStaticRoute_nexthopWithoutVpc = `
resource "cloudstack_static_route" "invalid" {
cidr = "192.168.0.0/16"
nexthop = "10.1.1.1"
}`

const testAccCloudStackStaticRoute_vpcWithoutNexthop = `
resource "cloudstack_vpc" "test" {
name = "terraform-vpc-test"
cidr = "10.0.0.0/8"
vpc_offering = "Default VPC offering"
zone = "Sandbox-simulator"
}

resource "cloudstack_static_route" "invalid" {
cidr = "192.168.0.0/16"
vpc_id = cloudstack_vpc.test.id
}`
Loading
Loading