diff --git a/.github/workflows/acceptance.yml b/.github/workflows/acceptance.yml index 7b3d178a..5261b58d 100644 --- a/.github/workflows/acceptance.yml +++ b/.github/workflows/acceptance.yml @@ -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: diff --git a/cloudstack/provider_test.go b/cloudstack/provider_test.go index fb868e4b..aab13c47 100644 --- a/cloudstack/provider_test.go +++ b/cloudstack/provider_test.go @@ -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" @@ -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") +} 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