diff --git a/cloudstack/provider_test.go b/cloudstack/provider_test.go index 1ac81980..ca47cbfb 100644 --- a/cloudstack/provider_test.go +++ b/cloudstack/provider_test.go @@ -23,6 +23,8 @@ import ( "context" "os" "regexp" + "strconv" + "strings" "testing" "github.com/apache/cloudstack-go/v2/cloudstack" @@ -147,6 +149,63 @@ func testAccPreCheck(t *testing.T) { } } +// 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 +} + +// 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, minVersion int, featureName string) { + t.Helper() + version := getCloudStackVersion(t) + if version == "" { + t.Skipf("Unable to determine CloudStack version, skipping %s test", featureName) + 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) + + const minVersionNum = 4022 // 4.22.0 + requireMinimumCloudStackVersion(t, minVersionNum, "Static route nexthop parameter") +} + // newTestClient creates a CloudStack client from environment variables for use in test PreCheck functions. // This is needed because PreCheck functions run before the test framework configures the provider, // so testAccProvider.Meta() is nil at that point. diff --git a/cloudstack/resource_cloudstack_static_route.go b/cloudstack/resource_cloudstack_static_route.go index d9240b76..19dd8d99 100644 --- a/cloudstack/resource_cloudstack_static_route.go +++ b/cloudstack/resource_cloudstack_static_route.go @@ -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"}, }, }, } @@ -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) @@ -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 { @@ -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 } @@ -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 +} diff --git a/cloudstack/resource_cloudstack_static_route_test.go b/cloudstack/resource_cloudstack_static_route_test.go index dcf754d1..5c97de9d 100644 --- a/cloudstack/resource_cloudstack_static_route_test.go +++ b/cloudstack/resource_cloudstack_static_route_test.go @@ -21,6 +21,7 @@ package cloudstack import ( "fmt" + "regexp" "testing" "github.com/apache/cloudstack-go/v2/cloudstack" @@ -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"), ), }, }, @@ -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) @@ -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 +}` diff --git a/website/docs/r/static_route.html.markdown b/website/docs/r/static_route.html.markdown index dab12a95..b7ea24ac 100644 --- a/website/docs/r/static_route.html.markdown +++ b/website/docs/r/static_route.html.markdown @@ -12,6 +12,8 @@ Creates a static route for the given private gateway or VPC. ## Example Usage +Using a private gateway: + ```hcl resource "cloudstack_static_route" "default" { cidr = "10.0.0.0/16" @@ -19,6 +21,16 @@ resource "cloudstack_static_route" "default" { } ``` +Using a nexthop IP address: + +```hcl +resource "cloudstack_static_route" "with_nexthop" { + cidr = "10.0.0.0/16" + nexthop = "192.168.1.1" + vpc_id = "76f607e3-e8dc-4971-8831-b2a2b0cc4cb4" +} +``` + ## Argument Reference The following arguments are supported: @@ -26,8 +38,17 @@ The following arguments are supported: * `cidr` - (Required) The CIDR for the static route. Changing this forces a new resource to be created. -* `gateway_id` - (Required) The ID of the Private gateway. Changing this forces - a new resource to be created. +* `gateway_id` - (Optional) The ID of the Private gateway. Changing this forces + a new resource to be created. Conflicts with `nexthop` and `vpc_id`. + +* `nexthop` - (Optional) The IP address of the nexthop for the static route. + Changing this forces a new resource to be created. Conflicts with `gateway_id`. + Must be used together with `vpc_id`. **Requires CloudStack 4.22.0+**. + +* `vpc_id` - (Optional) The ID of the VPC. Required when using `nexthop`. + Changing this forces a new resource to be created. Conflicts with `gateway_id`. + +**Note:** Either `gateway_id` or (`nexthop` + `vpc_id`) must be specified. ## Attributes Reference