diff --git a/docs/data-sources/vpn_gateway.md b/docs/data-sources/vpn_gateway.md
new file mode 100644
index 000000000..8da1e9115
--- /dev/null
+++ b/docs/data-sources/vpn_gateway.md
@@ -0,0 +1,50 @@
+---
+# generated by https://github.com/hashicorp/terraform-plugin-docs
+page_title: "stackit_vpn_gateway Data Source - stackit"
+subcategory: ""
+description: |-
+ VPN Gateway data source schema. Uses the default_region specified in the provider configuration as a fallback in case no region is defined on datasource level.
+---
+
+# stackit_vpn_gateway (Data Source)
+
+VPN Gateway data source schema. Uses the `default_region` specified in the provider configuration as a fallback in case no `region` is defined on datasource level.
+
+
+
+
+## Schema
+
+### Required
+
+- `gateway_id` (String) The server-generated UUID of the VPN gateway.
+- `project_id` (String) STACKIT project ID associated with the VPN gateway.
+
+### Read-Only
+
+- `availability_zones` (Attributes) Availability zones for the two tunnel endpoints. (see [below for nested schema](#nestedatt--availability_zones))
+- `bgp` (Attributes) BGP configuration. Only applicable when routing_type is BGP_ROUTE_BASED. (see [below for nested schema](#nestedatt--bgp))
+- `display_name` (String) A user-friendly name for the VPN gateway.
+- `id` (String) Terraform's internal resource identifier. Structured as "`project_id`,`region`,`gateway_id`".
+- `labels` (Map of String) Map of custom labels (key-value string pairs).
+- `plan_id` (String) The service plan identifier (e.g. p500).
+- `region` (String) STACKIT region (e.g. eu01).
+- `routing_type` (String) Routing architecture: POLICY_BASED, ROUTE_BASED, or BGP_ROUTE_BASED.
+- `state` (String) The current lifecycle state of the gateway (PENDING, READY, ERROR, DELETING).
+
+
+### Nested Schema for `availability_zones`
+
+Read-Only:
+
+- `tunnel1` (String) Availability zone for tunnel 1.
+- `tunnel2` (String) Availability zone for tunnel 2.
+
+
+
+### Nested Schema for `bgp`
+
+Read-Only:
+
+- `local_asn` (Number) Local ASN for BGP (private ASN range, 64512-4294967294).
+- `override_advertised_routes` (List of String) List of IPv4 CIDRs to advertise via BGP.
diff --git a/docs/index.md b/docs/index.md
index 300fcbd63..a8f566726 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -212,3 +212,4 @@ Note: AWS specific checks must be skipped as they do not work on STACKIT. For de
- `sqlserverflex_custom_endpoint` (String) Custom endpoint for the SQL Server Flex service
- `token_custom_endpoint` (String) Custom endpoint for the token API, which is used to request access tokens when using the key flow
- `use_oidc` (Boolean) Enables OIDC for Authentication. This can also be sourced from the `STACKIT_USE_OIDC` Environment Variable. Defaults to `false`.
+- `vpn_custom_endpoint` (String) Custom endpoint for the VPN service
diff --git a/docs/resources/vpn_gateway.md b/docs/resources/vpn_gateway.md
new file mode 100644
index 000000000..21b1e68e2
--- /dev/null
+++ b/docs/resources/vpn_gateway.md
@@ -0,0 +1,74 @@
+---
+# generated by https://github.com/hashicorp/terraform-plugin-docs
+page_title: "stackit_vpn_gateway Resource - stackit"
+subcategory: ""
+description: |-
+ VPN Gateway resource schema. Uses the default_region specified in the provider configuration as a fallback in case no region is defined on resource level.
+---
+
+# stackit_vpn_gateway (Resource)
+
+VPN Gateway resource schema. Uses the `default_region` specified in the provider configuration as a fallback in case no `region` is defined on resource level.
+
+## Example Usage
+
+```terraform
+resource "stackit_vpn_gateway" "example" {
+ project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
+ region = "eu01"
+ display_name = "example-vpn-gateway"
+ plan_id = "p500"
+ routing_type = "ROUTE_BASED"
+
+ availability_zones = {
+ tunnel1 = "eu01-1"
+ tunnel2 = "eu01-2"
+ }
+}
+
+# Only use the import statement, if you want to import an existing VPN gateway
+import {
+ to = stackit_vpn_gateway.example
+ id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx,eu01,xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
+}
+```
+
+
+## Schema
+
+### Required
+
+- `availability_zones` (Attributes) Availability zones for the two tunnel endpoints. (see [below for nested schema](#nestedatt--availability_zones))
+- `display_name` (String) A user-friendly name for the VPN gateway.
+- `plan_id` (String) The service plan identifier (e.g. p500).
+- `project_id` (String) STACKIT project ID associated with the VPN gateway.
+- `routing_type` (String) Routing architecture: POLICY_BASED, ROUTE_BASED, or BGP_ROUTE_BASED.
+
+### Optional
+
+- `bgp` (Attributes) BGP configuration. Only applicable when routing_type is BGP_ROUTE_BASED. (see [below for nested schema](#nestedatt--bgp))
+- `labels` (Map of String) Map of custom labels (key-value string pairs).
+- `region` (String) STACKIT region (e.g. eu01).
+
+### Read-Only
+
+- `gateway_id` (String) The server-generated UUID of the VPN gateway.
+- `id` (String) Terraform's internal resource identifier. Structured as "`project_id`,`region`,`gateway_id`".
+- `state` (String) The current lifecycle state of the gateway (PENDING, READY, ERROR, DELETING).
+
+
+### Nested Schema for `availability_zones`
+
+Required:
+
+- `tunnel1` (String) Availability zone for tunnel 1.
+- `tunnel2` (String) Availability zone for tunnel 2.
+
+
+
+### Nested Schema for `bgp`
+
+Optional:
+
+- `local_asn` (Number) Local ASN for BGP (private ASN range, 64512-4294967294).
+- `override_advertised_routes` (List of String) List of IPv4 CIDRs to advertise via BGP. If omitted, SNA network ranges are advertised.
diff --git a/examples/resources/stackit_vpn_gateway/resource.tf b/examples/resources/stackit_vpn_gateway/resource.tf
new file mode 100644
index 000000000..e89d5c915
--- /dev/null
+++ b/examples/resources/stackit_vpn_gateway/resource.tf
@@ -0,0 +1,18 @@
+resource "stackit_vpn_gateway" "example" {
+ project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
+ region = "eu01"
+ display_name = "example-vpn-gateway"
+ plan_id = "p500"
+ routing_type = "ROUTE_BASED"
+
+ availability_zones = {
+ tunnel1 = "eu01-1"
+ tunnel2 = "eu01-2"
+ }
+}
+
+# Only use the import statement, if you want to import an existing VPN gateway
+import {
+ to = stackit_vpn_gateway.example
+ id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx,eu01,xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
+}
diff --git a/go.mod b/go.mod
index f72bb4831..5671b8c41 100644
--- a/go.mod
+++ b/go.mod
@@ -44,6 +44,7 @@ require (
github.com/stackitcloud/stackit-sdk-go/services/sfs v0.9.0
github.com/stackitcloud/stackit-sdk-go/services/ske v1.14.0
github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex v1.10.0
+ github.com/stackitcloud/stackit-sdk-go/services/vpn v0.8.0
github.com/teambition/rrule-go v1.8.2
golang.org/x/mod v0.35.0
)
diff --git a/go.sum b/go.sum
index c1d413193..972d94068 100644
--- a/go.sum
+++ b/go.sum
@@ -734,6 +734,8 @@ github.com/stackitcloud/stackit-sdk-go/services/ske v1.14.0 h1:Zy3yxmHzW+ydu1nae
github.com/stackitcloud/stackit-sdk-go/services/ske v1.14.0/go.mod h1:TbqmZhLMofmfl+HhVl6oHYcI3zvXTm1vRjN3A/fOkM4=
github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex v1.10.0 h1:angvO3z0TGqZtdwTDsG/tgTw9hxB76A6leUsiUXQtME=
github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex v1.10.0/go.mod h1:AiUoMAqQcOlMgDtkVJlqI7P/VGD5xjN3dYjERGnwN/M=
+github.com/stackitcloud/stackit-sdk-go/services/vpn v0.8.0 h1:YCFkFcWLf6MaKGGuin5YztQGb5CadE/23ciCkvxJh1U=
+github.com/stackitcloud/stackit-sdk-go/services/vpn v0.8.0/go.mod h1:toIjQk1dhxdUFVyCWJJja0w/0nFpDid8MWX0ukQfvfo=
github.com/stbenjam/no-sprintf-host-port v0.3.1 h1:AyX7+dxI4IdLBPtDbsGAyqiTSLpCP9hWRrXQDU4Cm/g=
github.com/stbenjam/no-sprintf-host-port v0.3.1/go.mod h1:ODbZesTCHMVKthBHskvUUexdcNHAQRXk9NpSsL8p/HQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
diff --git a/stackit/internal/core/core.go b/stackit/internal/core/core.go
index 3ecf92de1..cc0210051 100644
--- a/stackit/internal/core/core.go
+++ b/stackit/internal/core/core.go
@@ -72,6 +72,7 @@ type ProviderData struct {
ServiceEnablementCustomEndpoint string
SfsCustomEndpoint string
ServiceAccountCustomEndpoint string
+ VpnCustomEndpoint string
EnableBetaResources bool
Experiments []string
diff --git a/stackit/internal/services/vpn/gateway/datasource.go b/stackit/internal/services/vpn/gateway/datasource.go
new file mode 100644
index 000000000..4554f00dc
--- /dev/null
+++ b/stackit/internal/services/vpn/gateway/datasource.go
@@ -0,0 +1,186 @@
+package gateway
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+
+ "github.com/hashicorp/terraform-plugin-framework/datasource"
+ "github.com/hashicorp/terraform-plugin-framework/datasource/schema"
+ "github.com/hashicorp/terraform-plugin-framework/schema/validator"
+ "github.com/hashicorp/terraform-plugin-framework/types"
+ "github.com/hashicorp/terraform-plugin-log/tflog"
+
+ "github.com/stackitcloud/stackit-sdk-go/core/oapierror"
+ vpn "github.com/stackitcloud/stackit-sdk-go/services/vpn/v1api"
+
+ "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
+ "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/vpn/utils"
+
+ "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion"
+ "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate"
+)
+
+var (
+ _ datasource.DataSource = (*vpnGatewayDataSource)(nil)
+ _ datasource.DataSourceWithConfigure = (*vpnGatewayDataSource)(nil)
+)
+
+type vpnGatewayDataSource struct {
+ client *vpn.APIClient
+ providerData core.ProviderData
+}
+
+func NewVPNGatewayDataSource() datasource.DataSource {
+ return &vpnGatewayDataSource{}
+}
+
+func (d *vpnGatewayDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) {
+ providerData, ok := conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics)
+ if !ok {
+ return
+ }
+
+ apiClient := utils.ConfigureClient(ctx, &providerData, &resp.Diagnostics)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+ d.client = apiClient
+ d.providerData = providerData
+ tflog.Info(ctx, "VPN client configured")
+}
+
+func (d *vpnGatewayDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) {
+ resp.TypeName = req.ProviderTypeName + "_vpn_gateway"
+}
+
+func (d *vpnGatewayDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) {
+ resp.Schema = schema.Schema{
+ Description: fmt.Sprintf("VPN Gateway data source schema. %s", core.DatasourceRegionFallbackDocstring),
+ Attributes: map[string]schema.Attribute{
+ "id": schema.StringAttribute{
+ Description: schemaDescriptions["id"],
+ Computed: true,
+ },
+ "project_id": schema.StringAttribute{
+ Description: schemaDescriptions["project_id"],
+ Required: true,
+ Validators: []validator.String{
+ validate.UUID(),
+ validate.NoSeparator(),
+ },
+ },
+ "region": schema.StringAttribute{
+ Description: schemaDescriptions["region"],
+ Computed: true,
+ },
+ "gateway_id": schema.StringAttribute{
+ Description: schemaDescriptions["gateway_id"],
+ Required: true,
+ Validators: []validator.String{
+ validate.UUID(),
+ validate.NoSeparator(),
+ },
+ },
+ "display_name": schema.StringAttribute{
+ Description: schemaDescriptions["display_name"],
+ Computed: true,
+ },
+ "plan_id": schema.StringAttribute{
+ Description: schemaDescriptions["plan_id"],
+ Computed: true,
+ },
+ "routing_type": schema.StringAttribute{
+ Description: schemaDescriptions["routing_type"],
+ Computed: true,
+ },
+ "availability_zones": schema.SingleNestedAttribute{
+ Description: schemaDescriptions["availability_zones"],
+ Computed: true,
+ Attributes: map[string]schema.Attribute{
+ "tunnel1": schema.StringAttribute{
+ Description: "Availability zone for tunnel 1.",
+ Computed: true,
+ },
+ "tunnel2": schema.StringAttribute{
+ Description: "Availability zone for tunnel 2.",
+ Computed: true,
+ },
+ },
+ },
+ "bgp": schema.SingleNestedAttribute{
+ Description: schemaDescriptions["bgp"],
+ Computed: true,
+ Attributes: map[string]schema.Attribute{
+ "local_asn": schema.Int64Attribute{
+ Description: "Local ASN for BGP (private ASN range, 64512-4294967294).",
+ Computed: true,
+ },
+ "override_advertised_routes": schema.ListAttribute{
+ Description: "List of IPv4 CIDRs to advertise via BGP.",
+ Computed: true,
+ ElementType: types.StringType,
+ },
+ },
+ },
+ "labels": schema.MapAttribute{
+ Description: schemaDescriptions["labels"],
+ Computed: true,
+ ElementType: types.StringType,
+ },
+ "state": schema.StringAttribute{
+ Description: schemaDescriptions["state"],
+ Computed: true,
+ },
+ },
+ }
+}
+
+func (d *vpnGatewayDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { // nolint:gocritic // function signature required by Terraform
+ var model Model
+ diags := req.Config.Get(ctx, &model)
+ resp.Diagnostics.Append(diags...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ ctx = core.InitProviderContext(ctx)
+
+ projectId := model.ProjectID.ValueString()
+ region := d.providerData.GetRegionWithOverride(model.Region)
+ gatewayId := model.GatewayID.ValueString()
+
+ ctx = tflog.SetField(ctx, "project_id", projectId)
+ ctx = tflog.SetField(ctx, "region", region)
+ ctx = tflog.SetField(ctx, "gateway_id", gatewayId)
+
+ gatewayResponse, err := d.client.DefaultAPI.GetGateway(ctx, projectId, vpn.Region(region), gatewayId).Execute()
+ if err != nil {
+ var oapiErr *oapierror.GenericOpenAPIError
+ ok := errors.As(err, &oapiErr)
+ if ok && oapiErr.StatusCode == http.StatusNotFound {
+ resp.State.RemoveResource(ctx)
+ return
+ }
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading VPN gateway", fmt.Sprintf("Calling API: %v", err))
+ return
+ }
+ ctx = core.LogResponse(ctx)
+
+ err = mapFields(ctx, gatewayResponse, &model, region)
+ if err != nil {
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading VPN gateway", fmt.Sprintf("Processing response: %v", err))
+ return
+ }
+
+ // Set state
+ diags = resp.State.Set(ctx, model)
+ resp.Diagnostics.Append(diags...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+ tflog.Info(ctx, "VPN gateway read", map[string]any{
+ "gateway_id": gatewayId,
+ })
+}
diff --git a/stackit/internal/services/vpn/gateway/datasource_test.go b/stackit/internal/services/vpn/gateway/datasource_test.go
new file mode 100644
index 000000000..d34a52e57
--- /dev/null
+++ b/stackit/internal/services/vpn/gateway/datasource_test.go
@@ -0,0 +1,74 @@
+package gateway
+
+import (
+ "context"
+ "testing"
+
+ "github.com/google/go-cmp/cmp"
+ "github.com/hashicorp/terraform-plugin-framework/types"
+ "github.com/stackitcloud/stackit-sdk-go/core/utils"
+ vpn "github.com/stackitcloud/stackit-sdk-go/services/vpn/v1api"
+)
+
+func TestDataSourceMapFields(t *testing.T) {
+ tests := []struct {
+ description string
+ input *vpn.GatewayResponse
+ expected Model
+ isValid bool
+ }{
+ {
+ "basic_gateway",
+ &vpn.GatewayResponse{
+ Id: new("gateway-id"),
+ DisplayName: "test-gateway",
+ PlanId: "p500",
+ RoutingType: vpn.ROUTINGTYPE_ROUTE_BASED,
+ AvailabilityZones: vpn.GatewayAvailabilityZones{
+ Tunnel1: "eu01-1",
+ Tunnel2: "eu01-2",
+ },
+ State: utils.Ptr(vpn.GATEWAYSTATUS_READY),
+ },
+ Model{
+ GatewayID: types.StringValue("gateway-id"),
+ DisplayName: types.StringValue("test-gateway"),
+ PlanID: types.StringValue("p500"),
+ RoutingType: types.StringValue("ROUTE_BASED"),
+ AvailabilityZones: &AvailabilityZonesModel{
+ Tunnel1: types.StringValue("eu01-1"),
+ Tunnel2: types.StringValue("eu01-2"),
+ },
+ State: types.StringValue("READY"),
+ },
+ true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.description, func(t *testing.T) {
+ var model Model
+ model.ProjectID = types.StringValue("test-project")
+ model.Region = types.StringValue("eu01")
+
+ err := mapFields(context.Background(), tt.input, &model, "eu01")
+
+ if !tt.isValid && err == nil {
+ t.Fatalf("expected error, got none")
+ }
+ if tt.isValid && err != nil {
+ t.Fatalf("expected no error, got %v", err)
+ }
+ if !tt.isValid {
+ return
+ }
+
+ if diff := cmp.Diff(model.GatewayID, tt.expected.GatewayID); diff != "" {
+ t.Fatalf("GatewayID mismatch (-got +want):\n%s", diff)
+ }
+ if diff := cmp.Diff(model.DisplayName, tt.expected.DisplayName); diff != "" {
+ t.Fatalf("DisplayName mismatch (-got +want):\n%s", diff)
+ }
+ })
+ }
+}
diff --git a/stackit/internal/services/vpn/gateway/resource.go b/stackit/internal/services/vpn/gateway/resource.go
new file mode 100644
index 000000000..b39b4a2e7
--- /dev/null
+++ b/stackit/internal/services/vpn/gateway/resource.go
@@ -0,0 +1,633 @@
+package gateway
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "regexp"
+ "strings"
+
+ "github.com/hashicorp/terraform-plugin-framework-validators/int64validator"
+ "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator"
+ "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
+ "github.com/hashicorp/terraform-plugin-framework/attr"
+ "github.com/hashicorp/terraform-plugin-framework/resource"
+ "github.com/hashicorp/terraform-plugin-framework/resource/schema"
+ "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
+ "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
+ "github.com/hashicorp/terraform-plugin-framework/schema/validator"
+ "github.com/hashicorp/terraform-plugin-framework/types"
+ "github.com/hashicorp/terraform-plugin-log/tflog"
+ "github.com/stackitcloud/stackit-sdk-go/core/oapierror"
+ vpn "github.com/stackitcloud/stackit-sdk-go/services/vpn/v1api"
+ "github.com/stackitcloud/stackit-sdk-go/services/vpn/v1api/wait"
+
+ "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion"
+ "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
+ "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/vpn/utils"
+ tfutils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils"
+ "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate"
+)
+
+var (
+ _ resource.Resource = &gatewayResource{}
+ _ resource.ResourceWithConfigure = &gatewayResource{}
+ _ resource.ResourceWithImportState = &gatewayResource{}
+ _ resource.ResourceWithModifyPlan = &gatewayResource{}
+
+ routingTypeOptions = []string{"POLICY_BASED", "ROUTE_BASED", "BGP_ROUTE_BASED"}
+)
+
+type AvailabilityZonesModel struct {
+ Tunnel1 types.String `tfsdk:"tunnel1"`
+ Tunnel2 types.String `tfsdk:"tunnel2"`
+}
+
+type BGPGatewayConfigModel struct {
+ LocalAsn types.Int64 `tfsdk:"local_asn"`
+ OverrideAdvertisedRoutes types.List `tfsdk:"override_advertised_routes"`
+}
+
+type Model struct {
+ ID types.String `tfsdk:"id"`
+ GatewayID types.String `tfsdk:"gateway_id"`
+ ProjectID types.String `tfsdk:"project_id"`
+ Region types.String `tfsdk:"region"`
+ DisplayName types.String `tfsdk:"display_name"`
+ PlanID types.String `tfsdk:"plan_id"`
+ RoutingType types.String `tfsdk:"routing_type"`
+ AvailabilityZones *AvailabilityZonesModel `tfsdk:"availability_zones"`
+ Bgp *BGPGatewayConfigModel `tfsdk:"bgp"`
+ Labels types.Map `tfsdk:"labels"`
+ State types.String `tfsdk:"state"`
+}
+
+var schemaDescriptions = map[string]string{
+ "id": "Terraform's internal resource identifier. Structured as \"`project_id`,`region`,`gateway_id`\".",
+ "gateway_id": "The server-generated UUID of the VPN gateway.",
+ "project_id": "STACKIT project ID associated with the VPN gateway.",
+ "region": "STACKIT region (e.g. eu01).",
+ "display_name": "A user-friendly name for the VPN gateway.",
+ "plan_id": "The service plan identifier (e.g. p500).",
+ "routing_type": "Routing architecture: POLICY_BASED, ROUTE_BASED, or BGP_ROUTE_BASED.",
+ "availability_zones": "Availability zones for the two tunnel endpoints.",
+ "bgp": "BGP configuration. Only applicable when routing_type is BGP_ROUTE_BASED.",
+ "labels": "Map of custom labels (key-value string pairs).",
+ "state": "The current lifecycle state of the gateway (PENDING, READY, ERROR, DELETING).",
+}
+
+type gatewayResource struct {
+ client *vpn.APIClient
+ providerData core.ProviderData
+}
+
+func NewGatewayResource() resource.Resource {
+ return &gatewayResource{}
+}
+
+func (r *gatewayResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
+ providerData, ok := conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics)
+ if !ok {
+ return
+ }
+
+ apiClient := utils.ConfigureClient(ctx, &providerData, &resp.Diagnostics)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+ r.client = apiClient
+ r.providerData = providerData
+ tflog.Info(ctx, "VPN client configured")
+}
+
+func (r *gatewayResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
+ resp.TypeName = req.ProviderTypeName + "_vpn_gateway"
+}
+
+func (r *gatewayResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
+ resp.Schema = schema.Schema{
+ Description: fmt.Sprintf("VPN Gateway resource schema. %s", core.ResourceRegionFallbackDocstring),
+ Attributes: map[string]schema.Attribute{
+ "id": schema.StringAttribute{
+ Description: schemaDescriptions["id"],
+ Computed: true,
+ PlanModifiers: []planmodifier.String{
+ stringplanmodifier.UseStateForUnknown(),
+ },
+ },
+ "gateway_id": schema.StringAttribute{
+ Description: schemaDescriptions["gateway_id"],
+ Computed: true,
+ Validators: []validator.String{
+ validate.UUID(),
+ validate.NoSeparator(),
+ },
+ PlanModifiers: []planmodifier.String{
+ stringplanmodifier.UseStateForUnknown(),
+ },
+ },
+ "project_id": schema.StringAttribute{
+ Description: schemaDescriptions["project_id"],
+ Required: true,
+ PlanModifiers: []planmodifier.String{
+ stringplanmodifier.RequiresReplace(),
+ },
+ Validators: []validator.String{
+ validate.UUID(),
+ validate.NoSeparator(),
+ },
+ },
+ "region": schema.StringAttribute{
+ Description: schemaDescriptions["region"],
+ Optional: true,
+ Computed: true,
+ PlanModifiers: []planmodifier.String{
+ stringplanmodifier.RequiresReplace(),
+ },
+ },
+ "display_name": schema.StringAttribute{
+ Description: schemaDescriptions["display_name"],
+ Required: true,
+ Validators: []validator.String{
+ stringvalidator.RegexMatches(
+ regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$`),
+ "must start and end with an alphanumeric character, may contain hyphens, and be 1-63 characters long",
+ ),
+ },
+ },
+ "plan_id": schema.StringAttribute{
+ Description: schemaDescriptions["plan_id"],
+ Required: true,
+ },
+ "routing_type": schema.StringAttribute{
+ Description: schemaDescriptions["routing_type"],
+ Required: true,
+ Validators: []validator.String{
+ stringvalidator.OneOf(routingTypeOptions...),
+ },
+ PlanModifiers: []planmodifier.String{
+ stringplanmodifier.RequiresReplace(),
+ },
+ },
+ "availability_zones": schema.SingleNestedAttribute{
+ Description: schemaDescriptions["availability_zones"],
+ Required: true,
+ Attributes: map[string]schema.Attribute{
+ "tunnel1": schema.StringAttribute{
+ Description: "Availability zone for tunnel 1.",
+ Required: true,
+ },
+ "tunnel2": schema.StringAttribute{
+ Description: "Availability zone for tunnel 2.",
+ Required: true,
+ },
+ },
+ },
+ "bgp": schema.SingleNestedAttribute{
+ Description: schemaDescriptions["bgp"],
+ Optional: true,
+ Attributes: map[string]schema.Attribute{
+ "local_asn": schema.Int64Attribute{
+ Description: "Local ASN for BGP (private ASN range, 64512-4294967294).",
+ Optional: true,
+ Validators: []validator.Int64{
+ int64validator.Between(64512, 4294967294),
+ },
+ },
+ "override_advertised_routes": schema.ListAttribute{
+ Description: "List of IPv4 CIDRs to advertise via BGP. If omitted, SNA network ranges are advertised.",
+ Optional: true,
+ ElementType: types.StringType,
+ Validators: []validator.List{
+ listvalidator.SizeAtMost(100),
+ listvalidator.ValueStringsAre(validate.CIDR()),
+ },
+ },
+ },
+ },
+ "labels": schema.MapAttribute{
+ Description: schemaDescriptions["labels"],
+ Optional: true,
+ ElementType: types.StringType,
+ },
+ "state": schema.StringAttribute{
+ Description: schemaDescriptions["state"],
+ Computed: true,
+ },
+ },
+ }
+}
+
+func (r *gatewayResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { // nolint:gocritic // function signature required by Terraform
+ var configModel Model
+ if req.Config.Raw.IsNull() {
+ return
+ }
+ resp.Diagnostics.Append(req.Config.Get(ctx, &configModel)...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ var planModel Model
+ resp.Diagnostics.Append(req.Plan.Get(ctx, &planModel)...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ tfutils.AdaptRegion(ctx, configModel.Region, &planModel.Region, r.providerData.GetRegion(), resp)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ resp.Diagnostics.Append(resp.Plan.Set(ctx, planModel)...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+}
+
+func (r *gatewayResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
+ idParts := strings.Split(req.ID, core.Separator)
+
+ if len(idParts) != 3 || idParts[0] == "" || idParts[1] == "" || idParts[2] == "" {
+ core.LogAndAddError(ctx, &resp.Diagnostics,
+ "Error importing VPN gateway",
+ fmt.Sprintf("Expected import identifier with format: [project_id],[region],[gateway_id] Got: %q", req.ID),
+ )
+ return
+ }
+
+ ctx = tfutils.SetAndLogStateFields(ctx, &resp.Diagnostics, &resp.State, map[string]any{
+ "project_id": idParts[0],
+ "region": idParts[1],
+ "gateway_id": idParts[2],
+ })
+ tflog.Info(ctx, "VPN gateway state imported")
+}
+
+func (r *gatewayResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { // nolint:gocritic // function signature required by Terraform
+ var model Model
+ diags := req.Plan.Get(ctx, &model)
+ resp.Diagnostics.Append(diags...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ ctx = core.InitProviderContext(ctx)
+
+ projectId := model.ProjectID.ValueString()
+ region := r.providerData.GetRegionWithOverride(model.Region)
+ ctx = tflog.SetField(ctx, "project_id", projectId)
+ ctx = tflog.SetField(ctx, "region", region)
+
+ payload, err := toCreatePayload(ctx, &model)
+ if err != nil {
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating VPN gateway", fmt.Sprintf("Creating API payload: %v", err))
+ return
+ }
+
+ createResp, err := r.client.DefaultAPI.CreateGateway(ctx, projectId, vpn.Region(region)).CreateGatewayPayload(*payload).Execute()
+ if err != nil {
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating VPN gateway", fmt.Sprintf("Calling API: %v", err))
+ return
+ }
+
+ ctx = core.LogResponse(ctx)
+
+ if createResp.Id == nil {
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating VPN gateway", "Got empty gateway id")
+ return
+ }
+ gatewayId := *createResp.Id
+
+ ctx = tfutils.SetAndLogStateFields(ctx, &resp.Diagnostics, &resp.State, map[string]any{
+ "project_id": projectId,
+ "region": region,
+ "gateway_id": gatewayId,
+ })
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ waitResp, err := wait.CreateGatewayWaitHandler(ctx, r.client.DefaultAPI, projectId, vpn.Region(region), gatewayId).WaitWithContext(ctx)
+ if err != nil {
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating VPN gateway", fmt.Sprintf("Gateway creation waiting: %v", err))
+ return
+ }
+
+ err = mapFields(ctx, waitResp, &model, region)
+ if err != nil {
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating VPN gateway", fmt.Sprintf("Processing API payload: %v", err))
+ return
+ }
+
+ diags = resp.State.Set(ctx, model)
+ resp.Diagnostics.Append(diags...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+ tflog.Info(ctx, "VPN gateway created")
+}
+
+func (r *gatewayResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { // nolint:gocritic // function signature required by Terraform
+ var model Model
+ diags := req.State.Get(ctx, &model)
+ resp.Diagnostics.Append(diags...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ ctx = core.InitProviderContext(ctx)
+
+ projectId := model.ProjectID.ValueString()
+ gatewayId := model.GatewayID.ValueString()
+ region := r.providerData.GetRegionWithOverride(model.Region)
+ ctx = tflog.SetField(ctx, "project_id", projectId)
+ ctx = tflog.SetField(ctx, "gateway_id", gatewayId)
+ ctx = tflog.SetField(ctx, "region", region)
+
+ gatewayResp, err := r.client.DefaultAPI.GetGateway(ctx, projectId, vpn.Region(region), gatewayId).Execute()
+ if err != nil {
+ var oapiErr *oapierror.GenericOpenAPIError
+ if errors.As(err, &oapiErr) && oapiErr.StatusCode == http.StatusNotFound {
+ resp.State.RemoveResource(ctx)
+ return
+ }
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading VPN gateway", err.Error())
+ return
+ }
+
+ ctx = core.LogResponse(ctx)
+
+ err = mapFields(ctx, gatewayResp, &model, region)
+ if err != nil {
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading VPN gateway", fmt.Sprintf("Processing API payload: %v", err))
+ return
+ }
+
+ diags = resp.State.Set(ctx, model)
+ resp.Diagnostics.Append(diags...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+ tflog.Info(ctx, "VPN gateway read")
+}
+
+func (r *gatewayResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform
+ var model Model
+ diags := req.Plan.Get(ctx, &model)
+ resp.Diagnostics.Append(diags...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ ctx = core.InitProviderContext(ctx)
+
+ projectId := model.ProjectID.ValueString()
+ gatewayId := model.GatewayID.ValueString()
+ region := r.providerData.GetRegionWithOverride(model.Region)
+ ctx = tflog.SetField(ctx, "project_id", projectId)
+ ctx = tflog.SetField(ctx, "gateway_id", gatewayId)
+ ctx = tflog.SetField(ctx, "region", region)
+
+ payload, err := toUpdatePayload(ctx, &model)
+ if err != nil {
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating VPN gateway", fmt.Sprintf("Creating API payload: %v", err))
+ return
+ }
+
+ _, err = r.client.DefaultAPI.UpdateGateway(ctx, projectId, vpn.Region(region), gatewayId).UpdateGatewayPayload(*payload).Execute()
+ if err != nil {
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating VPN gateway", err.Error())
+ return
+ }
+
+ ctx = core.LogResponse(ctx)
+
+ waitResp, err := wait.UpdateGatewayWaitHandler(ctx, r.client.DefaultAPI, projectId, vpn.Region(region), gatewayId).WaitWithContext(ctx)
+ if err != nil {
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating VPN gateway", fmt.Sprintf("Gateway update waiting: %v", err))
+ return
+ }
+
+ err = mapFields(ctx, waitResp, &model, region)
+ if err != nil {
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating VPN gateway", fmt.Sprintf("Processing API payload: %v", err))
+ return
+ }
+
+ diags = resp.State.Set(ctx, model)
+ resp.Diagnostics.Append(diags...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+ tflog.Info(ctx, "VPN gateway updated")
+}
+
+func (r *gatewayResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { // nolint:gocritic // function signature required by Terraform
+ var model Model
+ diags := req.State.Get(ctx, &model)
+ resp.Diagnostics.Append(diags...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ ctx = core.InitProviderContext(ctx)
+
+ projectId := model.ProjectID.ValueString()
+ gatewayId := model.GatewayID.ValueString()
+ region := r.providerData.GetRegionWithOverride(model.Region)
+ ctx = tflog.SetField(ctx, "project_id", projectId)
+ ctx = tflog.SetField(ctx, "gateway_id", gatewayId)
+ ctx = tflog.SetField(ctx, "region", region)
+
+ err := r.client.DefaultAPI.DeleteGateway(ctx, projectId, vpn.Region(region), gatewayId).Execute()
+ if err != nil {
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting VPN gateway", fmt.Sprintf("Calling API: %v", err))
+ return
+ }
+
+ ctx = core.LogResponse(ctx)
+
+ _, err = wait.DeleteGatewayWaitHandler(ctx, r.client.DefaultAPI, projectId, vpn.Region(region), gatewayId).WaitWithContext(ctx)
+ if err != nil {
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting VPN gateway", fmt.Sprintf("Gateway deletion waiting: %v", err))
+ return
+ }
+
+ tflog.Info(ctx, "VPN gateway deleted")
+}
+
+func toCreatePayload(ctx context.Context, model *Model) (*vpn.CreateGatewayPayload, error) {
+ if model == nil {
+ return nil, fmt.Errorf("nil model")
+ }
+ if model.AvailabilityZones == nil {
+ return nil, fmt.Errorf("availability_zones is required")
+ }
+
+ azTunnel1 := model.AvailabilityZones.Tunnel1.ValueString()
+ azTunnel2 := model.AvailabilityZones.Tunnel2.ValueString()
+
+ payload := &vpn.CreateGatewayPayload{
+ DisplayName: model.DisplayName.ValueString(),
+ PlanId: model.PlanID.ValueString(),
+ RoutingType: vpn.RoutingType(model.RoutingType.ValueString()),
+ AvailabilityZones: vpn.CreateGatewayPayloadAvailabilityZones{
+ Tunnel1: azTunnel1,
+ Tunnel2: azTunnel2,
+ },
+ }
+
+ if model.Bgp != nil {
+ bgpConfig := &vpn.BGPGatewayConfig{}
+ if !model.Bgp.LocalAsn.IsNull() && !model.Bgp.LocalAsn.IsUnknown() {
+ asn := model.Bgp.LocalAsn.ValueInt64()
+ bgpConfig.LocalAsn = &asn
+ }
+ if !model.Bgp.OverrideAdvertisedRoutes.IsNull() && !model.Bgp.OverrideAdvertisedRoutes.IsUnknown() {
+ routes := toStringSlice(ctx, model.Bgp.OverrideAdvertisedRoutes)
+ if len(routes) > 0 {
+ bgpConfig.OverrideAdvertisedRoutes = routes
+ }
+ }
+ payload.Bgp = bgpConfig
+ }
+
+ if !model.Labels.IsNull() && !model.Labels.IsUnknown() {
+ labels := make(map[string]string)
+ diags := model.Labels.ElementsAs(ctx, &labels, false)
+ if diags.HasError() {
+ return nil, fmt.Errorf("converting labels: %w", core.DiagsToError(diags))
+ }
+ if len(labels) > 0 {
+ payload.Labels = &labels
+ }
+ }
+
+ return payload, nil
+}
+
+func toUpdatePayload(ctx context.Context, model *Model) (*vpn.UpdateGatewayPayload, error) {
+ if model == nil {
+ return nil, fmt.Errorf("nil model")
+ }
+ if model.AvailabilityZones == nil {
+ return nil, fmt.Errorf("availability_zones is required")
+ }
+
+ azTunnel1 := model.AvailabilityZones.Tunnel1.ValueString()
+ azTunnel2 := model.AvailabilityZones.Tunnel2.ValueString()
+
+ payload := &vpn.UpdateGatewayPayload{
+ DisplayName: model.DisplayName.ValueString(),
+ PlanId: model.PlanID.ValueString(),
+ AvailabilityZones: vpn.UpdateGatewayPayloadAvailabilityZones{
+ Tunnel1: azTunnel1,
+ Tunnel2: azTunnel2,
+ },
+ RoutingType: vpn.RoutingType(model.RoutingType.ValueString()),
+ }
+
+ if model.Bgp != nil {
+ bgpConfig := &vpn.BGPGatewayConfig{}
+ if !model.Bgp.LocalAsn.IsNull() && !model.Bgp.LocalAsn.IsUnknown() {
+ asn := model.Bgp.LocalAsn.ValueInt64()
+ bgpConfig.LocalAsn = &asn
+ }
+ if !model.Bgp.OverrideAdvertisedRoutes.IsNull() && !model.Bgp.OverrideAdvertisedRoutes.IsUnknown() {
+ routes := toStringSlice(ctx, model.Bgp.OverrideAdvertisedRoutes)
+ if len(routes) > 0 {
+ bgpConfig.OverrideAdvertisedRoutes = routes
+ }
+ }
+ payload.Bgp = bgpConfig
+ }
+
+ if !model.Labels.IsNull() && !model.Labels.IsUnknown() {
+ labels := make(map[string]string)
+ diags := model.Labels.ElementsAs(ctx, &labels, false)
+ if diags.HasError() {
+ return nil, fmt.Errorf("converting labels: %w", core.DiagsToError(diags))
+ }
+ payload.Labels = &labels
+ }
+
+ return payload, nil
+}
+
+func mapFields(ctx context.Context, gateway *vpn.GatewayResponse, model *Model, region string) error {
+ if gateway == nil {
+ return fmt.Errorf("response input is nil")
+ }
+ if model == nil {
+ return fmt.Errorf("model input is nil")
+ }
+
+ var gatewayId string
+ if model.GatewayID.ValueString() != "" {
+ gatewayId = model.GatewayID.ValueString()
+ } else if gateway.Id != nil {
+ gatewayId = *gateway.Id
+ } else {
+ return fmt.Errorf("gateway id not present")
+ }
+
+ model.ID = tfutils.BuildInternalTerraformId(model.ProjectID.ValueString(), region, gatewayId)
+ model.GatewayID = types.StringValue(gatewayId)
+ model.DisplayName = types.StringValue(gateway.DisplayName)
+ model.PlanID = types.StringValue(gateway.PlanId)
+ model.RoutingType = types.StringValue(string(gateway.RoutingType))
+ model.Region = types.StringValue(region)
+
+ model.AvailabilityZones = &AvailabilityZonesModel{
+ Tunnel1: types.StringValue(string(gateway.AvailabilityZones.Tunnel1)),
+ Tunnel2: types.StringValue(string(gateway.AvailabilityZones.Tunnel2)),
+ }
+
+ if gateway.Bgp != nil {
+ bgpModel := &BGPGatewayConfigModel{}
+ if gateway.Bgp.LocalAsn != nil {
+ bgpModel.LocalAsn = types.Int64Value(int64(*gateway.Bgp.LocalAsn))
+ } else {
+ bgpModel.LocalAsn = types.Int64Null()
+ }
+ if len(gateway.Bgp.OverrideAdvertisedRoutes) > 0 {
+ routes := gateway.Bgp.OverrideAdvertisedRoutes
+ listVal, diags := types.ListValueFrom(ctx, types.StringType, routes)
+ if diags.HasError() {
+ return fmt.Errorf("mapping BGP routes: %w", core.DiagsToError(diags))
+ }
+ bgpModel.OverrideAdvertisedRoutes = listVal
+ } else {
+ bgpModel.OverrideAdvertisedRoutes = types.ListNull(types.StringType)
+ }
+ model.Bgp = bgpModel
+ }
+
+ if gateway.Labels != nil && len(*gateway.Labels) > 0 {
+ labelsMap := make(map[string]attr.Value)
+ for k, v := range *gateway.Labels {
+ labelsMap[k] = types.StringValue(v)
+ }
+ mapVal, diags := types.MapValue(types.StringType, labelsMap)
+ if diags.HasError() {
+ return fmt.Errorf("mapping labels: %w", core.DiagsToError(diags))
+ }
+ model.Labels = mapVal
+ } else {
+ model.Labels = types.MapNull(types.StringType)
+ }
+
+ if gateway.State != nil {
+ model.State = types.StringValue(string(*gateway.State))
+ }
+
+ return nil
+}
+
+func toStringSlice(ctx context.Context, list types.List) []string {
+ var result []string
+ list.ElementsAs(ctx, &result, false)
+ return result
+}
diff --git a/stackit/internal/services/vpn/gateway/resource_test.go b/stackit/internal/services/vpn/gateway/resource_test.go
new file mode 100644
index 000000000..d2998eb2c
--- /dev/null
+++ b/stackit/internal/services/vpn/gateway/resource_test.go
@@ -0,0 +1,287 @@
+package gateway
+
+import (
+ "context"
+ "testing"
+
+ "github.com/google/go-cmp/cmp"
+ "github.com/hashicorp/terraform-plugin-framework/types"
+ "github.com/stackitcloud/stackit-sdk-go/core/utils"
+ vpn "github.com/stackitcloud/stackit-sdk-go/services/vpn/v1api"
+)
+
+func TestMapFields(t *testing.T) {
+ tests := []struct {
+ description string
+ input *vpn.GatewayResponse
+ expected Model
+ isValid bool
+ }{
+ {
+ "default_ok",
+ &vpn.GatewayResponse{
+ Id: new("gateway-id"),
+ DisplayName: "test-gateway",
+ PlanId: "p500",
+ RoutingType: vpn.ROUTINGTYPE_ROUTE_BASED,
+ AvailabilityZones: vpn.GatewayAvailabilityZones{
+ Tunnel1: "eu01-1",
+ Tunnel2: "eu01-2",
+ },
+ State: utils.Ptr(vpn.GATEWAYSTATUS_READY),
+ },
+ Model{
+ GatewayID: types.StringValue("gateway-id"),
+ DisplayName: types.StringValue("test-gateway"),
+ PlanID: types.StringValue("p500"),
+ RoutingType: types.StringValue("ROUTE_BASED"),
+ AvailabilityZones: &AvailabilityZonesModel{
+ Tunnel1: types.StringValue("eu01-1"),
+ Tunnel2: types.StringValue("eu01-2"),
+ },
+ Bgp: nil,
+ Labels: types.MapNull(types.StringType),
+ State: types.StringValue("READY"),
+ },
+ true,
+ },
+ {
+ "with_bgp_and_labels",
+ &vpn.GatewayResponse{
+ Id: new("gateway-id"),
+ DisplayName: "test-gateway",
+ PlanId: "p500",
+ RoutingType: vpn.ROUTINGTYPE_BGP_ROUTE_BASED,
+ AvailabilityZones: vpn.GatewayAvailabilityZones{
+ Tunnel1: "eu01-1",
+ Tunnel2: "eu01-2",
+ },
+ Bgp: &vpn.BGPGatewayConfig{
+ LocalAsn: new(int64(65000)),
+ OverrideAdvertisedRoutes: []string{"10.0.0.0/16", "192.168.0.0/24"},
+ },
+ Labels: &map[string]string{
+ "env": "prod",
+ "team": "network",
+ },
+ State: utils.Ptr(vpn.GATEWAYSTATUS_READY),
+ },
+ Model{
+ GatewayID: types.StringValue("gateway-id"),
+ DisplayName: types.StringValue("test-gateway"),
+ PlanID: types.StringValue("p500"),
+ RoutingType: types.StringValue("BGP_ROUTE_BASED"),
+ AvailabilityZones: &AvailabilityZonesModel{
+ Tunnel1: types.StringValue("eu01-1"),
+ Tunnel2: types.StringValue("eu01-2"),
+ },
+ Bgp: &BGPGatewayConfigModel{
+ LocalAsn: types.Int64Value(65000),
+ },
+ State: types.StringValue("READY"),
+ },
+ true,
+ },
+ {
+ "nil_response",
+ nil,
+ Model{},
+ false,
+ },
+ {
+ "nil_gateway_id",
+ &vpn.GatewayResponse{
+ Id: nil,
+ DisplayName: "test-gateway",
+ },
+ Model{},
+ false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.description, func(t *testing.T) {
+ var model Model
+ model.ProjectID = types.StringValue("test-project")
+ model.Region = types.StringValue("eu01")
+
+ err := mapFields(context.Background(), tt.input, &model, "eu01")
+
+ if !tt.isValid && err == nil {
+ t.Fatalf("expected error, got none")
+ }
+ if tt.isValid && err != nil {
+ t.Fatalf("expected no error, got %v", err)
+ }
+ if !tt.isValid {
+ return
+ }
+
+ if diff := cmp.Diff(model.GatewayID, tt.expected.GatewayID); diff != "" {
+ t.Fatalf("GatewayID mismatch (-got +want):\n%s", diff)
+ }
+ if diff := cmp.Diff(model.DisplayName, tt.expected.DisplayName); diff != "" {
+ t.Fatalf("DisplayName mismatch (-got +want):\n%s", diff)
+ }
+ if diff := cmp.Diff(model.PlanID, tt.expected.PlanID); diff != "" {
+ t.Fatalf("PlanID mismatch (-got +want):\n%s", diff)
+ }
+ if diff := cmp.Diff(model.RoutingType, tt.expected.RoutingType); diff != "" {
+ t.Fatalf("RoutingType mismatch (-got +want):\n%s", diff)
+ }
+ if diff := cmp.Diff(model.State, tt.expected.State); diff != "" {
+ t.Fatalf("State mismatch (-got +want):\n%s", diff)
+ }
+
+ if diff := cmp.Diff(model.AvailabilityZones.Tunnel1, tt.expected.AvailabilityZones.Tunnel1); diff != "" {
+ t.Fatalf("AZ Tunnel1 mismatch (-got +want):\n%s", diff)
+ }
+ if diff := cmp.Diff(model.AvailabilityZones.Tunnel2, tt.expected.AvailabilityZones.Tunnel2); diff != "" {
+ t.Fatalf("AZ Tunnel2 mismatch (-got +want):\n%s", diff)
+ }
+
+ if tt.expected.Bgp != nil {
+ if model.Bgp == nil {
+ t.Fatalf("expected BGP config, got nil")
+ }
+ if diff := cmp.Diff(model.Bgp.LocalAsn, tt.expected.Bgp.LocalAsn); diff != "" {
+ t.Fatalf("BGP LocalAsn mismatch (-got +want):\n%s", diff)
+ }
+ }
+ })
+ }
+}
+
+func TestToCreatePayload(t *testing.T) {
+ tests := []struct {
+ description string
+ input Model
+ isValid bool
+ }{
+ {
+ "basic_gateway",
+ Model{
+ DisplayName: types.StringValue("test-gateway"),
+ PlanID: types.StringValue("p500"),
+ RoutingType: types.StringValue("ROUTE_BASED"),
+ AvailabilityZones: &AvailabilityZonesModel{
+ Tunnel1: types.StringValue("eu01-1"),
+ Tunnel2: types.StringValue("eu01-2"),
+ },
+ },
+ true,
+ },
+ {
+ "with_bgp",
+ Model{
+ DisplayName: types.StringValue("test-gateway"),
+ PlanID: types.StringValue("p500"),
+ RoutingType: types.StringValue("BGP_ROUTE_BASED"),
+ AvailabilityZones: &AvailabilityZonesModel{
+ Tunnel1: types.StringValue("eu01-1"),
+ Tunnel2: types.StringValue("eu01-2"),
+ },
+ Bgp: &BGPGatewayConfigModel{
+ LocalAsn: types.Int64Value(65000),
+ },
+ },
+ true,
+ },
+ {
+ "nil_model",
+ Model{},
+ false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.description, func(t *testing.T) {
+ var model *Model
+ if tt.isValid {
+ model = &tt.input
+ }
+
+ payload, err := toCreatePayload(context.Background(), model)
+
+ if !tt.isValid && err == nil {
+ t.Fatalf("expected error, got none")
+ }
+ if tt.isValid && err != nil {
+ t.Fatalf("expected no error, got %v", err)
+ }
+ if !tt.isValid {
+ return
+ }
+
+ if payload.DisplayName != tt.input.DisplayName.ValueString() {
+ t.Errorf("DisplayName mismatch: got %v, want %v", payload.DisplayName, tt.input.DisplayName.ValueString())
+ }
+ if payload.PlanId != tt.input.PlanID.ValueString() {
+ t.Errorf("PlanId mismatch: got %v, want %v", payload.PlanId, tt.input.PlanID.ValueString())
+ }
+ if string(payload.RoutingType) != tt.input.RoutingType.ValueString() {
+ t.Errorf("RoutingType mismatch: got %v, want %v", payload.RoutingType, tt.input.RoutingType.ValueString())
+ }
+
+ if tt.input.Bgp != nil {
+ if payload.Bgp == nil {
+ t.Errorf("expected BGP config, got nil")
+ }
+ }
+ })
+ }
+}
+
+func TestToUpdatePayload(t *testing.T) {
+ tests := []struct {
+ description string
+ input Model
+ isValid bool
+ }{
+ {
+ "basic_update",
+ Model{
+ DisplayName: types.StringValue("updated-gateway"),
+ PlanID: types.StringValue("p1000"),
+ AvailabilityZones: &AvailabilityZonesModel{
+ Tunnel1: types.StringValue("eu01-1"),
+ Tunnel2: types.StringValue("eu01-2"),
+ },
+ },
+ true,
+ },
+ {
+ "nil_model",
+ Model{},
+ false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.description, func(t *testing.T) {
+ var model *Model
+ if tt.isValid {
+ model = &tt.input
+ }
+
+ payload, err := toUpdatePayload(context.Background(), model)
+
+ if !tt.isValid && err == nil {
+ t.Fatalf("expected error, got none")
+ }
+ if tt.isValid && err != nil {
+ t.Fatalf("expected no error, got %v", err)
+ }
+ if !tt.isValid {
+ return
+ }
+
+ if payload.DisplayName != tt.input.DisplayName.ValueString() {
+ t.Errorf("DisplayName mismatch: got %v, want %v", payload.DisplayName, tt.input.DisplayName.ValueString())
+ }
+ if payload.PlanId != tt.input.PlanID.ValueString() {
+ t.Errorf("PlanId mismatch: got %v, want %v", payload.PlanId, tt.input.PlanID.ValueString())
+ }
+ })
+ }
+}
diff --git a/stackit/internal/services/vpn/testdata/gateway-max.tf b/stackit/internal/services/vpn/testdata/gateway-max.tf
new file mode 100644
index 000000000..4b7aba179
--- /dev/null
+++ b/stackit/internal/services/vpn/testdata/gateway-max.tf
@@ -0,0 +1,34 @@
+variable "project_id" {}
+variable "region" {}
+variable "display_name" {}
+variable "plan_id" {}
+variable "routing_type" {}
+variable "az_tunnel1" {}
+variable "az_tunnel2" {}
+variable "local_asn" {}
+variable "advertised_route_1" {}
+variable "advertised_route_2" {}
+variable "label_key" {}
+variable "label_value" {}
+
+resource "stackit_vpn_gateway" "gateway" {
+ project_id = var.project_id
+ region = var.region
+ display_name = var.display_name
+ plan_id = var.plan_id
+ routing_type = var.routing_type
+
+ availability_zones = {
+ tunnel1 = var.az_tunnel1
+ tunnel2 = var.az_tunnel2
+ }
+
+ bgp = {
+ local_asn = var.local_asn
+ override_advertised_routes = [var.advertised_route_1, var.advertised_route_2]
+ }
+
+ labels = {
+ (var.label_key) = var.label_value
+ }
+}
diff --git a/stackit/internal/services/vpn/testdata/gateway-min.tf b/stackit/internal/services/vpn/testdata/gateway-min.tf
new file mode 100644
index 000000000..2c4eb6588
--- /dev/null
+++ b/stackit/internal/services/vpn/testdata/gateway-min.tf
@@ -0,0 +1,20 @@
+variable "project_id" {}
+variable "region" {}
+variable "display_name" {}
+variable "plan_id" {}
+variable "routing_type" {}
+variable "az_tunnel1" {}
+variable "az_tunnel2" {}
+
+resource "stackit_vpn_gateway" "gateway" {
+ project_id = var.project_id
+ region = var.region
+ display_name = var.display_name
+ plan_id = var.plan_id
+ routing_type = var.routing_type
+
+ availability_zones = {
+ tunnel1 = var.az_tunnel1
+ tunnel2 = var.az_tunnel2
+ }
+}
diff --git a/stackit/internal/services/vpn/utils/utils.go b/stackit/internal/services/vpn/utils/utils.go
new file mode 100644
index 000000000..e8e6577ef
--- /dev/null
+++ b/stackit/internal/services/vpn/utils/utils.go
@@ -0,0 +1,32 @@
+package utils
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/hashicorp/terraform-plugin-framework/diag"
+ "github.com/stackitcloud/stackit-sdk-go/core/config"
+ vpn "github.com/stackitcloud/stackit-sdk-go/services/vpn/v1api"
+
+ "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
+ "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils"
+)
+
+func ConfigureClient(ctx context.Context, providerData *core.ProviderData, diags *diag.Diagnostics) *vpn.APIClient {
+ apiClientConfigOptions := []config.ConfigurationOption{
+ config.WithCustomAuth(providerData.RoundTripper),
+ utils.UserAgentConfigOption(providerData.Version),
+ }
+ if providerData.VpnCustomEndpoint != "" {
+ apiClientConfigOptions = append(apiClientConfigOptions, config.WithEndpoint(providerData.VpnCustomEndpoint))
+ } else {
+ apiClientConfigOptions = append(apiClientConfigOptions, config.WithRegion(providerData.GetRegion()))
+ }
+ apiClient, err := vpn.NewAPIClient(apiClientConfigOptions...)
+ if err != nil {
+ core.LogAndAddError(ctx, diags, "Error configuring API client", fmt.Sprintf("Configuring client: %v. This is an error related to the provider configuration, not to the resource configuration", err))
+ return nil
+ }
+
+ return apiClient
+}
diff --git a/stackit/internal/services/vpn/utils/utils_test.go b/stackit/internal/services/vpn/utils/utils_test.go
new file mode 100644
index 000000000..fde745c68
--- /dev/null
+++ b/stackit/internal/services/vpn/utils/utils_test.go
@@ -0,0 +1,95 @@
+package utils
+
+import (
+ "context"
+ "os"
+ "reflect"
+ "testing"
+
+ "github.com/hashicorp/terraform-plugin-framework/diag"
+ sdkClients "github.com/stackitcloud/stackit-sdk-go/core/clients"
+ "github.com/stackitcloud/stackit-sdk-go/core/config"
+ vpn "github.com/stackitcloud/stackit-sdk-go/services/vpn/v1api"
+
+ "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
+ "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils"
+)
+
+const (
+ testVersion = "1.2.3"
+ testCustomEndpoint = "https://vpn-custom-endpoint.api.stackit.cloud"
+)
+
+func TestConfigureClient(t *testing.T) {
+ /* mock authentication by setting service account token env variable */
+ os.Clearenv()
+ err := os.Setenv(sdkClients.ServiceAccountToken, "mock-val")
+ if err != nil {
+ t.Errorf("error setting env variable: %v", err)
+ }
+
+ type args struct {
+ providerData *core.ProviderData
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ expected *vpn.APIClient
+ }{
+ {
+ name: "default endpoint",
+ args: args{
+ providerData: &core.ProviderData{
+ Version: testVersion,
+ },
+ },
+ expected: func() *vpn.APIClient {
+ apiClient, err := vpn.NewAPIClient(
+ config.WithRegion("eu01"),
+ utils.UserAgentConfigOption(testVersion),
+ )
+ if err != nil {
+ t.Errorf("error configuring client: %v", err)
+ }
+ return apiClient
+ }(),
+ wantErr: false,
+ },
+ {
+ name: "custom endpoint",
+ args: args{
+ providerData: &core.ProviderData{
+ Version: testVersion,
+ VpnCustomEndpoint: testCustomEndpoint,
+ },
+ },
+ expected: func() *vpn.APIClient {
+ apiClient, err := vpn.NewAPIClient(
+ utils.UserAgentConfigOption(testVersion),
+ config.WithEndpoint(testCustomEndpoint),
+ )
+ if err != nil {
+ t.Errorf("error configuring client: %v", err)
+ }
+ return apiClient
+ }(),
+ wantErr: false,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ ctx := context.Background()
+ diags := diag.Diagnostics{}
+
+ actual := ConfigureClient(ctx, tt.args.providerData, &diags)
+ if diags.HasError() != tt.wantErr {
+ t.Errorf("ConfigureClient() error = %v, want %v", diags.HasError(), tt.wantErr)
+ }
+
+ if !reflect.DeepEqual(actual, tt.expected) {
+ t.Errorf("ConfigureClient() = %v, want %v", actual, tt.expected)
+ }
+ })
+ }
+}
diff --git a/stackit/internal/services/vpn/vpn_acc_test.go b/stackit/internal/services/vpn/vpn_acc_test.go
new file mode 100644
index 000000000..fbae0b010
--- /dev/null
+++ b/stackit/internal/services/vpn/vpn_acc_test.go
@@ -0,0 +1,266 @@
+package vpn_test
+
+import (
+ "context"
+ _ "embed"
+ "fmt"
+ "maps"
+ "strings"
+ "testing"
+
+ "github.com/hashicorp/terraform-plugin-testing/config"
+ "github.com/hashicorp/terraform-plugin-testing/helper/acctest"
+ "github.com/hashicorp/terraform-plugin-testing/helper/resource"
+ "github.com/hashicorp/terraform-plugin-testing/terraform"
+ vpn "github.com/stackitcloud/stackit-sdk-go/services/vpn/v1api"
+
+ "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
+ "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/testutil"
+)
+
+//go:embed testdata/gateway-min.tf
+var gatewayMinConfig string
+
+//go:embed testdata/gateway-max.tf
+var gatewayMaxConfig string
+
+var gatewayMinVars = config.Variables{
+ "project_id": config.StringVariable(testutil.ProjectId),
+ "region": config.StringVariable("eu01"),
+ "display_name": config.StringVariable("vpn-gw-acc-test-" + acctest.RandStringFromCharSet(8, acctest.CharSetAlpha)),
+ "plan_id": config.StringVariable("p500"),
+ "routing_type": config.StringVariable("ROUTE_BASED"),
+ "az_tunnel1": config.StringVariable("eu01-1"),
+ "az_tunnel2": config.StringVariable("eu01-2"),
+}
+
+var gatewayMinVarsUpdated = func() config.Variables {
+ updated := make(config.Variables, len(gatewayMinVars))
+ maps.Copy(updated, gatewayMinVars)
+ updated["display_name"] = config.StringVariable("vpn-gw-acc-test-updated-" + acctest.RandStringFromCharSet(8, acctest.CharSetAlpha))
+ updated["plan_id"] = config.StringVariable("p100")
+ return updated
+}()
+
+var gatewayMaxVars = config.Variables{
+ "project_id": config.StringVariable(testutil.ProjectId),
+ "region": config.StringVariable("eu01"),
+ "display_name": config.StringVariable("vpn-gw-acc-test-" + acctest.RandStringFromCharSet(8, acctest.CharSetAlpha)),
+ "plan_id": config.StringVariable("p500"),
+ "routing_type": config.StringVariable("BGP_ROUTE_BASED"),
+ "az_tunnel1": config.StringVariable("eu01-1"),
+ "az_tunnel2": config.StringVariable("eu01-2"),
+ "local_asn": config.IntegerVariable(65000),
+ "advertised_route_1": config.StringVariable("10.0.0.0/16"),
+ "advertised_route_2": config.StringVariable("192.168.0.0/24"),
+ "label_key": config.StringVariable("env"),
+ "label_value": config.StringVariable("test"),
+}
+
+var gatewayMaxVarsUpdated = func() config.Variables {
+ updated := make(config.Variables, len(gatewayMaxVars))
+ maps.Copy(updated, gatewayMaxVars)
+ updated["display_name"] = config.StringVariable("vpn-gw-acc-test-updated-" + acctest.RandStringFromCharSet(8, acctest.CharSetAlpha))
+ updated["local_asn"] = config.IntegerVariable(65001)
+ updated["label_value"] = config.StringVariable("production")
+ return updated
+}()
+
+func TestAccVpnGatewayResourceMin(t *testing.T) {
+ resource.Test(t, resource.TestCase{
+ ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories,
+ CheckDestroy: testAccCheckVpnGatewayDestroy,
+ Steps: []resource.TestStep{
+ // Creation
+ {
+ ConfigVariables: gatewayMinVars,
+ Config: fmt.Sprintf("%s\n%s", testutil.NewConfigBuilder().BuildProviderConfig(), gatewayMinConfig),
+ Check: resource.ComposeAggregateTestCheckFunc(
+ // Gateway data
+ resource.TestCheckResourceAttr("stackit_vpn_gateway.gateway", "project_id", testutil.ConvertConfigVariable(gatewayMinVars["project_id"])),
+ resource.TestCheckResourceAttr("stackit_vpn_gateway.gateway", "region", testutil.ConvertConfigVariable(gatewayMinVars["region"])),
+ resource.TestCheckResourceAttr("stackit_vpn_gateway.gateway", "display_name", testutil.ConvertConfigVariable(gatewayMinVars["display_name"])),
+ resource.TestCheckResourceAttr("stackit_vpn_gateway.gateway", "plan_id", testutil.ConvertConfigVariable(gatewayMinVars["plan_id"])),
+ resource.TestCheckResourceAttr("stackit_vpn_gateway.gateway", "routing_type", testutil.ConvertConfigVariable(gatewayMinVars["routing_type"])),
+ resource.TestCheckResourceAttrSet("stackit_vpn_gateway.gateway", "gateway_id"),
+ resource.TestCheckResourceAttrSet("stackit_vpn_gateway.gateway", "state"),
+ ),
+ },
+ // Data source
+ {
+ ConfigVariables: gatewayMinVars,
+ Config: fmt.Sprintf(`
+ %s
+ %s
+
+ data "stackit_vpn_gateway" "gateway" {
+ project_id = stackit_vpn_gateway.gateway.project_id
+ gateway_id = stackit_vpn_gateway.gateway.gateway_id
+ }
+ `,
+ testutil.NewConfigBuilder().BuildProviderConfig(), gatewayMinConfig,
+ ),
+ Check: resource.ComposeAggregateTestCheckFunc(
+ resource.TestCheckResourceAttr("data.stackit_vpn_gateway.gateway", "project_id", testutil.ConvertConfigVariable(gatewayMinVars["project_id"])),
+ resource.TestCheckResourceAttr("data.stackit_vpn_gateway.gateway", "display_name", testutil.ConvertConfigVariable(gatewayMinVars["display_name"])),
+ resource.TestCheckResourceAttr("data.stackit_vpn_gateway.gateway", "plan_id", testutil.ConvertConfigVariable(gatewayMinVars["plan_id"])),
+ resource.TestCheckResourceAttr("data.stackit_vpn_gateway.gateway", "routing_type", testutil.ConvertConfigVariable(gatewayMinVars["routing_type"])),
+ resource.TestCheckResourceAttr("data.stackit_vpn_gateway.gateway", "availability_zones.tunnel1", testutil.ConvertConfigVariable(gatewayMinVars["az_tunnel1"])),
+ resource.TestCheckResourceAttr("data.stackit_vpn_gateway.gateway", "availability_zones.tunnel2", testutil.ConvertConfigVariable(gatewayMinVars["az_tunnel2"])),
+
+ resource.TestCheckResourceAttrSet("data.stackit_vpn_gateway.gateway", "gateway_id"),
+ resource.TestCheckResourceAttrSet("data.stackit_vpn_gateway.gateway", "state"),
+
+ resource.TestCheckResourceAttrPair("data.stackit_vpn_gateway.gateway", "project_id", "stackit_vpn_gateway.gateway", "project_id"),
+ resource.TestCheckResourceAttrPair("data.stackit_vpn_gateway.gateway", "region", "stackit_vpn_gateway.gateway", "region"),
+ resource.TestCheckResourceAttrPair("data.stackit_vpn_gateway.gateway", "gateway_id", "stackit_vpn_gateway.gateway", "gateway_id"),
+ resource.TestCheckResourceAttrPair("data.stackit_vpn_gateway.gateway", "display_name", "stackit_vpn_gateway.gateway", "display_name"),
+ resource.TestCheckResourceAttrPair("data.stackit_vpn_gateway.gateway", "plan_id", "stackit_vpn_gateway.gateway", "plan_id"),
+ resource.TestCheckResourceAttrPair("data.stackit_vpn_gateway.gateway", "routing_type", "stackit_vpn_gateway.gateway", "routing_type"),
+ resource.TestCheckResourceAttrPair("data.stackit_vpn_gateway.gateway", "state", "stackit_vpn_gateway.gateway", "state"),
+ ),
+ },
+ // Update
+ {
+ ConfigVariables: gatewayMinVarsUpdated,
+ Config: fmt.Sprintf("%s\n%s", testutil.NewConfigBuilder().BuildProviderConfig(), gatewayMinConfig),
+ Check: resource.ComposeAggregateTestCheckFunc(
+ // Gateway data
+ resource.TestCheckResourceAttr("stackit_vpn_gateway.gateway", "display_name", testutil.ConvertConfigVariable(gatewayMinVarsUpdated["display_name"])),
+ resource.TestCheckResourceAttr("stackit_vpn_gateway.gateway", "plan_id", testutil.ConvertConfigVariable(gatewayMinVarsUpdated["plan_id"])),
+ ),
+ },
+ // Import
+ {
+ ConfigVariables: gatewayMinVars,
+ ResourceName: "stackit_vpn_gateway.gateway",
+ ImportStateIdFunc: func(s *terraform.State) (string, error) {
+ r, ok := s.RootModule().Resources["stackit_vpn_gateway.gateway"]
+ if !ok {
+ return "", fmt.Errorf("couldn't find resource stackit_vpn_gateway.gateway")
+ }
+ gatewayId, ok := r.Primary.Attributes["gateway_id"]
+ if !ok {
+ return "", fmt.Errorf("couldn't find attribute gateway_id")
+ }
+ return fmt.Sprintf("%s,%s,%s",
+ testutil.ConvertConfigVariable(gatewayMinVarsUpdated["project_id"]),
+ testutil.ConvertConfigVariable(gatewayMinVarsUpdated["region"]),
+ gatewayId,
+ ), nil
+ },
+ ImportState: true,
+ ImportStateVerify: true,
+ },
+ },
+ })
+}
+
+func TestAccVpnGatewayResourceMax(t *testing.T) {
+ resource.Test(t, resource.TestCase{
+ ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories,
+ CheckDestroy: testAccCheckVpnGatewayDestroy,
+ Steps: []resource.TestStep{
+ // Creation
+ {
+ ConfigVariables: gatewayMaxVars,
+ Config: fmt.Sprintf("%s\n%s", testutil.NewConfigBuilder().BuildProviderConfig(), gatewayMaxConfig),
+ Check: resource.ComposeAggregateTestCheckFunc(
+ // Gateway data
+ resource.TestCheckResourceAttr("stackit_vpn_gateway.gateway", "project_id", testutil.ConvertConfigVariable(gatewayMaxVars["project_id"])),
+ resource.TestCheckResourceAttr("stackit_vpn_gateway.gateway", "display_name", testutil.ConvertConfigVariable(gatewayMaxVars["display_name"])),
+ resource.TestCheckResourceAttr("stackit_vpn_gateway.gateway", "routing_type", testutil.ConvertConfigVariable(gatewayMaxVars["routing_type"])),
+ resource.TestCheckResourceAttr("stackit_vpn_gateway.gateway", "bgp.local_asn", testutil.ConvertConfigVariable(gatewayMaxVars["local_asn"])),
+ resource.TestCheckResourceAttrSet("stackit_vpn_gateway.gateway", "gateway_id"),
+ ),
+ },
+ // Data source
+ {
+ ConfigVariables: gatewayMaxVars,
+ Config: fmt.Sprintf(`
+ %s
+ %s
+
+ data "stackit_vpn_gateway" "gateway" {
+ project_id = stackit_vpn_gateway.gateway.project_id
+ gateway_id = stackit_vpn_gateway.gateway.gateway_id
+ }
+ `,
+ testutil.NewConfigBuilder().BuildProviderConfig(), gatewayMaxConfig,
+ ),
+ Check: resource.ComposeAggregateTestCheckFunc(
+ resource.TestCheckResourceAttr("data.stackit_vpn_gateway.gateway", "project_id", testutil.ConvertConfigVariable(gatewayMaxVars["project_id"])),
+ resource.TestCheckResourceAttr("data.stackit_vpn_gateway.gateway", "display_name", testutil.ConvertConfigVariable(gatewayMaxVars["display_name"])),
+ resource.TestCheckResourceAttr("data.stackit_vpn_gateway.gateway", "plan_id", testutil.ConvertConfigVariable(gatewayMaxVars["plan_id"])),
+ resource.TestCheckResourceAttr("data.stackit_vpn_gateway.gateway", "routing_type", testutil.ConvertConfigVariable(gatewayMaxVars["routing_type"])),
+ resource.TestCheckResourceAttr("data.stackit_vpn_gateway.gateway", "availability_zones.tunnel1", testutil.ConvertConfigVariable(gatewayMaxVars["az_tunnel1"])),
+ resource.TestCheckResourceAttr("data.stackit_vpn_gateway.gateway", "availability_zones.tunnel2", testutil.ConvertConfigVariable(gatewayMaxVars["az_tunnel2"])),
+ resource.TestCheckResourceAttr("data.stackit_vpn_gateway.gateway", "bgp.local_asn", testutil.ConvertConfigVariable(gatewayMaxVars["local_asn"])),
+ resource.TestCheckResourceAttr("data.stackit_vpn_gateway.gateway", "bgp.override_advertised_routes.0", testutil.ConvertConfigVariable(gatewayMaxVars["advertised_route_1"])),
+ resource.TestCheckResourceAttr("data.stackit_vpn_gateway.gateway", "bgp.override_advertised_routes.1", testutil.ConvertConfigVariable(gatewayMaxVars["advertised_route_2"])),
+ resource.TestCheckResourceAttr("data.stackit_vpn_gateway.gateway", "labels."+testutil.ConvertConfigVariable(gatewayMaxVars["label_key"]), testutil.ConvertConfigVariable(gatewayMaxVars["label_value"])),
+
+ resource.TestCheckResourceAttrSet("data.stackit_vpn_gateway.gateway", "gateway_id"),
+ resource.TestCheckResourceAttrSet("data.stackit_vpn_gateway.gateway", "state"),
+
+ resource.TestCheckResourceAttrPair("data.stackit_vpn_gateway.gateway", "project_id", "stackit_vpn_gateway.gateway", "project_id"),
+ resource.TestCheckResourceAttrPair("data.stackit_vpn_gateway.gateway", "region", "stackit_vpn_gateway.gateway", "region"),
+ resource.TestCheckResourceAttrPair("data.stackit_vpn_gateway.gateway", "gateway_id", "stackit_vpn_gateway.gateway", "gateway_id"),
+ resource.TestCheckResourceAttrPair("data.stackit_vpn_gateway.gateway", "display_name", "stackit_vpn_gateway.gateway", "display_name"),
+ resource.TestCheckResourceAttrPair("data.stackit_vpn_gateway.gateway", "plan_id", "stackit_vpn_gateway.gateway", "plan_id"),
+ resource.TestCheckResourceAttrPair("data.stackit_vpn_gateway.gateway", "routing_type", "stackit_vpn_gateway.gateway", "routing_type"),
+ resource.TestCheckResourceAttrPair("data.stackit_vpn_gateway.gateway", "bgp.local_asn", "stackit_vpn_gateway.gateway", "bgp.local_asn"),
+ resource.TestCheckResourceAttrPair("data.stackit_vpn_gateway.gateway", "state", "stackit_vpn_gateway.gateway", "state"),
+ ),
+ },
+ // Update
+ {
+ ConfigVariables: gatewayMaxVarsUpdated,
+ Config: fmt.Sprintf("%s\n%s", testutil.NewConfigBuilder().BuildProviderConfig(), gatewayMaxConfig),
+ Check: resource.ComposeAggregateTestCheckFunc(
+ resource.TestCheckResourceAttr("stackit_vpn_gateway.gateway", "display_name", testutil.ConvertConfigVariable(gatewayMaxVarsUpdated["display_name"])),
+ resource.TestCheckResourceAttr("stackit_vpn_gateway.gateway", "bgp.local_asn", testutil.ConvertConfigVariable(gatewayMaxVarsUpdated["local_asn"])),
+ ),
+ },
+ },
+ })
+}
+
+func testAccCheckVpnGatewayDestroy(s *terraform.State) error {
+ ctx := context.Background()
+ client, err := vpn.NewAPIClient(testutil.NewConfigBuilder().BuildClientOptions(testutil.VpnCustomEndpoint, true)...)
+ if err != nil {
+ return fmt.Errorf("creating client: %w", err)
+ }
+
+ gatewaysToDestroy := []string{}
+ for _, rs := range s.RootModule().Resources {
+ if rs.Type != "stackit_vpn_gateway" {
+ continue
+ }
+ // gateway terraform ID: "[project_id],[region],[gateway_id]"
+ gatewayId := strings.Split(rs.Primary.ID, core.Separator)[2]
+ gatewaysToDestroy = append(gatewaysToDestroy, gatewayId)
+ }
+
+ gatewaysResp, err := client.DefaultAPI.ListGateways(ctx, testutil.ProjectId, vpn.REGION_EU01).Execute()
+ if err != nil {
+ return fmt.Errorf("getting gateways: %w", err)
+ }
+
+ gateways := gatewaysResp.Gateways
+ for _, gateway := range gateways {
+ if gateway.Id == nil {
+ continue
+ }
+ for _, gatewayId := range gatewaysToDestroy {
+ if *gateway.Id == gatewayId {
+ err := client.DefaultAPI.DeleteGateway(ctx, testutil.ProjectId, vpn.REGION_EU01, *gateway.Id).Execute()
+ if err != nil {
+ return fmt.Errorf("destroying gateway %s during CheckDestroy: %w", gatewayId, err)
+ }
+ }
+ }
+ }
+ return nil
+}
diff --git a/stackit/internal/testutil/testutil.go b/stackit/internal/testutil/testutil.go
index 553e54774..727fb4563 100644
--- a/stackit/internal/testutil/testutil.go
+++ b/stackit/internal/testutil/testutil.go
@@ -97,6 +97,7 @@ var (
SFSCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_SFS_CUSTOM_ENDPOINT", providerName: "sfs_custom_endpoint"}
ServiceAccountCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_SERVICE_ACCOUNT_CUSTOM_ENDPOINT", providerName: "service_account_custom_endpoint"}
TokenCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_TOKEN_CUSTOM_ENDPOINT", providerName: "token_custom_endpoint"}
+ VpnCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_VPN_CUSTOM_ENDPOINT", providerName: "vpn_custom_endpoint"}
SKECustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_SKE_CUSTOM_ENDPOINT", providerName: "ske_custom_endpoint"}
IntakeCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_INTAKE_CUSTOM_ENDPOINT", providerName: "intake_custom_endpoint"}
@@ -131,6 +132,7 @@ var (
SFSCustomEndpoint,
ServiceAccountCustomEndpoint,
TokenCustomEndpoint,
+ VpnCustomEndpoint,
SKECustomEndpoint,
}
)
diff --git a/stackit/provider.go b/stackit/provider.go
index 1e484fefb..e586ca7d7 100644
--- a/stackit/provider.go
+++ b/stackit/provider.go
@@ -121,6 +121,7 @@ import (
skeMachineImages "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/ske/provideroptions/machineimages"
sqlServerFlexInstance "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/sqlserverflex/instance"
sqlServerFlexUser "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/sqlserverflex/user"
+ vpnGateway "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/vpn/gateway"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils"
)
@@ -199,6 +200,7 @@ type providerModel struct {
SkeCustomEndpoint types.String `tfsdk:"ske_custom_endpoint"`
SqlServerFlexCustomEndpoint types.String `tfsdk:"sqlserverflex_custom_endpoint"`
TokenCustomEndpoint types.String `tfsdk:"token_custom_endpoint"`
+ VpnCustomEndpoint types.String `tfsdk:"vpn_custom_endpoint"`
OIDCTokenRequestURL types.String `tfsdk:"oidc_request_url"`
OIDCTokenRequestToken types.String `tfsdk:"oidc_request_token"`
@@ -256,6 +258,7 @@ func (p *Provider) Schema(_ context.Context, _ provider.SchemaRequest, resp *pro
"service_enablement_custom_endpoint": "Custom endpoint for the Service Enablement API",
"sfs_custom_endpoint": "Custom endpoint for the Stackit Filestorage API",
"token_custom_endpoint": "Custom endpoint for the token API, which is used to request access tokens when using the key flow",
+ "vpn_custom_endpoint": "Custom endpoint for the VPN service",
"enable_beta_resources": "Enable beta resources. Default is false.",
"experiments": fmt.Sprintf("Enables experiments. These are unstable features without official support. More information can be found in the README. Available Experiments: %v", strings.Join(features.AvailableExperiments, ", ")),
}
@@ -466,6 +469,10 @@ func (p *Provider) Schema(_ context.Context, _ provider.SchemaRequest, resp *pro
Optional: true,
Description: descriptions["sfs_custom_endpoint"],
},
+ "vpn_custom_endpoint": schema.StringAttribute{
+ Optional: true,
+ Description: descriptions["vpn_custom_endpoint"],
+ },
"token_custom_endpoint": schema.StringAttribute{
Optional: true,
Description: descriptions["token_custom_endpoint"],
@@ -553,6 +560,7 @@ func (p *Provider) Configure(ctx context.Context, req provider.ConfigureRequest,
setStringField(providerConfig.SfsCustomEndpoint, func(v string) { providerData.SfsCustomEndpoint = v })
setStringField(providerConfig.SkeCustomEndpoint, func(v string) { providerData.SKECustomEndpoint = v })
setStringField(providerConfig.SqlServerFlexCustomEndpoint, func(v string) { providerData.SQLServerFlexCustomEndpoint = v })
+ setStringField(providerConfig.VpnCustomEndpoint, func(v string) { providerData.VpnCustomEndpoint = v })
if !(providerConfig.Experiments.IsUnknown() || providerConfig.Experiments.IsNull()) {
var experimentValues []string
@@ -713,6 +721,7 @@ func (p *Provider) DataSources(_ context.Context) []func() datasource.DataSource
compliancelock.NewComplianceLockDataSource,
serverBackupEnable.NewServerBackupEnableDataSource,
serverUpdateEnable.NewServerUpdateEnableDataSource,
+ vpnGateway.NewVPNGatewayDataSource,
}
dataSources = append(dataSources, customRole.NewCustomRoleDataSources()...)
dataSources = append(dataSources, iamRoleBindingsV1.NewRoleBindingsDatasources()...)
@@ -806,6 +815,7 @@ func (p *Provider) Resources(_ context.Context) []func() resource.Resource {
compliancelock.NewComplianceLockResource,
serverBackupEnable.NewServerBackupEnableResource,
serverUpdateEnable.NewServerUpdateEnableResource,
+ vpnGateway.NewGatewayResource,
}
resources = append(resources, roleAssignements.NewRoleAssignmentResources()...)
resources = append(resources, customRole.NewCustomRoleResources()...)