diff --git a/docs/data-sources/vpn_connection.md b/docs/data-sources/vpn_connection.md new file mode 100644 index 000000000..46476130e --- /dev/null +++ b/docs/data-sources/vpn_connection.md @@ -0,0 +1,147 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_vpn_connection Data Source - stackit" +subcategory: "" +description: |- + VPN Connection 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_connection (Data Source) + +VPN Connection data source schema. Uses the `default_region` specified in the provider configuration as a fallback in case no `region` is defined on datasource level. + +## Example Usage + +```terraform +data "stackit_vpn_connection" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + gateway_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + connection_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} +``` + + +## Schema + +### Required + +- `connection_id` (String) The server-generated UUID of the VPN connection. +- `gateway_id` (String) The UUID of the parent VPN gateway. +- `project_id` (String) STACKIT project ID. + +### Read-Only + +- `display_name` (String) A user-friendly name for the connection. +- `enabled` (Boolean) Whether this connection is enabled. +- `id` (String) Terraform's internal resource identifier. Structured as "`project_id`,`region`,`gateway_id`,`connection_id`". +- `labels` (Map of String) Map of custom labels. +- `local_subnet` (List of String) List of local IPv4 CIDRs to route through this connection. +- `region` (String) STACKIT region. +- `remote_subnet` (List of String) List of remote IPv4 CIDRs accessible via this connection. +- `static_routes` (List of String) List of static routes (IPv4 CIDRs) for route-based VPN. +- `tunnel1` (Attributes) (see [below for nested schema](#nestedatt--tunnel1)) +- `tunnel2` (Attributes) (see [below for nested schema](#nestedatt--tunnel2)) + + +### Nested Schema for `tunnel1` + +Read-Only: + +- `bgp` (Attributes) BGP configuration for this tunnel. (see [below for nested schema](#nestedatt--tunnel1--bgp)) +- `peering` (Attributes) Tunnel interface peering configuration. (see [below for nested schema](#nestedatt--tunnel1--peering)) +- `phase1` (Attributes) IKE Phase 1 configuration. (see [below for nested schema](#nestedatt--tunnel1--phase1)) +- `phase2` (Attributes) IKE Phase 2 configuration. (see [below for nested schema](#nestedatt--tunnel1--phase2)) +- `remote_address` (String) Remote peer IPv4 address for this tunnel. + + +### Nested Schema for `tunnel1.bgp` + +Read-Only: + +- `remote_asn` (Number) Remote AS number. + + + +### Nested Schema for `tunnel1.peering` + +Read-Only: + +- `local_address` (String) Local tunnel interface IPv4 address. +- `remote_address` (String) Remote tunnel interface IPv4 address. + + + +### Nested Schema for `tunnel1.phase1` + +Read-Only: + +- `dh_groups` (List of String) Diffie-Hellman groups. +- `encryption_algorithms` (List of String) Encryption algorithms. +- `integrity_algorithms` (List of String) Integrity/hash algorithms. +- `rekey_time` (Number) IKE re-keying time in seconds. + + + +### Nested Schema for `tunnel1.phase2` + +Read-Only: + +- `dh_groups` (List of String) Diffie-Hellman groups for PFS. +- `dpd_action` (String) DPD timeout action (clear or restart). +- `encryption_algorithms` (List of String) Encryption algorithms. +- `integrity_algorithms` (List of String) Integrity/hash algorithms. +- `rekey_time` (Number) Child SA re-keying time in seconds. +- `start_action` (String) Start action (none or start). + + + + +### Nested Schema for `tunnel2` + +Read-Only: + +- `bgp` (Attributes) BGP configuration for this tunnel. (see [below for nested schema](#nestedatt--tunnel2--bgp)) +- `peering` (Attributes) Tunnel interface peering configuration. (see [below for nested schema](#nestedatt--tunnel2--peering)) +- `phase1` (Attributes) IKE Phase 1 configuration. (see [below for nested schema](#nestedatt--tunnel2--phase1)) +- `phase2` (Attributes) IKE Phase 2 configuration. (see [below for nested schema](#nestedatt--tunnel2--phase2)) +- `remote_address` (String) Remote peer IPv4 address for this tunnel. + + +### Nested Schema for `tunnel2.bgp` + +Read-Only: + +- `remote_asn` (Number) Remote AS number. + + + +### Nested Schema for `tunnel2.peering` + +Read-Only: + +- `local_address` (String) Local tunnel interface IPv4 address. +- `remote_address` (String) Remote tunnel interface IPv4 address. + + + +### Nested Schema for `tunnel2.phase1` + +Read-Only: + +- `dh_groups` (List of String) Diffie-Hellman groups. +- `encryption_algorithms` (List of String) Encryption algorithms. +- `integrity_algorithms` (List of String) Integrity/hash algorithms. +- `rekey_time` (Number) IKE re-keying time in seconds. + + + +### Nested Schema for `tunnel2.phase2` + +Read-Only: + +- `dh_groups` (List of String) Diffie-Hellman groups for PFS. +- `dpd_action` (String) DPD timeout action (clear or restart). +- `encryption_algorithms` (List of String) Encryption algorithms. +- `integrity_algorithms` (List of String) Integrity/hash algorithms. +- `rekey_time` (Number) Child SA re-keying time in seconds. +- `start_action` (String) Start action (none or start). diff --git a/docs/resources/vpn_connection.md b/docs/resources/vpn_connection.md new file mode 100644 index 000000000..eb60caf37 --- /dev/null +++ b/docs/resources/vpn_connection.md @@ -0,0 +1,218 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_vpn_connection Resource - stackit" +subcategory: "" +description: |- + VPN Connection 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_connection (Resource) + +VPN Connection 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_connection" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + gateway_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + display_name = "example-vpn-connection" + + tunnel1 = { + remote_address = "198.51.100.10" + pre_shared_key_wo = "example-super-secret-key-tunnel1" + + phase1 = { + dh_groups = ["ecp384"] + encryption_algorithms = ["aes256"] + integrity_algorithms = ["sha2_384"] + } + + phase2 = { + dh_groups = ["ecp384"] + encryption_algorithms = ["aes256"] + integrity_algorithms = ["sha2_384"] + } + } + + tunnel2 = { + remote_address = "203.0.113.10" + pre_shared_key_wo = "example-super-secret-key-tunnel2" + + phase1 = { + dh_groups = ["ecp384"] + encryption_algorithms = ["aes256"] + integrity_algorithms = ["sha2_384"] + } + + phase2 = { + dh_groups = ["ecp384"] + encryption_algorithms = ["aes256"] + integrity_algorithms = ["sha2_384"] + } + } +} + +# Only use the import statement, if you want to import an existing VPN connection +import { + to = stackit_vpn_connection.example + id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx,eu01,xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx,xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} +``` + + +## Schema + +### Required + +- `display_name` (String) A user-friendly name for the connection. Must start and end with an alphanumeric character, may contain hyphens, and be 1-63 characters long. +- `gateway_id` (String) The UUID of the parent VPN gateway. +- `project_id` (String) STACKIT project ID. +- `tunnel1` (Attributes) Configuration for the IPsec tunnel1 + +~> Write-Only argument `pre_shared_key_wo` is available to use in place of `pre_shared_key`. Write-Only arguments are supported in HashiCorp Terraform 1.11.0 and later. [Learn more](https://developer.hashicorp.com/terraform/language/resources/ephemeral#write-only-arguments). (see [below for nested schema](#nestedatt--tunnel1)) +- `tunnel2` (Attributes) Configuration for the IPsec tunnel2 + +~> Write-Only argument `pre_shared_key_wo` is available to use in place of `pre_shared_key`. Write-Only arguments are supported in HashiCorp Terraform 1.11.0 and later. [Learn more](https://developer.hashicorp.com/terraform/language/resources/ephemeral#write-only-arguments). (see [below for nested schema](#nestedatt--tunnel2)) + +### Optional + +- `enabled` (Boolean) Whether this connection is enabled. Defaults to true. +- `labels` (Map of String) Map of custom labels. +- `local_subnet` (List of String) List of local IPv4 CIDRs to route through this connection. Optional for route-based and BGP configurations (defaults to 0.0.0.0/0). Mandatory for policy-based. +- `region` (String) STACKIT region. +- `remote_subnet` (List of String) List of remote IPv4 CIDRs accessible via this connection. Optional for route-based and BGP configurations (defaults to 0.0.0.0/0). Mandatory for policy-based. +- `static_routes` (List of String) List of static routes (IPv4 CIDRs) for route-based VPN. Mandatory for ROUTE_BASED gateways. + +### Read-Only + +- `connection_id` (String) The server-generated UUID of the VPN connection. +- `id` (String) Terraform's internal resource identifier. Structured as "`project_id`,`region`,`gateway_id`,`connection_id`". + + +### Nested Schema for `tunnel1` + +Required: + +- `phase1` (Attributes) (see [below for nested schema](#nestedatt--tunnel1--phase1)) +- `phase2` (Attributes) (see [below for nested schema](#nestedatt--tunnel1--phase2)) +- `remote_address` (String) Remote IPv4 address for the tunnel endpoint. + +Optional: + +- `bgp` (Attributes) (see [below for nested schema](#nestedatt--tunnel1--bgp)) +- `peering` (Attributes) (see [below for nested schema](#nestedatt--tunnel1--peering)) +- `pre_shared_key` (String, Sensitive) Pre-shared key for the IPsec tunnel. Minimum 20 characters. Write-only argument `pre_shared_key_wo` should be preferred. +- `pre_shared_key_wo` (String, Sensitive, [Write-only](https://developer.hashicorp.com/terraform/language/resources/ephemeral#write-only-arguments)) Pre-shared key for the IPsec tunnel. Minimum 20 characters. Write-only - never stored in state and never returned by the API. To rotate the key, update this value AND increment pre_shared_key_wo_version. Changing this field alone will NOT trigger an update. +- `pre_shared_key_wo_version` (Number) User-managed rotation counter for the pre-shared key. Must be incremented every time pre_shared_key_wo is changed. Terraform diffs this field to detect key rotations - changing pre_shared_key_wo alone will NOT trigger an update because it is write-only and never stored in state. + + +### Nested Schema for `tunnel1.phase1` + +Required: + +- `encryption_algorithms` (List of String) Encryption algorithms for Phase 1. Possible values are: `aes256`, `aes128gcm16`, `aes256gcm16`. +- `integrity_algorithms` (List of String) Integrity algorithms for Phase 1. Possible values are: `sha1`, `sha2_256`, `sha2_384`. + +Optional: + +- `dh_groups` (List of String) Diffie-Hellman groups for key exchange. Possible values are: `modp1024`, `modp2048`, `ecp256`, `ecp384`, `modp2048s256`. +- `rekey_time` (Number) Time to schedule an IKE re-keying in seconds. Range: 900-28800. Default: 14400. + + + +### Nested Schema for `tunnel1.phase2` + +Required: + +- `encryption_algorithms` (List of String) Encryption algorithms for Phase 2. Possible values are: `aes256`, `aes128gcm16`, `aes256gcm16`. +- `integrity_algorithms` (List of String) Integrity algorithms for Phase 2. Possible values are: `sha1`, `sha2_256`, `sha2_384`. + +Optional: + +- `dh_groups` (List of String) Diffie-Hellman groups for Phase 2. Possible values are: `modp1024`, `modp2048`, `ecp256`, `ecp384`, `modp2048s256`. +- `dpd_action` (String) Action to perform on DPD timeout. Default: 'restart'. Possible values are: `clear`, `restart`. +- `rekey_time` (Number) Time to schedule a Child SA re-keying in seconds. Range: 900-3600. Default: 3600. +- `start_action` (String) Action to perform after loading the connection configuration. Default: 'start'. Possible values are: `none`, `start`. + + + +### Nested Schema for `tunnel1.bgp` + +Required: + +- `remote_asn` (Number) Remote ASN for BGP peering (private ASN range, 64512-4294967294). + + + +### Nested Schema for `tunnel1.peering` + +Required: + +- `local_address` (String) Local tunnel interface IPv4 address. +- `remote_address` (String) Remote tunnel interface IPv4 address. + + + + +### Nested Schema for `tunnel2` + +Required: + +- `phase1` (Attributes) (see [below for nested schema](#nestedatt--tunnel2--phase1)) +- `phase2` (Attributes) (see [below for nested schema](#nestedatt--tunnel2--phase2)) +- `remote_address` (String) Remote IPv4 address for the tunnel endpoint. + +Optional: + +- `bgp` (Attributes) (see [below for nested schema](#nestedatt--tunnel2--bgp)) +- `peering` (Attributes) (see [below for nested schema](#nestedatt--tunnel2--peering)) +- `pre_shared_key` (String, Sensitive) Pre-shared key for the IPsec tunnel. Minimum 20 characters. Write-only argument `pre_shared_key_wo` should be preferred. +- `pre_shared_key_wo` (String, Sensitive, [Write-only](https://developer.hashicorp.com/terraform/language/resources/ephemeral#write-only-arguments)) Pre-shared key for the IPsec tunnel. Minimum 20 characters. Write-only - never stored in state and never returned by the API. To rotate the key, update this value AND increment pre_shared_key_wo_version. Changing this field alone will NOT trigger an update. +- `pre_shared_key_wo_version` (Number) User-managed rotation counter for the pre-shared key. Must be incremented every time pre_shared_key_wo is changed. Terraform diffs this field to detect key rotations - changing pre_shared_key_wo alone will NOT trigger an update because it is write-only and never stored in state. + + +### Nested Schema for `tunnel2.phase1` + +Required: + +- `encryption_algorithms` (List of String) Encryption algorithms for Phase 1. Possible values are: `aes256`, `aes128gcm16`, `aes256gcm16`. +- `integrity_algorithms` (List of String) Integrity algorithms for Phase 1. Possible values are: `sha1`, `sha2_256`, `sha2_384`. + +Optional: + +- `dh_groups` (List of String) Diffie-Hellman groups for key exchange. Possible values are: `modp1024`, `modp2048`, `ecp256`, `ecp384`, `modp2048s256`. +- `rekey_time` (Number) Time to schedule an IKE re-keying in seconds. Range: 900-28800. Default: 14400. + + + +### Nested Schema for `tunnel2.phase2` + +Required: + +- `encryption_algorithms` (List of String) Encryption algorithms for Phase 2. Possible values are: `aes256`, `aes128gcm16`, `aes256gcm16`. +- `integrity_algorithms` (List of String) Integrity algorithms for Phase 2. Possible values are: `sha1`, `sha2_256`, `sha2_384`. + +Optional: + +- `dh_groups` (List of String) Diffie-Hellman groups for Phase 2. Possible values are: `modp1024`, `modp2048`, `ecp256`, `ecp384`, `modp2048s256`. +- `dpd_action` (String) Action to perform on DPD timeout. Default: 'restart'. Possible values are: `clear`, `restart`. +- `rekey_time` (Number) Time to schedule a Child SA re-keying in seconds. Range: 900-3600. Default: 3600. +- `start_action` (String) Action to perform after loading the connection configuration. Default: 'start'. Possible values are: `none`, `start`. + + + +### Nested Schema for `tunnel2.bgp` + +Required: + +- `remote_asn` (Number) Remote ASN for BGP peering (private ASN range, 64512-4294967294). + + + +### Nested Schema for `tunnel2.peering` + +Required: + +- `local_address` (String) Local tunnel interface IPv4 address. +- `remote_address` (String) Remote tunnel interface IPv4 address. diff --git a/examples/data-sources/stackit_vpn_connection/data-source.tf b/examples/data-sources/stackit_vpn_connection/data-source.tf new file mode 100644 index 000000000..82d1f84ba --- /dev/null +++ b/examples/data-sources/stackit_vpn_connection/data-source.tf @@ -0,0 +1,5 @@ +data "stackit_vpn_connection" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + gateway_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + connection_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} diff --git a/examples/resources/stackit_vpn_connection/resource.tf b/examples/resources/stackit_vpn_connection/resource.tf new file mode 100644 index 000000000..1fdf5673f --- /dev/null +++ b/examples/resources/stackit_vpn_connection/resource.tf @@ -0,0 +1,45 @@ +resource "stackit_vpn_connection" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + gateway_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + display_name = "example-vpn-connection" + + tunnel1 = { + remote_address = "198.51.100.10" + pre_shared_key_wo = "example-super-secret-key-tunnel1" + + phase1 = { + dh_groups = ["ecp384"] + encryption_algorithms = ["aes256"] + integrity_algorithms = ["sha2_384"] + } + + phase2 = { + dh_groups = ["ecp384"] + encryption_algorithms = ["aes256"] + integrity_algorithms = ["sha2_384"] + } + } + + tunnel2 = { + remote_address = "203.0.113.10" + pre_shared_key_wo = "example-super-secret-key-tunnel2" + + phase1 = { + dh_groups = ["ecp384"] + encryption_algorithms = ["aes256"] + integrity_algorithms = ["sha2_384"] + } + + phase2 = { + dh_groups = ["ecp384"] + encryption_algorithms = ["aes256"] + integrity_algorithms = ["sha2_384"] + } + } +} + +# Only use the import statement, if you want to import an existing VPN connection +import { + to = stackit_vpn_connection.example + id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx,eu01,xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx,xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} diff --git a/stackit/internal/services/vpn/connection/datasource.go b/stackit/internal/services/vpn/connection/datasource.go new file mode 100644 index 000000000..420a81559 --- /dev/null +++ b/stackit/internal/services/vpn/connection/datasource.go @@ -0,0 +1,309 @@ +package connection + +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/conversion" + "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/validate" +) + +var ( + _ datasource.DataSource = (*vpnConnectionDataSource)(nil) + _ datasource.DataSourceWithConfigure = (*vpnConnectionDataSource)(nil) +) + +type DataSourceTunnelModel struct { + RemoteAddress types.String `tfsdk:"remote_address"` + Phase1 *Phase1Model `tfsdk:"phase1"` + Phase2 *Phase2Model `tfsdk:"phase2"` + Peering *PeeringConfigModel `tfsdk:"peering"` + Bgp *BGPTunnelConfigModel `tfsdk:"bgp"` +} + +type DataSourceModel struct { + CommonModel + Tunnel1 *DataSourceTunnelModel `tfsdk:"tunnel1"` + Tunnel2 *DataSourceTunnelModel `tfsdk:"tunnel2"` +} + +type vpnConnectionDataSource struct { + client *vpn.APIClient + providerData core.ProviderData +} + +func NewVPNConnectionDataSource() datasource.DataSource { + return &vpnConnectionDataSource{} +} + +func (d *vpnConnectionDataSource) 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 connection data source configured") +} + +func (d *vpnConnectionDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_vpn_connection" +} + +func DataSourceTunnelSchema() schema.SingleNestedAttribute { + return schema.SingleNestedAttribute{ + Computed: true, + Attributes: map[string]schema.Attribute{ + "remote_address": schema.StringAttribute{ + Description: "Remote peer IPv4 address for this tunnel.", + Computed: true, + }, + "phase1": schema.SingleNestedAttribute{ + Description: "IKE Phase 1 configuration.", + Computed: true, + Attributes: map[string]schema.Attribute{ + "dh_groups": schema.ListAttribute{ + Description: "Diffie-Hellman groups.", + Computed: true, + ElementType: types.StringType, + }, + "encryption_algorithms": schema.ListAttribute{ + Description: "Encryption algorithms.", + Computed: true, + ElementType: types.StringType, + }, + "integrity_algorithms": schema.ListAttribute{ + Description: "Integrity/hash algorithms.", + Computed: true, + ElementType: types.StringType, + }, + "rekey_time": schema.Int32Attribute{ + Description: "IKE re-keying time in seconds.", + Computed: true, + }, + }, + }, + "phase2": schema.SingleNestedAttribute{ + Description: "IKE Phase 2 configuration.", + Computed: true, + Attributes: map[string]schema.Attribute{ + "dh_groups": schema.ListAttribute{ + Description: "Diffie-Hellman groups for PFS.", + Computed: true, + ElementType: types.StringType, + }, + "encryption_algorithms": schema.ListAttribute{ + Description: "Encryption algorithms.", + Computed: true, + ElementType: types.StringType, + }, + "integrity_algorithms": schema.ListAttribute{ + Description: "Integrity/hash algorithms.", + Computed: true, + ElementType: types.StringType, + }, + "rekey_time": schema.Int32Attribute{ + Description: "Child SA re-keying time in seconds.", + Computed: true, + }, + "start_action": schema.StringAttribute{ + Description: "Start action (none or start).", + Computed: true, + }, + "dpd_action": schema.StringAttribute{ + Description: "DPD timeout action (clear or restart).", + Computed: true, + }, + }, + }, + "peering": schema.SingleNestedAttribute{ + Description: "Tunnel interface peering configuration.", + Computed: true, + Attributes: map[string]schema.Attribute{ + "local_address": schema.StringAttribute{ + Description: "Local tunnel interface IPv4 address.", + Computed: true, + }, + "remote_address": schema.StringAttribute{ + Description: "Remote tunnel interface IPv4 address.", + Computed: true, + }, + }, + }, + "bgp": schema.SingleNestedAttribute{ + Description: "BGP configuration for this tunnel.", + Computed: true, + Attributes: map[string]schema.Attribute{ + "remote_asn": schema.Int64Attribute{ + Description: "Remote AS number.", + Computed: true, + }, + }, + }, + }, + } +} + +func (d *vpnConnectionDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: fmt.Sprintf("VPN Connection data source schema. %s", core.DatasourceRegionFallbackDocstring), + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "Terraform's internal resource identifier. Structured as \"`project_id`,`region`,`gateway_id`,`connection_id`\".", + Computed: true, + }, + "project_id": schema.StringAttribute{ + Description: "STACKIT project ID.", + Required: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "region": schema.StringAttribute{ + Description: "STACKIT region.", + Computed: true, + }, + "gateway_id": schema.StringAttribute{ + Description: "The UUID of the parent VPN gateway.", + Required: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "connection_id": schema.StringAttribute{ + Description: "The server-generated UUID of the VPN connection.", + Required: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "display_name": schema.StringAttribute{ + Description: "A user-friendly name for the connection.", + Computed: true, + }, + "enabled": schema.BoolAttribute{ + Description: "Whether this connection is enabled.", + Computed: true, + }, + "remote_subnet": schema.ListAttribute{ + Description: "List of remote IPv4 CIDRs accessible via this connection.", + Computed: true, + ElementType: types.StringType, + }, + "local_subnet": schema.ListAttribute{ + Description: "List of local IPv4 CIDRs to route through this connection.", + Computed: true, + ElementType: types.StringType, + }, + "static_routes": schema.ListAttribute{ + Description: "List of static routes (IPv4 CIDRs) for route-based VPN.", + Computed: true, + ElementType: types.StringType, + }, + "tunnel1": DataSourceTunnelSchema(), + "tunnel2": DataSourceTunnelSchema(), + "labels": schema.MapAttribute{ + Description: "Map of custom labels.", + Computed: true, + ElementType: types.StringType, + }, + }, + } +} + +func (d *vpnConnectionDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { // nolint:gocritic // function signature required by Terraform + var model DataSourceModel + 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() + connectionId := model.ConnectionID.ValueString() + + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "region", region) + ctx = tflog.SetField(ctx, "gateway_id", gatewayId) + ctx = tflog.SetField(ctx, "connection_id", connectionId) + + connResp, err := d.client.DefaultAPI.GetGatewayConnection(ctx, projectId, region, gatewayId, connectionId).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 connection", fmt.Sprintf("Calling API: %v", err)) + return + } + ctx = core.LogResponse(ctx) + + err = mapDataSourceFields(ctx, connResp, &model, region) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading VPN connection", fmt.Sprintf("Processing response: %v", err)) + return + } + + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "VPN connection read", map[string]any{ + "connection_id": connectionId, + }) +} + +func mapDataSourceFields(ctx context.Context, conn connectionResponse, model *DataSourceModel, region string) error { + err := mapCommonFields(ctx, conn, &model.CommonModel, region) + if err != nil { + return err + } + + tunnel1 := conn.GetTunnel1() + if model.Tunnel1 == nil { + model.Tunnel1 = &DataSourceTunnelModel{} + } + err = mapTunnel(ctx, &tunnel1, model.Tunnel1) + if err != nil { + return fmt.Errorf("mapping tunnel1: %w", err) + } + + tunnel2 := conn.GetTunnel2() + if model.Tunnel2 == nil { + model.Tunnel2 = &DataSourceTunnelModel{} + } + err = mapTunnel(ctx, &tunnel2, model.Tunnel2) + if err != nil { + return fmt.Errorf("mapping tunnel2: %w", err) + } + + return nil +} diff --git a/stackit/internal/services/vpn/connection/datasource_test.go b/stackit/internal/services/vpn/connection/datasource_test.go new file mode 100644 index 000000000..2eba462c3 --- /dev/null +++ b/stackit/internal/services/vpn/connection/datasource_test.go @@ -0,0 +1,234 @@ +package connection + +import ( + "context" + "fmt" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + vpn "github.com/stackitcloud/stackit-sdk-go/services/vpn/v1api" +) + +func fixtureDataSourceTunnelModel(mods ...func(m *DataSourceTunnelModel)) *DataSourceTunnelModel { + resp := &DataSourceTunnelModel{ + RemoteAddress: types.StringValue("203.0.113.1"), + Phase1: &Phase1Model{ + BasePhaseModel: fixtureBasePhaseModel(), + }, + Phase2: &Phase2Model{ + BasePhaseModel: fixtureBasePhaseModel(func(m *BasePhaseModel) { + m.RekeyTime = types.Int32Value(3600) + }), + StartAction: types.StringValue("start"), + DpdAction: types.StringValue("restart"), + }, + } + for _, mod := range mods { + mod(resp) + } + return resp +} + +func fixtureDataSourceModel(mods ...func(m *DataSourceModel)) DataSourceModel { + resp := DataSourceModel{ + CommonModel: CommonModel{ + ID: types.StringValue(fmt.Sprintf("%s,%s,%s,%s", projectId, region, gatewayId, "connection-id")), + ConnectionID: types.StringValue("connection-id"), + ProjectID: types.StringValue(projectId), + Region: types.StringValue(region), + GatewayID: types.StringValue(gatewayId), + DisplayName: types.StringValue("test-connection"), + Enabled: types.BoolValue(true), + RemoteSubnet: types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("10.0.0.0/16"), + }), + LocalSubnet: types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("192.168.0.0/24"), + }), + StaticRoutes: types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("123.45.67.89"), + }), + Labels: types.MapNull(types.StringType), + }, + Tunnel1: fixtureDataSourceTunnelModel(), + Tunnel2: fixtureDataSourceTunnelModel(func(m *DataSourceTunnelModel) { + m.RemoteAddress = types.StringValue("203.0.113.2") + }), + } + for _, mod := range mods { + mod(&resp) + } + return resp +} + +func TestMapDataSourceFields(t *testing.T) { + tests := []struct { + description string + input *vpn.ConnectionResponse + expected DataSourceModel + isValid bool + }{ + { + description: "basic_connection", + input: fixtureConnectionResponse(), + expected: fixtureDataSourceModel(), + isValid: true, + }, + { + description: "minimal_connection", + input: &vpn.ConnectionResponse{ + Id: new("connection-id"), + }, + expected: DataSourceModel{ + CommonModel: CommonModel{ + ID: types.StringValue(fmt.Sprintf("%s,%s,%s,%s", projectId, region, gatewayId, "connection-id")), + ConnectionID: types.StringValue("connection-id"), + ProjectID: types.StringValue(projectId), + Region: types.StringValue(region), + GatewayID: types.StringValue(gatewayId), + DisplayName: types.StringValue(""), + Enabled: types.BoolNull(), + RemoteSubnet: basetypes.NewListNull(basetypes.StringType{}), + LocalSubnet: basetypes.NewListNull(basetypes.StringType{}), + StaticRoutes: basetypes.NewListNull(basetypes.StringType{}), + Labels: basetypes.NewMapNull(basetypes.StringType{}), + }, + Tunnel1: &DataSourceTunnelModel{ + RemoteAddress: types.StringValue(""), + Phase1: &Phase1Model{ + BasePhaseModel: BasePhaseModel{ + DhGroups: basetypes.NewListNull(basetypes.StringType{}), + EncryptionAlgorithms: basetypes.NewListNull(basetypes.StringType{}), + IntegrityAlgorithms: basetypes.NewListNull(basetypes.StringType{}), + }, + }, + Phase2: &Phase2Model{ + BasePhaseModel: BasePhaseModel{ + DhGroups: basetypes.NewListNull(basetypes.StringType{}), + EncryptionAlgorithms: basetypes.NewListNull(basetypes.StringType{}), + IntegrityAlgorithms: basetypes.NewListNull(basetypes.StringType{}), + }, + }, + }, + Tunnel2: &DataSourceTunnelModel{ + RemoteAddress: types.StringValue(""), + Phase1: &Phase1Model{ + BasePhaseModel: BasePhaseModel{ + DhGroups: basetypes.NewListNull(basetypes.StringType{}), + EncryptionAlgorithms: basetypes.NewListNull(basetypes.StringType{}), + IntegrityAlgorithms: basetypes.NewListNull(basetypes.StringType{}), + }, + }, + Phase2: &Phase2Model{ + BasePhaseModel: BasePhaseModel{ + DhGroups: basetypes.NewListNull(basetypes.StringType{}), + EncryptionAlgorithms: basetypes.NewListNull(basetypes.StringType{}), + IntegrityAlgorithms: basetypes.NewListNull(basetypes.StringType{}), + }, + }, + }, + }, + isValid: true, + }, + { + description: "connection_with_static_routes_and_bgp", + input: fixtureConnectionResponse(func(m *vpn.ConnectionResponse) { + m.StaticRoutes = []string{"10.0.0.0/8"} + m.Tunnel1.Bgp = &vpn.BGPTunnelConfig{ + RemoteAsn: 65000, + } + }), + expected: fixtureDataSourceModel(func(m *DataSourceModel) { + m.StaticRoutes = types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("10.0.0.0/8"), + }) + m.Tunnel1.Bgp = &BGPTunnelConfigModel{ + RemoteAsn: types.Int64Value(65000), + } + }), + isValid: true, + }, + { + description: "multiple_static_routes", + input: fixtureConnectionResponse(func(m *vpn.ConnectionResponse) { + m.StaticRoutes = []string{"10.0.0.0/8", "172.16.0.0/12"} + }), + expected: fixtureDataSourceModel(func(m *DataSourceModel) { + m.StaticRoutes = types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("10.0.0.0/8"), + types.StringValue("172.16.0.0/12"), + }) + }), + isValid: true, + }, + { + description: "empty_labels", + input: fixtureConnectionResponse(func(m *vpn.ConnectionResponse) { + m.Labels = &map[string]string{} + }), + expected: fixtureDataSourceModel(func(m *DataSourceModel) { + m.Labels = types.MapNull(types.StringType) + }), + isValid: true, + }, + { + description: "peering", + input: fixtureConnectionResponse(func(m *vpn.ConnectionResponse) { + m.Tunnel1.Peering = &vpn.PeeringConfig{ + LocalAddress: new("123.45.67.89"), + RemoteAddress: new("98.76.54.32"), + } + }), + expected: fixtureDataSourceModel(func(m *DataSourceModel) { + m.Tunnel1.Peering = &PeeringConfigModel{ + LocalAddress: types.StringValue("123.45.67.89"), + RemoteAddress: types.StringValue("98.76.54.32"), + } + }), + isValid: true, + }, + { + description: "nil_response", + input: nil, + isValid: false, + }, + { + description: "nil_connection_id", + input: &vpn.ConnectionResponse{ + Id: nil, + DisplayName: "test-connection", + }, + isValid: false, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + state := &DataSourceModel{ + CommonModel: CommonModel{ + ProjectID: types.StringValue(projectId), + Region: types.StringValue(region), + GatewayID: types.StringValue(gatewayId), + }, + Tunnel1: &DataSourceTunnelModel{}, + Tunnel2: &DataSourceTunnelModel{}, + } + + err := mapDataSourceFields(context.Background(), tt.input, state, region) + + 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 { + if diff := cmp.Diff(&tt.expected, state); diff != "" { + t.Fatalf("Data mismatch (-want +got):\n%s", diff) + } + } + }) + } +} diff --git a/stackit/internal/services/vpn/connection/resource.go b/stackit/internal/services/vpn/connection/resource.go new file mode 100644 index 000000000..f07240410 --- /dev/null +++ b/stackit/internal/services/vpn/connection/resource.go @@ -0,0 +1,1113 @@ +package connection + +import ( + "context" + "errors" + "fmt" + "net/http" + "regexp" + "strings" + + "github.com/hashicorp/terraform-plugin-framework-validators/int32validator" + "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/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/listdefault" + "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" + + sdkUtils "github.com/stackitcloud/stackit-sdk-go/core/utils" + + "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 = &vpnConnectionResource{} + _ resource.ResourceWithConfigure = &vpnConnectionResource{} + _ resource.ResourceWithImportState = &vpnConnectionResource{} + _ resource.ResourceWithModifyPlan = &vpnConnectionResource{} +) + +type BasePhaseModel struct { + DhGroups types.List `tfsdk:"dh_groups"` + EncryptionAlgorithms types.List `tfsdk:"encryption_algorithms"` + IntegrityAlgorithms types.List `tfsdk:"integrity_algorithms"` + RekeyTime types.Int32 `tfsdk:"rekey_time"` +} + +type Phase1Model struct { + BasePhaseModel +} + +type Phase2Model struct { + StartAction types.String `tfsdk:"start_action"` + DpdAction types.String `tfsdk:"dpd_action"` + BasePhaseModel +} + +type PeeringConfigModel struct { + LocalAddress types.String `tfsdk:"local_address"` + RemoteAddress types.String `tfsdk:"remote_address"` +} + +type BGPTunnelConfigModel struct { + RemoteAsn types.Int64 `tfsdk:"remote_asn"` +} + +type TunnelModel struct { + DataSourceTunnelModel + PreSharedKey types.String `tfsdk:"pre_shared_key"` + PreSharedKeyWo types.String `tfsdk:"pre_shared_key_wo"` + PreSharedKeyWoVersion types.Int64 `tfsdk:"pre_shared_key_wo_version"` +} + +// CommonModel is used in the resource and the datasource implementation to share most of the mapping logic +type CommonModel struct { + ID types.String `tfsdk:"id"` + ConnectionID types.String `tfsdk:"connection_id"` + ProjectID types.String `tfsdk:"project_id"` + Region types.String `tfsdk:"region"` + GatewayID types.String `tfsdk:"gateway_id"` + DisplayName types.String `tfsdk:"display_name"` + Enabled types.Bool `tfsdk:"enabled"` + RemoteSubnet types.List `tfsdk:"remote_subnet"` + LocalSubnet types.List `tfsdk:"local_subnet"` + StaticRoutes types.List `tfsdk:"static_routes"` + Labels types.Map `tfsdk:"labels"` +} + +// Model is used for the resource implementation +type Model struct { + CommonModel + Tunnel1 *TunnelModel `tfsdk:"tunnel1"` + Tunnel2 *TunnelModel `tfsdk:"tunnel2"` +} + +type BasePhasePayload interface { + GetDhGroupsOk() ([]vpn.PhaseDhGroupsInner, bool) + GetEncryptionAlgorithmsOk() ([]vpn.PhaseEncryptionAlgorithmsInner, bool) + GetIntegrityAlgorithmsOk() ([]vpn.PhaseIntegrityAlgorithmsInner, bool) + GetRekeyTimeOk() (*int32, bool) + + SetDhGroups([]vpn.PhaseDhGroupsInner) + SetEncryptionAlgorithms([]vpn.PhaseEncryptionAlgorithmsInner) + SetIntegrityAlgorithms([]vpn.PhaseIntegrityAlgorithmsInner) + SetRekeyTime(int32) +} + +type connectionPayload interface { + SetDisplayName(string) + SetTunnel1(vpn.TunnelConfiguration) + SetTunnel2(vpn.TunnelConfiguration) + SetEnabled(bool) + SetRemoteSubnets([]string) + SetLocalSubnets([]string) + SetStaticRoutes([]string) + SetLabels(map[string]string) +} + +type connectionResponse interface { + GetIdOk() (*string, bool) + GetDisplayName() string + GetTunnel1() vpn.TunnelConfiguration + GetTunnel2() vpn.TunnelConfiguration + GetEnabledOk() (*bool, bool) + GetRemoteSubnetsOk() ([]string, bool) + GetLocalSubnetsOk() ([]string, bool) + GetStaticRoutesOk() ([]string, bool) + GetLabelsOk() (*map[string]string, bool) +} + +var ( + dhGroupValues = sdkUtils.EnumSliceToStringSlice(vpn.AllowedPhaseDhGroupsInnerEnumValues) + encryptionAlgorithmValues = sdkUtils.EnumSliceToStringSlice(vpn.AllowedPhaseEncryptionAlgorithmsInnerEnumValues) + integrityAlgorithmValues = sdkUtils.EnumSliceToStringSlice(vpn.AllowedPhaseIntegrityAlgorithmsInnerEnumValues) + startActionValues = sdkUtils.EnumSliceToStringSlice(vpn.AllowedTunnelConfigurationPhase2AllOfStartActionEnumValues) + dpdActionValues = sdkUtils.EnumSliceToStringSlice(vpn.AllowedTunnelConfigurationPhase2AllOfDpdActionEnumValues) +) + +type vpnConnectionResource struct { + client *vpn.APIClient + providerData core.ProviderData +} + +func NewVpnConnectionResource() resource.Resource { + return &vpnConnectionResource{} +} + +func (r *vpnConnectionResource) 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 *vpnConnectionResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_vpn_connection" +} + +func (r *vpnConnectionResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + tunnelSchema := func(rootAttribute string) schema.SingleNestedAttribute { + return schema.SingleNestedAttribute{ + Description: fmt.Sprintf("Configuration for the IPsec %s.", rootAttribute), + MarkdownDescription: fmt.Sprintf("Configuration for the IPsec %s \n\n~> Write-Only argument `pre_shared_key_wo` is available to use in place of `pre_shared_key`. Write-Only arguments are supported in HashiCorp Terraform 1.11.0 and later. [Learn more](https://developer.hashicorp.com/terraform/language/resources/ephemeral#write-only-arguments).", rootAttribute), + Required: true, + Attributes: map[string]schema.Attribute{ + "pre_shared_key": schema.StringAttribute{ + Description: "Pre-shared key for the IPsec tunnel. Minimum 20 characters. Write-only argument `pre_shared_key_wo` should be preferred.", + Optional: true, + Sensitive: true, + Validators: []validator.String{ + stringvalidator.LengthAtLeast(20), + stringvalidator.ConflictsWith( + path.MatchRelative().AtParent().AtName("pre_shared_key_wo"), + path.MatchRelative().AtParent().AtName("pre_shared_key_wo_version"), + ), + stringvalidator.PreferWriteOnlyAttribute(path.MatchRoot(rootAttribute).AtName("pre_shared_key_wo")), + }, + }, + "pre_shared_key_wo": schema.StringAttribute{ + Description: "Pre-shared key for the IPsec tunnel. Minimum 20 characters. Write-only - never stored in state and never returned by the API. To rotate the key, update this value AND increment pre_shared_key_wo_version. Changing this field alone will NOT trigger an update.", + Optional: true, + WriteOnly: true, + Sensitive: true, + Validators: []validator.String{ + stringvalidator.LengthAtLeast(20), + stringvalidator.ConflictsWith(path.MatchRelative().AtParent().AtName("pre_shared_key")), + stringvalidator.AlsoRequires(path.MatchRelative().AtParent().AtName("pre_shared_key_wo_version")), + }, + }, + "pre_shared_key_wo_version": schema.Int64Attribute{ + Description: "User-managed rotation counter for the pre-shared key. Must be incremented every time pre_shared_key_wo is changed. Terraform diffs this field to detect key rotations - changing pre_shared_key_wo alone will NOT trigger an update because it is write-only and never stored in state.", + Optional: true, + Validators: []validator.Int64{ + int64validator.AlsoRequires(path.MatchRelative().AtParent().AtName("pre_shared_key_wo")), + int64validator.ConflictsWith(path.MatchRelative().AtParent().AtName("pre_shared_key")), + }, + }, + "remote_address": schema.StringAttribute{ + Description: "Remote IPv4 address for the tunnel endpoint.", + Required: true, + Validators: []validator.String{ + validate.IP(true), + }, + }, + "phase1": schema.SingleNestedAttribute{ + Required: true, + Attributes: map[string]schema.Attribute{ + "dh_groups": schema.ListAttribute{ + Description: fmt.Sprintf("Diffie-Hellman groups for key exchange. %s", tfutils.FormatPossibleValues(dhGroupValues...)), + Optional: true, + ElementType: types.StringType, + Validators: []validator.List{ + listvalidator.ValueStringsAre( + stringvalidator.OneOf(dhGroupValues...), + ), + }, + }, + "encryption_algorithms": schema.ListAttribute{ + Description: fmt.Sprintf("Encryption algorithms for Phase 1. %s", tfutils.FormatPossibleValues(encryptionAlgorithmValues...)), + Required: true, + ElementType: types.StringType, + Validators: []validator.List{ + listvalidator.ValueStringsAre( + stringvalidator.OneOf(encryptionAlgorithmValues...), + ), + }, + }, + "integrity_algorithms": schema.ListAttribute{ + Description: fmt.Sprintf("Integrity algorithms for Phase 1. %s", tfutils.FormatPossibleValues(integrityAlgorithmValues...)), + Required: true, + ElementType: types.StringType, + Validators: []validator.List{ + listvalidator.ValueStringsAre( + stringvalidator.OneOf(integrityAlgorithmValues...), + ), + }, + }, + "rekey_time": schema.Int32Attribute{ + Description: "Time to schedule an IKE re-keying in seconds. Range: 900-28800. Default: 14400.", + Optional: true, + Computed: true, + Validators: []validator.Int32{ + int32validator.Between(900, 28800), + }, + }, + }, + }, + "phase2": schema.SingleNestedAttribute{ + Required: true, + Attributes: map[string]schema.Attribute{ + "dh_groups": schema.ListAttribute{ + Description: fmt.Sprintf("Diffie-Hellman groups for Phase 2. %s", tfutils.FormatPossibleValues(dhGroupValues...)), + Optional: true, + ElementType: types.StringType, + Validators: []validator.List{ + listvalidator.ValueStringsAre( + stringvalidator.OneOf(dhGroupValues...), + ), + }, + }, + "encryption_algorithms": schema.ListAttribute{ + Description: fmt.Sprintf("Encryption algorithms for Phase 2. %s", tfutils.FormatPossibleValues(encryptionAlgorithmValues...)), + Required: true, + ElementType: types.StringType, + Validators: []validator.List{ + listvalidator.ValueStringsAre( + stringvalidator.OneOf(encryptionAlgorithmValues...), + ), + }, + }, + "integrity_algorithms": schema.ListAttribute{ + Description: fmt.Sprintf("Integrity algorithms for Phase 2. %s", tfutils.FormatPossibleValues(integrityAlgorithmValues...)), + Required: true, + ElementType: types.StringType, + Validators: []validator.List{ + listvalidator.ValueStringsAre( + stringvalidator.OneOf(integrityAlgorithmValues...), + ), + }, + }, + "rekey_time": schema.Int32Attribute{ + Description: "Time to schedule a Child SA re-keying in seconds. Range: 900-3600. Default: 3600.", + Optional: true, + Computed: true, + Validators: []validator.Int32{ + int32validator.Between(900, 3600), + }, + }, + "start_action": schema.StringAttribute{ + Description: fmt.Sprintf("Action to perform after loading the connection configuration. Default: 'start'. %s", tfutils.FormatPossibleValues(startActionValues...)), + Optional: true, + Computed: true, + Validators: []validator.String{ + stringvalidator.OneOf(startActionValues...), + }, + }, + "dpd_action": schema.StringAttribute{ + Description: fmt.Sprintf("Action to perform on DPD timeout. Default: 'restart'. %s", tfutils.FormatPossibleValues(dpdActionValues...)), + Optional: true, + Computed: true, + Validators: []validator.String{ + stringvalidator.OneOf(dpdActionValues...), + }, + }, + }, + }, + "peering": schema.SingleNestedAttribute{ + Optional: true, + Attributes: map[string]schema.Attribute{ + "local_address": schema.StringAttribute{ + Description: "Local tunnel interface IPv4 address.", + Required: true, + Validators: []validator.String{ + validate.IP(true), + }, + }, + "remote_address": schema.StringAttribute{ + Description: "Remote tunnel interface IPv4 address.", + Required: true, + Validators: []validator.String{ + validate.IP(true), + }, + }, + }, + }, + "bgp": schema.SingleNestedAttribute{ + Optional: true, + Attributes: map[string]schema.Attribute{ + "remote_asn": schema.Int64Attribute{ + Description: "Remote ASN for BGP peering (private ASN range, 64512-4294967294).", + Required: true, + Validators: []validator.Int64{ + int64validator.Between(64512, 4294967294), + }, + }, + }, + }, + }, + } + } + + resp.Schema = schema.Schema{ + Description: fmt.Sprintf("VPN Connection resource schema. %s", core.ResourceRegionFallbackDocstring), + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "Terraform's internal resource identifier. Structured as \"`project_id`,`region`,`gateway_id`,`connection_id`\".", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "connection_id": schema.StringAttribute{ + Description: "The server-generated UUID of the VPN connection.", + Computed: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "project_id": schema.StringAttribute{ + Description: "STACKIT project ID.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "region": schema.StringAttribute{ + Description: "STACKIT region.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "gateway_id": schema.StringAttribute{ + Description: "The UUID of the parent VPN gateway.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "display_name": schema.StringAttribute{ + Description: "A user-friendly name for the connection. Must start and end with an alphanumeric character, may contain hyphens, and be 1-63 characters long.", + 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", + ), + }, + }, + "enabled": schema.BoolAttribute{ + Description: "Whether this connection is enabled. Defaults to true.", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(true), + }, + "remote_subnet": schema.ListAttribute{ + Description: "List of remote IPv4 CIDRs accessible via this connection. Optional for route-based and BGP configurations (defaults to 0.0.0.0/0). Mandatory for policy-based.", + Optional: true, + Computed: true, + ElementType: types.StringType, + Validators: []validator.List{ + listvalidator.SizeBetween(1, 100), + listvalidator.ValueStringsAre(validate.CIDR()), + }, + }, + "local_subnet": schema.ListAttribute{ + Description: "List of local IPv4 CIDRs to route through this connection. Optional for route-based and BGP configurations (defaults to 0.0.0.0/0). Mandatory for policy-based.", + Optional: true, + Computed: true, + ElementType: types.StringType, + Validators: []validator.List{ + listvalidator.SizeBetween(1, 100), + listvalidator.ValueStringsAre(validate.CIDR()), + }, + }, + "static_routes": schema.ListAttribute{ + Description: "List of static routes (IPv4 CIDRs) for route-based VPN. Mandatory for ROUTE_BASED gateways.", + Optional: true, + Computed: true, + Default: listdefault.StaticValue(types.ListValueMust(types.StringType, []attr.Value{})), + ElementType: types.StringType, + Validators: []validator.List{ + listvalidator.ValueStringsAre(validate.CIDR()), + }, + }, + "tunnel1": tunnelSchema("tunnel1"), + "tunnel2": tunnelSchema("tunnel2"), + "labels": schema.MapAttribute{ + Description: "Map of custom labels.", + Optional: true, + ElementType: types.StringType, + Validators: validate.LabelValidators(), + }, + }, + } +} + +func (r *vpnConnectionResource) 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 *vpnConnectionResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + idParts := strings.Split(req.ID, core.Separator) + + if len(idParts) != 4 || idParts[0] == "" || idParts[1] == "" || idParts[2] == "" || idParts[3] == "" { + core.LogAndAddError(ctx, &resp.Diagnostics, + "Error importing VPN connection", + fmt.Sprintf("Expected import identifier with format: [project_id],[region],[gateway_id],[connection_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], + "connection_id": idParts[3], + }) + tflog.Info(ctx, "VPN connection state imported") +} + +func (r *vpnConnectionResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { // nolint:gocritic // function signature required by Terraform + // the regular plan model one always uses in the create implementation + var planModel Model + diags := req.Plan.Get(ctx, &planModel) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // The config model - this has to be used because Terraform doesn't include write-only field values in the + // plan and state models - for security measures. Write-only values should be only kept in the config model + // so that they never end up in the state (or plan). + var configModel Model + diags = req.Config.Get(ctx, &configModel) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + ctx = core.InitProviderContext(ctx) + + projectId := planModel.ProjectID.ValueString() + region := r.providerData.GetRegionWithOverride(planModel.Region) + gatewayId := planModel.GatewayID.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "region", region) + ctx = tflog.SetField(ctx, "gateway_id", gatewayId) + + payload, err := toCreatePayload(ctx, &planModel, &configModel) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating VPN connection", fmt.Sprintf("Creating API payload: %v", err)) + return + } + + createResp, err := r.client.DefaultAPI.CreateGatewayConnection(ctx, projectId, region, gatewayId).CreateGatewayConnectionPayload(*payload).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating VPN connection", fmt.Sprintf("Calling API: %v", err)) + return + } + + ctx = core.LogResponse(ctx) + + err = mapResourceFields(ctx, createResp, &planModel, region) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating VPN connection", fmt.Sprintf("Processing API payload: %v", err)) + return + } + + diags = resp.State.Set(ctx, planModel) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "VPN connection created") +} + +func (r *vpnConnectionResource) 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() + region := r.providerData.GetRegionWithOverride(model.Region) + gatewayId := model.GatewayID.ValueString() + connectionId := model.ConnectionID.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "region", region) + ctx = tflog.SetField(ctx, "gateway_id", gatewayId) + ctx = tflog.SetField(ctx, "connection_id", connectionId) + + connResp, err := r.client.DefaultAPI.GetGatewayConnection(ctx, projectId, region, gatewayId, connectionId).Execute() + if err != nil { + if oapiErr, ok := errors.AsType[*oapierror.GenericOpenAPIError](err); ok && oapiErr.StatusCode == http.StatusNotFound { + resp.State.RemoveResource(ctx) + return + } + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading VPN connection", err.Error()) + return + } + + ctx = core.LogResponse(ctx) + + err = mapResourceFields(ctx, connResp, &model, region) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading VPN connection", 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 connection read") +} + +func (r *vpnConnectionResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform + // the regular plan model one always uses in the update implementation + var planModel Model + diags := req.Plan.Get(ctx, &planModel) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // the state model - contains the "previous" values and is needed to compare the old write-only version field + // values to the new ones + var stateModel Model + diags = req.State.Get(ctx, &stateModel) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // The config model - this has to be used because Terraform doesn't include write-only field values in the + // plan and state models - for security measures. Write-only values should be only kept in the config model + // so that they never end up in the state (or plan). + var configModel Model + diags = req.Config.Get(ctx, &configModel) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + ctx = core.InitProviderContext(ctx) + + projectId := planModel.ProjectID.ValueString() + region := r.providerData.GetRegionWithOverride(planModel.Region) + gatewayId := planModel.GatewayID.ValueString() + connectionId := planModel.ConnectionID.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "region", region) + ctx = tflog.SetField(ctx, "gateway_id", gatewayId) + ctx = tflog.SetField(ctx, "connection_id", connectionId) + + payload, err := toUpdatePayload(ctx, &planModel, &stateModel, &configModel) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating VPN connection", fmt.Sprintf("Creating API payload: %v", err)) + return + } + + connResp, err := r.client.DefaultAPI.UpdateGatewayConnection(ctx, projectId, region, gatewayId, connectionId).UpdateGatewayConnectionPayload(*payload).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating VPN connection", err.Error()) + return + } + + ctx = core.LogResponse(ctx) + + err = mapResourceFields(ctx, connResp, &planModel, region) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating VPN connection", fmt.Sprintf("Processing API payload: %v", err)) + return + } + + diags = resp.State.Set(ctx, planModel) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "VPN connection updated") +} + +func (r *vpnConnectionResource) 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() + region := r.providerData.GetRegionWithOverride(model.Region) + gatewayId := model.GatewayID.ValueString() + connectionId := model.ConnectionID.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "region", region) + ctx = tflog.SetField(ctx, "gateway_id", gatewayId) + ctx = tflog.SetField(ctx, "connection_id", connectionId) + + err := r.client.DefaultAPI.DeleteGatewayConnection(ctx, projectId, region, gatewayId, connectionId).Execute() + if err != nil { + if oapiErr, ok := errors.AsType[*oapierror.GenericOpenAPIError](err); ok && oapiErr.StatusCode == http.StatusNotFound { + resp.State.RemoveResource(ctx) + return + } + core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting VPN connection", fmt.Sprintf("Calling API: %v", err)) + return + } + + ctx = core.LogResponse(ctx) + tflog.Info(ctx, "VPN connection deleted") +} + +func toCreatePayload(ctx context.Context, planModel, configModel *Model) (*vpn.CreateGatewayConnectionPayload, error) { + if planModel == nil { + return nil, fmt.Errorf("nil plan model") + } + if configModel == nil { + return nil, fmt.Errorf("nil config model") + } + + payload := &vpn.CreateGatewayConnectionPayload{} + err := toConnectionPayload(ctx, planModel, payload) + if err != nil { + return nil, err + } + + // Terraform keeps the write-only field values in the config model - and they shouldn't leave this config model + // to make sure they don't end up being stored in the state. In the plan model the write-only field values are just + // empty. That's why we read everything from the plan model. Except for the field values which are marked as + // write-only in the schema. They are read from the config model. + + // inline function to allow re-using the logic for tunnel1 & tunnel2 without being too confusing + getPresharedKey := func(planTunnelModel, configTunnelModel *TunnelModel) *string { + if !planTunnelModel.PreSharedKey.IsNull() && !planTunnelModel.PreSharedKey.IsUnknown() { + // handle the legacy fallback logic + return planTunnelModel.PreSharedKey.ValueStringPointer() + } else if (!configTunnelModel.PreSharedKeyWo.IsNull() && !configTunnelModel.PreSharedKeyWo.IsUnknown()) && + (!planTunnelModel.PreSharedKeyWoVersion.IsNull() && !planTunnelModel.PreSharedKeyWoVersion.IsUnknown()) { + // the user is using the write-only field + return configTunnelModel.PreSharedKeyWo.ValueStringPointer() + } + + return nil + } + + payload.Tunnel1.PreSharedKey = getPresharedKey(planModel.Tunnel1, configModel.Tunnel1) + payload.Tunnel2.PreSharedKey = getPresharedKey(planModel.Tunnel2, configModel.Tunnel2) + + return payload, nil +} + +func toUpdatePayload(ctx context.Context, planModel, stateModel, configModel *Model) (*vpn.UpdateGatewayConnectionPayload, error) { + if planModel == nil { + return nil, fmt.Errorf("nil plan model") + } + if stateModel == nil { + return nil, fmt.Errorf("nil state model") + } + if configModel == nil { + return nil, fmt.Errorf("nil config model") + } + + payload := &vpn.UpdateGatewayConnectionPayload{} + err := toConnectionPayload(ctx, planModel, payload) + if err != nil { + return nil, err + } + + // Terraform keeps the write-only field values in the config model - and they shouldn't leave this config model + // to make sure they don't end up being stored in the state. In the plan model the write-only field values are just + // empty. That's why we read everything from the plan model. Except for the field values which are marked as + // write-only in the schema. They are read from the config model. + + // inline function to allow re-using the logic for tunnel1 & tunnel2 without being too confusing + getPresharedKey := func(planTunnelModel, stateTunnelModel, configTunnelModel *TunnelModel) *string { + if !planTunnelModel.PreSharedKey.IsNull() { + // handle the legacy fallback logic + return planTunnelModel.PreSharedKey.ValueStringPointer() + } else if !configTunnelModel.PreSharedKeyWo.IsNull() { + // write-only field is set, handle the write-only and version logic + + // check if the version changed between state (old) and plan (new) + if !planTunnelModel.PreSharedKeyWoVersion.Equal(stateTunnelModel.PreSharedKeyWoVersion) { + // the user bumped the version meaning we need to send the write-only field value to the API + return configTunnelModel.PreSharedKeyWo.ValueStringPointer() + } + } + + return nil + } + + payload.Tunnel1.PreSharedKey = getPresharedKey(planModel.Tunnel1, stateModel.Tunnel1, configModel.Tunnel1) + payload.Tunnel2.PreSharedKey = getPresharedKey(planModel.Tunnel2, stateModel.Tunnel2, configModel.Tunnel2) + + return payload, nil +} + +// toConnectionPayload builds the API payloads for create and update. It does NOT set presharedkey payload since it's +// logic differs for create and update implementations +func toConnectionPayload(ctx context.Context, model *Model, payload connectionPayload) error { + tunnel1, err := toTunnelPayload(model.Tunnel1) + if err != nil { + return fmt.Errorf("converting tunnel1: %w", err) + } + + payload.SetTunnel1(*tunnel1) + + tunnel2, err := toTunnelPayload(model.Tunnel2) + if err != nil { + return fmt.Errorf("converting tunnel2: %w", err) + } + + payload.SetTunnel2(*tunnel2) + + payload.SetDisplayName(model.DisplayName.ValueString()) + + if !tfutils.IsUndefined(model.Enabled) { + payload.SetEnabled(model.Enabled.ValueBool()) + } + + if !tfutils.IsUndefined(model.RemoteSubnet) { + remoteSubnets, err := tfutils.ListValueToStringSlice(model.RemoteSubnet) + if err != nil { + return fmt.Errorf("converting remote_subnet: %w", err) + } + payload.SetRemoteSubnets(remoteSubnets) + } + + if !tfutils.IsUndefined(model.LocalSubnet) { + localSubnets, err := tfutils.ListValueToStringSlice(model.LocalSubnet) + if err != nil { + return fmt.Errorf("converting local_subnet: %w", err) + } + payload.SetLocalSubnets(localSubnets) + } + + if !tfutils.IsUndefined(model.StaticRoutes) { + staticRoutes, err := tfutils.ListValueToStringSlice(model.StaticRoutes) + if err != nil { + return fmt.Errorf("converting static_routes: %w", err) + } + payload.SetStaticRoutes(staticRoutes) + } + + labels, err := tfutils.LabelsToPayload(ctx, model.Labels) + if err != nil { + return err + } + payload.SetLabels(labels) + + return nil +} + +func toTunnelPayload(tunnel *TunnelModel) (*vpn.TunnelConfiguration, error) { + if tunnel == nil { + return nil, fmt.Errorf("nil tunnel model") + } + + config := &vpn.TunnelConfiguration{ + RemoteAddress: tunnel.RemoteAddress.ValueString(), + } + + if tunnel.Phase1 != nil { + phase1 := vpn.TunnelConfigurationPhase1{} + err := toBasePhasePayload(&tunnel.Phase1.BasePhaseModel, &phase1) + if err != nil { + return nil, err + } + config.Phase1 = phase1 + } + + if tunnel.Phase2 != nil { + phase2 := vpn.TunnelConfigurationPhase2{ + StartAction: conversion.StringValueToEnumPointer[vpn.TunnelConfigurationPhase2AllOfStartAction](tunnel.Phase2.StartAction), + DpdAction: conversion.StringValueToEnumPointer[vpn.TunnelConfigurationPhase2AllOfDpdAction](tunnel.Phase2.DpdAction), + } + + err := toBasePhasePayload(&tunnel.Phase2.BasePhaseModel, &phase2) + if err != nil { + return nil, err + } + + config.Phase2 = phase2 + } + + if tunnel.Peering != nil { + config.Peering = &vpn.PeeringConfig{ + LocalAddress: new(tunnel.Peering.LocalAddress.ValueString()), + RemoteAddress: new(tunnel.Peering.RemoteAddress.ValueString()), + } + } + + if tunnel.Bgp != nil { + config.Bgp = &vpn.BGPTunnelConfig{ + RemoteAsn: tunnel.Bgp.RemoteAsn.ValueInt64(), + } + } + + return config, nil +} + +func toBasePhasePayload(phaseModel *BasePhaseModel, phasePayload BasePhasePayload) error { + if phaseModel == nil { + return nil + } + + if !tfutils.IsUndefined(phaseModel.DhGroups) { + dhGroups, err := tfutils.ListValueToStringSlice(phaseModel.DhGroups) + if err != nil { + return fmt.Errorf("converting phase dh_groups: %w", err) + } + phasePayload.SetDhGroups(tfutils.Map(dhGroups, func(t string) vpn.PhaseDhGroupsInner { + return vpn.PhaseDhGroupsInner(t) + })) + } + + encAlgs, err := tfutils.ListValueToStringSlice(phaseModel.EncryptionAlgorithms) + if err != nil { + return fmt.Errorf("converting phase encryption_algorithms: %w", err) + } + phasePayload.SetEncryptionAlgorithms(tfutils.Map(encAlgs, func(t string) vpn.PhaseEncryptionAlgorithmsInner { + return vpn.PhaseEncryptionAlgorithmsInner(t) + })) + + intAlgs, err := tfutils.ListValueToStringSlice(phaseModel.IntegrityAlgorithms) + if err != nil { + return fmt.Errorf("converting phase integrity_algorithms: %w", err) + } + phasePayload.SetIntegrityAlgorithms(tfutils.Map(intAlgs, func(t string) vpn.PhaseIntegrityAlgorithmsInner { + return vpn.PhaseIntegrityAlgorithmsInner(t) + })) + + if !tfutils.IsUndefined(phaseModel.RekeyTime) { + rekeyTime := phaseModel.RekeyTime.ValueInt32() + phasePayload.SetRekeyTime(rekeyTime) + } + + return nil +} + +func mapCommonFields(ctx context.Context, conn connectionResponse, model *CommonModel, region string) error { + if conn == nil { + return fmt.Errorf("response input is nil") + } + if model == nil { + return fmt.Errorf("model input is nil") + } + + var connectionId string + if respConnectionId, _ := conn.GetIdOk(); respConnectionId != nil { + connectionId = *respConnectionId + } else if model.ConnectionID.ValueString() != "" { + connectionId = model.ConnectionID.ValueString() + } else { + return fmt.Errorf("connection id not present") + } + + model.ID = tfutils.BuildInternalTerraformId(model.ProjectID.ValueString(), region, model.GatewayID.ValueString(), connectionId) + model.ConnectionID = types.StringValue(connectionId) + model.DisplayName = types.StringValue(conn.GetDisplayName()) + model.Region = types.StringValue(region) + + enabled, _ := conn.GetEnabledOk() + model.Enabled = types.BoolPointerValue(enabled) + + model.RemoteSubnet = types.ListNull(types.StringType) + if remoteSubnets, _ := conn.GetRemoteSubnetsOk(); remoteSubnets != nil { + list, diags := types.ListValueFrom(ctx, types.StringType, remoteSubnets) + if diags.HasError() { + return fmt.Errorf("mapping remote_subnet: %w", core.DiagsToError(diags)) + } + model.RemoteSubnet = list + } + + model.LocalSubnet = types.ListNull(types.StringType) + if localSubnets, _ := conn.GetLocalSubnetsOk(); localSubnets != nil { + list, diags := types.ListValueFrom(ctx, types.StringType, localSubnets) + if diags.HasError() { + return fmt.Errorf("mapping local_subnet: %w", core.DiagsToError(diags)) + } + model.LocalSubnet = list + } + + model.StaticRoutes = types.ListNull(types.StringType) + if staticRoutes, _ := conn.GetStaticRoutesOk(); staticRoutes != nil { + list, diags := types.ListValueFrom(ctx, types.StringType, staticRoutes) + if diags.HasError() { + return fmt.Errorf("mapping static_routes: %w", core.DiagsToError(diags)) + } + model.StaticRoutes = list + } + + respLabels, _ := conn.GetLabelsOk() + labels, err := tfutils.MapLabels(ctx, respLabels, model.Labels) + if err != nil { + return fmt.Errorf("mapping labels: %w", err) + } + model.Labels = labels + + return nil +} + +func mapResourceFields(ctx context.Context, conn connectionResponse, model *Model, region string) error { + err := mapCommonFields(ctx, conn, &model.CommonModel, region) + if err != nil { + return err + } + + tunnel1 := conn.GetTunnel1() + if model.Tunnel1 == nil { + model.Tunnel1 = &TunnelModel{} + } + err = mapTunnel(ctx, &tunnel1, &model.Tunnel1.DataSourceTunnelModel) + if err != nil { + return fmt.Errorf("mapping tunnel1: %w", err) + } + + tunnel2 := conn.GetTunnel2() + if model.Tunnel2 == nil { + model.Tunnel2 = &TunnelModel{} + } + err = mapTunnel(ctx, &tunnel2, &model.Tunnel2.DataSourceTunnelModel) + if err != nil { + return fmt.Errorf("mapping tunnel2: %w", err) + } + + return nil +} + +func mapTunnel(ctx context.Context, apiTunnel *vpn.TunnelConfiguration, tfTunnel *DataSourceTunnelModel) error { + if apiTunnel == nil { + return fmt.Errorf("apiTunnel can not be nil") + } + if tfTunnel == nil { + return fmt.Errorf("tfTunnel can not be nil") + } + + tfTunnel.RemoteAddress = types.StringValue(apiTunnel.RemoteAddress) + + basePhase1, err := mapBasePhase(ctx, &apiTunnel.Phase1) + if err != nil { + return err + } + + tfTunnel.Phase1 = &Phase1Model{ + BasePhaseModel: basePhase1, + } + + basePhase2, err := mapBasePhase(ctx, &apiTunnel.Phase2) + if err != nil { + return err + } + + tfTunnel.Phase2 = &Phase2Model{ + BasePhaseModel: basePhase2, + StartAction: types.StringPointerValue((*string)(apiTunnel.Phase2.StartAction)), + DpdAction: types.StringPointerValue((*string)(apiTunnel.Phase2.DpdAction)), + } + + tfTunnel.Peering = nil + if apiTunnel.Peering != nil { + tfTunnel.Peering = &PeeringConfigModel{ + LocalAddress: types.StringPointerValue(apiTunnel.Peering.LocalAddress), + RemoteAddress: types.StringPointerValue(apiTunnel.Peering.RemoteAddress), + } + } + + tfTunnel.Bgp = nil + if apiTunnel.Bgp != nil { + tfTunnel.Bgp = &BGPTunnelConfigModel{ + RemoteAsn: types.Int64Value(apiTunnel.Bgp.RemoteAsn), + } + } + + return nil +} + +func mapBasePhase(ctx context.Context, apiPhase BasePhasePayload) (phase BasePhaseModel, err error) { + if apiPhase == nil { + return phase, fmt.Errorf("api phase can not be nil") + } + + phase.DhGroups = types.ListNull(types.StringType) + if dhGroups, _ := apiPhase.GetDhGroupsOk(); dhGroups != nil { + list, diags := types.ListValueFrom(ctx, types.StringType, dhGroups) + if diags.HasError() { + return phase, fmt.Errorf("mapping base phase dh_groups: %w", core.DiagsToError(diags)) + } + phase.DhGroups = list + } + + phase.EncryptionAlgorithms = types.ListNull(types.StringType) + if encryptionAlgorithms, _ := apiPhase.GetEncryptionAlgorithmsOk(); encryptionAlgorithms != nil { + list, diags := types.ListValueFrom(ctx, types.StringType, encryptionAlgorithms) + if diags.HasError() { + return phase, fmt.Errorf("mapping base phase encryption_algorithms: %w", core.DiagsToError(diags)) + } + phase.EncryptionAlgorithms = list + } + + phase.IntegrityAlgorithms = types.ListNull(types.StringType) + if integrityAlgorithms, _ := apiPhase.GetIntegrityAlgorithmsOk(); integrityAlgorithms != nil { + list, diags := types.ListValueFrom(ctx, types.StringType, integrityAlgorithms) + if diags.HasError() { + return phase, fmt.Errorf("mapping base phase integrity_algorithms: %w", core.DiagsToError(diags)) + } + phase.IntegrityAlgorithms = list + } + + rekeyTime, _ := apiPhase.GetRekeyTimeOk() + phase.RekeyTime = types.Int32PointerValue(rekeyTime) + + return phase, nil +} diff --git a/stackit/internal/services/vpn/connection/resource_test.go b/stackit/internal/services/vpn/connection/resource_test.go new file mode 100644 index 000000000..40ca8a9d4 --- /dev/null +++ b/stackit/internal/services/vpn/connection/resource_test.go @@ -0,0 +1,914 @@ +package connection + +import ( + "context" + "fmt" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/uuid" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/types" + vpn "github.com/stackitcloud/stackit-sdk-go/services/vpn/v1api" + + tfutils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" +) + +var ( + projectId = uuid.NewString() + gatewayId = uuid.NewString() + region = "eu01" +) + +func fixtureTunnelResponse(mods ...func(m *vpn.TunnelConfiguration)) vpn.TunnelConfiguration { + resp := vpn.TunnelConfiguration{ + RemoteAddress: "203.0.113.1", + Phase1: vpn.TunnelConfigurationPhase1{ + DhGroups: []vpn.PhaseDhGroupsInner{"modp2048"}, + EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256"}, + IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_256"}, + RekeyTime: new(int32(14400)), + }, + Phase2: vpn.TunnelConfigurationPhase2{ + DhGroups: []vpn.PhaseDhGroupsInner{"modp2048"}, + EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256"}, + IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_256"}, + RekeyTime: new(int32(3600)), + StartAction: vpn.TUNNELCONFIGURATIONPHASE2ALLOFSTARTACTION_START.Ptr(), + DpdAction: vpn.TUNNELCONFIGURATIONPHASE2ALLOFDPDACTION_RESTART.Ptr(), + }, + } + for _, mod := range mods { + mod(&resp) + } + return resp +} + +func fixtureConnectionResponse(mods ...func(m *vpn.ConnectionResponse)) *vpn.ConnectionResponse { + resp := &vpn.ConnectionResponse{ + Id: new("connection-id"), + DisplayName: "test-connection", + Enabled: new(true), + RemoteSubnets: []string{"10.0.0.0/16"}, + LocalSubnets: []string{"192.168.0.0/24"}, + StaticRoutes: []string{"123.45.67.89"}, + Tunnel1: fixtureTunnelResponse(), + Tunnel2: fixtureTunnelResponse(func(m *vpn.TunnelConfiguration) { + m.RemoteAddress = "203.0.113.2" + }), + } + for _, mod := range mods { + mod(resp) + } + return resp +} + +func fixtureBasePhaseModel(mods ...func(m *BasePhaseModel)) BasePhaseModel { + resp := BasePhaseModel{ + DhGroups: types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("modp2048"), + }), + EncryptionAlgorithms: types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("aes256"), + }), + IntegrityAlgorithms: types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("sha2_256"), + }), + RekeyTime: types.Int32Value(14400), + } + for _, mod := range mods { + mod(&resp) + } + return resp +} + +func fixtureTunnelModel(mods ...func(m *TunnelModel)) *TunnelModel { + resp := &TunnelModel{ + PreSharedKeyWoVersion: types.Int64Value(1), + DataSourceTunnelModel: DataSourceTunnelModel{ + RemoteAddress: types.StringValue("203.0.113.1"), + Phase1: &Phase1Model{ + BasePhaseModel: fixtureBasePhaseModel(), + }, + Phase2: &Phase2Model{ + BasePhaseModel: fixtureBasePhaseModel(func(m *BasePhaseModel) { + m.RekeyTime = types.Int32Value(3600) + }), + StartAction: types.StringValue("start"), + DpdAction: types.StringValue("restart"), + }, + }, + } + for _, mod := range mods { + mod(resp) + } + return resp +} + +func fixtureModel(mods ...func(m *Model)) Model { + resp := Model{ + CommonModel: CommonModel{ + ID: types.StringValue(fmt.Sprintf("%s,%s,%s,%s", projectId, region, gatewayId, "connection-id")), + ConnectionID: types.StringValue("connection-id"), + ProjectID: types.StringValue(projectId), + Region: types.StringValue(region), + GatewayID: types.StringValue(gatewayId), + DisplayName: types.StringValue("test-connection"), + Enabled: types.BoolValue(true), + RemoteSubnet: types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("10.0.0.0/16"), + }), + LocalSubnet: types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("192.168.0.0/24"), + }), + StaticRoutes: types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("123.45.67.89"), + }), + Labels: types.MapNull(types.StringType), + }, + Tunnel1: fixtureTunnelModel(), + Tunnel2: fixtureTunnelModel(func(m *TunnelModel) { + m.RemoteAddress = types.StringValue("203.0.113.2") + }), + } + for _, mod := range mods { + mod(&resp) + } + return resp +} + +func fixtureTunnelPayload(mods ...func(m *vpn.TunnelConfiguration)) vpn.TunnelConfiguration { + resp := vpn.TunnelConfiguration{ + PreSharedKey: new("secret123-at-least-20-chars"), + RemoteAddress: "203.0.113.1", + Phase1: vpn.TunnelConfigurationPhase1{ + DhGroups: []vpn.PhaseDhGroupsInner{"modp2048"}, + EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256"}, + IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_256"}, + RekeyTime: new(int32(14400)), + }, + Phase2: vpn.TunnelConfigurationPhase2{ + DhGroups: []vpn.PhaseDhGroupsInner{"modp2048"}, + EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256"}, + IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_256"}, + RekeyTime: new(int32(3600)), + StartAction: vpn.TUNNELCONFIGURATIONPHASE2ALLOFSTARTACTION_START.Ptr(), + DpdAction: vpn.TUNNELCONFIGURATIONPHASE2ALLOFDPDACTION_RESTART.Ptr(), + }, + } + for _, mod := range mods { + mod(&resp) + } + return resp +} + +func fixtureCreatePayload(mods ...func(m *vpn.CreateGatewayConnectionPayload)) *vpn.CreateGatewayConnectionPayload { + resp := &vpn.CreateGatewayConnectionPayload{ + DisplayName: "test-connection", + RemoteSubnets: []string{ + "10.0.0.0/16", + }, + LocalSubnets: []string{ + "192.168.0.0/24", + }, + StaticRoutes: []string{ + "123.45.67.89", + }, + Tunnel1: fixtureTunnelPayload(), + Tunnel2: fixtureTunnelPayload(func(m *vpn.TunnelConfiguration) { + m.PreSharedKey = new("secret456-at-least-20-chars") + m.RemoteAddress = "203.0.113.2" + }), + Enabled: new(true), + Labels: &map[string]string{}, + } + for _, mod := range mods { + mod(resp) + } + return resp +} + +func fixtureUpdatePayload(mods ...func(m *vpn.UpdateGatewayConnectionPayload)) *vpn.UpdateGatewayConnectionPayload { + resp := &vpn.UpdateGatewayConnectionPayload{ + DisplayName: "test-connection", + RemoteSubnets: []string{ + "10.0.0.0/16", + }, + LocalSubnets: []string{ + "192.168.0.0/24", + }, + StaticRoutes: []string{ + "123.45.67.89", + }, + Tunnel1: fixtureTunnelPayload(), + Tunnel2: fixtureTunnelPayload(func(m *vpn.TunnelConfiguration) { + m.PreSharedKey = new("secret456-at-least-20-chars") + m.RemoteAddress = "203.0.113.2" + }), + Enabled: new(true), + Labels: &map[string]string{}, + } + for _, mod := range mods { + mod(resp) + } + return resp +} + +func TestMapFields(t *testing.T) { + tests := []struct { + description string + input *vpn.ConnectionResponse + expected Model + isValid bool + }{ + { + description: "basic_connection", + input: fixtureConnectionResponse(), + expected: fixtureModel(), + isValid: true, + }, + { + description: "minimal_connection", + input: &vpn.ConnectionResponse{ + Id: new("connection-id"), + Tunnel1: vpn.TunnelConfiguration{}, + Tunnel2: vpn.TunnelConfiguration{}, + }, + expected: Model{ + CommonModel: CommonModel{ + ID: types.StringValue(fmt.Sprintf("%s,%s,%s,%s", projectId, region, gatewayId, "connection-id")), + ConnectionID: types.StringValue("connection-id"), + ProjectID: types.StringValue(projectId), + Region: types.StringValue(region), + GatewayID: types.StringValue(gatewayId), + DisplayName: types.StringValue(""), + RemoteSubnet: types.ListNull(types.StringType), + LocalSubnet: types.ListNull(types.StringType), + StaticRoutes: types.ListNull(types.StringType), + Labels: types.MapNull(types.StringType), + }, + Tunnel1: &TunnelModel{ + PreSharedKeyWoVersion: types.Int64Value(1), + DataSourceTunnelModel: DataSourceTunnelModel{ + RemoteAddress: types.StringValue(""), + Phase1: &Phase1Model{ + BasePhaseModel: BasePhaseModel{ + DhGroups: types.ListNull(types.StringType), + EncryptionAlgorithms: types.ListNull(types.StringType), + IntegrityAlgorithms: types.ListNull(types.StringType), + }, + }, + Phase2: &Phase2Model{ + BasePhaseModel: BasePhaseModel{ + DhGroups: types.ListNull(types.StringType), + EncryptionAlgorithms: types.ListNull(types.StringType), + IntegrityAlgorithms: types.ListNull(types.StringType), + }, + }, + }, + }, + Tunnel2: &TunnelModel{ + PreSharedKeyWoVersion: types.Int64Value(1), + DataSourceTunnelModel: DataSourceTunnelModel{ + RemoteAddress: types.StringValue(""), + Phase1: &Phase1Model{ + BasePhaseModel: BasePhaseModel{ + DhGroups: types.ListNull(types.StringType), + EncryptionAlgorithms: types.ListNull(types.StringType), + IntegrityAlgorithms: types.ListNull(types.StringType), + }, + }, + Phase2: &Phase2Model{ + BasePhaseModel: BasePhaseModel{ + DhGroups: types.ListNull(types.StringType), + EncryptionAlgorithms: types.ListNull(types.StringType), + IntegrityAlgorithms: types.ListNull(types.StringType), + }, + }, + }, + }, + }, + isValid: true, + }, + { + description: "connection_with_static_routes_and_bgp", + input: fixtureConnectionResponse(func(m *vpn.ConnectionResponse) { + m.StaticRoutes = []string{"10.0.0.0/8"} + m.Tunnel1.Bgp = &vpn.BGPTunnelConfig{ + RemoteAsn: 65000, + } + }), + expected: fixtureModel(func(m *Model) { + m.StaticRoutes = types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("10.0.0.0/8"), + }) + m.Tunnel1.Bgp = &BGPTunnelConfigModel{ + RemoteAsn: types.Int64Value(65000), + } + }), + isValid: true, + }, + { + description: "multiple_static_routes", + input: fixtureConnectionResponse(func(m *vpn.ConnectionResponse) { + m.StaticRoutes = []string{"10.0.0.0/8", "172.16.0.0/12"} + }), + expected: fixtureModel(func(m *Model) { + m.StaticRoutes = types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("10.0.0.0/8"), + types.StringValue("172.16.0.0/12"), + }) + }), + isValid: true, + }, + { + description: "empty_labels", + input: fixtureConnectionResponse(func(m *vpn.ConnectionResponse) { + m.Labels = &map[string]string{} + }), + expected: fixtureModel(func(m *Model) { + m.Labels = types.MapNull(types.StringType) + }), + isValid: true, + }, + { + description: "peering", + input: fixtureConnectionResponse(func(m *vpn.ConnectionResponse) { + m.Tunnel1.Peering = &vpn.PeeringConfig{ + LocalAddress: new("123.45.67.89"), + RemoteAddress: new("98.76.54.32"), + } + }), + expected: fixtureModel(func(m *Model) { + m.Tunnel1.Peering = &PeeringConfigModel{ + LocalAddress: types.StringValue("123.45.67.89"), + RemoteAddress: types.StringValue("98.76.54.32"), + } + }), + isValid: true, + }, + { + description: "nil_response", + input: nil, + expected: Model{}, + isValid: false, + }, + { + description: "nil_connection_id", + input: &vpn.ConnectionResponse{ + Id: nil, + DisplayName: "test-connection", + }, + expected: Model{}, + isValid: false, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + state := &Model{ + CommonModel: CommonModel{ + ProjectID: types.StringValue(projectId), + Region: types.StringValue(region), + GatewayID: types.StringValue(gatewayId), + }, + Tunnel1: &TunnelModel{ + PreSharedKeyWoVersion: types.Int64Value(1), + }, + Tunnel2: &TunnelModel{ + PreSharedKeyWoVersion: types.Int64Value(1), + }, + } + + err := mapResourceFields(context.Background(), tt.input, state, region) + + 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 { + if diff := cmp.Diff(&tt.expected, state); diff != "" { + t.Fatalf("Data mismatch (-want +got):\n%s", diff) + } + } + }) + } +} + +func TestToCreatePayload(t *testing.T) { + type args struct { + planModel *Model + configModel *Model + } + + tests := []struct { + description string + args args + expected *vpn.CreateGatewayConnectionPayload + isValid bool + }{ + // NOTE: Before diving into these tests read the comments in the function implementation :) + { + description: "basic connection - no pre-shared key set", + args: args{ + planModel: new(fixtureModel(func(m *Model) { + m.Tunnel1.PreSharedKey = types.StringNull() + m.Tunnel1.PreSharedKeyWo = types.StringNull() + m.Tunnel1.PreSharedKeyWoVersion = types.Int64Null() + + m.Tunnel2.PreSharedKey = types.StringNull() + m.Tunnel2.PreSharedKeyWo = types.StringNull() + m.Tunnel2.PreSharedKeyWoVersion = types.Int64Null() + })), + configModel: &Model{ + Tunnel1: &TunnelModel{}, + Tunnel2: &TunnelModel{}, + }, + }, + expected: fixtureCreatePayload(func(m *vpn.CreateGatewayConnectionPayload) { + m.Tunnel1.PreSharedKey = nil + m.Tunnel2.PreSharedKey = nil + }), + isValid: true, + }, + { + description: "basic connection - pre-shared key set via legacy field", + args: args{ + planModel: new(fixtureModel(func(m *Model) { + m.Tunnel1.PreSharedKey = types.StringValue("secret123-at-least-20-chars") + m.Tunnel1.PreSharedKeyWo = types.StringNull() + m.Tunnel1.PreSharedKeyWoVersion = types.Int64Null() + + m.Tunnel2.PreSharedKey = types.StringValue("secret456-at-least-20-chars") + m.Tunnel2.PreSharedKeyWo = types.StringNull() + m.Tunnel2.PreSharedKeyWoVersion = types.Int64Null() + })), + configModel: &Model{}, + }, + expected: fixtureCreatePayload(func(m *vpn.CreateGatewayConnectionPayload) { + m.Tunnel1.PreSharedKey = new("secret123-at-least-20-chars") + m.Tunnel2.PreSharedKey = new("secret456-at-least-20-chars") + }), + isValid: true, + }, + { + description: "basic connection - write only pre-shared key set together with write only version", + args: args{ + planModel: new(fixtureModel(func(m *Model) { + m.Tunnel1.PreSharedKey = types.StringNull() + // write-only fields are always null in the plan and struct model - they are/should only be present in the config model + m.Tunnel1.PreSharedKeyWo = types.StringNull() + m.Tunnel1.PreSharedKeyWoVersion = types.Int64Value(1) + + m.Tunnel2.PreSharedKey = types.StringNull() + // write-only fields are always null in the plan and struct model - they are/should only be present in the config model + m.Tunnel2.PreSharedKeyWo = types.StringNull() + m.Tunnel2.PreSharedKeyWoVersion = types.Int64Value(0) + })), + configModel: &Model{ + Tunnel1: &TunnelModel{ + // write-only fields are always null in the plan and struct model - they are/should only be present in the config model + PreSharedKeyWo: types.StringValue("secret123-at-least-20-chars"), + }, + Tunnel2: &TunnelModel{ + // write-only fields are always null in the plan and struct model - they are/should only be present in the config model + PreSharedKeyWo: types.StringValue("secret456-at-least-20-chars"), + }, + }, + }, + expected: fixtureCreatePayload(func(m *vpn.CreateGatewayConnectionPayload) { + m.Tunnel1.PreSharedKey = new("secret123-at-least-20-chars") + m.Tunnel2.PreSharedKey = new("secret456-at-least-20-chars") + }), + isValid: true, + }, + { + description: "basic connection - write only pre-shared key set but write only version not set", + args: args{ + planModel: new(fixtureModel(func(m *Model) { + m.Tunnel1.PreSharedKey = types.StringNull() + // write-only fields are always null in the plan and struct model - they are/should only be present in the config model + m.Tunnel1.PreSharedKeyWo = types.StringNull() + m.Tunnel1.PreSharedKeyWoVersion = types.Int64Null() + + m.Tunnel2.PreSharedKey = types.StringNull() + // write-only fields are always null in the plan and struct model - they are/should only be present in the config model + m.Tunnel2.PreSharedKeyWo = types.StringNull() + m.Tunnel2.PreSharedKeyWoVersion = types.Int64Null() + })), + configModel: &Model{ + Tunnel1: &TunnelModel{ + // write-only fields are always null in the plan and struct model - they are/should only be present in the config model + PreSharedKeyWo: types.StringValue("secret123-at-least-20-chars"), + }, + Tunnel2: &TunnelModel{ + // write-only fields are always null in the plan and struct model - they are/should only be present in the config model + PreSharedKeyWo: types.StringValue("secret456-at-least-20-chars"), + }, + }, + }, + expected: fixtureCreatePayload(func(m *vpn.CreateGatewayConnectionPayload) { + // must be nil because the write only version is not set + m.Tunnel1.PreSharedKey = nil + m.Tunnel2.PreSharedKey = nil + }), + isValid: true, + }, + { + description: "minimal_create", + args: args{ + planModel: &Model{ + Tunnel1: &TunnelModel{}, + Tunnel2: &TunnelModel{}, + }, + configModel: &Model{ + Tunnel1: &TunnelModel{}, + Tunnel2: &TunnelModel{}, + }, + }, + expected: &vpn.CreateGatewayConnectionPayload{ + Labels: &map[string]string{}, + }, + isValid: true, + }, + { + description: "plan model is nil", + args: args{ + planModel: nil, + configModel: &Model{}, + }, + expected: nil, + isValid: false, + }, + { + description: "config model is nil", + args: args{ + planModel: &Model{}, + configModel: nil, + }, + expected: nil, + isValid: false, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + payload, err := toCreatePayload(context.Background(), tt.args.planModel, tt.args.configModel) + + if !tt.isValid && err == nil { + t.Fatalf("Should have failed") + } + if tt.isValid && err != nil { + t.Fatalf("Should not have failed: %v", err) + } + if tt.isValid { + diff := cmp.Diff(tt.expected, payload) + if diff != "" { + t.Fatalf("Data does not match (-want +got):\n%s", diff) + } + } + }) + } +} + +func TestToUpdatePayload(t *testing.T) { + type args struct { + planModel *Model + stateModel *Model + configModel *Model + } + + tests := []struct { + description string + args args + expected *vpn.UpdateGatewayConnectionPayload + isValid bool + }{ + // NOTE: Before diving into these tests read the comments in the function implementation :) + { + description: "basic update - update of pre-shared key legacy field", + args: args{ + planModel: new(fixtureModel(func(m *Model) { + m.Tunnel1.PreSharedKey = types.StringValue("secret123-at-least-20-chars") + m.Tunnel1.PreSharedKeyWo = types.StringNull() + m.Tunnel1.PreSharedKeyWoVersion = types.Int64Value(1) + + m.Tunnel2.PreSharedKey = types.StringValue("secret456-at-least-20-chars") + m.Tunnel2.PreSharedKeyWo = types.StringNull() + m.Tunnel2.PreSharedKeyWoVersion = types.Int64Value(1) + })), + stateModel: &Model{ + Tunnel1: &TunnelModel{ + PreSharedKey: types.StringValue("old-secret-123-foo-bar"), + PreSharedKeyWo: types.StringNull(), + PreSharedKeyWoVersion: types.Int64Null(), + }, + Tunnel2: &TunnelModel{ + PreSharedKey: types.StringValue("old-secret-456-foo-bar"), + PreSharedKeyWo: types.StringNull(), + PreSharedKeyWoVersion: types.Int64Null(), + }, + }, + configModel: &Model{}, + }, + expected: fixtureUpdatePayload(func(m *vpn.UpdateGatewayConnectionPayload) { + m.Tunnel1.PreSharedKey = new("secret123-at-least-20-chars") + m.Tunnel2.PreSharedKey = new("secret456-at-least-20-chars") + }), + isValid: true, + }, + { + description: "basic update - from pre-shared key legacy field to write only field", + args: args{ + planModel: new(fixtureModel(func(m *Model) { + m.Tunnel1.PreSharedKey = types.StringNull() + // write-only fields are always null in the plan and struct model - they are/should only be present in the config model + m.Tunnel1.PreSharedKeyWo = types.StringNull() + m.Tunnel1.PreSharedKeyWoVersion = types.Int64Value(1) + + m.Tunnel2.PreSharedKey = types.StringNull() + // write-only fields are always null in the plan and struct model - they are/should only be present in the config model + m.Tunnel2.PreSharedKeyWo = types.StringNull() + m.Tunnel2.PreSharedKeyWoVersion = types.Int64Value(1) + })), + stateModel: &Model{ + Tunnel1: &TunnelModel{ + PreSharedKey: types.StringValue("old-secret-123-foo-bar"), + PreSharedKeyWo: types.StringNull(), + PreSharedKeyWoVersion: types.Int64Null(), + }, + Tunnel2: &TunnelModel{ + PreSharedKey: types.StringValue("old-secret-456-foo-bar"), + PreSharedKeyWo: types.StringNull(), + PreSharedKeyWoVersion: types.Int64Null(), + }, + }, + configModel: &Model{ + Tunnel1: &TunnelModel{ + // write-only fields are always null in the plan and struct model - they are/should only be present in the config model + PreSharedKeyWo: types.StringValue("secret123-at-least-20-chars"), + }, + Tunnel2: &TunnelModel{ + // write-only fields are always null in the plan and struct model - they are/should only be present in the config model + PreSharedKeyWo: types.StringValue("secret456-at-least-20-chars"), + }, + }, + }, + expected: fixtureUpdatePayload(func(m *vpn.UpdateGatewayConnectionPayload) { + m.Tunnel1.PreSharedKey = new("secret123-at-least-20-chars") + m.Tunnel2.PreSharedKey = new("secret456-at-least-20-chars") + }), + isValid: true, + }, + { + description: "basic update - pre-shared key was previously set via write-only field but version was not updated now", + args: args{ + planModel: new(fixtureModel(func(m *Model) { + m.Tunnel1.PreSharedKey = types.StringNull() + // write-only fields are always null in the plan and struct model - they are/should only be present in the config model + m.Tunnel1.PreSharedKeyWo = types.StringNull() + m.Tunnel1.PreSharedKeyWoVersion = types.Int64Value(1) // note: same version as in state model + + m.Tunnel2.PreSharedKey = types.StringNull() + // write-only fields are always null in the plan and struct model - they are/should only be present in the config model + m.Tunnel2.PreSharedKeyWo = types.StringNull() + m.Tunnel2.PreSharedKeyWoVersion = types.Int64Value(5) // note: same version as in state model + })), + stateModel: &Model{ + Tunnel1: &TunnelModel{ + PreSharedKey: types.StringNull(), + // write-only fields are always null in the plan and struct model - they are/should only be present in the config model + PreSharedKeyWo: types.StringNull(), + PreSharedKeyWoVersion: types.Int64Value(1), // note: same version as in plan model + }, + Tunnel2: &TunnelModel{ + PreSharedKey: types.StringNull(), + // write-only fields are always null in the plan and struct model - they are/should only be present in the config model + PreSharedKeyWo: types.StringNull(), + PreSharedKeyWoVersion: types.Int64Value(5), // note: same version as in plan model + }, + }, + configModel: &Model{ + Tunnel1: &TunnelModel{ + // new write-only field value is irrelevant because the version didn't change + PreSharedKeyWo: types.StringValue("foo-bar-something-new-123"), + }, + Tunnel2: &TunnelModel{ + // new write-only field value is irrelevant because the version didn't change + PreSharedKeyWo: types.StringValue("foo-bar-something-new-456"), + }, + }, + }, + expected: fixtureUpdatePayload(func(m *vpn.UpdateGatewayConnectionPayload) { + // pre-shared key must be nil in the update payload since the WriteOnly Version didn't change between + // old (stateModel) and new (planModel) + m.Tunnel1.PreSharedKey = nil + m.Tunnel2.PreSharedKey = nil + }), + isValid: true, + }, + { + description: "basic update - preshared key was previously set via write-only field and is updated now", + args: args{ + planModel: new(fixtureModel(func(m *Model) { + m.Tunnel1.PreSharedKey = types.StringNull() + // write-only fields are always null in the plan and struct model - they are/should only be present in the config model + m.Tunnel1.PreSharedKeyWo = types.StringNull() + m.Tunnel1.PreSharedKeyWoVersion = types.Int64Value(2) + + m.Tunnel2.PreSharedKey = types.StringNull() + // write-only fields are always null in the plan and struct model - they are/should only be present in the config model + m.Tunnel2.PreSharedKeyWo = types.StringNull() + m.Tunnel2.PreSharedKeyWoVersion = types.Int64Value(4) + })), + stateModel: &Model{ + Tunnel1: &TunnelModel{ + PreSharedKey: types.StringNull(), + // write-only fields are always null in the plan and struct model - they are/should only be present in the config model + PreSharedKeyWo: types.StringNull(), + PreSharedKeyWoVersion: types.Int64Value(1), + }, + Tunnel2: &TunnelModel{ + PreSharedKey: types.StringNull(), + // write-only fields are always null in the plan and struct model - they are/should only be present in the config model + PreSharedKeyWo: types.StringNull(), + PreSharedKeyWoVersion: types.Int64Value(5), + }, + }, + configModel: &Model{ + Tunnel1: &TunnelModel{ + PreSharedKeyWo: types.StringValue("foo-bar-something-new-123"), + }, + Tunnel2: &TunnelModel{ + PreSharedKeyWo: types.StringValue("foo-bar-something-new-456"), + }, + }, + }, + expected: fixtureUpdatePayload(func(m *vpn.UpdateGatewayConnectionPayload) { + m.Tunnel1.PreSharedKey = new("foo-bar-something-new-123") + m.Tunnel2.PreSharedKey = new("foo-bar-something-new-456") + }), + isValid: true, + }, + { + description: "minimal_update", + args: args{ + planModel: &Model{ + Tunnel1: &TunnelModel{}, + Tunnel2: &TunnelModel{}, + }, + stateModel: &Model{}, + configModel: &Model{ + Tunnel1: &TunnelModel{}, + Tunnel2: &TunnelModel{}, + }, + }, + expected: &vpn.UpdateGatewayConnectionPayload{ + Labels: &map[string]string{}, + }, + isValid: true, + }, + { + description: "peering", + args: args{ + planModel: new(fixtureModel(func(m *Model) { + m.Tunnel1.Peering = &PeeringConfigModel{ + LocalAddress: types.StringValue("123.45.67.89"), + RemoteAddress: types.StringValue("98.76.54.32"), + } + })), + stateModel: &Model{}, + configModel: &Model{ + Tunnel1: &TunnelModel{}, + Tunnel2: &TunnelModel{}, + }, + }, + expected: fixtureUpdatePayload(func(m *vpn.UpdateGatewayConnectionPayload) { + m.Tunnel1.Peering = &vpn.PeeringConfig{ + LocalAddress: new("123.45.67.89"), + RemoteAddress: new("98.76.54.32"), + } + m.Tunnel1.PreSharedKey = nil + m.Tunnel2.PreSharedKey = nil + }), + isValid: true, + }, + { + description: "plan model is nil", + args: args{ + planModel: nil, + stateModel: &Model{}, + configModel: &Model{}, + }, + expected: nil, + isValid: false, + }, + { + description: "state model is nil", + args: args{ + planModel: &Model{}, + stateModel: nil, + configModel: &Model{}, + }, + expected: nil, + isValid: false, + }, + { + description: "config model is nil", + args: args{ + planModel: &Model{}, + stateModel: &Model{}, + configModel: nil, + }, + expected: nil, + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + payload, err := toUpdatePayload(context.Background(), tt.args.planModel, tt.args.stateModel, tt.args.configModel) + + if !tt.isValid && err == nil { + t.Fatalf("Should have failed") + } + if tt.isValid && err != nil { + t.Fatalf("Should not have failed: %v", err) + } + if tt.isValid { + diff := cmp.Diff(tt.expected, payload) + if diff != "" { + t.Fatalf("Data does not match (-want +got):\n%s", diff) + } + } + }) + } +} + +func TestToTunnelConfiguration(t *testing.T) { + tests := []struct { + description string + input *TunnelModel + isValid bool + }{ + { + description: "valid_tunnel", + input: fixtureTunnelModel(), + isValid: true, + }, + { + description: "tunnel_with_bgp", + input: fixtureTunnelModel(func(m *TunnelModel) { + m.Bgp = &BGPTunnelConfigModel{ + RemoteAsn: types.Int64Value(65000), + } + }), + isValid: true, + }, + { + description: "empty_tunnel", + input: &TunnelModel{}, + isValid: true, + }, + { + description: "nil_tunnel", + input: nil, + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + config, err := toTunnelPayload(tt.input) + + 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 config.RemoteAddress != tt.input.RemoteAddress.ValueString() { + t.Errorf("RemoteAddress mismatch: got %v, want %v", config.RemoteAddress, tt.input.RemoteAddress.ValueString()) + } + if !tfutils.IsUndefined(tt.input.PreSharedKeyWo) { + if config.PreSharedKey == nil || *config.PreSharedKey != tt.input.PreSharedKeyWo.ValueString() { + t.Errorf("PreSharedKey mismatch") + } + } else if config.PreSharedKey != nil { + t.Errorf("PreSharedKey should be omitted") + } + + if tt.input.Bgp != nil { + if config.Bgp == nil { + t.Errorf("expected BGP config, got nil") + } else if config.Bgp.RemoteAsn != tt.input.Bgp.RemoteAsn.ValueInt64() { + t.Errorf("RemoteAsn mismatch: got %v, want %v", config.Bgp.RemoteAsn, tt.input.Bgp.RemoteAsn.ValueInt64()) + } + } + }) + } +} diff --git a/stackit/internal/services/vpn/testdata/connection-max.tf b/stackit/internal/services/vpn/testdata/connection-max.tf new file mode 100644 index 000000000..2fc8374eb --- /dev/null +++ b/stackit/internal/services/vpn/testdata/connection-max.tf @@ -0,0 +1,87 @@ +variable "connection_display_name" {} +variable "tunnel1_remote_address" {} +variable "tunnel1_psk" {} +variable "tunnel1_psk_version" {} +variable "tunnel1_bgp_remote_asn" {} +variable "tunnel2_remote_address" {} +variable "tunnel2_psk" {} +variable "tunnel2_psk_version" {} +variable "tunnel2_bgp_remote_asn" {} +variable "remote_subnet" {} +variable "local_subnet" {} +variable "tunnel1_local_peering" {} +variable "tunnel1_remote_peering" {} +variable "tunnel2_local_peering" {} +variable "tunnel2_remote_peering" {} + +resource "stackit_vpn_connection" "connection" { + project_id = stackit_vpn_gateway.gateway.project_id + region = stackit_vpn_gateway.gateway.region + gateway_id = stackit_vpn_gateway.gateway.gateway_id + display_name = var.connection_display_name + + remote_subnet = [var.remote_subnet] + local_subnet = [var.local_subnet] + + tunnel1 = { + remote_address = var.tunnel1_remote_address + # in the MIN test we use the legacy field, in the MAX test the write-only field + pre_shared_key_wo = var.tunnel1_psk + pre_shared_key_wo_version = var.tunnel1_psk_version + + phase1 = { + dh_groups = ["modp2048", "ecp256"] + encryption_algorithms = ["aes256", "aes128gcm16"] + integrity_algorithms = ["sha2_256", "sha2_384"] + rekey_time = 25920 + } + + phase2 = { + dh_groups = ["modp2048", "ecp256"] + encryption_algorithms = ["aes256", "aes128gcm16"] + integrity_algorithms = ["sha2_256", "sha2_384"] + rekey_time = 3240 + start_action = "start" + } + + peering = { + local_address = var.tunnel1_local_peering + remote_address = var.tunnel1_remote_peering + } + + bgp = { + remote_asn = var.tunnel1_bgp_remote_asn + } + } + + tunnel2 = { + remote_address = var.tunnel2_remote_address + # in the MIN test we use the legacy field, in the MAX test the write-only field + pre_shared_key_wo = var.tunnel2_psk + pre_shared_key_wo_version = var.tunnel2_psk_version + + phase1 = { + dh_groups = ["modp2048", "ecp256"] + encryption_algorithms = ["aes256", "aes128gcm16"] + integrity_algorithms = ["sha2_256", "sha2_384"] + rekey_time = 25920 + } + + phase2 = { + dh_groups = ["modp2048", "ecp256"] + encryption_algorithms = ["aes256", "aes128gcm16"] + integrity_algorithms = ["sha2_256", "sha2_384"] + rekey_time = 3240 + start_action = "start" + } + + peering = { + local_address = var.tunnel2_local_peering + remote_address = var.tunnel2_remote_peering + } + + bgp = { + remote_asn = var.tunnel2_bgp_remote_asn + } + } +} diff --git a/stackit/internal/services/vpn/testdata/connection-min.tf b/stackit/internal/services/vpn/testdata/connection-min.tf new file mode 100644 index 000000000..e7e095c79 --- /dev/null +++ b/stackit/internal/services/vpn/testdata/connection-min.tf @@ -0,0 +1,48 @@ +variable "connection_display_name" {} +variable "tunnel1_remote_address" {} +variable "tunnel1_psk" {} +variable "tunnel2_remote_address" {} +variable "tunnel2_psk" {} + +resource "stackit_vpn_connection" "connection" { + project_id = stackit_vpn_gateway.gateway.project_id + region = stackit_vpn_gateway.gateway.region + gateway_id = stackit_vpn_gateway.gateway.gateway_id + display_name = var.connection_display_name + + tunnel1 = { + remote_address = var.tunnel1_remote_address + # in the MIN test we use the legacy field, in the MAX test the write-only field + pre_shared_key = var.tunnel1_psk + + phase1 = { + dh_groups = ["ecp384"] + encryption_algorithms = ["aes256"] + integrity_algorithms = ["sha2_384"] + } + + phase2 = { + dh_groups = ["ecp384"] + encryption_algorithms = ["aes256"] + integrity_algorithms = ["sha2_384"] + } + } + + tunnel2 = { + remote_address = var.tunnel2_remote_address + # in the MIN test we use the legacy field, in the MAX test the write-only field + pre_shared_key = var.tunnel2_psk + + phase1 = { + dh_groups = ["ecp384"] + encryption_algorithms = ["aes256"] + integrity_algorithms = ["sha2_384"] + } + + phase2 = { + dh_groups = ["ecp384"] + encryption_algorithms = ["aes256"] + integrity_algorithms = ["sha2_384"] + } + } +} diff --git a/stackit/internal/services/vpn/vpn_acc_test.go b/stackit/internal/services/vpn/vpn_acc_test.go index 387a5ed1f..3fa9693ba 100644 --- a/stackit/internal/services/vpn/vpn_acc_test.go +++ b/stackit/internal/services/vpn/vpn_acc_test.go @@ -3,8 +3,11 @@ package vpn_test import ( "context" _ "embed" + "errors" "fmt" "maps" + "net/http" + "slices" "strings" "testing" @@ -12,6 +15,7 @@ import ( "github.com/hashicorp/terraform-plugin-testing/helper/acctest" "github.com/hashicorp/terraform-plugin-testing/helper/resource" "github.com/hashicorp/terraform-plugin-testing/terraform" + "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" @@ -24,6 +28,12 @@ var gatewayMinConfig string //go:embed testdata/gateway-max.tf var gatewayMaxConfig string +//go:embed testdata/connection-min.tf +var connectionMinConfig string + +//go:embed testdata/connection-max.tf +var connectionMaxConfig string + var gatewayMinVars = config.Variables{ "project_id": config.StringVariable(testutil.ProjectId), "display_name": config.StringVariable("vpn-gw-acc-test-" + acctest.RandStringFromCharSet(8, acctest.CharSetAlpha)), @@ -79,10 +89,71 @@ var gatewayMaxVarsUpdated2 = func() config.Variables { return updated }() +var connectionMinVars = func() config.Variables { + vars := make(config.Variables, len(gatewayMinVars)+5) + maps.Copy(vars, gatewayMinVars) + vars["connection_display_name"] = config.StringVariable("vpn-conn-" + acctest.RandStringFromCharSet(8, acctest.CharSetAlpha)) + vars["tunnel1_remote_address"] = config.StringVariable("203.0.113.1") + vars["tunnel1_psk"] = config.StringVariable("Super.Secret_$hared3Key_1") + vars["tunnel2_remote_address"] = config.StringVariable("203.0.113.2") + vars["tunnel2_psk"] = config.StringVariable("Super.Secret_$hared3Key_2") + return vars +}() + +var connectionMinVarsUpdated = func() config.Variables { + updated := make(config.Variables, len(connectionMinVars)) + maps.Copy(updated, connectionMinVars) + updated["connection_display_name"] = config.StringVariable("vpn-conn-updated-" + acctest.RandStringFromCharSet(8, acctest.CharSetAlpha)) + return updated +}() + +var connectionMaxVars = func() config.Variables { + vars := make(config.Variables) + maps.Copy(vars, gatewayMaxVars) // BGP_ROUTE_BASED gateway with local_asn, labels, etc. + vars["connection_display_name"] = config.StringVariable("vpn-conn-" + acctest.RandStringFromCharSet(8, acctest.CharSetAlpha)) + vars["tunnel1_remote_address"] = config.StringVariable("203.0.113.1") + vars["tunnel1_psk"] = config.StringVariable("Super.Secret_$hared3Key_1") + vars["tunnel1_psk_version"] = config.IntegerVariable(1) + vars["tunnel1_bgp_remote_asn"] = config.IntegerVariable(65001) + vars["tunnel2_remote_address"] = config.StringVariable("203.0.113.2") + vars["tunnel2_psk"] = config.StringVariable("Super.Secret_$hared3Key_2") + vars["tunnel2_psk_version"] = config.IntegerVariable(1) + vars["tunnel2_bgp_remote_asn"] = config.IntegerVariable(65002) + vars["remote_subnet"] = config.StringVariable("10.10.10.0/24") + vars["local_subnet"] = config.StringVariable("192.168.0.0/24") + vars["tunnel1_local_peering"] = config.StringVariable("192.168.0.1") + vars["tunnel1_remote_peering"] = config.StringVariable("10.10.10.1") + vars["tunnel2_local_peering"] = config.StringVariable("192.168.0.2") + vars["tunnel2_remote_peering"] = config.StringVariable("10.10.10.2") + return vars +}() + +// connectionMaxVarsUpdated changes non-PSK mutable fields to exercise updates. +var connectionMaxVarsUpdated = func() config.Variables { + updated := make(config.Variables) + maps.Copy(updated, connectionMaxVars) + updated["connection_display_name"] = config.StringVariable("vpn-conn-updated-" + acctest.RandStringFromCharSet(8, acctest.CharSetAlpha)) + updated["tunnel1_bgp_remote_asn"] = config.IntegerVariable(65003) + updated["tunnel2_bgp_remote_asn"] = config.IntegerVariable(65004) + return updated +}() + +// connectionMaxVarsPskRotated exercises the write-only PSK rotation workflow: +// both tunnel PSKs are replaced and their versions incremented from 1 → 2. +var connectionMaxVarsPskRotated = func() config.Variables { + rotated := make(config.Variables) + maps.Copy(rotated, connectionMaxVarsUpdated) + rotated["tunnel1_psk"] = config.StringVariable("Super.Secret_Rotated_$hared3Key_1!") + rotated["tunnel1_psk_version"] = config.IntegerVariable(2) + rotated["tunnel2_psk"] = config.StringVariable("Super.Secret_Rotated_$hared3Key_2!") + rotated["tunnel2_psk_version"] = config.IntegerVariable(2) + return rotated +}() + func TestAccVpnGatewayResourceMin(t *testing.T) { resource.Test(t, resource.TestCase{ ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, - CheckDestroy: testAccCheckVpnGatewayDestroy, + CheckDestroy: testAccCheckVpnResourcesDestroy, Steps: []resource.TestStep{ // Creation { @@ -203,7 +274,7 @@ func TestAccVpnGatewayResourceMin(t *testing.T) { func TestAccVpnGatewayResourceMax(t *testing.T) { resource.Test(t, resource.TestCase{ ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, - CheckDestroy: testAccCheckVpnGatewayDestroy, + CheckDestroy: testAccCheckVpnResourcesDestroy, Steps: []resource.TestStep{ // Creation { @@ -351,40 +422,539 @@ func TestAccVpnGatewayResourceMax(t *testing.T) { }) } -func testAccCheckVpnGatewayDestroy(s *terraform.State) error { +func TestAccVpnConnectionResourceMin(t *testing.T) { + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, + CheckDestroy: testAccCheckVpnResourcesDestroy, + Steps: []resource.TestStep{ + // Creation + { + ConfigVariables: connectionMinVars, + Config: fmt.Sprintf("%s\n%s\n%s", testutil.NewConfigBuilder().BuildProviderConfig(), gatewayMinConfig, connectionMinConfig), + Check: resource.ComposeAggregateTestCheckFunc( + // Gateway + resource.TestCheckResourceAttr("stackit_vpn_gateway.gateway", "project_id", testutil.ProjectId), + resource.TestCheckResourceAttr("stackit_vpn_gateway.gateway", "region", testutil.Region), + resource.TestCheckResourceAttr("stackit_vpn_gateway.gateway", "display_name", testutil.ConvertConfigVariable(connectionMinVars["display_name"])), + resource.TestCheckResourceAttr("stackit_vpn_gateway.gateway", "plan_id", testutil.ConvertConfigVariable(connectionMinVars["plan_id"])), + resource.TestCheckResourceAttr("stackit_vpn_gateway.gateway", "routing_type", testutil.ConvertConfigVariable(connectionMinVars["routing_type"])), + resource.TestCheckResourceAttr("stackit_vpn_gateway.gateway", "availability_zones.tunnel1", testutil.ConvertConfigVariable(connectionMinVars["az_tunnel1"])), + resource.TestCheckResourceAttr("stackit_vpn_gateway.gateway", "availability_zones.tunnel2", testutil.ConvertConfigVariable(connectionMinVars["az_tunnel2"])), + resource.TestCheckResourceAttrSet("stackit_vpn_gateway.gateway", "gateway_id"), + // Connection – identity & top-level + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "project_id", testutil.ProjectId), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "region", testutil.Region), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "display_name", testutil.ConvertConfigVariable(connectionMinVars["connection_display_name"])), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "enabled", "true"), + resource.TestCheckResourceAttrSet("stackit_vpn_connection.connection", "connection_id"), + resource.TestCheckResourceAttrPair("stackit_vpn_connection.connection", "gateway_id", "stackit_vpn_gateway.gateway", "gateway_id"), + // Connection – tunnel1 + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel1.remote_address", testutil.ConvertConfigVariable(connectionMinVars["tunnel1_remote_address"])), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel1.phase1.dh_groups.#", "1"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel1.phase1.dh_groups.0", "ecp384"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel1.phase1.encryption_algorithms.#", "1"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel1.phase1.encryption_algorithms.0", "aes256"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel1.phase1.integrity_algorithms.#", "1"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel1.phase1.integrity_algorithms.0", "sha2_384"), + resource.TestCheckResourceAttrSet("stackit_vpn_connection.connection", "tunnel1.phase1.rekey_time"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel1.phase2.dh_groups.#", "1"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel1.phase2.dh_groups.0", "ecp384"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel1.phase2.encryption_algorithms.#", "1"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel1.phase2.encryption_algorithms.0", "aes256"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel1.phase2.integrity_algorithms.#", "1"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel1.phase2.integrity_algorithms.0", "sha2_384"), + resource.TestCheckResourceAttrSet("stackit_vpn_connection.connection", "tunnel1.phase2.rekey_time"), + resource.TestCheckResourceAttrSet("stackit_vpn_connection.connection", "tunnel1.phase2.start_action"), + resource.TestCheckResourceAttrSet("stackit_vpn_connection.connection", "tunnel1.phase2.dpd_action"), + // Connection – tunnel2 + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel2.remote_address", testutil.ConvertConfigVariable(connectionMinVars["tunnel2_remote_address"])), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel2.phase1.dh_groups.#", "1"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel2.phase1.dh_groups.0", "ecp384"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel2.phase1.encryption_algorithms.#", "1"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel2.phase1.encryption_algorithms.0", "aes256"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel2.phase1.integrity_algorithms.#", "1"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel2.phase1.integrity_algorithms.0", "sha2_384"), + resource.TestCheckResourceAttrSet("stackit_vpn_connection.connection", "tunnel2.phase1.rekey_time"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel2.phase2.dh_groups.#", "1"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel2.phase2.dh_groups.0", "ecp384"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel2.phase2.encryption_algorithms.#", "1"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel2.phase2.encryption_algorithms.0", "aes256"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel2.phase2.integrity_algorithms.#", "1"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel2.phase2.integrity_algorithms.0", "sha2_384"), + resource.TestCheckResourceAttrSet("stackit_vpn_connection.connection", "tunnel2.phase2.rekey_time"), + resource.TestCheckResourceAttrSet("stackit_vpn_connection.connection", "tunnel2.phase2.start_action"), + resource.TestCheckResourceAttrSet("stackit_vpn_connection.connection", "tunnel2.phase2.dpd_action"), + ), + }, + // Data source + { + ConfigVariables: connectionMinVars, + Config: fmt.Sprintf(` + %s + %s + %s + + data "stackit_vpn_connection" "connection" { + project_id = stackit_vpn_connection.connection.project_id + gateway_id = stackit_vpn_connection.connection.gateway_id + connection_id = stackit_vpn_connection.connection.connection_id + } + `, + testutil.NewConfigBuilder().BuildProviderConfig(), gatewayMinConfig, connectionMinConfig, + ), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("data.stackit_vpn_connection.connection", "project_id", testutil.ProjectId), + resource.TestCheckResourceAttr("data.stackit_vpn_connection.connection", "region", testutil.Region), + resource.TestCheckResourceAttr("data.stackit_vpn_connection.connection", "display_name", testutil.ConvertConfigVariable(connectionMinVars["connection_display_name"])), + resource.TestCheckResourceAttr("data.stackit_vpn_connection.connection", "enabled", "true"), + resource.TestCheckResourceAttr("data.stackit_vpn_connection.connection", "tunnel1.remote_address", testutil.ConvertConfigVariable(connectionMinVars["tunnel1_remote_address"])), + resource.TestCheckResourceAttr("data.stackit_vpn_connection.connection", "tunnel1.phase1.dh_groups.#", "1"), + resource.TestCheckResourceAttr("data.stackit_vpn_connection.connection", "tunnel1.phase1.dh_groups.0", "ecp384"), + resource.TestCheckResourceAttr("data.stackit_vpn_connection.connection", "tunnel1.phase1.encryption_algorithms.0", "aes256"), + resource.TestCheckResourceAttr("data.stackit_vpn_connection.connection", "tunnel1.phase1.integrity_algorithms.0", "sha2_384"), + resource.TestCheckResourceAttr("data.stackit_vpn_connection.connection", "tunnel2.remote_address", testutil.ConvertConfigVariable(connectionMinVars["tunnel2_remote_address"])), + resource.TestCheckResourceAttr("data.stackit_vpn_connection.connection", "tunnel2.phase1.dh_groups.0", "ecp384"), + resource.TestCheckResourceAttr("data.stackit_vpn_connection.connection", "tunnel2.phase1.encryption_algorithms.0", "aes256"), + resource.TestCheckResourceAttr("data.stackit_vpn_connection.connection", "tunnel2.phase1.integrity_algorithms.0", "sha2_384"), + + resource.TestCheckResourceAttrSet("data.stackit_vpn_connection.connection", "connection_id"), + resource.TestCheckResourceAttrSet("data.stackit_vpn_connection.connection", "gateway_id"), + + resource.TestCheckResourceAttrPair("data.stackit_vpn_connection.connection", "project_id", "stackit_vpn_connection.connection", "project_id"), + resource.TestCheckResourceAttrPair("data.stackit_vpn_connection.connection", "region", "stackit_vpn_connection.connection", "region"), + resource.TestCheckResourceAttrPair("data.stackit_vpn_connection.connection", "gateway_id", "stackit_vpn_connection.connection", "gateway_id"), + resource.TestCheckResourceAttrPair("data.stackit_vpn_connection.connection", "connection_id", "stackit_vpn_connection.connection", "connection_id"), + resource.TestCheckResourceAttrPair("data.stackit_vpn_connection.connection", "display_name", "stackit_vpn_connection.connection", "display_name"), + ), + }, + // Update + { + ConfigVariables: connectionMinVarsUpdated, + Config: fmt.Sprintf("%s\n%s\n%s", testutil.NewConfigBuilder().BuildProviderConfig(), gatewayMinConfig, connectionMinConfig), + Check: resource.ComposeAggregateTestCheckFunc( + // Gateway unchanged + resource.TestCheckResourceAttr("stackit_vpn_gateway.gateway", "project_id", testutil.ProjectId), + resource.TestCheckResourceAttr("stackit_vpn_gateway.gateway", "region", testutil.Region), + resource.TestCheckResourceAttr("stackit_vpn_gateway.gateway", "display_name", testutil.ConvertConfigVariable(connectionMinVarsUpdated["display_name"])), + resource.TestCheckResourceAttr("stackit_vpn_gateway.gateway", "plan_id", testutil.ConvertConfigVariable(connectionMinVarsUpdated["plan_id"])), + resource.TestCheckResourceAttr("stackit_vpn_gateway.gateway", "routing_type", testutil.ConvertConfigVariable(connectionMinVarsUpdated["routing_type"])), + resource.TestCheckResourceAttr("stackit_vpn_gateway.gateway", "availability_zones.tunnel1", testutil.ConvertConfigVariable(connectionMinVarsUpdated["az_tunnel1"])), + resource.TestCheckResourceAttr("stackit_vpn_gateway.gateway", "availability_zones.tunnel2", testutil.ConvertConfigVariable(connectionMinVarsUpdated["az_tunnel2"])), + resource.TestCheckResourceAttrSet("stackit_vpn_gateway.gateway", "gateway_id"), + // Connection – all fields + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "project_id", testutil.ProjectId), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "region", testutil.Region), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "display_name", testutil.ConvertConfigVariable(connectionMinVarsUpdated["connection_display_name"])), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "enabled", "true"), + resource.TestCheckResourceAttrSet("stackit_vpn_connection.connection", "connection_id"), + resource.TestCheckResourceAttrPair("stackit_vpn_connection.connection", "gateway_id", "stackit_vpn_gateway.gateway", "gateway_id"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel1.remote_address", testutil.ConvertConfigVariable(connectionMinVarsUpdated["tunnel1_remote_address"])), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel1.phase1.dh_groups.0", "ecp384"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel1.phase1.encryption_algorithms.0", "aes256"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel1.phase1.integrity_algorithms.0", "sha2_384"), + resource.TestCheckResourceAttrSet("stackit_vpn_connection.connection", "tunnel1.phase1.rekey_time"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel1.phase2.dh_groups.0", "ecp384"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel1.phase2.encryption_algorithms.0", "aes256"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel1.phase2.integrity_algorithms.0", "sha2_384"), + resource.TestCheckResourceAttrSet("stackit_vpn_connection.connection", "tunnel1.phase2.rekey_time"), + resource.TestCheckResourceAttrSet("stackit_vpn_connection.connection", "tunnel1.phase2.start_action"), + resource.TestCheckResourceAttrSet("stackit_vpn_connection.connection", "tunnel1.phase2.dpd_action"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel2.remote_address", testutil.ConvertConfigVariable(connectionMinVarsUpdated["tunnel2_remote_address"])), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel2.phase1.dh_groups.0", "ecp384"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel2.phase1.encryption_algorithms.0", "aes256"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel2.phase1.integrity_algorithms.0", "sha2_384"), + resource.TestCheckResourceAttrSet("stackit_vpn_connection.connection", "tunnel2.phase1.rekey_time"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel2.phase2.dh_groups.0", "ecp384"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel2.phase2.encryption_algorithms.0", "aes256"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel2.phase2.integrity_algorithms.0", "sha2_384"), + resource.TestCheckResourceAttrSet("stackit_vpn_connection.connection", "tunnel2.phase2.rekey_time"), + resource.TestCheckResourceAttrSet("stackit_vpn_connection.connection", "tunnel2.phase2.start_action"), + resource.TestCheckResourceAttrSet("stackit_vpn_connection.connection", "tunnel2.phase2.dpd_action"), + ), + }, + // Import + { + ConfigVariables: connectionMinVars, + ResourceName: "stackit_vpn_connection.connection", + ImportStateIdFunc: func(s *terraform.State) (string, error) { + r, ok := s.RootModule().Resources["stackit_vpn_connection.connection"] + if !ok { + return "", fmt.Errorf("couldn't find resource stackit_vpn_connection.connection") + } + connectionId, ok := r.Primary.Attributes["connection_id"] + if !ok { + return "", fmt.Errorf("couldn't find attribute connection_id") + } + gatewayId, ok := r.Primary.Attributes["gateway_id"] + if !ok { + return "", fmt.Errorf("couldn't find attribute gateway_id") + } + return fmt.Sprintf("%s,%s,%s,%s", + testutil.ProjectId, + testutil.Region, + gatewayId, + connectionId, + ), nil + }, + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"tunnel1.pre_shared_key", "tunnel2.pre_shared_key"}, + }, + }, + }) +} + +func TestAccVpnConnectionResourceMax(t *testing.T) { + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, + CheckDestroy: testAccCheckVpnResourcesDestroy, + Steps: []resource.TestStep{ + // Creation – BGP_ROUTE_BASED gateway + full connection config including BGP tunnel peers + { + ConfigVariables: connectionMaxVars, + Config: fmt.Sprintf("%s\n%s\n%s", testutil.NewConfigBuilder().BuildProviderConfig(), gatewayMaxConfig, connectionMaxConfig), + Check: resource.ComposeAggregateTestCheckFunc( + // Gateway + resource.TestCheckResourceAttr("stackit_vpn_gateway.gateway", "project_id", testutil.ProjectId), + resource.TestCheckResourceAttr("stackit_vpn_gateway.gateway", "region", testutil.ConvertConfigVariable(connectionMaxVars["region"])), + resource.TestCheckResourceAttr("stackit_vpn_gateway.gateway", "display_name", testutil.ConvertConfigVariable(connectionMaxVars["display_name"])), + resource.TestCheckResourceAttr("stackit_vpn_gateway.gateway", "plan_id", testutil.ConvertConfigVariable(connectionMaxVars["plan_id"])), + resource.TestCheckResourceAttr("stackit_vpn_gateway.gateway", "routing_type", testutil.ConvertConfigVariable(connectionMaxVars["routing_type"])), + resource.TestCheckResourceAttr("stackit_vpn_gateway.gateway", "availability_zones.tunnel1", testutil.ConvertConfigVariable(connectionMaxVars["az_tunnel1"])), + resource.TestCheckResourceAttr("stackit_vpn_gateway.gateway", "availability_zones.tunnel2", testutil.ConvertConfigVariable(connectionMaxVars["az_tunnel2"])), + resource.TestCheckResourceAttr("stackit_vpn_gateway.gateway", "bgp.local_asn", testutil.ConvertConfigVariable(connectionMaxVars["local_asn"])), + resource.TestCheckResourceAttr("stackit_vpn_gateway.gateway", "bgp.override_advertised_routes.#", "2"), + resource.TestCheckResourceAttr("stackit_vpn_gateway.gateway", "bgp.override_advertised_routes.0", testutil.ConvertConfigVariable(connectionMaxVars["advertised_route_1"])), + resource.TestCheckResourceAttr("stackit_vpn_gateway.gateway", "bgp.override_advertised_routes.1", testutil.ConvertConfigVariable(connectionMaxVars["advertised_route_2"])), + resource.TestCheckResourceAttr("stackit_vpn_gateway.gateway", "labels."+testutil.ConvertConfigVariable(connectionMaxVars["label_key"]), testutil.ConvertConfigVariable(connectionMaxVars["label_value"])), + resource.TestCheckResourceAttrSet("stackit_vpn_gateway.gateway", "gateway_id"), + // Connection – identity & top-level + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "project_id", testutil.ProjectId), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "region", testutil.ConvertConfigVariable(connectionMaxVars["region"])), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "display_name", testutil.ConvertConfigVariable(connectionMaxVars["connection_display_name"])), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "enabled", "true"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "remote_subnet.#", "1"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "remote_subnet.0", testutil.ConvertConfigVariable(connectionMaxVars["remote_subnet"])), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "local_subnet.#", "1"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "local_subnet.0", testutil.ConvertConfigVariable(connectionMaxVars["local_subnet"])), + resource.TestCheckResourceAttrSet("stackit_vpn_connection.connection", "connection_id"), + resource.TestCheckResourceAttrPair("stackit_vpn_connection.connection", "gateway_id", "stackit_vpn_gateway.gateway", "gateway_id"), + // Connection – tunnel1 + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel1.remote_address", testutil.ConvertConfigVariable(connectionMaxVars["tunnel1_remote_address"])), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel1.pre_shared_key_wo_version", testutil.ConvertConfigVariable(connectionMaxVars["tunnel1_psk_version"])), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel1.phase1.dh_groups.#", "2"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel1.phase1.dh_groups.0", "modp2048"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel1.phase1.dh_groups.1", "ecp256"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel1.phase1.encryption_algorithms.#", "2"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel1.phase1.encryption_algorithms.0", "aes256"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel1.phase1.encryption_algorithms.1", "aes128gcm16"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel1.phase1.integrity_algorithms.#", "2"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel1.phase1.integrity_algorithms.0", "sha2_256"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel1.phase1.integrity_algorithms.1", "sha2_384"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel1.phase1.rekey_time", "25920"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel1.phase2.dh_groups.#", "2"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel1.phase2.dh_groups.0", "modp2048"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel1.phase2.dh_groups.1", "ecp256"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel1.phase2.encryption_algorithms.#", "2"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel1.phase2.encryption_algorithms.0", "aes256"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel1.phase2.encryption_algorithms.1", "aes128gcm16"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel1.phase2.integrity_algorithms.#", "2"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel1.phase2.integrity_algorithms.0", "sha2_256"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel1.phase2.integrity_algorithms.1", "sha2_384"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel1.phase2.rekey_time", "3240"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel1.phase2.start_action", "start"), + resource.TestCheckResourceAttrSet("stackit_vpn_connection.connection", "tunnel1.phase2.dpd_action"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel1.peering.local_address", testutil.ConvertConfigVariable(connectionMaxVars["tunnel1_local_peering"])), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel1.peering.remote_address", testutil.ConvertConfigVariable(connectionMaxVars["tunnel1_remote_peering"])), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel1.bgp.remote_asn", testutil.ConvertConfigVariable(connectionMaxVars["tunnel1_bgp_remote_asn"])), + // Connection – tunnel2 + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel2.remote_address", testutil.ConvertConfigVariable(connectionMaxVars["tunnel2_remote_address"])), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel2.pre_shared_key_wo_version", testutil.ConvertConfigVariable(connectionMaxVars["tunnel2_psk_version"])), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel2.phase1.dh_groups.#", "2"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel2.phase1.dh_groups.0", "modp2048"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel2.phase1.dh_groups.1", "ecp256"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel2.phase1.encryption_algorithms.#", "2"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel2.phase1.encryption_algorithms.0", "aes256"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel2.phase1.encryption_algorithms.1", "aes128gcm16"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel2.phase1.integrity_algorithms.#", "2"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel2.phase1.integrity_algorithms.0", "sha2_256"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel2.phase1.integrity_algorithms.1", "sha2_384"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel2.phase1.rekey_time", "25920"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel2.phase2.dh_groups.#", "2"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel2.phase2.dh_groups.0", "modp2048"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel2.phase2.dh_groups.1", "ecp256"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel2.phase2.encryption_algorithms.#", "2"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel2.phase2.encryption_algorithms.0", "aes256"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel2.phase2.encryption_algorithms.1", "aes128gcm16"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel2.phase2.integrity_algorithms.#", "2"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel2.phase2.integrity_algorithms.0", "sha2_256"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel2.phase2.integrity_algorithms.1", "sha2_384"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel2.phase2.rekey_time", "3240"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel2.phase2.start_action", "start"), + resource.TestCheckResourceAttrSet("stackit_vpn_connection.connection", "tunnel2.phase2.dpd_action"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel2.peering.local_address", testutil.ConvertConfigVariable(connectionMaxVars["tunnel2_local_peering"])), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel2.peering.remote_address", testutil.ConvertConfigVariable(connectionMaxVars["tunnel2_remote_peering"])), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel2.bgp.remote_asn", testutil.ConvertConfigVariable(connectionMaxVars["tunnel2_bgp_remote_asn"])), + ), + }, + // Data source + { + ConfigVariables: connectionMaxVars, + Config: fmt.Sprintf(` + %s + %s + %s + + data "stackit_vpn_connection" "connection" { + project_id = stackit_vpn_connection.connection.project_id + gateway_id = stackit_vpn_connection.connection.gateway_id + connection_id = stackit_vpn_connection.connection.connection_id + } + `, + testutil.NewConfigBuilder().BuildProviderConfig(), gatewayMaxConfig, connectionMaxConfig, + ), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("data.stackit_vpn_connection.connection", "project_id", testutil.ProjectId), + resource.TestCheckResourceAttr("data.stackit_vpn_connection.connection", "region", testutil.ConvertConfigVariable(connectionMaxVars["region"])), + resource.TestCheckResourceAttr("data.stackit_vpn_connection.connection", "display_name", testutil.ConvertConfigVariable(connectionMaxVars["connection_display_name"])), + resource.TestCheckResourceAttr("data.stackit_vpn_connection.connection", "enabled", "true"), + resource.TestCheckResourceAttr("data.stackit_vpn_connection.connection", "remote_subnet.#", "1"), + resource.TestCheckResourceAttr("data.stackit_vpn_connection.connection", "remote_subnet.0", testutil.ConvertConfigVariable(connectionMaxVars["remote_subnet"])), + resource.TestCheckResourceAttr("data.stackit_vpn_connection.connection", "local_subnet.#", "1"), + resource.TestCheckResourceAttr("data.stackit_vpn_connection.connection", "local_subnet.0", testutil.ConvertConfigVariable(connectionMaxVars["local_subnet"])), + resource.TestCheckResourceAttr("data.stackit_vpn_connection.connection", "tunnel1.remote_address", testutil.ConvertConfigVariable(connectionMaxVars["tunnel1_remote_address"])), + resource.TestCheckResourceAttr("data.stackit_vpn_connection.connection", "tunnel1.phase1.dh_groups.0", "modp2048"), + resource.TestCheckResourceAttr("data.stackit_vpn_connection.connection", "tunnel1.phase1.dh_groups.1", "ecp256"), + resource.TestCheckResourceAttr("data.stackit_vpn_connection.connection", "tunnel1.phase1.encryption_algorithms.0", "aes256"), + resource.TestCheckResourceAttr("data.stackit_vpn_connection.connection", "tunnel1.phase1.encryption_algorithms.1", "aes128gcm16"), + resource.TestCheckResourceAttr("data.stackit_vpn_connection.connection", "tunnel1.phase1.integrity_algorithms.0", "sha2_256"), + resource.TestCheckResourceAttr("data.stackit_vpn_connection.connection", "tunnel1.phase1.integrity_algorithms.1", "sha2_384"), + resource.TestCheckResourceAttr("data.stackit_vpn_connection.connection", "tunnel1.phase1.rekey_time", "25920"), + resource.TestCheckResourceAttr("data.stackit_vpn_connection.connection", "tunnel1.phase2.rekey_time", "3240"), + resource.TestCheckResourceAttr("data.stackit_vpn_connection.connection", "tunnel1.phase2.start_action", "start"), + resource.TestCheckResourceAttr("data.stackit_vpn_connection.connection", "tunnel1.peering.local_address", testutil.ConvertConfigVariable(connectionMaxVars["tunnel1_local_peering"])), + resource.TestCheckResourceAttr("data.stackit_vpn_connection.connection", "tunnel1.peering.remote_address", testutil.ConvertConfigVariable(connectionMaxVars["tunnel1_remote_peering"])), + resource.TestCheckResourceAttr("data.stackit_vpn_connection.connection", "tunnel1.bgp.remote_asn", testutil.ConvertConfigVariable(connectionMaxVars["tunnel1_bgp_remote_asn"])), + resource.TestCheckResourceAttr("data.stackit_vpn_connection.connection", "tunnel2.remote_address", testutil.ConvertConfigVariable(connectionMaxVars["tunnel2_remote_address"])), + resource.TestCheckResourceAttr("data.stackit_vpn_connection.connection", "tunnel2.phase1.rekey_time", "25920"), + resource.TestCheckResourceAttr("data.stackit_vpn_connection.connection", "tunnel2.phase2.rekey_time", "3240"), + resource.TestCheckResourceAttr("data.stackit_vpn_connection.connection", "tunnel2.phase2.start_action", "start"), + resource.TestCheckResourceAttr("data.stackit_vpn_connection.connection", "tunnel2.peering.local_address", testutil.ConvertConfigVariable(connectionMaxVars["tunnel2_local_peering"])), + resource.TestCheckResourceAttr("data.stackit_vpn_connection.connection", "tunnel2.peering.remote_address", testutil.ConvertConfigVariable(connectionMaxVars["tunnel2_remote_peering"])), + resource.TestCheckResourceAttr("data.stackit_vpn_connection.connection", "tunnel2.bgp.remote_asn", testutil.ConvertConfigVariable(connectionMaxVars["tunnel2_bgp_remote_asn"])), + + resource.TestCheckResourceAttrSet("data.stackit_vpn_connection.connection", "connection_id"), + resource.TestCheckResourceAttrSet("data.stackit_vpn_connection.connection", "gateway_id"), + + resource.TestCheckResourceAttrPair("data.stackit_vpn_connection.connection", "project_id", "stackit_vpn_connection.connection", "project_id"), + resource.TestCheckResourceAttrPair("data.stackit_vpn_connection.connection", "region", "stackit_vpn_connection.connection", "region"), + resource.TestCheckResourceAttrPair("data.stackit_vpn_connection.connection", "gateway_id", "stackit_vpn_connection.connection", "gateway_id"), + resource.TestCheckResourceAttrPair("data.stackit_vpn_connection.connection", "connection_id", "stackit_vpn_connection.connection", "connection_id"), + resource.TestCheckResourceAttrPair("data.stackit_vpn_connection.connection", "display_name", "stackit_vpn_connection.connection", "display_name"), + ), + }, + // Update – change display name and BGP remote ASNs; verify no other drift + { + ConfigVariables: connectionMaxVarsUpdated, + Config: fmt.Sprintf("%s\n%s\n%s", testutil.NewConfigBuilder().BuildProviderConfig(), gatewayMaxConfig, connectionMaxConfig), + Check: resource.ComposeAggregateTestCheckFunc( + // Gateway unchanged + resource.TestCheckResourceAttr("stackit_vpn_gateway.gateway", "project_id", testutil.ProjectId), + resource.TestCheckResourceAttr("stackit_vpn_gateway.gateway", "region", testutil.ConvertConfigVariable(connectionMaxVarsUpdated["region"])), + resource.TestCheckResourceAttr("stackit_vpn_gateway.gateway", "display_name", testutil.ConvertConfigVariable(connectionMaxVarsUpdated["display_name"])), + resource.TestCheckResourceAttr("stackit_vpn_gateway.gateway", "plan_id", testutil.ConvertConfigVariable(connectionMaxVarsUpdated["plan_id"])), + resource.TestCheckResourceAttr("stackit_vpn_gateway.gateway", "routing_type", testutil.ConvertConfigVariable(connectionMaxVarsUpdated["routing_type"])), + resource.TestCheckResourceAttr("stackit_vpn_gateway.gateway", "bgp.local_asn", testutil.ConvertConfigVariable(connectionMaxVarsUpdated["local_asn"])), + resource.TestCheckResourceAttrSet("stackit_vpn_gateway.gateway", "gateway_id"), + // Connection + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "project_id", testutil.ProjectId), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "region", testutil.ConvertConfigVariable(connectionMaxVarsUpdated["region"])), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "display_name", testutil.ConvertConfigVariable(connectionMaxVarsUpdated["connection_display_name"])), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "enabled", "true"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "remote_subnet.0", testutil.ConvertConfigVariable(connectionMaxVarsUpdated["remote_subnet"])), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "local_subnet.0", testutil.ConvertConfigVariable(connectionMaxVarsUpdated["local_subnet"])), + resource.TestCheckResourceAttrSet("stackit_vpn_connection.connection", "connection_id"), + resource.TestCheckResourceAttrPair("stackit_vpn_connection.connection", "gateway_id", "stackit_vpn_gateway.gateway", "gateway_id"), + // tunnel1 + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel1.remote_address", testutil.ConvertConfigVariable(connectionMaxVarsUpdated["tunnel1_remote_address"])), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel1.pre_shared_key_wo_version", testutil.ConvertConfigVariable(connectionMaxVarsUpdated["tunnel1_psk_version"])), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel1.phase1.dh_groups.0", "modp2048"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel1.phase1.dh_groups.1", "ecp256"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel1.phase1.encryption_algorithms.0", "aes256"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel1.phase1.encryption_algorithms.1", "aes128gcm16"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel1.phase1.integrity_algorithms.0", "sha2_256"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel1.phase1.integrity_algorithms.1", "sha2_384"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel1.phase1.rekey_time", "25920"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel1.phase2.rekey_time", "3240"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel1.phase2.start_action", "start"), + resource.TestCheckResourceAttrSet("stackit_vpn_connection.connection", "tunnel1.phase2.dpd_action"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel1.peering.local_address", testutil.ConvertConfigVariable(connectionMaxVarsUpdated["tunnel1_local_peering"])), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel1.peering.remote_address", testutil.ConvertConfigVariable(connectionMaxVarsUpdated["tunnel1_remote_peering"])), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel1.bgp.remote_asn", testutil.ConvertConfigVariable(connectionMaxVarsUpdated["tunnel1_bgp_remote_asn"])), + // tunnel2 + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel2.remote_address", testutil.ConvertConfigVariable(connectionMaxVarsUpdated["tunnel2_remote_address"])), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel2.pre_shared_key_wo_version", testutil.ConvertConfigVariable(connectionMaxVarsUpdated["tunnel2_psk_version"])), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel2.phase1.dh_groups.0", "modp2048"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel2.phase1.dh_groups.1", "ecp256"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel2.phase1.encryption_algorithms.0", "aes256"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel2.phase1.encryption_algorithms.1", "aes128gcm16"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel2.phase1.integrity_algorithms.0", "sha2_256"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel2.phase1.integrity_algorithms.1", "sha2_384"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel2.phase1.rekey_time", "25920"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel2.phase2.rekey_time", "3240"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel2.phase2.start_action", "start"), + resource.TestCheckResourceAttrSet("stackit_vpn_connection.connection", "tunnel2.phase2.dpd_action"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel2.peering.local_address", testutil.ConvertConfigVariable(connectionMaxVarsUpdated["tunnel2_local_peering"])), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel2.peering.remote_address", testutil.ConvertConfigVariable(connectionMaxVarsUpdated["tunnel2_remote_peering"])), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel2.bgp.remote_asn", testutil.ConvertConfigVariable(connectionMaxVarsUpdated["tunnel2_bgp_remote_asn"])), + ), + }, + // PSK rotation – increment pre_shared_key_wo_version 1 → 2 on both tunnels. + // The write-only pre_shared_key_wo values are replaced; the provider reads the + // version from state to detect the rotation and re-sends the new key to the API. + // Verifying the new version value in state (and no unintended plan diff) is the + // observable signal that the rotation was applied correctly. + { + ConfigVariables: connectionMaxVarsPskRotated, + Config: fmt.Sprintf("%s\n%s\n%s", testutil.NewConfigBuilder().BuildProviderConfig(), gatewayMaxConfig, connectionMaxConfig), + Check: resource.ComposeAggregateTestCheckFunc( + // Rotated version counters must be persisted in state + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel1.pre_shared_key_wo_version", testutil.ConvertConfigVariable(connectionMaxVarsPskRotated["tunnel1_psk_version"])), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel2.pre_shared_key_wo_version", testutil.ConvertConfigVariable(connectionMaxVarsPskRotated["tunnel2_psk_version"])), + // All other fields must be unchanged – catches unintended drift + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "project_id", testutil.ProjectId), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "region", testutil.ConvertConfigVariable(connectionMaxVarsPskRotated["region"])), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "display_name", testutil.ConvertConfigVariable(connectionMaxVarsPskRotated["connection_display_name"])), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "enabled", "true"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "remote_subnet.0", testutil.ConvertConfigVariable(connectionMaxVarsPskRotated["remote_subnet"])), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "local_subnet.0", testutil.ConvertConfigVariable(connectionMaxVarsPskRotated["local_subnet"])), + resource.TestCheckResourceAttrSet("stackit_vpn_connection.connection", "connection_id"), + resource.TestCheckResourceAttrPair("stackit_vpn_connection.connection", "gateway_id", "stackit_vpn_gateway.gateway", "gateway_id"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel1.remote_address", testutil.ConvertConfigVariable(connectionMaxVarsPskRotated["tunnel1_remote_address"])), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel1.phase1.rekey_time", "25920"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel1.phase2.rekey_time", "3240"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel1.phase2.start_action", "start"), + resource.TestCheckResourceAttrSet("stackit_vpn_connection.connection", "tunnel1.phase2.dpd_action"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel1.peering.local_address", testutil.ConvertConfigVariable(connectionMaxVarsPskRotated["tunnel1_local_peering"])), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel1.peering.remote_address", testutil.ConvertConfigVariable(connectionMaxVarsPskRotated["tunnel1_remote_peering"])), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel1.bgp.remote_asn", testutil.ConvertConfigVariable(connectionMaxVarsPskRotated["tunnel1_bgp_remote_asn"])), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel2.remote_address", testutil.ConvertConfigVariable(connectionMaxVarsPskRotated["tunnel2_remote_address"])), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel2.phase1.rekey_time", "25920"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel2.phase2.rekey_time", "3240"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel2.phase2.start_action", "start"), + resource.TestCheckResourceAttrSet("stackit_vpn_connection.connection", "tunnel2.phase2.dpd_action"), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel2.peering.local_address", testutil.ConvertConfigVariable(connectionMaxVarsPskRotated["tunnel2_local_peering"])), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel2.peering.remote_address", testutil.ConvertConfigVariable(connectionMaxVarsPskRotated["tunnel2_remote_peering"])), + resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "tunnel2.bgp.remote_asn", testutil.ConvertConfigVariable(connectionMaxVarsPskRotated["tunnel2_bgp_remote_asn"])), + ), + }, + // Import + { + ConfigVariables: connectionMaxVars, + ResourceName: "stackit_vpn_connection.connection", + ImportStateIdFunc: func(s *terraform.State) (string, error) { + r, ok := s.RootModule().Resources["stackit_vpn_connection.connection"] + if !ok { + return "", fmt.Errorf("couldn't find resource stackit_vpn_connection.connection") + } + connectionId, ok := r.Primary.Attributes["connection_id"] + if !ok { + return "", fmt.Errorf("couldn't find attribute connection_id") + } + gatewayId, ok := r.Primary.Attributes["gateway_id"] + if !ok { + return "", fmt.Errorf("couldn't find attribute gateway_id") + } + return fmt.Sprintf("%s,%s,%s,%s", + testutil.ProjectId, + testutil.Region, + gatewayId, + connectionId, + ), nil + }, + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"tunnel1.pre_shared_key_wo", "tunnel2.pre_shared_key_wo", "tunnel1.pre_shared_key_wo_version", "tunnel2.pre_shared_key_wo_version"}, + }, + }, + }) +} + +func testAccCheckVpnResourcesDestroy(s *terraform.State) error { ctx := context.Background() client, err := vpn.NewAPIClient(testutil.NewConfigBuilder().BuildClientOptions(testutil.VpnCustomEndpoint, false)...) if err != nil { return fmt.Errorf("creating client: %w", err) } - gatewaysToDestroy := []string{} + gatewayIdsToDestroy := []string{} for _, rs := range s.RootModule().Resources { - if rs.Type != "stackit_vpn_gateway" { + var gatewayId string + switch rs.Type { + case "stackit_vpn_gateway": + // gateway terraform ID: "[project_id],[region],[gateway_id]" + parts := strings.Split(rs.Primary.ID, core.Separator) + if len(parts) > 2 { + gatewayId = parts[2] + } else if attrId, ok := rs.Primary.Attributes["gateway_id"]; ok && attrId != "" { + gatewayId = attrId + } + case "stackit_vpn_connection": + // connection terraform ID: "[project_id],[region],[gateway_id],[connection_id]" + parts := strings.Split(rs.Primary.ID, core.Separator) + if len(parts) > 2 { + gatewayId = parts[2] + } else if attrId, ok := rs.Primary.Attributes["gateway_id"]; ok && attrId != "" { + gatewayId = attrId + } + default: + continue + } + if gatewayId == "" { continue } - // gateway terraform ID: "[project_id],[region],[gateway_id]" - gatewayId := strings.Split(rs.Primary.ID, core.Separator)[2] - gatewaysToDestroy = append(gatewaysToDestroy, gatewayId) + if !slices.Contains(gatewayIdsToDestroy, gatewayId) { + gatewayIdsToDestroy = append(gatewayIdsToDestroy, gatewayId) + } + } + + if len(gatewayIdsToDestroy) == 0 { + return nil } gatewaysResp, err := client.DefaultAPI.ListGateways(ctx, testutil.ProjectId, testutil.Region).Execute() if err != nil { - return fmt.Errorf("getting gateways: %w", err) + return fmt.Errorf("listing gateways during CheckDestroy: %w", err) } - gateways := gatewaysResp.Gateways - for _, gateway := range gateways { - if gateway.Id == nil { + for _, gateway := range gatewaysResp.Gateways { + if gateway.Id == nil || !slices.Contains(gatewayIdsToDestroy, *gateway.Id) { continue } - for _, gatewayId := range gatewaysToDestroy { - if *gateway.Id == gatewayId { - err := client.DefaultAPI.DeleteGateway(ctx, testutil.ProjectId, testutil.Region, *gateway.Id).Execute() - if err != nil { - return fmt.Errorf("destroying gateway %s during CheckDestroy: %w", gatewayId, err) + + connectionsResp, err := client.DefaultAPI.ListGatewayConnections(ctx, testutil.ProjectId, testutil.Region, *gateway.Id).Execute() + if err != nil { + return fmt.Errorf("listing connections for gateway %s during CheckDestroy: %w", *gateway.Id, err) + } + for _, conn := range connectionsResp.Connections { + if conn.Id == nil { + continue + } + err := client.DefaultAPI.DeleteGatewayConnection(ctx, testutil.ProjectId, testutil.Region, *gateway.Id, *conn.Id).Execute() + if err != nil { + if oapiErr, ok := errors.AsType[*oapierror.GenericOpenAPIError](err); ok && (oapiErr.StatusCode == http.StatusNotFound || oapiErr.StatusCode == http.StatusGone) { + continue } + return fmt.Errorf("destroying connection %s during CheckDestroy: %w", *conn.Id, err) + } + } + + err = client.DefaultAPI.DeleteGateway(ctx, testutil.ProjectId, testutil.Region, *gateway.Id).Execute() + if err != nil { + if oapiErr, ok := errors.AsType[*oapierror.GenericOpenAPIError](err); ok && (oapiErr.StatusCode == http.StatusNotFound || oapiErr.StatusCode == http.StatusGone) { + continue } + return fmt.Errorf("destroying gateway %s during CheckDestroy: %w", *gateway.Id, err) } } return nil diff --git a/stackit/internal/services/vpn/vpn_test.go b/stackit/internal/services/vpn/vpn_test.go new file mode 100644 index 000000000..0f3d7bc4b --- /dev/null +++ b/stackit/internal/services/vpn/vpn_test.go @@ -0,0 +1,84 @@ +package vpn + +import ( + "fmt" + "regexp" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/testutil" +) + +// Test can't be moved to resource test file because of dependency cycle +func TestConnectionResourceValidationExclusivePresharedKeyFields(t *testing.T) { + config := func(tunnel1PresharedKeyConfig string) string { + return fmt.Sprintf(` + provider "stackit" { + default_region = "eu01" + service_account_token = "mock-server-needs-no-auth" + } + + resource "stackit_vpn_connection" "connection" { + project_id = "4e684f79-a12c-449d-aa89-bcd9d8aafaf2" + gateway_id = "3dee3fb9-59f0-4f97-8eeb-a4da37d05a00" + display_name = "foo" + + tunnel1 = { + remote_address = "203.0.113.1" + %s + phase1 = { + dh_groups = ["ecp384"] + encryption_algorithms = ["aes256"] + integrity_algorithms = ["sha2_384"] + } + phase2 = { + dh_groups = ["ecp384"] + encryption_algorithms = ["aes256"] + integrity_algorithms = ["sha2_384"] + } + } + + tunnel2 = { + remote_address = "203.0.113.2" + pre_shared_key = "secret-345-minimum-20-characters" + phase1 = { + dh_groups = ["ecp384"] + encryption_algorithms = ["aes256"] + integrity_algorithms = ["sha2_384"] + } + phase2 = { + dh_groups = ["ecp384"] + encryption_algorithms = ["aes256"] + integrity_algorithms = ["sha2_384"] + } + } + } +`, tunnel1PresharedKeyConfig) + } + + resource.UnitTest(t, resource.TestCase{ + ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + // FAIL - pre-shared key via write-only field without version + Config: config(`pre_shared_key_wo = "secret-123-minimum-20-characters"`), + ExpectError: regexp.MustCompile("Invalid Attribute Combination"), + }, + { + // FAIL - pre-shared key via legacy field AND write-only field + Config: config( + `pre_shared_key = "secret-123-minimum-20-characters" + pre_shared_key_wo = "secret-123-minimum-20-characters" + pre_shared_key_wo_version = 1 + `), + ExpectError: regexp.MustCompile("Invalid Attribute Combination"), + }, + { + // FAIL - pre-shared key write-only field missing only version set + Config: config(`pre_shared_key_wo_version = 1`), + ExpectError: regexp.MustCompile("Invalid Attribute Combination"), + }, + }, + }) +} diff --git a/stackit/provider.go b/stackit/provider.go index 7c653f474..a195454d8 100644 --- a/stackit/provider.go +++ b/stackit/provider.go @@ -128,6 +128,7 @@ import ( telemetryRouterAccessToken "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/telemetryrouter/accesstoken" telemetryRouterDestination "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/telemetryrouter/destination" telemetryRouterInstance "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/telemetryrouter/instance" + vpnConnection "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/vpn/connection" vpnGateway "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/vpn/gateway" vpnGatewayStatus "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/vpn/gateway_status" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" @@ -758,6 +759,7 @@ func (p *Provider) DataSources(_ context.Context) []func() datasource.DataSource telemetryRouterDestination.NewTelemetryRouterDestinationDataSource, telemetryLink.NewTelemetryLinkDataSource, vpnGateway.NewVPNGatewayDataSource, + vpnConnection.NewVPNConnectionDataSource, vpnGatewayStatus.NewVPNGatewayStatusDataSource, } dataSources = append(dataSources, customRole.NewCustomRoleDataSources()...) @@ -858,6 +860,7 @@ func (p *Provider) Resources(_ context.Context) []func() resource.Resource { telemetryRouterInstance.NewTelemetryRouterInstanceResource, telemetryRouterDestination.NewTelemetryRouterDestinationResource, telemetryLink.NewTelemetryLinkResource, + vpnConnection.NewVpnConnectionResource, vpnGateway.NewGatewayResource, } resources = append(resources, roleAssignements.NewRoleAssignmentResources()...)