From 1c7f643a43ec198407d30dfb28b439cbe2fd131c Mon Sep 17 00:00:00 2001 From: "Inter, Sven" Date: Wed, 10 Jun 2026 18:01:15 +0200 Subject: [PATCH 01/28] feat(vpn): Onboarding VPN Connection relates to STACKITTPR-551 --- docs/data-sources/vpn_connection.md | 151 +++ docs/resources/vpn_connection.md | 212 +++ .../stackit_vpn_connection/data-source.tf | 5 + .../stackit_vpn_connection/resource.tf | 45 + .../services/vpn/connection/datasource.go | 309 +++++ .../services/vpn/connection/resource.go | 1150 +++++++++++++++++ .../services/vpn/connection/resource_test.go | 1054 +++++++++++++++ .../services/vpn/testdata/connection-max.tf | 85 ++ .../services/vpn/testdata/connection-min.tf | 46 + stackit/internal/services/vpn/vpn_acc_test.go | 611 ++++++++- stackit/provider.go | 9 + 11 files changed, 3660 insertions(+), 17 deletions(-) create mode 100644 docs/data-sources/vpn_connection.md create mode 100644 docs/resources/vpn_connection.md create mode 100644 examples/data-sources/stackit_vpn_connection/data-source.tf create mode 100644 examples/resources/stackit_vpn_connection/resource.tf create mode 100644 stackit/internal/services/vpn/connection/datasource.go create mode 100644 stackit/internal/services/vpn/connection/resource.go create mode 100644 stackit/internal/services/vpn/connection/resource_test.go create mode 100644 stackit/internal/services/vpn/testdata/connection-max.tf create mode 100644 stackit/internal/services/vpn/testdata/connection-min.tf diff --git a/docs/data-sources/vpn_connection.md b/docs/data-sources/vpn_connection.md new file mode 100644 index 000000000..48d60845e --- /dev/null +++ b/docs/data-sources/vpn_connection.md @@ -0,0 +1,151 @@ +--- +# 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)) +- `pre_shared_key_wo` (String, Sensitive) +- `pre_shared_key_wo_version` (Number) +- `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)) +- `pre_shared_key_wo` (String, Sensitive) +- `pre_shared_key_wo_version` (Number) +- `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..4ca2d8d28 --- /dev/null +++ b/docs/resources/vpn_connection.md @@ -0,0 +1,212 @@ +--- +# 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) (see [below for nested schema](#nestedatt--tunnel1)) +- `tunnel2` (Attributes) (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)) +- `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. +- `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_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)) +- `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. +- `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_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..e1e4c2c7f --- /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/core" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/vpn/utils" + + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" +) + +var ( + _ datasource.DataSource = (*vpnConnectionDataSource)(nil) + _ datasource.DataSourceWithConfigure = (*vpnConnectionDataSource)(nil) +) + +var datasourceSchemaDescriptions = map[string]string{ + "id": "Terraform's internal resource identifier. Structured as \"`project_id`,`region`,`gateway_id`,`connection_id`\".", + "project_id": "STACKIT project ID.", + "region": "STACKIT region.", + "gateway_id": "The UUID of the parent VPN gateway.", + "connection_id": "The server-generated UUID of the VPN connection.", + "display_name": "A user-friendly name for the connection.", + "enabled": "Whether this connection is enabled.", + "remote_subnet": "List of remote IPv4 CIDRs accessible via this connection.", + "local_subnet": "List of local IPv4 CIDRs to route through this connection.", + "static_routes": "List of static routes (IPv4 CIDRs) for route-based VPN.", + "labels": "Map of custom labels.", +} + +var datasourceTunnelSchemaDescriptions = map[string]string{ + "remote_address": "Remote peer IPv4 address for this tunnel.", + "phase1": "IKE Phase 1 configuration.", + "phase1_dh_groups": "Diffie-Hellman groups.", + "phase1_encryption_algorithms": "Encryption algorithms.", + "phase1_integrity_algorithms": "Integrity/hash algorithms.", + "phase1_rekey_time": "IKE re-keying time in seconds.", + "phase2": "IKE Phase 2 configuration.", + "phase2_dh_groups": "Diffie-Hellman groups for PFS.", + "phase2_encryption_algorithms": "Encryption algorithms.", + "phase2_integrity_algorithms": "Integrity/hash algorithms.", + "phase2_rekey_time": "Child SA re-keying time in seconds.", + "phase2_start_action": "Start action (none or start).", + "phase2_dpd_action": "DPD timeout action (clear or restart).", + "peering": "Tunnel interface peering configuration.", + "peering_local_address": "Local tunnel interface IPv4 address.", + "peering_remote_address": "Remote tunnel interface IPv4 address.", + "bgp": "BGP configuration for this tunnel.", + "bgp_remote_asn": "Remote AS number.", +} + +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 (d *vpnConnectionDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + tunnelSchema := schema.SingleNestedAttribute{ + Computed: true, + Attributes: map[string]schema.Attribute{ + "pre_shared_key_wo": schema.StringAttribute{ + Computed: true, + Sensitive: true, + }, + "pre_shared_key_wo_version": schema.Int64Attribute{ + Computed: true, + }, + "remote_address": schema.StringAttribute{ + Description: datasourceTunnelSchemaDescriptions["remote_address"], + Computed: true, + }, + "phase1": schema.SingleNestedAttribute{ + Description: datasourceTunnelSchemaDescriptions["phase1"], + Computed: true, + Attributes: map[string]schema.Attribute{ + "dh_groups": schema.ListAttribute{ + Description: datasourceTunnelSchemaDescriptions["phase1_dh_groups"], + Computed: true, + ElementType: types.StringType, + }, + "encryption_algorithms": schema.ListAttribute{ + Description: datasourceTunnelSchemaDescriptions["phase1_encryption_algorithms"], + Computed: true, + ElementType: types.StringType, + }, + "integrity_algorithms": schema.ListAttribute{ + Description: datasourceTunnelSchemaDescriptions["phase1_integrity_algorithms"], + Computed: true, + ElementType: types.StringType, + }, + "rekey_time": schema.Int32Attribute{ + Description: datasourceTunnelSchemaDescriptions["phase1_rekey_time"], + Computed: true, + }, + }, + }, + "phase2": schema.SingleNestedAttribute{ + Description: datasourceTunnelSchemaDescriptions["phase2"], + Computed: true, + Attributes: map[string]schema.Attribute{ + "dh_groups": schema.ListAttribute{ + Description: datasourceTunnelSchemaDescriptions["phase2_dh_groups"], + Computed: true, + ElementType: types.StringType, + }, + "encryption_algorithms": schema.ListAttribute{ + Description: datasourceTunnelSchemaDescriptions["phase2_encryption_algorithms"], + Computed: true, + ElementType: types.StringType, + }, + "integrity_algorithms": schema.ListAttribute{ + Description: datasourceTunnelSchemaDescriptions["phase2_integrity_algorithms"], + Computed: true, + ElementType: types.StringType, + }, + "rekey_time": schema.Int32Attribute{ + Description: datasourceTunnelSchemaDescriptions["phase2_rekey_time"], + Computed: true, + }, + "start_action": schema.StringAttribute{ + Description: datasourceTunnelSchemaDescriptions["phase2_start_action"], + Computed: true, + }, + "dpd_action": schema.StringAttribute{ + Description: datasourceTunnelSchemaDescriptions["phase2_dpd_action"], + Computed: true, + }, + }, + }, + "peering": schema.SingleNestedAttribute{ + Description: datasourceTunnelSchemaDescriptions["peering"], + Computed: true, + Attributes: map[string]schema.Attribute{ + "local_address": schema.StringAttribute{ + Description: datasourceTunnelSchemaDescriptions["peering_local_address"], + Computed: true, + }, + "remote_address": schema.StringAttribute{ + Description: datasourceTunnelSchemaDescriptions["peering_remote_address"], + Computed: true, + }, + }, + }, + "bgp": schema.SingleNestedAttribute{ + Description: datasourceTunnelSchemaDescriptions["bgp"], + Computed: true, + Attributes: map[string]schema.Attribute{ + "remote_asn": schema.Int64Attribute{ + Description: datasourceTunnelSchemaDescriptions["bgp_remote_asn"], + Computed: true, + }, + }, + }, + }, + } + + resp.Schema = schema.Schema{ + Description: fmt.Sprintf("VPN Connection data source schema. %s", core.DatasourceRegionFallbackDocstring), + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: datasourceSchemaDescriptions["id"], + Computed: true, + }, + "project_id": schema.StringAttribute{ + Description: datasourceSchemaDescriptions["project_id"], + Required: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "region": schema.StringAttribute{ + Description: datasourceSchemaDescriptions["region"], + Computed: true, + }, + "gateway_id": schema.StringAttribute{ + Description: datasourceSchemaDescriptions["gateway_id"], + Required: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "connection_id": schema.StringAttribute{ + Description: datasourceSchemaDescriptions["connection_id"], + Required: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "display_name": schema.StringAttribute{ + Description: datasourceSchemaDescriptions["display_name"], + Computed: true, + }, + "enabled": schema.BoolAttribute{ + Description: datasourceSchemaDescriptions["enabled"], + Computed: true, + }, + "remote_subnet": schema.ListAttribute{ + Description: datasourceSchemaDescriptions["remote_subnet"], + Computed: true, + ElementType: types.StringType, + }, + "local_subnet": schema.ListAttribute{ + Description: datasourceSchemaDescriptions["local_subnet"], + Computed: true, + ElementType: types.StringType, + }, + "static_routes": schema.ListAttribute{ + Description: datasourceSchemaDescriptions["static_routes"], + Computed: true, + ElementType: types.StringType, + }, + "tunnel1": tunnelSchema, + "tunnel2": tunnelSchema, + "labels": schema.MapAttribute{ + Description: datasourceSchemaDescriptions["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 Model + diags := req.Config.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + ctx = core.InitProviderContext(ctx) + + projectId := model.ProjectID.ValueString() + region := d.providerData.GetRegionWithOverride(model.Region) + gatewayId := model.GatewayID.ValueString() + 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, vpn.Region(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 = mapFields(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, + }) +} diff --git a/stackit/internal/services/vpn/connection/resource.go b/stackit/internal/services/vpn/connection/resource.go new file mode 100644 index 000000000..26d5cf745 --- /dev/null +++ b/stackit/internal/services/vpn/connection/resource.go @@ -0,0 +1,1150 @@ +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/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/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/stackitcloud/stackit-sdk-go/core/oapierror" + vpn "github.com/stackitcloud/stackit-sdk-go/services/vpn/v1api" + + "github.com/stackitcloud/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 Phase1Model 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 Phase2Model 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"` + StartAction types.String `tfsdk:"start_action"` + DpdAction types.String `tfsdk:"dpd_action"` +} + +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 { + PreSharedKeyWo types.String `tfsdk:"pre_shared_key_wo"` + PreSharedKeyWoVersion types.Int64 `tfsdk:"pre_shared_key_wo_version"` + RemoteAddress types.String `tfsdk:"remote_address"` + Phase1 *Phase1Model `tfsdk:"phase1"` + Phase2 *Phase2Model `tfsdk:"phase2"` + Peering *PeeringConfigModel `tfsdk:"peering"` + Bgp *BGPTunnelConfigModel `tfsdk:"bgp"` +} + +type Model 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"` + Tunnel1 *TunnelModel `tfsdk:"tunnel1"` + Tunnel2 *TunnelModel `tfsdk:"tunnel2"` + Labels types.Map `tfsdk:"labels"` +} + +var ( + dhGroupValues = []string{"modp1024", "modp2048", "ecp256", "ecp384", "modp2048s256"} + encryptionAlgorithmValues = []string{"aes256", "aes128gcm16", "aes256gcm16"} + integrityAlgorithmValues = []string{"sha1", "sha2_256", "sha2_384"} + startActionValues = []string{"none", "start"} + dpdActionValues = []string{"clear", "restart"} +) + +var schemaDescriptions = map[string]string{ + "id": "Terraform's internal resource identifier. Structured as \"`project_id`,`region`,`gateway_id`,`connection_id`\".", + "connection_id": "The server-generated UUID of the VPN connection.", + "project_id": "STACKIT project ID.", + "region": "STACKIT region.", + "gateway_id": "The UUID of the parent VPN gateway.", + "display_name": "A user-friendly name for the connection. Must start and end with an alphanumeric character, may contain hyphens, and be 1-63 characters long.", + "enabled": "Whether this connection is enabled. Defaults to true.", + "remote_subnet": "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.", + "local_subnet": "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.", + "static_routes": "List of static routes (IPv4 CIDRs) for route-based VPN. Mandatory for ROUTE_BASED gateways.", + "tunnel1": "Configuration for the first IPsec tunnel.", + "tunnel2": "Configuration for the second IPsec tunnel.", + "labels": "Map of custom labels.", +} + +var tunnelSchemaDescriptions = map[string]string{ + "pre_shared_key_wo": "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": "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.", + "remote_address": "Remote IPv4 address for the tunnel endpoint.", + "phase1_dh_groups": fmt.Sprintf("Diffie-Hellman groups for key exchange. %s", tfutils.FormatPossibleValues(dhGroupValues...)), + "phase1_encryption_algorithms": fmt.Sprintf("Encryption algorithms for Phase 1. %s", tfutils.FormatPossibleValues(encryptionAlgorithmValues...)), + "phase1_integrity_algorithms": fmt.Sprintf("Integrity algorithms for Phase 1. %s", tfutils.FormatPossibleValues(integrityAlgorithmValues...)), + "phase1_rekey_time": "Time to schedule an IKE re-keying in seconds. Range: 900-28800. Default: 14400.", + "phase2_dh_groups": fmt.Sprintf("Diffie-Hellman groups for Phase 2. %s", tfutils.FormatPossibleValues(dhGroupValues...)), + "phase2_encryption_algorithms": fmt.Sprintf("Encryption algorithms for Phase 2. %s", tfutils.FormatPossibleValues(encryptionAlgorithmValues...)), + "phase2_integrity_algorithms": fmt.Sprintf("Integrity algorithms for Phase 2. %s", tfutils.FormatPossibleValues(integrityAlgorithmValues...)), + "phase2_rekey_time": "Time to schedule a Child SA re-keying in seconds. Range: 900-3600. Default: 3600.", + "phase2_start_action": fmt.Sprintf("Action to perform after loading the connection configuration. Default: 'start'. %s", tfutils.FormatPossibleValues(startActionValues...)), + "phase2_dpd_action": fmt.Sprintf("Action to perform on DPD timeout. Default: 'restart'. %s", tfutils.FormatPossibleValues(dpdActionValues...)), + "peering_local_address": "Local tunnel interface IPv4 address.", + "peering_remote_address": "Remote tunnel interface IPv4 address.", + "bgp_remote_asn": "Remote ASN for BGP peering (private ASN range, 64512-4294967294).", +} + +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 := schema.SingleNestedAttribute{ + Required: true, + Attributes: map[string]schema.Attribute{ + "pre_shared_key_wo": schema.StringAttribute{ + Description: tunnelSchemaDescriptions["pre_shared_key_wo"], + Required: true, + Sensitive: true, + WriteOnly: true, + Validators: []validator.String{ + stringvalidator.LengthAtLeast(20), + }, + }, + "pre_shared_key_wo_version": schema.Int64Attribute{ + Description: tunnelSchemaDescriptions["pre_shared_key_wo_version"], + Optional: true, + }, + "remote_address": schema.StringAttribute{ + Description: tunnelSchemaDescriptions["remote_address"], + Required: true, + Validators: []validator.String{ + validate.IP(true), + }, + }, + "phase1": schema.SingleNestedAttribute{ + Required: true, + Attributes: map[string]schema.Attribute{ + "dh_groups": schema.ListAttribute{ + Description: tunnelSchemaDescriptions["phase1_dh_groups"], + Optional: true, + ElementType: types.StringType, + Validators: []validator.List{ + listvalidator.ValueStringsAre( + stringvalidator.OneOf(dhGroupValues...), + ), + }, + }, + "encryption_algorithms": schema.ListAttribute{ + Description: tunnelSchemaDescriptions["phase1_encryption_algorithms"], + Required: true, + ElementType: types.StringType, + Validators: []validator.List{ + listvalidator.ValueStringsAre( + stringvalidator.OneOf(encryptionAlgorithmValues...), + ), + }, + }, + "integrity_algorithms": schema.ListAttribute{ + Description: tunnelSchemaDescriptions["phase1_integrity_algorithms"], + Required: true, + ElementType: types.StringType, + Validators: []validator.List{ + listvalidator.ValueStringsAre( + stringvalidator.OneOf(integrityAlgorithmValues...), + ), + }, + }, + "rekey_time": schema.Int32Attribute{ + Description: tunnelSchemaDescriptions["phase1_rekey_time"], + 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: tunnelSchemaDescriptions["phase2_dh_groups"], + Optional: true, + ElementType: types.StringType, + Validators: []validator.List{ + listvalidator.ValueStringsAre( + stringvalidator.OneOf(dhGroupValues...), + ), + }, + }, + "encryption_algorithms": schema.ListAttribute{ + Description: tunnelSchemaDescriptions["phase2_encryption_algorithms"], + Required: true, + ElementType: types.StringType, + Validators: []validator.List{ + listvalidator.ValueStringsAre( + stringvalidator.OneOf(encryptionAlgorithmValues...), + ), + }, + }, + "integrity_algorithms": schema.ListAttribute{ + Description: tunnelSchemaDescriptions["phase2_integrity_algorithms"], + Required: true, + ElementType: types.StringType, + Validators: []validator.List{ + listvalidator.ValueStringsAre( + stringvalidator.OneOf(integrityAlgorithmValues...), + ), + }, + }, + "rekey_time": schema.Int32Attribute{ + Description: tunnelSchemaDescriptions["phase2_rekey_time"], + Optional: true, + Computed: true, + Validators: []validator.Int32{ + int32validator.Between(900, 3600), + }, + }, + "start_action": schema.StringAttribute{ + Description: tunnelSchemaDescriptions["phase2_start_action"], + Optional: true, + Computed: true, + Validators: []validator.String{ + stringvalidator.OneOf(startActionValues...), + }, + }, + "dpd_action": schema.StringAttribute{ + Description: tunnelSchemaDescriptions["phase2_dpd_action"], + 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: tunnelSchemaDescriptions["peering_local_address"], + Required: true, + Validators: []validator.String{ + validate.IP(true), + }, + }, + "remote_address": schema.StringAttribute{ + Description: tunnelSchemaDescriptions["peering_remote_address"], + Required: true, + Validators: []validator.String{ + validate.IP(true), + }, + }, + }, + }, + "bgp": schema.SingleNestedAttribute{ + Optional: true, + Attributes: map[string]schema.Attribute{ + "remote_asn": schema.Int64Attribute{ + Description: tunnelSchemaDescriptions["bgp_remote_asn"], + 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: schemaDescriptions["id"], + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "connection_id": schema.StringAttribute{ + Description: schemaDescriptions["connection_id"], + Computed: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "project_id": schema.StringAttribute{ + Description: schemaDescriptions["project_id"], + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "region": schema.StringAttribute{ + Description: schemaDescriptions["region"], + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "gateway_id": schema.StringAttribute{ + Description: schemaDescriptions["gateway_id"], + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "display_name": schema.StringAttribute{ + Description: schemaDescriptions["display_name"], + Required: true, + Validators: []validator.String{ + stringvalidator.RegexMatches( + regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$`), + "must start and end with an alphanumeric character, may contain hyphens, and be 1-63 characters long", + ), + }, + }, + "enabled": schema.BoolAttribute{ + Description: schemaDescriptions["enabled"], + Optional: true, + Computed: true, + Default: booldefault.StaticBool(true), + }, + "remote_subnet": schema.ListAttribute{ + Description: schemaDescriptions["remote_subnet"], + Optional: true, + Computed: true, + ElementType: types.StringType, + Validators: []validator.List{ + listvalidator.SizeBetween(1, 100), + listvalidator.ValueStringsAre(validate.CIDR()), + }, + }, + "local_subnet": schema.ListAttribute{ + Description: schemaDescriptions["local_subnet"], + Optional: true, + Computed: true, + ElementType: types.StringType, + Validators: []validator.List{ + listvalidator.SizeBetween(1, 100), + listvalidator.ValueStringsAre(validate.CIDR()), + }, + }, + "static_routes": schema.ListAttribute{ + Description: schemaDescriptions["static_routes"], + Optional: true, + ElementType: types.StringType, + Validators: []validator.List{ + listvalidator.ValueStringsAre(validate.CIDR()), + }, + }, + "tunnel1": tunnelSchema, + "tunnel2": tunnelSchema, + "labels": schema.MapAttribute{ + Description: schemaDescriptions["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 + var model Model + diags := req.Plan.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + var configModel Model + diags = req.Config.Get(ctx, &configModel) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + model.Tunnel1.PreSharedKeyWo = configModel.Tunnel1.PreSharedKeyWo + model.Tunnel2.PreSharedKeyWo = configModel.Tunnel2.PreSharedKeyWo + + ctx = core.InitProviderContext(ctx) + + projectId := model.ProjectID.ValueString() + region := r.providerData.GetRegionWithOverride(model.Region) + gatewayId := model.GatewayID.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "region", region) + ctx = tflog.SetField(ctx, "gateway_id", gatewayId) + + payload, err := toCreatePayload(ctx, &model) + 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, vpn.Region(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) + + if createResp.Id == nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating VPN connection", "Got empty connection id") + return + } + connectionId := *createResp.Id + + ctx = tfutils.SetAndLogStateFields(ctx, &resp.Diagnostics, &resp.State, map[string]any{ + "project_id": projectId, + "region": region, + "gateway_id": gatewayId, + "connection_id": connectionId, + }) + if resp.Diagnostics.HasError() { + return + } + + connResp, err := r.client.DefaultAPI.GetGatewayConnection(ctx, projectId, vpn.Region(region), gatewayId, connectionId).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating VPN connection", fmt.Sprintf("Reading created connection: %v", err)) + return + } + + err = mapFields(ctx, connResp, &model, 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, model) + 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, vpn.Region(region), gatewayId, connectionId).Execute() + if err != nil { + oapiErr, ok := err.(*oapierror.GenericOpenAPIError) //nolint:errorlint //complaining that error.As should be used to catch wrapped errors, but this error should not be wrapped + if 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 = mapFields(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 + var model Model + diags := req.Plan.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + var configModel Model + diags = req.Config.Get(ctx, &configModel) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + var stateModel Model + diags = req.State.Get(ctx, &stateModel) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // tunnel1 PSK rotation + if !model.Tunnel1.PreSharedKeyWoVersion.IsNull() { + pv := model.Tunnel1.PreSharedKeyWoVersion.ValueInt64() + sv := stateModel.Tunnel1.PreSharedKeyWoVersion.ValueInt64() + if pv < sv { + resp.Diagnostics.AddAttributeError( + path.Root("tunnel1").AtName("pre_shared_key_wo_version"), + "Version must not decrease", + fmt.Sprintf( + "`pre_shared_key_wo_version` must be incremented to rotate the pre-shared key, got %d (current: %d).", + pv, sv, + ), + ) + return + } + if pv > sv { + // Secret must be read from Config, not Plan — write-only values are always null in plan. + model.Tunnel1.PreSharedKeyWo = configModel.Tunnel1.PreSharedKeyWo + } + } + + // tunnel2 PSK rotation + if !model.Tunnel2.PreSharedKeyWoVersion.IsNull() { + pv := model.Tunnel2.PreSharedKeyWoVersion.ValueInt64() + sv := stateModel.Tunnel2.PreSharedKeyWoVersion.ValueInt64() + if pv < sv { + resp.Diagnostics.AddAttributeError( + path.Root("tunnel2").AtName("pre_shared_key_wo_version"), + "Version must not decrease", + fmt.Sprintf( + "`pre_shared_key_wo_version` must be incremented to rotate the pre-shared key, got %d (current: %d).", + pv, sv, + ), + ) + return + } + if pv > sv { + model.Tunnel2.PreSharedKeyWo = configModel.Tunnel2.PreSharedKeyWo + } + } + + 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) + + payload, err := toUpdatePayload(ctx, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating VPN connection", fmt.Sprintf("Creating API payload: %v", err)) + return + } + + _, err = r.client.DefaultAPI.UpdateGatewayConnection(ctx, projectId, vpn.Region(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) + + connResp, err := r.client.DefaultAPI.GetGatewayConnection(ctx, projectId, vpn.Region(region), gatewayId, connectionId).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating VPN connection", fmt.Sprintf("Reading updated connection: %v", err)) + return + } + + err = mapFields(ctx, connResp, &model, 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, model) + 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, vpn.Region(region), gatewayId, connectionId).Execute() + if err != nil { + var oapiErr *oapierror.GenericOpenAPIError + if errors.As(err, &oapiErr) && oapiErr.StatusCode == http.StatusNotFound { + resp.State.RemoveResource(ctx) + return + } + core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting VPN connection", fmt.Sprintf("Calling API: %v", err)) + return + } + + ctx = core.LogResponse(ctx) + tflog.Info(ctx, "VPN connection deleted") +} + +func toCreatePayload(_ context.Context, model *Model) (*vpn.CreateGatewayConnectionPayload, error) { + if model == nil { + return nil, fmt.Errorf("nil model") + } + + fields, err := toConnectionFields(model) + if err != nil { + return nil, err + } + + // The spec's Connection schema `#/components/schemas/Connection` (used for create) has no labels field, + // unlike ConnectionResponse which does. The API does accept labels on create despite the omission, but because + // CreateGatewayConnectionPayload is generated from Connection, there is no Labels field on the + // struct. Once the spec is corrected and the SDK regenerated, uncomment the block below. + + // nolint:gocritic // commented-out code (blocked by spec/SDK gap — see comment above) + // labels, err := tfutils.LabelsToPayload(ctx, model.Labels) + // if err != nil { + // return nil, err + // } + + return &vpn.CreateGatewayConnectionPayload{ + DisplayName: fields.displayName, + Tunnel1: fields.tunnel1, + Tunnel2: fields.tunnel2, + Enabled: fields.enabled, + RemoteSubnets: fields.remoteSubnets, + LocalSubnets: fields.localSubnets, + StaticRoutes: fields.staticRoutes, + // Labels: &labels, // blocked by spec/SDK gap — see comment above + }, nil +} + +func toUpdatePayload(_ context.Context, model *Model) (*vpn.UpdateGatewayConnectionPayload, error) { + if model == nil { + return nil, fmt.Errorf("nil model") + } + + fields, err := toConnectionFields(model) + if err != nil { + return nil, err + } + + // The spec's Connection schema `#/components/schemas/Connection` (used for update) has no labels field, + // unlike ConnectionResponse which does. The API does accept labels on update despite the omission, but because + // UpdateGatewayConnectionPayload is generated from Connection, there is no Labels field on the + // struct. Once the spec is corrected and the SDK regenerated, uncomment the block below. + + // nolint:gocritic // commented-out code (blocked by spec/SDK gap — see comment above) + // labels, err := tfutils.LabelsToPayload(ctx, model.Labels) + // if err != nil { + // return nil, err + // } + + return &vpn.UpdateGatewayConnectionPayload{ + DisplayName: fields.displayName, + Tunnel1: fields.tunnel1, + Tunnel2: fields.tunnel2, + Enabled: fields.enabled, + RemoteSubnets: fields.remoteSubnets, + LocalSubnets: fields.localSubnets, + StaticRoutes: fields.staticRoutes, + // Labels: &labels, // blocked by spec/SDK gap — see comment above + }, nil +} + +type connectionFields struct { + displayName string + tunnel1 vpn.TunnelConfiguration + tunnel2 vpn.TunnelConfiguration + enabled *bool + remoteSubnets []string + localSubnets []string + staticRoutes []string +} + +func toConnectionFields(model *Model) (*connectionFields, error) { + tunnel1, err := toTunnelConfiguration(model.Tunnel1) + if err != nil { + return nil, fmt.Errorf("converting tunnel1: %w", err) + } + + tunnel2, err := toTunnelConfiguration(model.Tunnel2) + if err != nil { + return nil, fmt.Errorf("converting tunnel2: %w", err) + } + + fields := &connectionFields{ + displayName: model.DisplayName.ValueString(), + tunnel1: *tunnel1, + tunnel2: *tunnel2, + } + + if !model.Enabled.IsNull() && !model.Enabled.IsUnknown() { + enabled := model.Enabled.ValueBool() + fields.enabled = &enabled + } + + if !model.RemoteSubnet.IsNull() && !model.RemoteSubnet.IsUnknown() { + remoteSubnets, err := tfutils.ListValueToStringSlice(model.RemoteSubnet) + if err != nil { + return nil, fmt.Errorf("converting remote_subnet: %w", err) + } + fields.remoteSubnets = remoteSubnets + } + + if !model.LocalSubnet.IsNull() && !model.LocalSubnet.IsUnknown() { + localSubnets, err := tfutils.ListValueToStringSlice(model.LocalSubnet) + if err != nil { + return nil, fmt.Errorf("converting local_subnet: %w", err) + } + fields.localSubnets = localSubnets + } + + if !model.StaticRoutes.IsNull() { + staticRoutes, err := tfutils.ListValueToStringSlice(model.StaticRoutes) + if err != nil { + return nil, fmt.Errorf("converting static_routes: %w", err) + } + fields.staticRoutes = staticRoutes + } + + return fields, nil +} + +func toTunnelConfiguration(tunnel *TunnelModel) (*vpn.TunnelConfiguration, error) { + if tunnel == nil { + return nil, fmt.Errorf("nil tunnel model") + } + + config := &vpn.TunnelConfiguration{ + RemoteAddress: tunnel.RemoteAddress.ValueString(), + } + if !tunnel.PreSharedKeyWo.IsNull() { + preSharedKey := tunnel.PreSharedKeyWo.ValueString() + config.PreSharedKey = &preSharedKey + } + + if tunnel.Phase1 != nil { + phase1 := vpn.TunnelConfigurationPhase1{} + if !tunnel.Phase1.DhGroups.IsNull() { + dhGroups, err := tfutils.ListValueToStringSlice(tunnel.Phase1.DhGroups) + if err != nil { + return nil, fmt.Errorf("converting phase1 dh_groups: %w", err) + } + phase1.DhGroups = dhGroups + } + encAlgs, err := tfutils.ListValueToStringSlice(tunnel.Phase1.EncryptionAlgorithms) + if err != nil { + return nil, fmt.Errorf("converting phase1 encryption_algorithms: %w", err) + } + phase1.EncryptionAlgorithms = encAlgs + intAlgs, err := tfutils.ListValueToStringSlice(tunnel.Phase1.IntegrityAlgorithms) + if err != nil { + return nil, fmt.Errorf("converting phase1 integrity_algorithms: %w", err) + } + phase1.IntegrityAlgorithms = intAlgs + if !tunnel.Phase1.RekeyTime.IsNull() && !tunnel.Phase1.RekeyTime.IsUnknown() { + rekeyTime := tunnel.Phase1.RekeyTime.ValueInt32() + phase1.RekeyTime = &rekeyTime + } + config.Phase1 = phase1 + } + + if tunnel.Phase2 != nil { + phase2 := vpn.TunnelConfigurationPhase2{} + if !tunnel.Phase2.DhGroups.IsNull() { + dhGroups, err := tfutils.ListValueToStringSlice(tunnel.Phase2.DhGroups) + if err != nil { + return nil, fmt.Errorf("converting phase2 dh_groups: %w", err) + } + phase2.DhGroups = dhGroups + } + encAlgs, err := tfutils.ListValueToStringSlice(tunnel.Phase2.EncryptionAlgorithms) + if err != nil { + return nil, fmt.Errorf("converting phase2 encryption_algorithms: %w", err) + } + phase2.EncryptionAlgorithms = encAlgs + intAlgs, err := tfutils.ListValueToStringSlice(tunnel.Phase2.IntegrityAlgorithms) + if err != nil { + return nil, fmt.Errorf("converting phase2 integrity_algorithms: %w", err) + } + phase2.IntegrityAlgorithms = intAlgs + if !tunnel.Phase2.RekeyTime.IsNull() && !tunnel.Phase2.RekeyTime.IsUnknown() { + rekeyTime := tunnel.Phase2.RekeyTime.ValueInt32() + phase2.RekeyTime = &rekeyTime + } + if !tunnel.Phase2.StartAction.IsNull() && !tunnel.Phase2.StartAction.IsUnknown() { + startAction := tunnel.Phase2.StartAction.ValueString() + phase2.StartAction = &startAction + } + if !tunnel.Phase2.DpdAction.IsNull() && !tunnel.Phase2.DpdAction.IsUnknown() { + dpdAction := tunnel.Phase2.DpdAction.ValueString() + phase2.DpdAction = &dpdAction + } + config.Phase2 = phase2 + } + + if tunnel.Peering != nil { + localAddr := tunnel.Peering.LocalAddress.ValueString() + remoteAddr := tunnel.Peering.RemoteAddress.ValueString() + config.Peering = &vpn.PeeringConfig{ + LocalAddress: &localAddr, + RemoteAddress: &remoteAddr, + } + } + + if tunnel.Bgp != nil { + remoteAsn := tunnel.Bgp.RemoteAsn.ValueInt64() + config.Bgp = &vpn.BGPTunnelConfig{ + RemoteAsn: remoteAsn, + } + } + + return config, nil +} + +func mapFields(ctx context.Context, conn *vpn.ConnectionResponse, model *Model, 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 conn.Id != nil { + connectionId = *conn.Id + } 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.DisplayName) + model.Region = types.StringValue(region) + + if conn.Enabled != nil { + model.Enabled = types.BoolValue(*conn.Enabled) + } else { + model.Enabled = types.BoolValue(true) + } + + if conn.RemoteSubnets != nil { + list, diags := types.ListValueFrom(ctx, types.StringType, conn.RemoteSubnets) + if diags.HasError() { + return fmt.Errorf("mapping remote_subnet: %w", core.DiagsToError(diags)) + } + model.RemoteSubnet = list + } else { + model.RemoteSubnet = types.ListNull(types.StringType) + } + + if conn.LocalSubnets != nil { + list, diags := types.ListValueFrom(ctx, types.StringType, conn.LocalSubnets) + if diags.HasError() { + return fmt.Errorf("mapping local_subnet: %w", core.DiagsToError(diags)) + } + model.LocalSubnet = list + } else { + model.LocalSubnet = types.ListNull(types.StringType) + } + + if conn.StaticRoutes != nil { + list, diags := types.ListValueFrom(ctx, types.StringType, conn.StaticRoutes) + if diags.HasError() { + return fmt.Errorf("mapping static_routes: %w", core.DiagsToError(diags)) + } + model.StaticRoutes = list + } else { + model.StaticRoutes = types.ListNull(types.StringType) + } + + tunnel1, err := mapTunnel(ctx, &conn.Tunnel1, model.Tunnel1) + if err != nil { + return fmt.Errorf("mapping tunnel1: %w", err) + } + model.Tunnel1 = tunnel1 + + tunnel2, err := mapTunnel(ctx, &conn.Tunnel2, model.Tunnel2) + if err != nil { + return fmt.Errorf("mapping tunnel2: %w", err) + } + model.Tunnel2 = tunnel2 + + labels, err := tfutils.MapLabels(ctx, conn.Labels, model.Labels) + if err != nil { + return fmt.Errorf("mapping labels: %w", err) + } + model.Labels = labels + + return nil +} + +func mapTunnel(ctx context.Context, apiTunnel *vpn.TunnelConfiguration, cuurrentTunnel *TunnelModel) (*TunnelModel, error) { + tunnel := &TunnelModel{ + RemoteAddress: types.StringValue(string(apiTunnel.RemoteAddress)), + } + phase1 := &Phase1Model{} + if len(apiTunnel.Phase1.DhGroups) > 0 { + list, diags := types.ListValueFrom(ctx, types.StringType, apiTunnel.Phase1.DhGroups) + if diags.HasError() { + return nil, fmt.Errorf("mapping phase1 dh_groups: %w", core.DiagsToError(diags)) + } + phase1.DhGroups = list + } else { + phase1.DhGroups = types.ListNull(types.StringType) + } + if len(apiTunnel.Phase1.EncryptionAlgorithms) > 0 { + list, diags := types.ListValueFrom(ctx, types.StringType, apiTunnel.Phase1.EncryptionAlgorithms) + if diags.HasError() { + return nil, fmt.Errorf("mapping phase1 encryption_algorithms: %w", core.DiagsToError(diags)) + } + phase1.EncryptionAlgorithms = list + } else { + phase1.EncryptionAlgorithms = types.ListNull(types.StringType) + } + if len(apiTunnel.Phase1.IntegrityAlgorithms) > 0 { + list, diags := types.ListValueFrom(ctx, types.StringType, apiTunnel.Phase1.IntegrityAlgorithms) + if diags.HasError() { + return nil, fmt.Errorf("mapping phase1 integrity_algorithms: %w", core.DiagsToError(diags)) + } + phase1.IntegrityAlgorithms = list + } else { + phase1.IntegrityAlgorithms = types.ListNull(types.StringType) + } + if apiTunnel.Phase1.RekeyTime != nil { + phase1.RekeyTime = types.Int32Value(*apiTunnel.Phase1.RekeyTime) + } else { + phase1.RekeyTime = types.Int32Null() + } + tunnel.Phase1 = phase1 + + phase2 := &Phase2Model{} + if len(apiTunnel.Phase2.DhGroups) > 0 { + list, diags := types.ListValueFrom(ctx, types.StringType, apiTunnel.Phase2.DhGroups) + if diags.HasError() { + return nil, fmt.Errorf("mapping phase2 dh_groups: %w", core.DiagsToError(diags)) + } + phase2.DhGroups = list + } else { + phase2.DhGroups = types.ListNull(types.StringType) + } + if len(apiTunnel.Phase2.EncryptionAlgorithms) > 0 { + list, diags := types.ListValueFrom(ctx, types.StringType, apiTunnel.Phase2.EncryptionAlgorithms) + if diags.HasError() { + return nil, fmt.Errorf("mapping phase2 encryption_algorithms: %w", core.DiagsToError(diags)) + } + phase2.EncryptionAlgorithms = list + } else { + phase2.EncryptionAlgorithms = types.ListNull(types.StringType) + } + if len(apiTunnel.Phase2.IntegrityAlgorithms) > 0 { + list, diags := types.ListValueFrom(ctx, types.StringType, apiTunnel.Phase2.IntegrityAlgorithms) + if diags.HasError() { + return nil, fmt.Errorf("mapping phase2 integrity_algorithms: %w", core.DiagsToError(diags)) + } + phase2.IntegrityAlgorithms = list + } else { + phase2.IntegrityAlgorithms = types.ListNull(types.StringType) + } + if apiTunnel.Phase2.RekeyTime != nil { + phase2.RekeyTime = types.Int32Value(*apiTunnel.Phase2.RekeyTime) + } else { + phase2.RekeyTime = types.Int32Null() + } + if apiTunnel.Phase2.StartAction != nil { + phase2.StartAction = types.StringValue(*apiTunnel.Phase2.StartAction) + } else { + phase2.StartAction = types.StringNull() + } + if apiTunnel.Phase2.DpdAction != nil { + phase2.DpdAction = types.StringValue(*apiTunnel.Phase2.DpdAction) + } else { + phase2.DpdAction = types.StringNull() + } + tunnel.Phase2 = phase2 + + if apiTunnel.Peering != nil { + peering := &PeeringConfigModel{} + if apiTunnel.Peering.LocalAddress != nil { + peering.LocalAddress = types.StringValue(*apiTunnel.Peering.LocalAddress) + } else { + peering.LocalAddress = types.StringNull() + } + if apiTunnel.Peering.RemoteAddress != nil { + peering.RemoteAddress = types.StringValue(*apiTunnel.Peering.RemoteAddress) + } else { + peering.RemoteAddress = types.StringNull() + } + tunnel.Peering = peering + } + + if apiTunnel.Bgp != nil { + tunnel.Bgp = &BGPTunnelConfigModel{ + RemoteAsn: types.Int64Value(int64(apiTunnel.Bgp.RemoteAsn)), + } + } + + // could be nil for Read after a terraform import + if cuurrentTunnel != nil { + tunnel.PreSharedKeyWoVersion = cuurrentTunnel.PreSharedKeyWoVersion + } else { + tunnel.PreSharedKeyWoVersion = types.Int64Null() + } + + return tunnel, 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..0477ffced --- /dev/null +++ b/stackit/internal/services/vpn/connection/resource_test.go @@ -0,0 +1,1054 @@ +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" +) + +var ( + projectId = uuid.NewString() + gatewayId = uuid.NewString() + region = "eu01" +) + +func TestMapFields(t *testing.T) { + tests := []struct { + description string + input *vpn.ConnectionResponse + expected Model + isValid bool + }{ + { + description: "basic_connection", + input: &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"}, + Tunnel1: vpn.TunnelConfiguration{ + RemoteAddress: "203.0.113.1", + Phase1: vpn.TunnelConfigurationPhase1{ + DhGroups: []string{"modp2048"}, + EncryptionAlgorithms: []string{"aes256"}, + IntegrityAlgorithms: []string{"sha2_256"}, + RekeyTime: new(int32(14400)), + }, + Phase2: vpn.TunnelConfigurationPhase2{ + DhGroups: []string{"modp2048"}, + EncryptionAlgorithms: []string{"aes256"}, + IntegrityAlgorithms: []string{"sha2_256"}, + RekeyTime: new(int32(3600)), + StartAction: new("start"), + DpdAction: new("restart"), + }, + }, + Tunnel2: vpn.TunnelConfiguration{ + RemoteAddress: "203.0.113.2", + Phase1: vpn.TunnelConfigurationPhase1{ + DhGroups: []string{"modp2048"}, + EncryptionAlgorithms: []string{"aes256"}, + IntegrityAlgorithms: []string{"sha2_256"}, + RekeyTime: new(int32(14400)), + }, + Phase2: vpn.TunnelConfigurationPhase2{ + DhGroups: []string{"modp2048"}, + EncryptionAlgorithms: []string{"aes256"}, + IntegrityAlgorithms: []string{"sha2_256"}, + RekeyTime: new(int32(3600)), + StartAction: new("start"), + DpdAction: new("restart"), + }, + }, + }, + expected: Model{ + 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.ListNull(types.StringType), + Tunnel1: &TunnelModel{ + PreSharedKeyWoVersion: types.Int64Value(1), + RemoteAddress: types.StringValue("203.0.113.1"), + Phase1: &Phase1Model{ + 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), + }, + Phase2: &Phase2Model{ + 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(3600), + StartAction: types.StringValue("start"), + DpdAction: types.StringValue("restart"), + }, + }, + Tunnel2: &TunnelModel{ + PreSharedKeyWoVersion: types.Int64Value(1), + RemoteAddress: types.StringValue("203.0.113.2"), + Phase1: &Phase1Model{ + 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), + }, + Phase2: &Phase2Model{ + 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(3600), + StartAction: types.StringValue("start"), + DpdAction: types.StringValue("restart"), + }, + }, + Labels: types.MapNull(types.StringType), + }, + isValid: true, + }, + { + description: "connection_with_static_routes_and_bgp", + input: &vpn.ConnectionResponse{ + Id: new("conn-id-2"), + DisplayName: "bgp-connection", + Enabled: new(false), + StaticRoutes: []string{"10.0.0.0/8"}, + Tunnel1: vpn.TunnelConfiguration{ + RemoteAddress: "203.0.113.10", + Phase1: vpn.TunnelConfigurationPhase1{ + EncryptionAlgorithms: []string{"aes256gcm16"}, + IntegrityAlgorithms: []string{"sha2_384"}, + }, + Phase2: vpn.TunnelConfigurationPhase2{ + EncryptionAlgorithms: []string{"aes256gcm16"}, + IntegrityAlgorithms: []string{"sha2_384"}, + DpdAction: new("clear"), + StartAction: new("none"), + }, + Peering: &vpn.PeeringConfig{ + LocalAddress: new("169.254.0.1"), + RemoteAddress: new("169.254.0.2"), + }, + Bgp: &vpn.BGPTunnelConfig{ + RemoteAsn: 65000, + }, + }, + Tunnel2: vpn.TunnelConfiguration{ + RemoteAddress: "203.0.113.11", + Phase1: vpn.TunnelConfigurationPhase1{ + EncryptionAlgorithms: []string{"aes256gcm16"}, + IntegrityAlgorithms: []string{"sha2_384"}, + }, + Phase2: vpn.TunnelConfigurationPhase2{ + EncryptionAlgorithms: []string{"aes256gcm16"}, + IntegrityAlgorithms: []string{"sha2_384"}, + DpdAction: new("clear"), + StartAction: new("none"), + }, + }, + }, + expected: Model{ + ID: types.StringValue(fmt.Sprintf("%s,%s,%s,%s", projectId, region, gatewayId, "conn-id-2")), + ConnectionID: types.StringValue("conn-id-2"), + ProjectID: types.StringValue(projectId), + Region: types.StringValue(region), + GatewayID: types.StringValue(gatewayId), + DisplayName: types.StringValue("bgp-connection"), + Enabled: types.BoolValue(false), + RemoteSubnet: types.ListNull(types.StringType), + LocalSubnet: types.ListNull(types.StringType), + StaticRoutes: types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("10.0.0.0/8"), + }), + Tunnel1: &TunnelModel{ + PreSharedKeyWoVersion: types.Int64Value(1), + RemoteAddress: types.StringValue("203.0.113.10"), + Phase1: &Phase1Model{ + DhGroups: types.ListNull(types.StringType), + EncryptionAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("aes256gcm16")}), + IntegrityAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("sha2_384")}), + RekeyTime: types.Int32Null(), + }, + Phase2: &Phase2Model{ + DhGroups: types.ListNull(types.StringType), + EncryptionAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("aes256gcm16")}), + IntegrityAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("sha2_384")}), + RekeyTime: types.Int32Null(), + StartAction: types.StringValue("none"), + DpdAction: types.StringValue("clear"), + }, + Peering: &PeeringConfigModel{ + LocalAddress: types.StringValue("169.254.0.1"), + RemoteAddress: types.StringValue("169.254.0.2"), + }, + Bgp: &BGPTunnelConfigModel{ + RemoteAsn: types.Int64Value(65000), + }, + }, + Tunnel2: &TunnelModel{ + PreSharedKeyWoVersion: types.Int64Value(1), + RemoteAddress: types.StringValue("203.0.113.11"), + Phase1: &Phase1Model{ + DhGroups: types.ListNull(types.StringType), + EncryptionAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("aes256gcm16")}), + IntegrityAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("sha2_384")}), + RekeyTime: types.Int32Null(), + }, + Phase2: &Phase2Model{ + DhGroups: types.ListNull(types.StringType), + EncryptionAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("aes256gcm16")}), + IntegrityAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("sha2_384")}), + RekeyTime: types.Int32Null(), + StartAction: types.StringValue("none"), + DpdAction: types.StringValue("clear"), + }, + }, + Labels: types.MapNull(types.StringType), + }, + isValid: true, + }, + { + description: "multiple_static_routes", + input: &vpn.ConnectionResponse{ + Id: new("conn-id-3"), + DisplayName: "static-routes-connection", + StaticRoutes: []string{"10.0.0.0/8", "172.16.0.0/12"}, + Tunnel1: vpn.TunnelConfiguration{ + RemoteAddress: "1.2.3.4", + Phase1: vpn.TunnelConfigurationPhase1{ + EncryptionAlgorithms: []string{"aes256"}, + IntegrityAlgorithms: []string{"sha2_256"}, + }, + Phase2: vpn.TunnelConfigurationPhase2{ + EncryptionAlgorithms: []string{"aes256"}, + IntegrityAlgorithms: []string{"sha2_256"}, + }, + }, + Tunnel2: vpn.TunnelConfiguration{ + RemoteAddress: "5.6.7.8", + Phase1: vpn.TunnelConfigurationPhase1{ + EncryptionAlgorithms: []string{"aes256"}, + IntegrityAlgorithms: []string{"sha2_256"}, + }, + Phase2: vpn.TunnelConfigurationPhase2{ + EncryptionAlgorithms: []string{"aes256"}, + IntegrityAlgorithms: []string{"sha2_256"}, + }, + }, + }, + expected: Model{ + ID: types.StringValue(fmt.Sprintf("%s,%s,%s,%s", projectId, region, gatewayId, "conn-id-3")), + ConnectionID: types.StringValue("conn-id-3"), + ProjectID: types.StringValue(projectId), + Region: types.StringValue(region), + GatewayID: types.StringValue(gatewayId), + DisplayName: types.StringValue("static-routes-connection"), + Enabled: types.BoolValue(true), + RemoteSubnet: types.ListNull(types.StringType), + LocalSubnet: types.ListNull(types.StringType), + StaticRoutes: types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("10.0.0.0/8"), + types.StringValue("172.16.0.0/12"), + }), + Tunnel1: &TunnelModel{ + PreSharedKeyWoVersion: types.Int64Value(1), + RemoteAddress: types.StringValue("1.2.3.4"), + Phase1: &Phase1Model{ + DhGroups: types.ListNull(types.StringType), + EncryptionAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("aes256")}), + IntegrityAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("sha2_256")}), + RekeyTime: types.Int32Null(), + }, + Phase2: &Phase2Model{ + DhGroups: types.ListNull(types.StringType), + EncryptionAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("aes256")}), + IntegrityAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("sha2_256")}), + RekeyTime: types.Int32Null(), + StartAction: types.StringNull(), + DpdAction: types.StringNull(), + }, + }, + Tunnel2: &TunnelModel{ + PreSharedKeyWoVersion: types.Int64Value(1), + RemoteAddress: types.StringValue("5.6.7.8"), + Phase1: &Phase1Model{ + DhGroups: types.ListNull(types.StringType), + EncryptionAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("aes256")}), + IntegrityAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("sha2_256")}), + RekeyTime: types.Int32Null(), + }, + Phase2: &Phase2Model{ + DhGroups: types.ListNull(types.StringType), + EncryptionAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("aes256")}), + IntegrityAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("sha2_256")}), + RekeyTime: types.Int32Null(), + StartAction: types.StringNull(), + DpdAction: types.StringNull(), + }, + }, + Labels: types.MapNull(types.StringType), + }, + isValid: true, + }, + { + description: "empty_labels", + input: &vpn.ConnectionResponse{ + Id: new("conn-id-4"), + DisplayName: "empty-labels-connection", + Labels: &map[string]string{}, + Tunnel1: vpn.TunnelConfiguration{ + RemoteAddress: "1.2.3.4", + Phase1: vpn.TunnelConfigurationPhase1{ + EncryptionAlgorithms: []string{"aes256"}, + IntegrityAlgorithms: []string{"sha2_256"}, + }, + Phase2: vpn.TunnelConfigurationPhase2{ + EncryptionAlgorithms: []string{"aes256"}, + IntegrityAlgorithms: []string{"sha2_256"}, + }, + }, + Tunnel2: vpn.TunnelConfiguration{ + RemoteAddress: "5.6.7.8", + Phase1: vpn.TunnelConfigurationPhase1{ + EncryptionAlgorithms: []string{"aes256"}, + IntegrityAlgorithms: []string{"sha2_256"}, + }, + Phase2: vpn.TunnelConfigurationPhase2{ + EncryptionAlgorithms: []string{"aes256"}, + IntegrityAlgorithms: []string{"sha2_256"}, + }, + }, + }, + expected: Model{ + ID: types.StringValue(fmt.Sprintf("%s,%s,%s,%s", projectId, region, gatewayId, "conn-id-4")), + ConnectionID: types.StringValue("conn-id-4"), + ProjectID: types.StringValue(projectId), + Region: types.StringValue(region), + GatewayID: types.StringValue(gatewayId), + DisplayName: types.StringValue("empty-labels-connection"), + Enabled: types.BoolValue(true), + RemoteSubnet: types.ListNull(types.StringType), + LocalSubnet: types.ListNull(types.StringType), + StaticRoutes: types.ListNull(types.StringType), + Tunnel1: &TunnelModel{ + PreSharedKeyWoVersion: types.Int64Value(1), + RemoteAddress: types.StringValue("1.2.3.4"), + Phase1: &Phase1Model{ + DhGroups: types.ListNull(types.StringType), + EncryptionAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("aes256")}), + IntegrityAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("sha2_256")}), + RekeyTime: types.Int32Null(), + }, + Phase2: &Phase2Model{ + DhGroups: types.ListNull(types.StringType), + EncryptionAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("aes256")}), + IntegrityAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("sha2_256")}), + RekeyTime: types.Int32Null(), + StartAction: types.StringNull(), + DpdAction: types.StringNull(), + }, + }, + Tunnel2: &TunnelModel{ + PreSharedKeyWoVersion: types.Int64Value(1), + RemoteAddress: types.StringValue("5.6.7.8"), + Phase1: &Phase1Model{ + DhGroups: types.ListNull(types.StringType), + EncryptionAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("aes256")}), + IntegrityAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("sha2_256")}), + RekeyTime: types.Int32Null(), + }, + Phase2: &Phase2Model{ + DhGroups: types.ListNull(types.StringType), + EncryptionAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("aes256")}), + IntegrityAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("sha2_256")}), + RekeyTime: types.Int32Null(), + StartAction: types.StringNull(), + DpdAction: types.StringNull(), + }, + }, + Labels: types.MapNull(types.StringType), + }, + isValid: true, + }, + { + description: "asymmetric_phase_fields", + input: &vpn.ConnectionResponse{ + Id: new("conn-id-5"), + DisplayName: "asymmetric-connection", + Tunnel1: vpn.TunnelConfiguration{ + RemoteAddress: "1.2.3.4", + Phase1: vpn.TunnelConfigurationPhase1{ + EncryptionAlgorithms: []string{"aes256"}, + IntegrityAlgorithms: []string{"sha2_256"}, + RekeyTime: new(int32(7200)), + }, + Phase2: vpn.TunnelConfigurationPhase2{ + EncryptionAlgorithms: []string{"aes256"}, + IntegrityAlgorithms: []string{"sha2_256"}, + RekeyTime: new(int32(1800)), + StartAction: new("none"), + DpdAction: new("clear"), + }, + }, + Tunnel2: vpn.TunnelConfiguration{ + RemoteAddress: "5.6.7.8", + Phase1: vpn.TunnelConfigurationPhase1{ + EncryptionAlgorithms: []string{"aes256"}, + IntegrityAlgorithms: []string{"sha2_256"}, + }, + Phase2: vpn.TunnelConfigurationPhase2{ + EncryptionAlgorithms: []string{"aes256"}, + IntegrityAlgorithms: []string{"sha2_256"}, + }, + }, + }, + expected: Model{ + ID: types.StringValue(fmt.Sprintf("%s,%s,%s,%s", projectId, region, gatewayId, "conn-id-5")), + ConnectionID: types.StringValue("conn-id-5"), + ProjectID: types.StringValue(projectId), + Region: types.StringValue(region), + GatewayID: types.StringValue(gatewayId), + DisplayName: types.StringValue("asymmetric-connection"), + Enabled: types.BoolValue(true), + RemoteSubnet: types.ListNull(types.StringType), + LocalSubnet: types.ListNull(types.StringType), + StaticRoutes: types.ListNull(types.StringType), + Tunnel1: &TunnelModel{ + PreSharedKeyWoVersion: types.Int64Value(1), + RemoteAddress: types.StringValue("1.2.3.4"), + Phase1: &Phase1Model{ + DhGroups: types.ListNull(types.StringType), + EncryptionAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("aes256")}), + IntegrityAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("sha2_256")}), + RekeyTime: types.Int32Value(7200), + }, + Phase2: &Phase2Model{ + DhGroups: types.ListNull(types.StringType), + EncryptionAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("aes256")}), + IntegrityAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("sha2_256")}), + RekeyTime: types.Int32Value(1800), + StartAction: types.StringValue("none"), + DpdAction: types.StringValue("clear"), + }, + }, + Tunnel2: &TunnelModel{ + PreSharedKeyWoVersion: types.Int64Value(1), + RemoteAddress: types.StringValue("5.6.7.8"), + Phase1: &Phase1Model{ + DhGroups: types.ListNull(types.StringType), + EncryptionAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("aes256")}), + IntegrityAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("sha2_256")}), + RekeyTime: types.Int32Null(), + }, + Phase2: &Phase2Model{ + DhGroups: types.ListNull(types.StringType), + EncryptionAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("aes256")}), + IntegrityAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("sha2_256")}), + RekeyTime: types.Int32Null(), + StartAction: types.StringNull(), + DpdAction: types.StringNull(), + }, + }, + Labels: types.MapNull(types.StringType), + }, + 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{ + 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 := mapFields(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) { + tests := []struct { + description string + input *Model + expected *vpn.CreateGatewayConnectionPayload + isValid bool + }{ + { + description: "basic_connection", + input: &Model{ + DisplayName: types.StringValue("test-connection"), + Enabled: types.BoolValue(true), + RemoteSubnet: types.ListNull(types.StringType), + LocalSubnet: types.ListNull(types.StringType), + StaticRoutes: types.ListNull(types.StringType), + Tunnel1: &TunnelModel{ + RemoteAddress: types.StringValue("203.0.113.1"), + PreSharedKeyWo: types.StringValue("secret123-at-least-20-chars"), + Phase1: &Phase1Model{ + DhGroups: types.ListNull(types.StringType), + EncryptionAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("aes256")}), + IntegrityAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("sha2_384")}), + RekeyTime: types.Int32Null(), + }, + Phase2: &Phase2Model{ + DhGroups: types.ListNull(types.StringType), + EncryptionAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("aes256")}), + IntegrityAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("sha2_384")}), + RekeyTime: types.Int32Null(), + StartAction: types.StringNull(), + DpdAction: types.StringNull(), + }, + }, + Tunnel2: &TunnelModel{ + RemoteAddress: types.StringValue("203.0.113.2"), + PreSharedKeyWo: types.StringValue("secret456-at-least-20-chars"), + Phase1: &Phase1Model{ + DhGroups: types.ListNull(types.StringType), + EncryptionAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("aes256")}), + IntegrityAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("sha2_384")}), + RekeyTime: types.Int32Null(), + }, + Phase2: &Phase2Model{ + DhGroups: types.ListNull(types.StringType), + EncryptionAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("aes256")}), + IntegrityAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("sha2_384")}), + RekeyTime: types.Int32Null(), + StartAction: types.StringNull(), + DpdAction: types.StringNull(), + }, + }, + }, + expected: &vpn.CreateGatewayConnectionPayload{ + DisplayName: "test-connection", + Tunnel1: vpn.TunnelConfiguration{ + PreSharedKey: new("secret123-at-least-20-chars"), + RemoteAddress: "203.0.113.1", + Phase1: vpn.TunnelConfigurationPhase1{ + DhGroups: nil, + EncryptionAlgorithms: []string{"aes256"}, + IntegrityAlgorithms: []string{"sha2_384"}, + }, + Phase2: vpn.TunnelConfigurationPhase2{ + DhGroups: nil, + EncryptionAlgorithms: []string{"aes256"}, + IntegrityAlgorithms: []string{"sha2_384"}, + }, + }, + Tunnel2: vpn.TunnelConfiguration{ + PreSharedKey: new("secret456-at-least-20-chars"), + RemoteAddress: "203.0.113.2", + Phase1: vpn.TunnelConfigurationPhase1{ + DhGroups: nil, + EncryptionAlgorithms: []string{"aes256"}, + IntegrityAlgorithms: []string{"sha2_384"}, + }, + Phase2: vpn.TunnelConfigurationPhase2{ + DhGroups: nil, + EncryptionAlgorithms: []string{"aes256"}, + IntegrityAlgorithms: []string{"sha2_384"}, + }, + }, + Enabled: new(true), + }, + isValid: true, + }, + { + description: "with_phase2_fields", + input: &Model{ + DisplayName: types.StringValue("test"), + Enabled: types.BoolValue(true), + RemoteSubnet: types.ListNull(types.StringType), + LocalSubnet: types.ListNull(types.StringType), + StaticRoutes: types.ListNull(types.StringType), + Tunnel1: &TunnelModel{ + RemoteAddress: types.StringValue("1.2.3.4"), + PreSharedKeyWo: types.StringValue("super-secret-key-at-least-20"), + PreSharedKeyWoVersion: types.Int64Null(), + Phase1: &Phase1Model{ + DhGroups: types.ListNull(types.StringType), + EncryptionAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("aes256")}), + IntegrityAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("sha2_384")}), + RekeyTime: types.Int32Value(7200), + }, + Phase2: &Phase2Model{ + DhGroups: types.ListNull(types.StringType), + EncryptionAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("aes256")}), + IntegrityAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("sha2_384")}), + RekeyTime: types.Int32Value(1800), + StartAction: types.StringValue("none"), + DpdAction: types.StringValue("clear"), + }, + }, + Tunnel2: &TunnelModel{ + RemoteAddress: types.StringValue("5.6.7.8"), + PreSharedKeyWo: types.StringValue("super-secret-key-at-least-20"), + PreSharedKeyWoVersion: types.Int64Null(), + Phase1: &Phase1Model{ + DhGroups: types.ListNull(types.StringType), + EncryptionAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("aes256")}), + IntegrityAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("sha2_384")}), + RekeyTime: types.Int32Null(), + }, + Phase2: &Phase2Model{ + DhGroups: types.ListNull(types.StringType), + EncryptionAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("aes256")}), + IntegrityAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("sha2_384")}), + RekeyTime: types.Int32Null(), + StartAction: types.StringNull(), + DpdAction: types.StringNull(), + }, + }, + }, + expected: &vpn.CreateGatewayConnectionPayload{ + DisplayName: "test", + Tunnel1: vpn.TunnelConfiguration{ + PreSharedKey: new("super-secret-key-at-least-20"), + RemoteAddress: "1.2.3.4", + Phase1: vpn.TunnelConfigurationPhase1{ + DhGroups: nil, + EncryptionAlgorithms: []string{"aes256"}, + IntegrityAlgorithms: []string{"sha2_384"}, + RekeyTime: new(int32(7200)), + }, + Phase2: vpn.TunnelConfigurationPhase2{ + DhGroups: nil, + EncryptionAlgorithms: []string{"aes256"}, + IntegrityAlgorithms: []string{"sha2_384"}, + RekeyTime: new(int32(1800)), + StartAction: new("none"), + DpdAction: new("clear"), + }, + }, + Tunnel2: vpn.TunnelConfiguration{ + PreSharedKey: new("super-secret-key-at-least-20"), + RemoteAddress: "5.6.7.8", + Phase1: vpn.TunnelConfigurationPhase1{ + DhGroups: nil, + EncryptionAlgorithms: []string{"aes256"}, + IntegrityAlgorithms: []string{"sha2_384"}, + }, + Phase2: vpn.TunnelConfigurationPhase2{ + DhGroups: nil, + EncryptionAlgorithms: []string{"aes256"}, + IntegrityAlgorithms: []string{"sha2_384"}, + }, + }, + Enabled: new(true), + }, + isValid: true, + }, + { + description: "nil_model", + input: nil, + expected: nil, + isValid: false, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + payload, err := toCreatePayload(context.Background(), tt.input) + + 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) { + tests := []struct { + description string + input *Model + expected *vpn.UpdateGatewayConnectionPayload + isValid bool + }{ + { + description: "basic_update", + input: &Model{ + DisplayName: types.StringValue("updated-connection"), + Enabled: types.BoolValue(false), + RemoteSubnet: types.ListNull(types.StringType), + LocalSubnet: types.ListNull(types.StringType), + StaticRoutes: types.ListNull(types.StringType), + Tunnel1: &TunnelModel{ + RemoteAddress: types.StringValue("203.0.113.1"), + PreSharedKeyWo: types.StringValue("secret123-at-least-20-chars"), + Phase1: &Phase1Model{ + DhGroups: types.ListNull(types.StringType), + EncryptionAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("aes256")}), + IntegrityAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("sha2_384")}), + RekeyTime: types.Int32Null(), + }, + Phase2: &Phase2Model{ + DhGroups: types.ListNull(types.StringType), + EncryptionAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("aes256")}), + IntegrityAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("sha2_384")}), + RekeyTime: types.Int32Null(), + StartAction: types.StringNull(), + DpdAction: types.StringNull(), + }, + }, + Tunnel2: &TunnelModel{ + RemoteAddress: types.StringValue("203.0.113.2"), + PreSharedKeyWo: types.StringValue("secret456-at-least-20-chars"), + Phase1: &Phase1Model{ + DhGroups: types.ListNull(types.StringType), + EncryptionAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("aes256")}), + IntegrityAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("sha2_384")}), + RekeyTime: types.Int32Null(), + }, + Phase2: &Phase2Model{ + DhGroups: types.ListNull(types.StringType), + EncryptionAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("aes256")}), + IntegrityAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("sha2_384")}), + RekeyTime: types.Int32Null(), + StartAction: types.StringNull(), + DpdAction: types.StringNull(), + }, + }, + }, + expected: &vpn.UpdateGatewayConnectionPayload{ + DisplayName: "updated-connection", + Tunnel1: vpn.TunnelConfiguration{ + PreSharedKey: new("secret123-at-least-20-chars"), + RemoteAddress: "203.0.113.1", + Phase1: vpn.TunnelConfigurationPhase1{ + DhGroups: nil, + EncryptionAlgorithms: []string{"aes256"}, + IntegrityAlgorithms: []string{"sha2_384"}, + }, + Phase2: vpn.TunnelConfigurationPhase2{ + DhGroups: nil, + EncryptionAlgorithms: []string{"aes256"}, + IntegrityAlgorithms: []string{"sha2_384"}, + }, + }, + Tunnel2: vpn.TunnelConfiguration{ + PreSharedKey: new("secret456-at-least-20-chars"), + RemoteAddress: "203.0.113.2", + Phase1: vpn.TunnelConfigurationPhase1{ + DhGroups: nil, + EncryptionAlgorithms: []string{"aes256"}, + IntegrityAlgorithms: []string{"sha2_384"}, + }, + Phase2: vpn.TunnelConfigurationPhase2{ + DhGroups: nil, + EncryptionAlgorithms: []string{"aes256"}, + IntegrityAlgorithms: []string{"sha2_384"}, + }, + }, + Enabled: new(false), + }, + isValid: true, + }, + { + description: "update_without_psk", + input: &Model{ + DisplayName: types.StringValue("updated-connection"), + Enabled: types.BoolValue(false), + RemoteSubnet: types.ListNull(types.StringType), + LocalSubnet: types.ListNull(types.StringType), + StaticRoutes: types.ListNull(types.StringType), + Tunnel1: &TunnelModel{ + RemoteAddress: types.StringValue("203.0.113.1"), + PreSharedKeyWo: types.StringNull(), + Phase1: &Phase1Model{ + DhGroups: types.ListNull(types.StringType), + EncryptionAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("aes256")}), + IntegrityAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("sha2_384")}), + RekeyTime: types.Int32Null(), + }, + Phase2: &Phase2Model{ + DhGroups: types.ListNull(types.StringType), + EncryptionAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("aes256")}), + IntegrityAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("sha2_384")}), + RekeyTime: types.Int32Null(), + StartAction: types.StringNull(), + DpdAction: types.StringNull(), + }, + }, + Tunnel2: &TunnelModel{ + RemoteAddress: types.StringValue("203.0.113.2"), + PreSharedKeyWo: types.StringNull(), + Phase1: &Phase1Model{ + DhGroups: types.ListNull(types.StringType), + EncryptionAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("aes256")}), + IntegrityAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("sha2_384")}), + RekeyTime: types.Int32Null(), + }, + Phase2: &Phase2Model{ + DhGroups: types.ListNull(types.StringType), + EncryptionAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("aes256")}), + IntegrityAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("sha2_384")}), + RekeyTime: types.Int32Null(), + StartAction: types.StringNull(), + DpdAction: types.StringNull(), + }, + }, + }, + expected: &vpn.UpdateGatewayConnectionPayload{ + DisplayName: "updated-connection", + Tunnel1: vpn.TunnelConfiguration{ + PreSharedKey: nil, + RemoteAddress: "203.0.113.1", + Phase1: vpn.TunnelConfigurationPhase1{ + DhGroups: nil, + EncryptionAlgorithms: []string{"aes256"}, + IntegrityAlgorithms: []string{"sha2_384"}, + }, + Phase2: vpn.TunnelConfigurationPhase2{ + DhGroups: nil, + EncryptionAlgorithms: []string{"aes256"}, + IntegrityAlgorithms: []string{"sha2_384"}, + }, + }, + Tunnel2: vpn.TunnelConfiguration{ + PreSharedKey: nil, + RemoteAddress: "203.0.113.2", + Phase1: vpn.TunnelConfigurationPhase1{ + DhGroups: nil, + EncryptionAlgorithms: []string{"aes256"}, + IntegrityAlgorithms: []string{"sha2_384"}, + }, + Phase2: vpn.TunnelConfigurationPhase2{ + DhGroups: nil, + EncryptionAlgorithms: []string{"aes256"}, + IntegrityAlgorithms: []string{"sha2_384"}, + }, + }, + Enabled: new(false), + }, + isValid: true, + }, + { + description: "nil_model", + input: nil, + expected: nil, + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + payload, err := toUpdatePayload(context.Background(), tt.input) + + 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: &TunnelModel{ + RemoteAddress: types.StringValue("203.0.113.1"), + PreSharedKeyWo: types.StringValue("secret123-at-least-20-chars"), + PreSharedKeyWoVersion: types.Int64Null(), + Phase1: &Phase1Model{ + DhGroups: types.ListNull(types.StringType), + EncryptionAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("aes256")}), + IntegrityAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("sha2_384")}), + RekeyTime: types.Int32Null(), + }, + Phase2: &Phase2Model{ + DhGroups: types.ListNull(types.StringType), + EncryptionAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("aes256")}), + IntegrityAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("sha2_384")}), + RekeyTime: types.Int32Null(), + StartAction: types.StringNull(), + DpdAction: types.StringNull(), + }, + }, + isValid: true, + }, + { + description: "tunnel_with_bgp", + input: &TunnelModel{ + RemoteAddress: types.StringValue("203.0.113.1"), + PreSharedKeyWo: types.StringValue("secret123-at-least-20-chars"), + PreSharedKeyWoVersion: types.Int64Null(), + Phase1: &Phase1Model{ + DhGroups: types.ListNull(types.StringType), + EncryptionAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("aes256")}), + IntegrityAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("sha2_384")}), + RekeyTime: types.Int32Null(), + }, + Phase2: &Phase2Model{ + DhGroups: types.ListNull(types.StringType), + EncryptionAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("aes256")}), + IntegrityAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("sha2_384")}), + RekeyTime: types.Int32Null(), + StartAction: types.StringNull(), + DpdAction: types.StringNull(), + }, + Bgp: &BGPTunnelConfigModel{ + RemoteAsn: types.Int64Value(65000), + }, + }, + isValid: true, + }, + { + description: "tunnel_without_psk", + input: &TunnelModel{ + RemoteAddress: types.StringValue("203.0.113.1"), + PreSharedKeyWo: types.StringNull(), + PreSharedKeyWoVersion: types.Int64Null(), + Phase1: &Phase1Model{ + DhGroups: types.ListNull(types.StringType), + EncryptionAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("aes256")}), + IntegrityAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("sha2_384")}), + RekeyTime: types.Int32Null(), + }, + Phase2: &Phase2Model{ + DhGroups: types.ListNull(types.StringType), + EncryptionAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("aes256")}), + IntegrityAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("sha2_384")}), + RekeyTime: types.Int32Null(), + StartAction: types.StringNull(), + DpdAction: types.StringNull(), + }, + }, + isValid: true, + }, + { + description: "nil_tunnel", + input: nil, + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + config, err := toTunnelConfiguration(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 !tt.input.PreSharedKeyWo.IsNull() && !tt.input.PreSharedKeyWo.IsUnknown() { + 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..599f0bce0 --- /dev/null +++ b/stackit/internal/services/vpn/testdata/connection-max.tf @@ -0,0 +1,85 @@ +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 + 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 + 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..23953c2d8 --- /dev/null +++ b/stackit/internal/services/vpn/testdata/connection-min.tf @@ -0,0 +1,46 @@ +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 + pre_shared_key_wo = 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 + pre_shared_key_wo = 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 535e2061e..d7aa43eb8 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,72 @@ var gatewayMaxVarsUpdated2 = func() config.Variables { return updated }() +var connectionMinVars = func() config.Variables { + vars := make(config.Variables, len(gatewayMinVars)+5) + maps.Copy(vars, gatewayMinVars) + // vars["plan_id"] = config.StringVariable("p500") + 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 { @@ -171,7 +243,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 { @@ -287,40 +359,545 @@ 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"), + resource.TestCheckResourceAttrSet("stackit_vpn_gateway.gateway", "state"), + // 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"), + resource.TestCheckResourceAttrSet("stackit_vpn_gateway.gateway", "state"), + // 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_wo", "tunnel2.pre_shared_key_wo"}, + }, + }, + }) +} + +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"), + resource.TestCheckResourceAttrSet("stackit_vpn_gateway.gateway", "state"), + // 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"), + resource.TestCheckResourceAttrSet("stackit_vpn_gateway.gateway", "state"), + // 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, vpn.Region(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, vpn.Region(testutil.Region), *gateway.Id, *conn.Id).Execute() + if err != nil { + var oapiErr *oapierror.GenericOpenAPIError + if errors.As(err, &oapiErr) && (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, vpn.Region(testutil.Region), *gateway.Id).Execute() + if err != nil { + var oapiErr *oapierror.GenericOpenAPIError + if errors.As(err, &oapiErr) && (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/provider.go b/stackit/provider.go index 0470af21a..3b89884e6 100644 --- a/stackit/provider.go +++ b/stackit/provider.go @@ -124,10 +124,14 @@ import ( skeMachineImages "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/ske/provideroptions/machineimages" sqlServerFlexInstance "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/sqlserverflex/instance" sqlServerFlexUser "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/sqlserverflex/user" +<<<<<<< HEAD telemetryLink "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/telemetrylink/link" 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" +>>>>>>> 2fb59bdf (feat(vpn): Onboarding VPN Connection) vpnGateway "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/vpn/gateway" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" ) @@ -757,6 +761,7 @@ func (p *Provider) DataSources(_ context.Context) []func() datasource.DataSource telemetryRouterDestination.NewTelemetryRouterDestinationDataSource, telemetryLink.NewTelemetryLinkDataSource, vpnGateway.NewVPNGatewayDataSource, + vpnConnection.NewVPNConnectionDataSource, } dataSources = append(dataSources, customRole.NewCustomRoleDataSources()...) dataSources = append(dataSources, iamRoleBindingsV1.NewRoleBindingsDatasources()...) @@ -852,10 +857,14 @@ func (p *Provider) Resources(_ context.Context) []func() resource.Resource { compliancelock.NewComplianceLockResource, serverBackupEnable.NewServerBackupEnableResource, serverUpdateEnable.NewServerUpdateEnableResource, +<<<<<<< HEAD telemetryRouterAccessToken.NewTelemetryRouterAccessTokenResource, telemetryRouterInstance.NewTelemetryRouterInstanceResource, telemetryRouterDestination.NewTelemetryRouterDestinationResource, telemetryLink.NewTelemetryLinkResource, +======= + vpnConnection.NewVpnConnectionResource, +>>>>>>> 2fb59bdf (feat(vpn): Onboarding VPN Connection) vpnGateway.NewGatewayResource, } resources = append(resources, roleAssignements.NewRoleAssignmentResources()...) From cd0e6f7a346e5222c70e77b120b2bdb3722a5edd Mon Sep 17 00:00:00 2001 From: Manuel Vaas Date: Thu, 11 Jun 2026 10:12:10 +0200 Subject: [PATCH 02/28] upgrade vpn sdk version --- .../services/vpn/connection/datasource.go | 2 +- .../services/vpn/connection/resource.go | 56 ++++-- .../services/vpn/connection/resource_test.go | 176 +++++++++--------- stackit/internal/services/vpn/vpn_acc_test.go | 6 +- stackit/provider.go | 6 - 5 files changed, 132 insertions(+), 114 deletions(-) diff --git a/stackit/internal/services/vpn/connection/datasource.go b/stackit/internal/services/vpn/connection/datasource.go index e1e4c2c7f..a7651c1dc 100644 --- a/stackit/internal/services/vpn/connection/datasource.go +++ b/stackit/internal/services/vpn/connection/datasource.go @@ -279,7 +279,7 @@ func (d *vpnConnectionDataSource) Read(ctx context.Context, req datasource.ReadR ctx = tflog.SetField(ctx, "gateway_id", gatewayId) ctx = tflog.SetField(ctx, "connection_id", connectionId) - connResp, err := d.client.DefaultAPI.GetGatewayConnection(ctx, projectId, vpn.Region(region), gatewayId, connectionId).Execute() + connResp, err := d.client.DefaultAPI.GetGatewayConnection(ctx, projectId, region, gatewayId, connectionId).Execute() if err != nil { var oapiErr *oapierror.GenericOpenAPIError ok := errors.As(err, &oapiErr) diff --git a/stackit/internal/services/vpn/connection/resource.go b/stackit/internal/services/vpn/connection/resource.go index 26d5cf745..36d80ef69 100644 --- a/stackit/internal/services/vpn/connection/resource.go +++ b/stackit/internal/services/vpn/connection/resource.go @@ -506,7 +506,7 @@ func (r *vpnConnectionResource) Create(ctx context.Context, req resource.CreateR return } - createResp, err := r.client.DefaultAPI.CreateGatewayConnection(ctx, projectId, vpn.Region(region), gatewayId).CreateGatewayConnectionPayload(*payload).Execute() + 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 @@ -530,7 +530,7 @@ func (r *vpnConnectionResource) Create(ctx context.Context, req resource.CreateR return } - connResp, err := r.client.DefaultAPI.GetGatewayConnection(ctx, projectId, vpn.Region(region), gatewayId, connectionId).Execute() + connResp, err := r.client.DefaultAPI.GetGatewayConnection(ctx, projectId, region, gatewayId, connectionId).Execute() if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating VPN connection", fmt.Sprintf("Reading created connection: %v", err)) return @@ -569,7 +569,7 @@ func (r *vpnConnectionResource) Read(ctx context.Context, req resource.ReadReque ctx = tflog.SetField(ctx, "gateway_id", gatewayId) ctx = tflog.SetField(ctx, "connection_id", connectionId) - connResp, err := r.client.DefaultAPI.GetGatewayConnection(ctx, projectId, vpn.Region(region), gatewayId, connectionId).Execute() + connResp, err := r.client.DefaultAPI.GetGatewayConnection(ctx, projectId, region, gatewayId, connectionId).Execute() if err != nil { oapiErr, ok := err.(*oapierror.GenericOpenAPIError) //nolint:errorlint //complaining that error.As should be used to catch wrapped errors, but this error should not be wrapped if ok && oapiErr.StatusCode == http.StatusNotFound { @@ -676,7 +676,7 @@ func (r *vpnConnectionResource) Update(ctx context.Context, req resource.UpdateR return } - _, err = r.client.DefaultAPI.UpdateGatewayConnection(ctx, projectId, vpn.Region(region), gatewayId, connectionId).UpdateGatewayConnectionPayload(*payload).Execute() + _, 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 @@ -684,7 +684,7 @@ func (r *vpnConnectionResource) Update(ctx context.Context, req resource.UpdateR ctx = core.LogResponse(ctx) - connResp, err := r.client.DefaultAPI.GetGatewayConnection(ctx, projectId, vpn.Region(region), gatewayId, connectionId).Execute() + connResp, err := r.client.DefaultAPI.GetGatewayConnection(ctx, projectId, region, gatewayId, connectionId).Execute() if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating VPN connection", fmt.Sprintf("Reading updated connection: %v", err)) return @@ -723,7 +723,7 @@ func (r *vpnConnectionResource) Delete(ctx context.Context, req resource.DeleteR ctx = tflog.SetField(ctx, "gateway_id", gatewayId) ctx = tflog.SetField(ctx, "connection_id", connectionId) - err := r.client.DefaultAPI.DeleteGatewayConnection(ctx, projectId, vpn.Region(region), gatewayId, connectionId).Execute() + err := r.client.DefaultAPI.DeleteGatewayConnection(ctx, projectId, region, gatewayId, connectionId).Execute() if err != nil { var oapiErr *oapierror.GenericOpenAPIError if errors.As(err, &oapiErr) && oapiErr.StatusCode == http.StatusNotFound { @@ -883,18 +883,30 @@ func toTunnelConfiguration(tunnel *TunnelModel) (*vpn.TunnelConfiguration, error if err != nil { return nil, fmt.Errorf("converting phase1 dh_groups: %w", err) } - phase1.DhGroups = dhGroups + dhGroupsInner := []vpn.PhaseDhGroupsInner{} + for _, item := range dhGroups { + dhGroupsInner = append(dhGroupsInner, vpn.PhaseDhGroupsInner(item)) + } + phase1.DhGroups = dhGroupsInner } encAlgs, err := tfutils.ListValueToStringSlice(tunnel.Phase1.EncryptionAlgorithms) if err != nil { return nil, fmt.Errorf("converting phase1 encryption_algorithms: %w", err) } - phase1.EncryptionAlgorithms = encAlgs + encAlgsInner := []vpn.PhaseEncryptionAlgorithmsInner{} + for _, item := range encAlgs { + encAlgsInner = append(encAlgsInner, vpn.PhaseEncryptionAlgorithmsInner(item)) + } + phase1.EncryptionAlgorithms = encAlgsInner intAlgs, err := tfutils.ListValueToStringSlice(tunnel.Phase1.IntegrityAlgorithms) if err != nil { return nil, fmt.Errorf("converting phase1 integrity_algorithms: %w", err) } - phase1.IntegrityAlgorithms = intAlgs + intAlgsInner := []vpn.PhaseIntegrityAlgorithmsInner{} + for _, item := range intAlgs { + intAlgsInner = append(intAlgsInner, vpn.PhaseIntegrityAlgorithmsInner(item)) + } + phase1.IntegrityAlgorithms = intAlgsInner if !tunnel.Phase1.RekeyTime.IsNull() && !tunnel.Phase1.RekeyTime.IsUnknown() { rekeyTime := tunnel.Phase1.RekeyTime.ValueInt32() phase1.RekeyTime = &rekeyTime @@ -909,29 +921,41 @@ func toTunnelConfiguration(tunnel *TunnelModel) (*vpn.TunnelConfiguration, error if err != nil { return nil, fmt.Errorf("converting phase2 dh_groups: %w", err) } - phase2.DhGroups = dhGroups + dhGroupsInner := []vpn.PhaseDhGroupsInner{} + for _, item := range dhGroups { + dhGroupsInner = append(dhGroupsInner, vpn.PhaseDhGroupsInner(item)) + } + phase2.DhGroups = dhGroupsInner } encAlgs, err := tfutils.ListValueToStringSlice(tunnel.Phase2.EncryptionAlgorithms) if err != nil { return nil, fmt.Errorf("converting phase2 encryption_algorithms: %w", err) } - phase2.EncryptionAlgorithms = encAlgs + encAlgsInner := []vpn.PhaseEncryptionAlgorithmsInner{} + for _, item := range encAlgs { + encAlgsInner = append(encAlgsInner, vpn.PhaseEncryptionAlgorithmsInner(item)) + } + phase2.EncryptionAlgorithms = encAlgsInner intAlgs, err := tfutils.ListValueToStringSlice(tunnel.Phase2.IntegrityAlgorithms) if err != nil { return nil, fmt.Errorf("converting phase2 integrity_algorithms: %w", err) } - phase2.IntegrityAlgorithms = intAlgs + intAlgsInner := []vpn.PhaseIntegrityAlgorithmsInner{} + for _, item := range intAlgs { + intAlgsInner = append(intAlgsInner, vpn.PhaseIntegrityAlgorithmsInner(item)) + } + phase2.IntegrityAlgorithms = intAlgsInner if !tunnel.Phase2.RekeyTime.IsNull() && !tunnel.Phase2.RekeyTime.IsUnknown() { rekeyTime := tunnel.Phase2.RekeyTime.ValueInt32() phase2.RekeyTime = &rekeyTime } if !tunnel.Phase2.StartAction.IsNull() && !tunnel.Phase2.StartAction.IsUnknown() { startAction := tunnel.Phase2.StartAction.ValueString() - phase2.StartAction = &startAction + phase2.StartAction = vpn.TunnelConfigurationPhase2AllOfStartAction(startAction).Ptr() } if !tunnel.Phase2.DpdAction.IsNull() && !tunnel.Phase2.DpdAction.IsUnknown() { dpdAction := tunnel.Phase2.DpdAction.ValueString() - phase2.DpdAction = &dpdAction + phase2.DpdAction = vpn.TunnelConfigurationPhase2AllOfDpdAction(dpdAction).Ptr() } config.Phase2 = phase2 } @@ -1107,12 +1131,12 @@ func mapTunnel(ctx context.Context, apiTunnel *vpn.TunnelConfiguration, cuurrent phase2.RekeyTime = types.Int32Null() } if apiTunnel.Phase2.StartAction != nil { - phase2.StartAction = types.StringValue(*apiTunnel.Phase2.StartAction) + phase2.StartAction = types.StringValue(string(*apiTunnel.Phase2.StartAction)) } else { phase2.StartAction = types.StringNull() } if apiTunnel.Phase2.DpdAction != nil { - phase2.DpdAction = types.StringValue(*apiTunnel.Phase2.DpdAction) + phase2.DpdAction = types.StringValue(string(*apiTunnel.Phase2.DpdAction)) } else { phase2.DpdAction = types.StringNull() } diff --git a/stackit/internal/services/vpn/connection/resource_test.go b/stackit/internal/services/vpn/connection/resource_test.go index 0477ffced..b158c5a04 100644 --- a/stackit/internal/services/vpn/connection/resource_test.go +++ b/stackit/internal/services/vpn/connection/resource_test.go @@ -36,35 +36,35 @@ func TestMapFields(t *testing.T) { Tunnel1: vpn.TunnelConfiguration{ RemoteAddress: "203.0.113.1", Phase1: vpn.TunnelConfigurationPhase1{ - DhGroups: []string{"modp2048"}, - EncryptionAlgorithms: []string{"aes256"}, - IntegrityAlgorithms: []string{"sha2_256"}, + DhGroups: []vpn.PhaseDhGroupsInner{"modp2048"}, + EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256"}, + IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_256"}, RekeyTime: new(int32(14400)), }, Phase2: vpn.TunnelConfigurationPhase2{ - DhGroups: []string{"modp2048"}, - EncryptionAlgorithms: []string{"aes256"}, - IntegrityAlgorithms: []string{"sha2_256"}, + DhGroups: []vpn.PhaseDhGroupsInner{"modp2048"}, + EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256"}, + IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_256"}, RekeyTime: new(int32(3600)), - StartAction: new("start"), - DpdAction: new("restart"), + StartAction: vpn.TUNNELCONFIGURATIONPHASE2ALLOFSTARTACTION_START.Ptr(), + DpdAction: vpn.TUNNELCONFIGURATIONPHASE2ALLOFDPDACTION_RESTART.Ptr(), }, }, Tunnel2: vpn.TunnelConfiguration{ RemoteAddress: "203.0.113.2", Phase1: vpn.TunnelConfigurationPhase1{ - DhGroups: []string{"modp2048"}, - EncryptionAlgorithms: []string{"aes256"}, - IntegrityAlgorithms: []string{"sha2_256"}, + DhGroups: []vpn.PhaseDhGroupsInner{"modp2048"}, + EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256"}, + IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_256"}, RekeyTime: new(int32(14400)), }, Phase2: vpn.TunnelConfigurationPhase2{ - DhGroups: []string{"modp2048"}, - EncryptionAlgorithms: []string{"aes256"}, - IntegrityAlgorithms: []string{"sha2_256"}, + DhGroups: []vpn.PhaseDhGroupsInner{"modp2048"}, + EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256"}, + IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_256"}, RekeyTime: new(int32(3600)), - StartAction: new("start"), - DpdAction: new("restart"), + StartAction: vpn.TUNNELCONFIGURATIONPHASE2ALLOFSTARTACTION_START.Ptr(), + DpdAction: vpn.TUNNELCONFIGURATIONPHASE2ALLOFDPDACTION_RESTART.Ptr(), }, }, }, @@ -157,14 +157,14 @@ func TestMapFields(t *testing.T) { Tunnel1: vpn.TunnelConfiguration{ RemoteAddress: "203.0.113.10", Phase1: vpn.TunnelConfigurationPhase1{ - EncryptionAlgorithms: []string{"aes256gcm16"}, - IntegrityAlgorithms: []string{"sha2_384"}, + EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256gcm16"}, + IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_384"}, }, Phase2: vpn.TunnelConfigurationPhase2{ - EncryptionAlgorithms: []string{"aes256gcm16"}, - IntegrityAlgorithms: []string{"sha2_384"}, - DpdAction: new("clear"), - StartAction: new("none"), + EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256gcm16"}, + IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_384"}, + StartAction: vpn.TUNNELCONFIGURATIONPHASE2ALLOFSTARTACTION_NONE.Ptr(), + DpdAction: vpn.TUNNELCONFIGURATIONPHASE2ALLOFDPDACTION_CLEAR.Ptr(), }, Peering: &vpn.PeeringConfig{ LocalAddress: new("169.254.0.1"), @@ -177,14 +177,14 @@ func TestMapFields(t *testing.T) { Tunnel2: vpn.TunnelConfiguration{ RemoteAddress: "203.0.113.11", Phase1: vpn.TunnelConfigurationPhase1{ - EncryptionAlgorithms: []string{"aes256gcm16"}, - IntegrityAlgorithms: []string{"sha2_384"}, + EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256gcm16"}, + IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_384"}, }, Phase2: vpn.TunnelConfigurationPhase2{ - EncryptionAlgorithms: []string{"aes256gcm16"}, - IntegrityAlgorithms: []string{"sha2_384"}, - DpdAction: new("clear"), - StartAction: new("none"), + EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256gcm16"}, + IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_384"}, + StartAction: vpn.TUNNELCONFIGURATIONPHASE2ALLOFSTARTACTION_NONE.Ptr(), + DpdAction: vpn.TUNNELCONFIGURATIONPHASE2ALLOFDPDACTION_CLEAR.Ptr(), }, }, }, @@ -257,23 +257,23 @@ func TestMapFields(t *testing.T) { Tunnel1: vpn.TunnelConfiguration{ RemoteAddress: "1.2.3.4", Phase1: vpn.TunnelConfigurationPhase1{ - EncryptionAlgorithms: []string{"aes256"}, - IntegrityAlgorithms: []string{"sha2_256"}, + EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256"}, + IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_256"}, }, Phase2: vpn.TunnelConfigurationPhase2{ - EncryptionAlgorithms: []string{"aes256"}, - IntegrityAlgorithms: []string{"sha2_256"}, + EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256"}, + IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_256"}, }, }, Tunnel2: vpn.TunnelConfiguration{ RemoteAddress: "5.6.7.8", Phase1: vpn.TunnelConfigurationPhase1{ - EncryptionAlgorithms: []string{"aes256"}, - IntegrityAlgorithms: []string{"sha2_256"}, + EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256"}, + IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_256"}, }, Phase2: vpn.TunnelConfigurationPhase2{ - EncryptionAlgorithms: []string{"aes256"}, - IntegrityAlgorithms: []string{"sha2_256"}, + EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256"}, + IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_256"}, }, }, }, @@ -340,23 +340,23 @@ func TestMapFields(t *testing.T) { Tunnel1: vpn.TunnelConfiguration{ RemoteAddress: "1.2.3.4", Phase1: vpn.TunnelConfigurationPhase1{ - EncryptionAlgorithms: []string{"aes256"}, - IntegrityAlgorithms: []string{"sha2_256"}, + EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256"}, + IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_256"}, }, Phase2: vpn.TunnelConfigurationPhase2{ - EncryptionAlgorithms: []string{"aes256"}, - IntegrityAlgorithms: []string{"sha2_256"}, + EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256"}, + IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_256"}, }, }, Tunnel2: vpn.TunnelConfiguration{ RemoteAddress: "5.6.7.8", Phase1: vpn.TunnelConfigurationPhase1{ - EncryptionAlgorithms: []string{"aes256"}, - IntegrityAlgorithms: []string{"sha2_256"}, + EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256"}, + IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_256"}, }, Phase2: vpn.TunnelConfigurationPhase2{ - EncryptionAlgorithms: []string{"aes256"}, - IntegrityAlgorithms: []string{"sha2_256"}, + EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256"}, + IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_256"}, }, }, }, @@ -419,27 +419,27 @@ func TestMapFields(t *testing.T) { Tunnel1: vpn.TunnelConfiguration{ RemoteAddress: "1.2.3.4", Phase1: vpn.TunnelConfigurationPhase1{ - EncryptionAlgorithms: []string{"aes256"}, - IntegrityAlgorithms: []string{"sha2_256"}, + EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256"}, + IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_256"}, RekeyTime: new(int32(7200)), }, Phase2: vpn.TunnelConfigurationPhase2{ - EncryptionAlgorithms: []string{"aes256"}, - IntegrityAlgorithms: []string{"sha2_256"}, + EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256"}, + IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_256"}, RekeyTime: new(int32(1800)), - StartAction: new("none"), - DpdAction: new("clear"), + StartAction: vpn.TUNNELCONFIGURATIONPHASE2ALLOFSTARTACTION_NONE.Ptr(), + DpdAction: vpn.TUNNELCONFIGURATIONPHASE2ALLOFDPDACTION_CLEAR.Ptr(), }, }, Tunnel2: vpn.TunnelConfiguration{ RemoteAddress: "5.6.7.8", Phase1: vpn.TunnelConfigurationPhase1{ - EncryptionAlgorithms: []string{"aes256"}, - IntegrityAlgorithms: []string{"sha2_256"}, + EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256"}, + IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_256"}, }, Phase2: vpn.TunnelConfigurationPhase2{ - EncryptionAlgorithms: []string{"aes256"}, - IntegrityAlgorithms: []string{"sha2_256"}, + EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256"}, + IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_256"}, }, }, }, @@ -600,13 +600,13 @@ func TestToCreatePayload(t *testing.T) { RemoteAddress: "203.0.113.1", Phase1: vpn.TunnelConfigurationPhase1{ DhGroups: nil, - EncryptionAlgorithms: []string{"aes256"}, - IntegrityAlgorithms: []string{"sha2_384"}, + EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256"}, + IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_384"}, }, Phase2: vpn.TunnelConfigurationPhase2{ DhGroups: nil, - EncryptionAlgorithms: []string{"aes256"}, - IntegrityAlgorithms: []string{"sha2_384"}, + EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256"}, + IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_384"}, }, }, Tunnel2: vpn.TunnelConfiguration{ @@ -614,13 +614,13 @@ func TestToCreatePayload(t *testing.T) { RemoteAddress: "203.0.113.2", Phase1: vpn.TunnelConfigurationPhase1{ DhGroups: nil, - EncryptionAlgorithms: []string{"aes256"}, - IntegrityAlgorithms: []string{"sha2_384"}, + EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256"}, + IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_384"}, }, Phase2: vpn.TunnelConfigurationPhase2{ DhGroups: nil, - EncryptionAlgorithms: []string{"aes256"}, - IntegrityAlgorithms: []string{"sha2_384"}, + EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256"}, + IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_384"}, }, }, Enabled: new(true), @@ -681,17 +681,17 @@ func TestToCreatePayload(t *testing.T) { RemoteAddress: "1.2.3.4", Phase1: vpn.TunnelConfigurationPhase1{ DhGroups: nil, - EncryptionAlgorithms: []string{"aes256"}, - IntegrityAlgorithms: []string{"sha2_384"}, + EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256"}, + IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_384"}, RekeyTime: new(int32(7200)), }, Phase2: vpn.TunnelConfigurationPhase2{ DhGroups: nil, - EncryptionAlgorithms: []string{"aes256"}, - IntegrityAlgorithms: []string{"sha2_384"}, + EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256"}, + IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_384"}, RekeyTime: new(int32(1800)), - StartAction: new("none"), - DpdAction: new("clear"), + StartAction: vpn.TUNNELCONFIGURATIONPHASE2ALLOFSTARTACTION_NONE.Ptr(), + DpdAction: vpn.TUNNELCONFIGURATIONPHASE2ALLOFDPDACTION_CLEAR.Ptr(), }, }, Tunnel2: vpn.TunnelConfiguration{ @@ -699,13 +699,13 @@ func TestToCreatePayload(t *testing.T) { RemoteAddress: "5.6.7.8", Phase1: vpn.TunnelConfigurationPhase1{ DhGroups: nil, - EncryptionAlgorithms: []string{"aes256"}, - IntegrityAlgorithms: []string{"sha2_384"}, + EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256"}, + IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_384"}, }, Phase2: vpn.TunnelConfigurationPhase2{ DhGroups: nil, - EncryptionAlgorithms: []string{"aes256"}, - IntegrityAlgorithms: []string{"sha2_384"}, + EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256"}, + IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_384"}, }, }, Enabled: new(true), @@ -798,13 +798,13 @@ func TestToUpdatePayload(t *testing.T) { RemoteAddress: "203.0.113.1", Phase1: vpn.TunnelConfigurationPhase1{ DhGroups: nil, - EncryptionAlgorithms: []string{"aes256"}, - IntegrityAlgorithms: []string{"sha2_384"}, + EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256"}, + IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_384"}, }, Phase2: vpn.TunnelConfigurationPhase2{ DhGroups: nil, - EncryptionAlgorithms: []string{"aes256"}, - IntegrityAlgorithms: []string{"sha2_384"}, + EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256"}, + IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_384"}, }, }, Tunnel2: vpn.TunnelConfiguration{ @@ -812,13 +812,13 @@ func TestToUpdatePayload(t *testing.T) { RemoteAddress: "203.0.113.2", Phase1: vpn.TunnelConfigurationPhase1{ DhGroups: nil, - EncryptionAlgorithms: []string{"aes256"}, - IntegrityAlgorithms: []string{"sha2_384"}, + EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256"}, + IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_384"}, }, Phase2: vpn.TunnelConfigurationPhase2{ DhGroups: nil, - EncryptionAlgorithms: []string{"aes256"}, - IntegrityAlgorithms: []string{"sha2_384"}, + EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256"}, + IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_384"}, }, }, Enabled: new(false), @@ -877,13 +877,13 @@ func TestToUpdatePayload(t *testing.T) { RemoteAddress: "203.0.113.1", Phase1: vpn.TunnelConfigurationPhase1{ DhGroups: nil, - EncryptionAlgorithms: []string{"aes256"}, - IntegrityAlgorithms: []string{"sha2_384"}, + EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256"}, + IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_384"}, }, Phase2: vpn.TunnelConfigurationPhase2{ DhGroups: nil, - EncryptionAlgorithms: []string{"aes256"}, - IntegrityAlgorithms: []string{"sha2_384"}, + EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256"}, + IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_384"}, }, }, Tunnel2: vpn.TunnelConfiguration{ @@ -891,13 +891,13 @@ func TestToUpdatePayload(t *testing.T) { RemoteAddress: "203.0.113.2", Phase1: vpn.TunnelConfigurationPhase1{ DhGroups: nil, - EncryptionAlgorithms: []string{"aes256"}, - IntegrityAlgorithms: []string{"sha2_384"}, + EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256"}, + IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_384"}, }, Phase2: vpn.TunnelConfigurationPhase2{ DhGroups: nil, - EncryptionAlgorithms: []string{"aes256"}, - IntegrityAlgorithms: []string{"sha2_384"}, + EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256"}, + IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_384"}, }, }, Enabled: new(false), diff --git a/stackit/internal/services/vpn/vpn_acc_test.go b/stackit/internal/services/vpn/vpn_acc_test.go index d7aa43eb8..cd2d7f094 100644 --- a/stackit/internal/services/vpn/vpn_acc_test.go +++ b/stackit/internal/services/vpn/vpn_acc_test.go @@ -873,7 +873,7 @@ func testAccCheckVpnResourcesDestroy(s *terraform.State) error { continue } - connectionsResp, err := client.DefaultAPI.ListGatewayConnections(ctx, testutil.ProjectId, vpn.Region(testutil.Region), *gateway.Id).Execute() + 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) } @@ -881,7 +881,7 @@ func testAccCheckVpnResourcesDestroy(s *terraform.State) error { if conn.Id == nil { continue } - err := client.DefaultAPI.DeleteGatewayConnection(ctx, testutil.ProjectId, vpn.Region(testutil.Region), *gateway.Id, *conn.Id).Execute() + err := client.DefaultAPI.DeleteGatewayConnection(ctx, testutil.ProjectId, testutil.Region, *gateway.Id, *conn.Id).Execute() if err != nil { var oapiErr *oapierror.GenericOpenAPIError if errors.As(err, &oapiErr) && (oapiErr.StatusCode == http.StatusNotFound || oapiErr.StatusCode == http.StatusGone) { @@ -891,7 +891,7 @@ func testAccCheckVpnResourcesDestroy(s *terraform.State) error { } } - err = client.DefaultAPI.DeleteGateway(ctx, testutil.ProjectId, vpn.Region(testutil.Region), *gateway.Id).Execute() + err = client.DefaultAPI.DeleteGateway(ctx, testutil.ProjectId, testutil.Region, *gateway.Id).Execute() if err != nil { var oapiErr *oapierror.GenericOpenAPIError if errors.As(err, &oapiErr) && (oapiErr.StatusCode == http.StatusNotFound || oapiErr.StatusCode == http.StatusGone) { diff --git a/stackit/provider.go b/stackit/provider.go index 3b89884e6..a0c66f343 100644 --- a/stackit/provider.go +++ b/stackit/provider.go @@ -124,14 +124,11 @@ import ( skeMachineImages "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/ske/provideroptions/machineimages" sqlServerFlexInstance "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/sqlserverflex/instance" sqlServerFlexUser "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/sqlserverflex/user" -<<<<<<< HEAD telemetryLink "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/telemetrylink/link" 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" ->>>>>>> 2fb59bdf (feat(vpn): Onboarding VPN Connection) vpnGateway "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/vpn/gateway" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" ) @@ -857,14 +854,11 @@ func (p *Provider) Resources(_ context.Context) []func() resource.Resource { compliancelock.NewComplianceLockResource, serverBackupEnable.NewServerBackupEnableResource, serverUpdateEnable.NewServerUpdateEnableResource, -<<<<<<< HEAD telemetryRouterAccessToken.NewTelemetryRouterAccessTokenResource, telemetryRouterInstance.NewTelemetryRouterInstanceResource, telemetryRouterDestination.NewTelemetryRouterDestinationResource, telemetryLink.NewTelemetryLinkResource, -======= vpnConnection.NewVpnConnectionResource, ->>>>>>> 2fb59bdf (feat(vpn): Onboarding VPN Connection) vpnGateway.NewGatewayResource, } resources = append(resources, roleAssignements.NewRoleAssignmentResources()...) From ea37e463f68a4f91386d12d979671ee9aec50484 Mon Sep 17 00:00:00 2001 From: Manuel Vaas Date: Thu, 11 Jun 2026 16:47:42 +0200 Subject: [PATCH 03/28] add labels and preSharedKey --- docs/data-sources/vpn_connection.md | 4 - docs/resources/vpn_connection.md | 10 +- .../services/vpn/connection/datasource.go | 7 - .../services/vpn/connection/resource.go | 140 ++++++++++-------- .../services/vpn/connection/resource_test.go | 8 +- 5 files changed, 95 insertions(+), 74 deletions(-) diff --git a/docs/data-sources/vpn_connection.md b/docs/data-sources/vpn_connection.md index 48d60845e..46476130e 100644 --- a/docs/data-sources/vpn_connection.md +++ b/docs/data-sources/vpn_connection.md @@ -51,8 +51,6 @@ Read-Only: - `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)) -- `pre_shared_key_wo` (String, Sensitive) -- `pre_shared_key_wo_version` (Number) - `remote_address` (String) Remote peer IPv4 address for this tunnel. @@ -106,8 +104,6 @@ Read-Only: - `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)) -- `pre_shared_key_wo` (String, Sensitive) -- `pre_shared_key_wo_version` (Number) - `remote_address` (String) Remote peer IPv4 address for this tunnel. diff --git a/docs/resources/vpn_connection.md b/docs/resources/vpn_connection.md index 4ca2d8d28..9661d7d44 100644 --- a/docs/resources/vpn_connection.md +++ b/docs/resources/vpn_connection.md @@ -68,8 +68,12 @@ import { - `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) (see [below for nested schema](#nestedatt--tunnel1)) -- `tunnel2` (Attributes) (see [below for nested schema](#nestedatt--tunnel2)) +- `tunnel1` (Attributes) Configuration for the IPsec tunnel. + +-> **Note:** 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 tunnel. + +-> **Note:** 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 @@ -92,6 +96,7 @@ Required: - `phase1` (Attributes) (see [below for nested schema](#nestedatt--tunnel1--phase1)) - `phase2` (Attributes) (see [below for nested schema](#nestedatt--tunnel1--phase2)) +- `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. - `remote_address` (String) Remote IPv4 address for the tunnel endpoint. @@ -156,6 +161,7 @@ Required: - `phase1` (Attributes) (see [below for nested schema](#nestedatt--tunnel2--phase1)) - `phase2` (Attributes) (see [below for nested schema](#nestedatt--tunnel2--phase2)) +- `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. - `remote_address` (String) Remote IPv4 address for the tunnel endpoint. diff --git a/stackit/internal/services/vpn/connection/datasource.go b/stackit/internal/services/vpn/connection/datasource.go index a7651c1dc..3ecf74a83 100644 --- a/stackit/internal/services/vpn/connection/datasource.go +++ b/stackit/internal/services/vpn/connection/datasource.go @@ -94,13 +94,6 @@ func (d *vpnConnectionDataSource) Schema(_ context.Context, _ datasource.SchemaR tunnelSchema := schema.SingleNestedAttribute{ Computed: true, Attributes: map[string]schema.Attribute{ - "pre_shared_key_wo": schema.StringAttribute{ - Computed: true, - Sensitive: true, - }, - "pre_shared_key_wo_version": schema.Int64Attribute{ - Computed: true, - }, "remote_address": schema.StringAttribute{ Description: datasourceTunnelSchemaDescriptions["remote_address"], Computed: true, diff --git a/stackit/internal/services/vpn/connection/resource.go b/stackit/internal/services/vpn/connection/resource.go index 36d80ef69..2df2b8106 100644 --- a/stackit/internal/services/vpn/connection/resource.go +++ b/stackit/internal/services/vpn/connection/resource.go @@ -11,6 +11,7 @@ import ( "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/objectvalidator" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/resource" @@ -24,6 +25,8 @@ import ( "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" @@ -64,6 +67,7 @@ type BGPTunnelConfigModel struct { } type TunnelModel struct { + PreSharedKey types.String `tfsdk:"pre_shared_key"` PreSharedKeyWo types.String `tfsdk:"pre_shared_key_wo"` PreSharedKeyWoVersion types.Int64 `tfsdk:"pre_shared_key_wo_version"` RemoteAddress types.String `tfsdk:"remote_address"` @@ -90,11 +94,11 @@ type Model struct { } var ( - dhGroupValues = []string{"modp1024", "modp2048", "ecp256", "ecp384", "modp2048s256"} - encryptionAlgorithmValues = []string{"aes256", "aes128gcm16", "aes256gcm16"} - integrityAlgorithmValues = []string{"sha1", "sha2_256", "sha2_384"} - startActionValues = []string{"none", "start"} - dpdActionValues = []string{"clear", "restart"} + 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) ) var schemaDescriptions = map[string]string{ @@ -114,6 +118,8 @@ var schemaDescriptions = map[string]string{ } var tunnelSchemaDescriptions = map[string]string{ + "tunnel": "Configuration for the IPsec tunnel.", + "pre_shared_key": "Pre-shared key for the IPsec tunnel. Minimum 20 characters. Write-only argument `pre_shared_key_wo` should be preferred.", "pre_shared_key_wo": "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": "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.", "remote_address": "Remote IPv4 address for the tunnel endpoint.", @@ -162,8 +168,29 @@ func (r *vpnConnectionResource) Metadata(_ context.Context, req resource.Metadat func (r *vpnConnectionResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { tunnelSchema := schema.SingleNestedAttribute{ - Required: true, + Description: tunnelSchemaDescriptions["tunnel"], + MarkdownDescription: fmt.Sprintf("%s \n\n-> **Note:** 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).", tunnelSchemaDescriptions["tunnel"]), + Required: true, + Validators: []validator.Object{ + objectvalidator.ExactlyOneOf( + path.MatchRelative().AtName("pre_shared_key"), + path.MatchRelative().AtName("pre_shared_key_wo"), + ), + }, Attributes: map[string]schema.Attribute{ + "pre_shared_key": schema.StringAttribute{ + Description: tunnelSchemaDescriptions["pre_shared_key"], + Required: 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.MatchRelative().AtParent().AtName("key_payload_base64_wo")), + }, + }, "pre_shared_key_wo": schema.StringAttribute{ Description: tunnelSchemaDescriptions["pre_shared_key_wo"], Required: true, @@ -171,11 +198,16 @@ func (r *vpnConnectionResource) Schema(_ context.Context, _ resource.SchemaReque WriteOnly: true, Validators: []validator.String{ stringvalidator.LengthAtLeast(20), + stringvalidator.ConflictsWith(path.MatchRelative().AtParent().AtName("pre_shared_key")), }, }, "pre_shared_key_wo_version": schema.Int64Attribute{ Description: tunnelSchemaDescriptions["pre_shared_key_wo_version"], 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: tunnelSchemaDescriptions["remote_address"], @@ -488,7 +520,9 @@ func (r *vpnConnectionResource) Create(ctx context.Context, req resource.CreateR if resp.Diagnostics.HasError() { return } + model.Tunnel1.PreSharedKey = configModel.Tunnel1.PreSharedKey model.Tunnel1.PreSharedKeyWo = configModel.Tunnel1.PreSharedKeyWo + model.Tunnel2.PreSharedKey = configModel.Tunnel2.PreSharedKey model.Tunnel2.PreSharedKeyWo = configModel.Tunnel2.PreSharedKeyWo ctx = core.InitProviderContext(ctx) @@ -571,8 +605,8 @@ func (r *vpnConnectionResource) Read(ctx context.Context, req resource.ReadReque connResp, err := r.client.DefaultAPI.GetGatewayConnection(ctx, projectId, region, gatewayId, connectionId).Execute() if err != nil { - oapiErr, ok := err.(*oapierror.GenericOpenAPIError) //nolint:errorlint //complaining that error.As should be used to catch wrapped errors, but this error should not be wrapped - if ok && oapiErr.StatusCode == http.StatusNotFound { + var oapiErr *oapierror.GenericOpenAPIError + if errors.As(err, &oapiErr) && oapiErr.StatusCode == http.StatusNotFound { resp.State.RemoveResource(ctx) return } @@ -619,17 +653,14 @@ func (r *vpnConnectionResource) Update(ctx context.Context, req resource.UpdateR } // tunnel1 PSK rotation - if !model.Tunnel1.PreSharedKeyWoVersion.IsNull() { + if !tfutils.IsUndefined(model.Tunnel1.PreSharedKeyWoVersion) { pv := model.Tunnel1.PreSharedKeyWoVersion.ValueInt64() sv := stateModel.Tunnel1.PreSharedKeyWoVersion.ValueInt64() if pv < sv { resp.Diagnostics.AddAttributeError( path.Root("tunnel1").AtName("pre_shared_key_wo_version"), "Version must not decrease", - fmt.Sprintf( - "`pre_shared_key_wo_version` must be incremented to rotate the pre-shared key, got %d (current: %d).", - pv, sv, - ), + fmt.Sprintf("`pre_shared_key_wo_version` must be incremented to rotate the pre-shared key, got %d (current: %d).", pv, sv), ) return } @@ -640,21 +671,19 @@ func (r *vpnConnectionResource) Update(ctx context.Context, req resource.UpdateR } // tunnel2 PSK rotation - if !model.Tunnel2.PreSharedKeyWoVersion.IsNull() { + if !tfutils.IsUndefined(model.Tunnel2.PreSharedKeyWoVersion) { pv := model.Tunnel2.PreSharedKeyWoVersion.ValueInt64() sv := stateModel.Tunnel2.PreSharedKeyWoVersion.ValueInt64() if pv < sv { resp.Diagnostics.AddAttributeError( path.Root("tunnel2").AtName("pre_shared_key_wo_version"), "Version must not decrease", - fmt.Sprintf( - "`pre_shared_key_wo_version` must be incremented to rotate the pre-shared key, got %d (current: %d).", - pv, sv, - ), + fmt.Sprintf("`pre_shared_key_wo_version` must be incremented to rotate the pre-shared key, got %d (current: %d).", pv, sv), ) return } if pv > sv { + // Secret must be read from Config, not Plan — write-only values are always null in plan. model.Tunnel2.PreSharedKeyWo = configModel.Tunnel2.PreSharedKeyWo } } @@ -738,27 +767,16 @@ func (r *vpnConnectionResource) Delete(ctx context.Context, req resource.DeleteR tflog.Info(ctx, "VPN connection deleted") } -func toCreatePayload(_ context.Context, model *Model) (*vpn.CreateGatewayConnectionPayload, error) { +func toCreatePayload(ctx context.Context, model *Model) (*vpn.CreateGatewayConnectionPayload, error) { if model == nil { return nil, fmt.Errorf("nil model") } - fields, err := toConnectionFields(model) + fields, err := toConnectionFields(ctx, model) if err != nil { return nil, err } - // The spec's Connection schema `#/components/schemas/Connection` (used for create) has no labels field, - // unlike ConnectionResponse which does. The API does accept labels on create despite the omission, but because - // CreateGatewayConnectionPayload is generated from Connection, there is no Labels field on the - // struct. Once the spec is corrected and the SDK regenerated, uncomment the block below. - - // nolint:gocritic // commented-out code (blocked by spec/SDK gap — see comment above) - // labels, err := tfutils.LabelsToPayload(ctx, model.Labels) - // if err != nil { - // return nil, err - // } - return &vpn.CreateGatewayConnectionPayload{ DisplayName: fields.displayName, Tunnel1: fields.tunnel1, @@ -767,31 +785,20 @@ func toCreatePayload(_ context.Context, model *Model) (*vpn.CreateGatewayConnect RemoteSubnets: fields.remoteSubnets, LocalSubnets: fields.localSubnets, StaticRoutes: fields.staticRoutes, - // Labels: &labels, // blocked by spec/SDK gap — see comment above + Labels: &fields.labels, }, nil } -func toUpdatePayload(_ context.Context, model *Model) (*vpn.UpdateGatewayConnectionPayload, error) { +func toUpdatePayload(ctx context.Context, model *Model) (*vpn.UpdateGatewayConnectionPayload, error) { if model == nil { return nil, fmt.Errorf("nil model") } - fields, err := toConnectionFields(model) + fields, err := toConnectionFields(ctx, model) if err != nil { return nil, err } - // The spec's Connection schema `#/components/schemas/Connection` (used for update) has no labels field, - // unlike ConnectionResponse which does. The API does accept labels on update despite the omission, but because - // UpdateGatewayConnectionPayload is generated from Connection, there is no Labels field on the - // struct. Once the spec is corrected and the SDK regenerated, uncomment the block below. - - // nolint:gocritic // commented-out code (blocked by spec/SDK gap — see comment above) - // labels, err := tfutils.LabelsToPayload(ctx, model.Labels) - // if err != nil { - // return nil, err - // } - return &vpn.UpdateGatewayConnectionPayload{ DisplayName: fields.displayName, Tunnel1: fields.tunnel1, @@ -800,7 +807,7 @@ func toUpdatePayload(_ context.Context, model *Model) (*vpn.UpdateGatewayConnect RemoteSubnets: fields.remoteSubnets, LocalSubnets: fields.localSubnets, StaticRoutes: fields.staticRoutes, - // Labels: &labels, // blocked by spec/SDK gap — see comment above + Labels: &fields.labels, }, nil } @@ -812,9 +819,10 @@ type connectionFields struct { remoteSubnets []string localSubnets []string staticRoutes []string + labels map[string]string } -func toConnectionFields(model *Model) (*connectionFields, error) { +func toConnectionFields(ctx context.Context, model *Model) (*connectionFields, error) { tunnel1, err := toTunnelConfiguration(model.Tunnel1) if err != nil { return nil, fmt.Errorf("converting tunnel1: %w", err) @@ -831,12 +839,12 @@ func toConnectionFields(model *Model) (*connectionFields, error) { tunnel2: *tunnel2, } - if !model.Enabled.IsNull() && !model.Enabled.IsUnknown() { + if !tfutils.IsUndefined(model.Enabled) { enabled := model.Enabled.ValueBool() fields.enabled = &enabled } - if !model.RemoteSubnet.IsNull() && !model.RemoteSubnet.IsUnknown() { + if !tfutils.IsUndefined(model.RemoteSubnet) { remoteSubnets, err := tfutils.ListValueToStringSlice(model.RemoteSubnet) if err != nil { return nil, fmt.Errorf("converting remote_subnet: %w", err) @@ -844,7 +852,7 @@ func toConnectionFields(model *Model) (*connectionFields, error) { fields.remoteSubnets = remoteSubnets } - if !model.LocalSubnet.IsNull() && !model.LocalSubnet.IsUnknown() { + if !tfutils.IsUndefined(model.LocalSubnet) { localSubnets, err := tfutils.ListValueToStringSlice(model.LocalSubnet) if err != nil { return nil, fmt.Errorf("converting local_subnet: %w", err) @@ -852,7 +860,7 @@ func toConnectionFields(model *Model) (*connectionFields, error) { fields.localSubnets = localSubnets } - if !model.StaticRoutes.IsNull() { + if !tfutils.IsUndefined(model.StaticRoutes) { staticRoutes, err := tfutils.ListValueToStringSlice(model.StaticRoutes) if err != nil { return nil, fmt.Errorf("converting static_routes: %w", err) @@ -860,6 +868,11 @@ func toConnectionFields(model *Model) (*connectionFields, error) { fields.staticRoutes = staticRoutes } + fields.labels, err = tfutils.LabelsToPayload(ctx, model.Labels) + if err != nil { + return nil, err + } + return fields, nil } @@ -871,14 +884,17 @@ func toTunnelConfiguration(tunnel *TunnelModel) (*vpn.TunnelConfiguration, error config := &vpn.TunnelConfiguration{ RemoteAddress: tunnel.RemoteAddress.ValueString(), } - if !tunnel.PreSharedKeyWo.IsNull() { - preSharedKey := tunnel.PreSharedKeyWo.ValueString() - config.PreSharedKey = &preSharedKey + + if !tfutils.IsUndefined(tunnel.PreSharedKeyWo) { + config.PreSharedKey = tunnel.PreSharedKeyWo.ValueStringPointer() + } else if !tfutils.IsUndefined(tunnel.PreSharedKey) { + config.PreSharedKey = tunnel.PreSharedKey.ValueStringPointer() } if tunnel.Phase1 != nil { phase1 := vpn.TunnelConfigurationPhase1{} - if !tunnel.Phase1.DhGroups.IsNull() { + + if !tfutils.IsUndefined(tunnel.Phase1.DhGroups) { dhGroups, err := tfutils.ListValueToStringSlice(tunnel.Phase1.DhGroups) if err != nil { return nil, fmt.Errorf("converting phase1 dh_groups: %w", err) @@ -889,6 +905,7 @@ func toTunnelConfiguration(tunnel *TunnelModel) (*vpn.TunnelConfiguration, error } phase1.DhGroups = dhGroupsInner } + encAlgs, err := tfutils.ListValueToStringSlice(tunnel.Phase1.EncryptionAlgorithms) if err != nil { return nil, fmt.Errorf("converting phase1 encryption_algorithms: %w", err) @@ -898,6 +915,7 @@ func toTunnelConfiguration(tunnel *TunnelModel) (*vpn.TunnelConfiguration, error encAlgsInner = append(encAlgsInner, vpn.PhaseEncryptionAlgorithmsInner(item)) } phase1.EncryptionAlgorithms = encAlgsInner + intAlgs, err := tfutils.ListValueToStringSlice(tunnel.Phase1.IntegrityAlgorithms) if err != nil { return nil, fmt.Errorf("converting phase1 integrity_algorithms: %w", err) @@ -907,16 +925,18 @@ func toTunnelConfiguration(tunnel *TunnelModel) (*vpn.TunnelConfiguration, error intAlgsInner = append(intAlgsInner, vpn.PhaseIntegrityAlgorithmsInner(item)) } phase1.IntegrityAlgorithms = intAlgsInner - if !tunnel.Phase1.RekeyTime.IsNull() && !tunnel.Phase1.RekeyTime.IsUnknown() { + + if !tfutils.IsUndefined(tunnel.Phase1.RekeyTime) { rekeyTime := tunnel.Phase1.RekeyTime.ValueInt32() phase1.RekeyTime = &rekeyTime } + config.Phase1 = phase1 } if tunnel.Phase2 != nil { phase2 := vpn.TunnelConfigurationPhase2{} - if !tunnel.Phase2.DhGroups.IsNull() { + if !tfutils.IsUndefined(tunnel.Phase2.DhGroups) { dhGroups, err := tfutils.ListValueToStringSlice(tunnel.Phase2.DhGroups) if err != nil { return nil, fmt.Errorf("converting phase2 dh_groups: %w", err) @@ -945,15 +965,15 @@ func toTunnelConfiguration(tunnel *TunnelModel) (*vpn.TunnelConfiguration, error intAlgsInner = append(intAlgsInner, vpn.PhaseIntegrityAlgorithmsInner(item)) } phase2.IntegrityAlgorithms = intAlgsInner - if !tunnel.Phase2.RekeyTime.IsNull() && !tunnel.Phase2.RekeyTime.IsUnknown() { + if !tfutils.IsUndefined(tunnel.Phase2.RekeyTime) { rekeyTime := tunnel.Phase2.RekeyTime.ValueInt32() phase2.RekeyTime = &rekeyTime } - if !tunnel.Phase2.StartAction.IsNull() && !tunnel.Phase2.StartAction.IsUnknown() { + if !tfutils.IsUndefined(tunnel.Phase2.StartAction) { startAction := tunnel.Phase2.StartAction.ValueString() phase2.StartAction = vpn.TunnelConfigurationPhase2AllOfStartAction(startAction).Ptr() } - if !tunnel.Phase2.DpdAction.IsNull() && !tunnel.Phase2.DpdAction.IsUnknown() { + if !tfutils.IsUndefined(tunnel.Phase2.DpdAction) { dpdAction := tunnel.Phase2.DpdAction.ValueString() phase2.DpdAction = vpn.TunnelConfigurationPhase2AllOfDpdAction(dpdAction).Ptr() } diff --git a/stackit/internal/services/vpn/connection/resource_test.go b/stackit/internal/services/vpn/connection/resource_test.go index b158c5a04..a1bf3c2f3 100644 --- a/stackit/internal/services/vpn/connection/resource_test.go +++ b/stackit/internal/services/vpn/connection/resource_test.go @@ -10,6 +10,8 @@ import ( "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 ( @@ -624,6 +626,7 @@ func TestToCreatePayload(t *testing.T) { }, }, Enabled: new(true), + Labels: &map[string]string{}, }, isValid: true, }, @@ -708,6 +711,7 @@ func TestToCreatePayload(t *testing.T) { IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_384"}, }, }, + Labels: &map[string]string{}, Enabled: new(true), }, isValid: true, @@ -821,6 +825,7 @@ func TestToUpdatePayload(t *testing.T) { IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_384"}, }, }, + Labels: &map[string]string{}, Enabled: new(false), }, isValid: true, @@ -900,6 +905,7 @@ func TestToUpdatePayload(t *testing.T) { IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_384"}, }, }, + Labels: &map[string]string{}, Enabled: new(false), }, isValid: true, @@ -1034,7 +1040,7 @@ func TestToTunnelConfiguration(t *testing.T) { if config.RemoteAddress != tt.input.RemoteAddress.ValueString() { t.Errorf("RemoteAddress mismatch: got %v, want %v", config.RemoteAddress, tt.input.RemoteAddress.ValueString()) } - if !tt.input.PreSharedKeyWo.IsNull() && !tt.input.PreSharedKeyWo.IsUnknown() { + if !tfutils.IsUndefined(tt.input.PreSharedKeyWo) { if config.PreSharedKey == nil || *config.PreSharedKey != tt.input.PreSharedKeyWo.ValueString() { t.Errorf("PreSharedKey mismatch") } From ec2b39c0672fb04423d0d485e5c29518896355ce Mon Sep 17 00:00:00 2001 From: Manuel Vaas Date: Thu, 18 Jun 2026 16:34:45 +0200 Subject: [PATCH 04/28] fix static routes --- stackit/internal/services/vpn/connection/resource.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/stackit/internal/services/vpn/connection/resource.go b/stackit/internal/services/vpn/connection/resource.go index 2df2b8106..73e14efb4 100644 --- a/stackit/internal/services/vpn/connection/resource.go +++ b/stackit/internal/services/vpn/connection/resource.go @@ -13,10 +13,12 @@ import ( "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" "github.com/hashicorp/terraform-plugin-framework-validators/objectvalidator" "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" @@ -180,7 +182,7 @@ func (r *vpnConnectionResource) Schema(_ context.Context, _ resource.SchemaReque Attributes: map[string]schema.Attribute{ "pre_shared_key": schema.StringAttribute{ Description: tunnelSchemaDescriptions["pre_shared_key"], - Required: true, + Optional: true, Sensitive: true, Validators: []validator.String{ stringvalidator.LengthAtLeast(20), @@ -193,7 +195,7 @@ func (r *vpnConnectionResource) Schema(_ context.Context, _ resource.SchemaReque }, "pre_shared_key_wo": schema.StringAttribute{ Description: tunnelSchemaDescriptions["pre_shared_key_wo"], - Required: true, + Optional: true, Sensitive: true, WriteOnly: true, Validators: []validator.String{ @@ -442,6 +444,8 @@ func (r *vpnConnectionResource) Schema(_ context.Context, _ resource.SchemaReque "static_routes": schema.ListAttribute{ Description: schemaDescriptions["static_routes"], Optional: true, + Computed: true, + Default: listdefault.StaticValue(types.ListValueMust(types.StringType, []attr.Value{})), ElementType: types.StringType, Validators: []validator.List{ listvalidator.ValueStringsAre(validate.CIDR()), From 4773ceeb701cd7dc56965f3a256b5b3f52e6309c Mon Sep 17 00:00:00 2001 From: Manuel Vaas Date: Fri, 19 Jun 2026 09:55:39 +0200 Subject: [PATCH 05/28] fixed validators, datasource and acc tests --- .../services/vpn/connection/datasource.go | 216 +++++++++++++++++- .../services/vpn/connection/resource.go | 39 ++-- stackit/internal/services/vpn/vpn_acc_test.go | 5 - 3 files changed, 228 insertions(+), 32 deletions(-) diff --git a/stackit/internal/services/vpn/connection/datasource.go b/stackit/internal/services/vpn/connection/datasource.go index 3ecf74a83..7554a1451 100644 --- a/stackit/internal/services/vpn/connection/datasource.go +++ b/stackit/internal/services/vpn/connection/datasource.go @@ -17,6 +17,7 @@ import ( "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/conversion" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" @@ -27,6 +28,30 @@ var ( _ 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 { + 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"` + Tunnel1 *DataSourceTunnelModel `tfsdk:"tunnel1"` + Tunnel2 *DataSourceTunnelModel `tfsdk:"tunnel2"` + Labels types.Map `tfsdk:"labels"` +} + var datasourceSchemaDescriptions = map[string]string{ "id": "Terraform's internal resource identifier. Structured as \"`project_id`,`region`,`gateway_id`,`connection_id`\".", "project_id": "STACKIT project ID.", @@ -253,7 +278,7 @@ func (d *vpnConnectionDataSource) Schema(_ context.Context, _ datasource.SchemaR } func (d *vpnConnectionDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { // nolint:gocritic // function signature required by Terraform - var model Model + var model DataSourceModel diags := req.Config.Get(ctx, &model) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { @@ -285,7 +310,7 @@ func (d *vpnConnectionDataSource) Read(ctx context.Context, req datasource.ReadR } ctx = core.LogResponse(ctx) - err = mapFields(ctx, connResp, &model, region) + 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 @@ -300,3 +325,190 @@ func (d *vpnConnectionDataSource) Read(ctx context.Context, req datasource.ReadR "connection_id": connectionId, }) } + +func mapDataSourceFields(ctx context.Context, conn *vpn.ConnectionResponse, model *DataSourceModel, 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 conn.Id != nil { + connectionId = *conn.Id + } 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.DisplayName) + model.Region = types.StringValue(region) + + if conn.Enabled != nil { + model.Enabled = types.BoolValue(*conn.Enabled) + } else { + model.Enabled = types.BoolValue(true) + } + + if conn.RemoteSubnets != nil { + list, diags := types.ListValueFrom(ctx, types.StringType, conn.RemoteSubnets) + if diags.HasError() { + return fmt.Errorf("mapping remote_subnet: %w", core.DiagsToError(diags)) + } + model.RemoteSubnet = list + } else { + model.RemoteSubnet = types.ListNull(types.StringType) + } + + if conn.LocalSubnets != nil { + list, diags := types.ListValueFrom(ctx, types.StringType, conn.LocalSubnets) + if diags.HasError() { + return fmt.Errorf("mapping local_subnet: %w", core.DiagsToError(diags)) + } + model.LocalSubnet = list + } else { + model.LocalSubnet = types.ListNull(types.StringType) + } + + if conn.StaticRoutes != nil { + list, diags := types.ListValueFrom(ctx, types.StringType, conn.StaticRoutes) + if diags.HasError() { + return fmt.Errorf("mapping static_routes: %w", core.DiagsToError(diags)) + } + model.StaticRoutes = list + } else { + model.StaticRoutes = types.ListNull(types.StringType) + } + + tunnel1, err := mapDataSourceTunnel(ctx, &conn.Tunnel1, model.Tunnel1) + if err != nil { + return fmt.Errorf("mapping tunnel1: %w", err) + } + model.Tunnel1 = tunnel1 + + tunnel2, err := mapDataSourceTunnel(ctx, &conn.Tunnel2, model.Tunnel2) + if err != nil { + return fmt.Errorf("mapping tunnel2: %w", err) + } + model.Tunnel2 = tunnel2 + + labels, err := tfutils.MapLabels(ctx, conn.Labels, model.Labels) + if err != nil { + return fmt.Errorf("mapping labels: %w", err) + } + model.Labels = labels + + return nil +} + +func mapDataSourceTunnel(ctx context.Context, apiTunnel *vpn.TunnelConfiguration, currentTunnel *DataSourceTunnelModel) (*DataSourceTunnelModel, error) { + tunnel := &DataSourceTunnelModel{ + RemoteAddress: types.StringValue(string(apiTunnel.RemoteAddress)), + } + phase1 := &Phase1Model{} + if len(apiTunnel.Phase1.DhGroups) > 0 { + list, diags := types.ListValueFrom(ctx, types.StringType, apiTunnel.Phase1.DhGroups) + if diags.HasError() { + return nil, fmt.Errorf("mapping phase1 dh_groups: %w", core.DiagsToError(diags)) + } + phase1.DhGroups = list + } else { + phase1.DhGroups = types.ListNull(types.StringType) + } + if len(apiTunnel.Phase1.EncryptionAlgorithms) > 0 { + list, diags := types.ListValueFrom(ctx, types.StringType, apiTunnel.Phase1.EncryptionAlgorithms) + if diags.HasError() { + return nil, fmt.Errorf("mapping phase1 encryption_algorithms: %w", core.DiagsToError(diags)) + } + phase1.EncryptionAlgorithms = list + } else { + phase1.EncryptionAlgorithms = types.ListNull(types.StringType) + } + if len(apiTunnel.Phase1.IntegrityAlgorithms) > 0 { + list, diags := types.ListValueFrom(ctx, types.StringType, apiTunnel.Phase1.IntegrityAlgorithms) + if diags.HasError() { + return nil, fmt.Errorf("mapping phase1 integrity_algorithms: %w", core.DiagsToError(diags)) + } + phase1.IntegrityAlgorithms = list + } else { + phase1.IntegrityAlgorithms = types.ListNull(types.StringType) + } + if apiTunnel.Phase1.RekeyTime != nil { + phase1.RekeyTime = types.Int32Value(*apiTunnel.Phase1.RekeyTime) + } else { + phase1.RekeyTime = types.Int32Null() + } + tunnel.Phase1 = phase1 + + phase2 := &Phase2Model{} + if len(apiTunnel.Phase2.DhGroups) > 0 { + list, diags := types.ListValueFrom(ctx, types.StringType, apiTunnel.Phase2.DhGroups) + if diags.HasError() { + return nil, fmt.Errorf("mapping phase2 dh_groups: %w", core.DiagsToError(diags)) + } + phase2.DhGroups = list + } else { + phase2.DhGroups = types.ListNull(types.StringType) + } + if len(apiTunnel.Phase2.EncryptionAlgorithms) > 0 { + list, diags := types.ListValueFrom(ctx, types.StringType, apiTunnel.Phase2.EncryptionAlgorithms) + if diags.HasError() { + return nil, fmt.Errorf("mapping phase2 encryption_algorithms: %w", core.DiagsToError(diags)) + } + phase2.EncryptionAlgorithms = list + } else { + phase2.EncryptionAlgorithms = types.ListNull(types.StringType) + } + if len(apiTunnel.Phase2.IntegrityAlgorithms) > 0 { + list, diags := types.ListValueFrom(ctx, types.StringType, apiTunnel.Phase2.IntegrityAlgorithms) + if diags.HasError() { + return nil, fmt.Errorf("mapping phase2 integrity_algorithms: %w", core.DiagsToError(diags)) + } + phase2.IntegrityAlgorithms = list + } else { + phase2.IntegrityAlgorithms = types.ListNull(types.StringType) + } + if apiTunnel.Phase2.RekeyTime != nil { + phase2.RekeyTime = types.Int32Value(*apiTunnel.Phase2.RekeyTime) + } else { + phase2.RekeyTime = types.Int32Null() + } + if apiTunnel.Phase2.StartAction != nil { + phase2.StartAction = types.StringValue(string(*apiTunnel.Phase2.StartAction)) + } else { + phase2.StartAction = types.StringNull() + } + if apiTunnel.Phase2.DpdAction != nil { + phase2.DpdAction = types.StringValue(string(*apiTunnel.Phase2.DpdAction)) + } else { + phase2.DpdAction = types.StringNull() + } + tunnel.Phase2 = phase2 + + if apiTunnel.Peering != nil { + peering := &PeeringConfigModel{} + if apiTunnel.Peering.LocalAddress != nil { + peering.LocalAddress = types.StringValue(*apiTunnel.Peering.LocalAddress) + } else { + peering.LocalAddress = types.StringNull() + } + if apiTunnel.Peering.RemoteAddress != nil { + peering.RemoteAddress = types.StringValue(*apiTunnel.Peering.RemoteAddress) + } else { + peering.RemoteAddress = types.StringNull() + } + tunnel.Peering = peering + } + + if apiTunnel.Bgp != nil { + tunnel.Bgp = &BGPTunnelConfigModel{ + RemoteAsn: types.Int64Value(int64(apiTunnel.Bgp.RemoteAsn)), + } + } + + return tunnel, nil +} diff --git a/stackit/internal/services/vpn/connection/resource.go b/stackit/internal/services/vpn/connection/resource.go index 73e14efb4..0244b640e 100644 --- a/stackit/internal/services/vpn/connection/resource.go +++ b/stackit/internal/services/vpn/connection/resource.go @@ -11,7 +11,6 @@ import ( "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/objectvalidator" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/path" @@ -168,39 +167,30 @@ func (r *vpnConnectionResource) Metadata(_ context.Context, req resource.Metadat resp.TypeName = req.ProviderTypeName + "_vpn_connection" } -func (r *vpnConnectionResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { - tunnelSchema := schema.SingleNestedAttribute{ +func tunnelSchema(rootAttribute string) schema.SingleNestedAttribute { + return schema.SingleNestedAttribute{ Description: tunnelSchemaDescriptions["tunnel"], MarkdownDescription: fmt.Sprintf("%s \n\n-> **Note:** 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).", tunnelSchemaDescriptions["tunnel"]), Required: true, - Validators: []validator.Object{ - objectvalidator.ExactlyOneOf( - path.MatchRelative().AtName("pre_shared_key"), - path.MatchRelative().AtName("pre_shared_key_wo"), - ), - }, Attributes: map[string]schema.Attribute{ "pre_shared_key": schema.StringAttribute{ Description: tunnelSchemaDescriptions["pre_shared_key"], 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.MatchRelative().AtParent().AtName("key_payload_base64_wo")), + stringvalidator.PreferWriteOnlyAttribute(path.MatchRoot(rootAttribute).AtName("pre_shared_key_wo")), }, }, "pre_shared_key_wo": schema.StringAttribute{ Description: tunnelSchemaDescriptions["pre_shared_key_wo"], Optional: true, - Sensitive: true, WriteOnly: true, Validators: []validator.String{ stringvalidator.LengthAtLeast(20), - stringvalidator.ConflictsWith(path.MatchRelative().AtParent().AtName("pre_shared_key")), + stringvalidator.ExactlyOneOf( + path.MatchRelative().AtParent().AtName("pre_shared_key"), + path.MatchRelative().AtParent().AtName("pre_shared_key_wo"), + ), }, }, "pre_shared_key_wo_version": schema.Int64Attribute{ @@ -208,7 +198,6 @@ func (r *vpnConnectionResource) Schema(_ context.Context, _ resource.SchemaReque 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{ @@ -353,7 +342,9 @@ func (r *vpnConnectionResource) Schema(_ context.Context, _ resource.SchemaReque }, }, } +} +func (r *vpnConnectionResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { resp.Schema = schema.Schema{ Description: fmt.Sprintf("VPN Connection resource schema. %s", core.ResourceRegionFallbackDocstring), Attributes: map[string]schema.Attribute{ @@ -451,8 +442,8 @@ func (r *vpnConnectionResource) Schema(_ context.Context, _ resource.SchemaReque listvalidator.ValueStringsAre(validate.CIDR()), }, }, - "tunnel1": tunnelSchema, - "tunnel2": tunnelSchema, + "tunnel1": tunnelSchema("tunnel1"), + "tunnel2": tunnelSchema("tunnel2"), "labels": schema.MapAttribute{ Description: schemaDescriptions["labels"], Optional: true, @@ -524,9 +515,7 @@ func (r *vpnConnectionResource) Create(ctx context.Context, req resource.CreateR if resp.Diagnostics.HasError() { return } - model.Tunnel1.PreSharedKey = configModel.Tunnel1.PreSharedKey model.Tunnel1.PreSharedKeyWo = configModel.Tunnel1.PreSharedKeyWo - model.Tunnel2.PreSharedKey = configModel.Tunnel2.PreSharedKey model.Tunnel2.PreSharedKeyWo = configModel.Tunnel2.PreSharedKeyWo ctx = core.InitProviderContext(ctx) @@ -1082,7 +1071,7 @@ func mapFields(ctx context.Context, conn *vpn.ConnectionResponse, model *Model, return nil } -func mapTunnel(ctx context.Context, apiTunnel *vpn.TunnelConfiguration, cuurrentTunnel *TunnelModel) (*TunnelModel, error) { +func mapTunnel(ctx context.Context, apiTunnel *vpn.TunnelConfiguration, currentTunnel *TunnelModel) (*TunnelModel, error) { tunnel := &TunnelModel{ RemoteAddress: types.StringValue(string(apiTunnel.RemoteAddress)), } @@ -1188,8 +1177,8 @@ func mapTunnel(ctx context.Context, apiTunnel *vpn.TunnelConfiguration, cuurrent } // could be nil for Read after a terraform import - if cuurrentTunnel != nil { - tunnel.PreSharedKeyWoVersion = cuurrentTunnel.PreSharedKeyWoVersion + if currentTunnel != nil { + tunnel.PreSharedKeyWoVersion = currentTunnel.PreSharedKeyWoVersion } else { tunnel.PreSharedKeyWoVersion = types.Int64Null() } diff --git a/stackit/internal/services/vpn/vpn_acc_test.go b/stackit/internal/services/vpn/vpn_acc_test.go index cd2d7f094..8f1166672 100644 --- a/stackit/internal/services/vpn/vpn_acc_test.go +++ b/stackit/internal/services/vpn/vpn_acc_test.go @@ -92,7 +92,6 @@ var gatewayMaxVarsUpdated2 = func() config.Variables { var connectionMinVars = func() config.Variables { vars := make(config.Variables, len(gatewayMinVars)+5) maps.Copy(vars, gatewayMinVars) - // vars["plan_id"] = config.StringVariable("p500") 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") @@ -378,7 +377,6 @@ func TestAccVpnConnectionResourceMin(t *testing.T) { 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"), - resource.TestCheckResourceAttrSet("stackit_vpn_gateway.gateway", "state"), // Connection – identity & top-level resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "project_id", testutil.ProjectId), resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "region", testutil.Region), @@ -479,7 +477,6 @@ func TestAccVpnConnectionResourceMin(t *testing.T) { 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"), - resource.TestCheckResourceAttrSet("stackit_vpn_gateway.gateway", "state"), // Connection – all fields resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "project_id", testutil.ProjectId), resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "region", testutil.Region), @@ -567,7 +564,6 @@ func TestAccVpnConnectionResourceMax(t *testing.T) { 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"), - resource.TestCheckResourceAttrSet("stackit_vpn_gateway.gateway", "state"), // 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"])), @@ -706,7 +702,6 @@ func TestAccVpnConnectionResourceMax(t *testing.T) { 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"), - resource.TestCheckResourceAttrSet("stackit_vpn_gateway.gateway", "state"), // Connection resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "project_id", testutil.ProjectId), resource.TestCheckResourceAttr("stackit_vpn_connection.connection", "region", testutil.ConvertConfigVariable(connectionMaxVarsUpdated["region"])), From bedf3dd8db0f17cf568a3870c5da023a4aa42e09 Mon Sep 17 00:00:00 2001 From: Manuel Vaas Date: Fri, 19 Jun 2026 09:55:55 +0200 Subject: [PATCH 06/28] generate docs --- docs/resources/vpn_connection.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/resources/vpn_connection.md b/docs/resources/vpn_connection.md index 9661d7d44..fe73a36fc 100644 --- a/docs/resources/vpn_connection.md +++ b/docs/resources/vpn_connection.md @@ -96,14 +96,14 @@ Required: - `phase1` (Attributes) (see [below for nested schema](#nestedatt--tunnel1--phase1)) - `phase2` (Attributes) (see [below for nested schema](#nestedatt--tunnel1--phase2)) -- `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. - `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) 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, [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. @@ -161,14 +161,14 @@ Required: - `phase1` (Attributes) (see [below for nested schema](#nestedatt--tunnel2--phase1)) - `phase2` (Attributes) (see [below for nested schema](#nestedatt--tunnel2--phase2)) -- `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. - `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) 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, [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. From c9acac887f33f7dac816f6e37b20a0892345c47c Mon Sep 17 00:00:00 2001 From: Manuel Vaas Date: Fri, 19 Jun 2026 10:29:52 +0200 Subject: [PATCH 07/28] Fixed PreSharedKey --- stackit/internal/services/vpn/connection/resource.go | 1 + 1 file changed, 1 insertion(+) diff --git a/stackit/internal/services/vpn/connection/resource.go b/stackit/internal/services/vpn/connection/resource.go index 0244b640e..193eb64c9 100644 --- a/stackit/internal/services/vpn/connection/resource.go +++ b/stackit/internal/services/vpn/connection/resource.go @@ -1074,6 +1074,7 @@ func mapFields(ctx context.Context, conn *vpn.ConnectionResponse, model *Model, func mapTunnel(ctx context.Context, apiTunnel *vpn.TunnelConfiguration, currentTunnel *TunnelModel) (*TunnelModel, error) { tunnel := &TunnelModel{ RemoteAddress: types.StringValue(string(apiTunnel.RemoteAddress)), + PreSharedKey: currentTunnel.PreSharedKey, } phase1 := &Phase1Model{} if len(apiTunnel.Phase1.DhGroups) > 0 { From 70e382be39a06d4bde31546a8772df17a4ffb096 Mon Sep 17 00:00:00 2001 From: Manuel Vaas Date: Fri, 19 Jun 2026 13:35:04 +0200 Subject: [PATCH 08/28] refactored --- .../services/vpn/connection/datasource.go | 142 +-- .../vpn/connection/datasource_test.go | 157 +++ .../services/vpn/connection/resource.go | 182 +-- .../services/vpn/connection/resource_test.go | 1062 +++++------------ 4 files changed, 565 insertions(+), 978 deletions(-) create mode 100644 stackit/internal/services/vpn/connection/datasource_test.go diff --git a/stackit/internal/services/vpn/connection/datasource.go b/stackit/internal/services/vpn/connection/datasource.go index 7554a1451..fec4e7641 100644 --- a/stackit/internal/services/vpn/connection/datasource.go +++ b/stackit/internal/services/vpn/connection/datasource.go @@ -52,7 +52,7 @@ type DataSourceModel struct { Labels types.Map `tfsdk:"labels"` } -var datasourceSchemaDescriptions = map[string]string{ +var dataSourceSchemaDescriptions = map[string]string{ "id": "Terraform's internal resource identifier. Structured as \"`project_id`,`region`,`gateway_id`,`connection_id`\".", "project_id": "STACKIT project ID.", "region": "STACKIT region.", @@ -66,7 +66,7 @@ var datasourceSchemaDescriptions = map[string]string{ "labels": "Map of custom labels.", } -var datasourceTunnelSchemaDescriptions = map[string]string{ +var dataSourceTunnelSchemaDescriptions = map[string]string{ "remote_address": "Remote peer IPv4 address for this tunnel.", "phase1": "IKE Phase 1 configuration.", "phase1_dh_groups": "Diffie-Hellman groups.", @@ -120,87 +120,87 @@ func (d *vpnConnectionDataSource) Schema(_ context.Context, _ datasource.SchemaR Computed: true, Attributes: map[string]schema.Attribute{ "remote_address": schema.StringAttribute{ - Description: datasourceTunnelSchemaDescriptions["remote_address"], + Description: dataSourceTunnelSchemaDescriptions["remote_address"], Computed: true, }, "phase1": schema.SingleNestedAttribute{ - Description: datasourceTunnelSchemaDescriptions["phase1"], + Description: dataSourceTunnelSchemaDescriptions["phase1"], Computed: true, Attributes: map[string]schema.Attribute{ "dh_groups": schema.ListAttribute{ - Description: datasourceTunnelSchemaDescriptions["phase1_dh_groups"], + Description: dataSourceTunnelSchemaDescriptions["phase1_dh_groups"], Computed: true, ElementType: types.StringType, }, "encryption_algorithms": schema.ListAttribute{ - Description: datasourceTunnelSchemaDescriptions["phase1_encryption_algorithms"], + Description: dataSourceTunnelSchemaDescriptions["phase1_encryption_algorithms"], Computed: true, ElementType: types.StringType, }, "integrity_algorithms": schema.ListAttribute{ - Description: datasourceTunnelSchemaDescriptions["phase1_integrity_algorithms"], + Description: dataSourceTunnelSchemaDescriptions["phase1_integrity_algorithms"], Computed: true, ElementType: types.StringType, }, "rekey_time": schema.Int32Attribute{ - Description: datasourceTunnelSchemaDescriptions["phase1_rekey_time"], + Description: dataSourceTunnelSchemaDescriptions["phase1_rekey_time"], Computed: true, }, }, }, "phase2": schema.SingleNestedAttribute{ - Description: datasourceTunnelSchemaDescriptions["phase2"], + Description: dataSourceTunnelSchemaDescriptions["phase2"], Computed: true, Attributes: map[string]schema.Attribute{ "dh_groups": schema.ListAttribute{ - Description: datasourceTunnelSchemaDescriptions["phase2_dh_groups"], + Description: dataSourceTunnelSchemaDescriptions["phase2_dh_groups"], Computed: true, ElementType: types.StringType, }, "encryption_algorithms": schema.ListAttribute{ - Description: datasourceTunnelSchemaDescriptions["phase2_encryption_algorithms"], + Description: dataSourceTunnelSchemaDescriptions["phase2_encryption_algorithms"], Computed: true, ElementType: types.StringType, }, "integrity_algorithms": schema.ListAttribute{ - Description: datasourceTunnelSchemaDescriptions["phase2_integrity_algorithms"], + Description: dataSourceTunnelSchemaDescriptions["phase2_integrity_algorithms"], Computed: true, ElementType: types.StringType, }, "rekey_time": schema.Int32Attribute{ - Description: datasourceTunnelSchemaDescriptions["phase2_rekey_time"], + Description: dataSourceTunnelSchemaDescriptions["phase2_rekey_time"], Computed: true, }, "start_action": schema.StringAttribute{ - Description: datasourceTunnelSchemaDescriptions["phase2_start_action"], + Description: dataSourceTunnelSchemaDescriptions["phase2_start_action"], Computed: true, }, "dpd_action": schema.StringAttribute{ - Description: datasourceTunnelSchemaDescriptions["phase2_dpd_action"], + Description: dataSourceTunnelSchemaDescriptions["phase2_dpd_action"], Computed: true, }, }, }, "peering": schema.SingleNestedAttribute{ - Description: datasourceTunnelSchemaDescriptions["peering"], + Description: dataSourceTunnelSchemaDescriptions["peering"], Computed: true, Attributes: map[string]schema.Attribute{ "local_address": schema.StringAttribute{ - Description: datasourceTunnelSchemaDescriptions["peering_local_address"], + Description: dataSourceTunnelSchemaDescriptions["peering_local_address"], Computed: true, }, "remote_address": schema.StringAttribute{ - Description: datasourceTunnelSchemaDescriptions["peering_remote_address"], + Description: dataSourceTunnelSchemaDescriptions["peering_remote_address"], Computed: true, }, }, }, "bgp": schema.SingleNestedAttribute{ - Description: datasourceTunnelSchemaDescriptions["bgp"], + Description: dataSourceTunnelSchemaDescriptions["bgp"], Computed: true, Attributes: map[string]schema.Attribute{ "remote_asn": schema.Int64Attribute{ - Description: datasourceTunnelSchemaDescriptions["bgp_remote_asn"], + Description: dataSourceTunnelSchemaDescriptions["bgp_remote_asn"], Computed: true, }, }, @@ -212,11 +212,11 @@ func (d *vpnConnectionDataSource) Schema(_ context.Context, _ datasource.SchemaR Description: fmt.Sprintf("VPN Connection data source schema. %s", core.DatasourceRegionFallbackDocstring), Attributes: map[string]schema.Attribute{ "id": schema.StringAttribute{ - Description: datasourceSchemaDescriptions["id"], + Description: dataSourceSchemaDescriptions["id"], Computed: true, }, "project_id": schema.StringAttribute{ - Description: datasourceSchemaDescriptions["project_id"], + Description: dataSourceSchemaDescriptions["project_id"], Required: true, Validators: []validator.String{ validate.UUID(), @@ -224,11 +224,11 @@ func (d *vpnConnectionDataSource) Schema(_ context.Context, _ datasource.SchemaR }, }, "region": schema.StringAttribute{ - Description: datasourceSchemaDescriptions["region"], + Description: dataSourceSchemaDescriptions["region"], Computed: true, }, "gateway_id": schema.StringAttribute{ - Description: datasourceSchemaDescriptions["gateway_id"], + Description: dataSourceSchemaDescriptions["gateway_id"], Required: true, Validators: []validator.String{ validate.UUID(), @@ -236,7 +236,7 @@ func (d *vpnConnectionDataSource) Schema(_ context.Context, _ datasource.SchemaR }, }, "connection_id": schema.StringAttribute{ - Description: datasourceSchemaDescriptions["connection_id"], + Description: dataSourceSchemaDescriptions["connection_id"], Required: true, Validators: []validator.String{ validate.UUID(), @@ -244,32 +244,32 @@ func (d *vpnConnectionDataSource) Schema(_ context.Context, _ datasource.SchemaR }, }, "display_name": schema.StringAttribute{ - Description: datasourceSchemaDescriptions["display_name"], + Description: dataSourceSchemaDescriptions["display_name"], Computed: true, }, "enabled": schema.BoolAttribute{ - Description: datasourceSchemaDescriptions["enabled"], + Description: dataSourceSchemaDescriptions["enabled"], Computed: true, }, "remote_subnet": schema.ListAttribute{ - Description: datasourceSchemaDescriptions["remote_subnet"], + Description: dataSourceSchemaDescriptions["remote_subnet"], Computed: true, ElementType: types.StringType, }, "local_subnet": schema.ListAttribute{ - Description: datasourceSchemaDescriptions["local_subnet"], + Description: dataSourceSchemaDescriptions["local_subnet"], Computed: true, ElementType: types.StringType, }, "static_routes": schema.ListAttribute{ - Description: datasourceSchemaDescriptions["static_routes"], + Description: dataSourceSchemaDescriptions["static_routes"], Computed: true, ElementType: types.StringType, }, "tunnel1": tunnelSchema, "tunnel2": tunnelSchema, "labels": schema.MapAttribute{ - Description: datasourceSchemaDescriptions["labels"], + Description: dataSourceSchemaDescriptions["labels"], Computed: true, ElementType: types.StringType, }, @@ -409,83 +409,15 @@ func mapDataSourceTunnel(ctx context.Context, apiTunnel *vpn.TunnelConfiguration tunnel := &DataSourceTunnelModel{ RemoteAddress: types.StringValue(string(apiTunnel.RemoteAddress)), } - phase1 := &Phase1Model{} - if len(apiTunnel.Phase1.DhGroups) > 0 { - list, diags := types.ListValueFrom(ctx, types.StringType, apiTunnel.Phase1.DhGroups) - if diags.HasError() { - return nil, fmt.Errorf("mapping phase1 dh_groups: %w", core.DiagsToError(diags)) - } - phase1.DhGroups = list - } else { - phase1.DhGroups = types.ListNull(types.StringType) - } - if len(apiTunnel.Phase1.EncryptionAlgorithms) > 0 { - list, diags := types.ListValueFrom(ctx, types.StringType, apiTunnel.Phase1.EncryptionAlgorithms) - if diags.HasError() { - return nil, fmt.Errorf("mapping phase1 encryption_algorithms: %w", core.DiagsToError(diags)) - } - phase1.EncryptionAlgorithms = list - } else { - phase1.EncryptionAlgorithms = types.ListNull(types.StringType) - } - if len(apiTunnel.Phase1.IntegrityAlgorithms) > 0 { - list, diags := types.ListValueFrom(ctx, types.StringType, apiTunnel.Phase1.IntegrityAlgorithms) - if diags.HasError() { - return nil, fmt.Errorf("mapping phase1 integrity_algorithms: %w", core.DiagsToError(diags)) - } - phase1.IntegrityAlgorithms = list - } else { - phase1.IntegrityAlgorithms = types.ListNull(types.StringType) - } - if apiTunnel.Phase1.RekeyTime != nil { - phase1.RekeyTime = types.Int32Value(*apiTunnel.Phase1.RekeyTime) - } else { - phase1.RekeyTime = types.Int32Null() + phase1, err := mapPhase1(ctx, apiTunnel.Phase1) + if err != nil { + return nil, err } tunnel.Phase1 = phase1 - phase2 := &Phase2Model{} - if len(apiTunnel.Phase2.DhGroups) > 0 { - list, diags := types.ListValueFrom(ctx, types.StringType, apiTunnel.Phase2.DhGroups) - if diags.HasError() { - return nil, fmt.Errorf("mapping phase2 dh_groups: %w", core.DiagsToError(diags)) - } - phase2.DhGroups = list - } else { - phase2.DhGroups = types.ListNull(types.StringType) - } - if len(apiTunnel.Phase2.EncryptionAlgorithms) > 0 { - list, diags := types.ListValueFrom(ctx, types.StringType, apiTunnel.Phase2.EncryptionAlgorithms) - if diags.HasError() { - return nil, fmt.Errorf("mapping phase2 encryption_algorithms: %w", core.DiagsToError(diags)) - } - phase2.EncryptionAlgorithms = list - } else { - phase2.EncryptionAlgorithms = types.ListNull(types.StringType) - } - if len(apiTunnel.Phase2.IntegrityAlgorithms) > 0 { - list, diags := types.ListValueFrom(ctx, types.StringType, apiTunnel.Phase2.IntegrityAlgorithms) - if diags.HasError() { - return nil, fmt.Errorf("mapping phase2 integrity_algorithms: %w", core.DiagsToError(diags)) - } - phase2.IntegrityAlgorithms = list - } else { - phase2.IntegrityAlgorithms = types.ListNull(types.StringType) - } - if apiTunnel.Phase2.RekeyTime != nil { - phase2.RekeyTime = types.Int32Value(*apiTunnel.Phase2.RekeyTime) - } else { - phase2.RekeyTime = types.Int32Null() - } - if apiTunnel.Phase2.StartAction != nil { - phase2.StartAction = types.StringValue(string(*apiTunnel.Phase2.StartAction)) - } else { - phase2.StartAction = types.StringNull() - } - if apiTunnel.Phase2.DpdAction != nil { - phase2.DpdAction = types.StringValue(string(*apiTunnel.Phase2.DpdAction)) - } else { - phase2.DpdAction = types.StringNull() + phase2, err := mapPhase2(ctx, apiTunnel.Phase2) + if err != nil { + return nil, err } tunnel.Phase2 = phase2 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..33ef68fad --- /dev/null +++ b/stackit/internal/services/vpn/connection/datasource_test.go @@ -0,0 +1,157 @@ +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" + 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{ + 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.ListNull(types.StringType), + Tunnel1: fixtureDataSourceTunnelModel(), + Tunnel2: fixtureDataSourceTunnelModel(func(m *DataSourceTunnelModel) { + m.RemoteAddress = types.StringValue("203.0.113.2") + }), + Labels: types.MapNull(types.StringType), + } + 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: "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: "nil_response", + input: nil, + expected: DataSourceModel{}, + isValid: false, + }, + { + description: "nil_connection_id", + input: &vpn.ConnectionResponse{ + Id: nil, + DisplayName: "test-connection", + }, + expected: DataSourceModel{}, + isValid: false, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + state := &DataSourceModel{ + 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 index 193eb64c9..c78b61c3f 100644 --- a/stackit/internal/services/vpn/connection/resource.go +++ b/stackit/internal/services/vpn/connection/resource.go @@ -42,20 +42,21 @@ var ( _ resource.ResourceWithModifyPlan = &vpnConnectionResource{} ) -type Phase1Model struct { +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 { - DhGroups types.List `tfsdk:"dh_groups"` - EncryptionAlgorithms types.List `tfsdk:"encryption_algorithms"` - IntegrityAlgorithms types.List `tfsdk:"integrity_algorithms"` - RekeyTime types.Int32 `tfsdk:"rekey_time"` - StartAction types.String `tfsdk:"start_action"` - DpdAction types.String `tfsdk:"dpd_action"` + StartAction types.String `tfsdk:"start_action"` + DpdAction types.String `tfsdk:"dpd_action"` + BasePhaseModel } type PeeringConfigModel struct { @@ -1076,83 +1077,15 @@ func mapTunnel(ctx context.Context, apiTunnel *vpn.TunnelConfiguration, currentT RemoteAddress: types.StringValue(string(apiTunnel.RemoteAddress)), PreSharedKey: currentTunnel.PreSharedKey, } - phase1 := &Phase1Model{} - if len(apiTunnel.Phase1.DhGroups) > 0 { - list, diags := types.ListValueFrom(ctx, types.StringType, apiTunnel.Phase1.DhGroups) - if diags.HasError() { - return nil, fmt.Errorf("mapping phase1 dh_groups: %w", core.DiagsToError(diags)) - } - phase1.DhGroups = list - } else { - phase1.DhGroups = types.ListNull(types.StringType) - } - if len(apiTunnel.Phase1.EncryptionAlgorithms) > 0 { - list, diags := types.ListValueFrom(ctx, types.StringType, apiTunnel.Phase1.EncryptionAlgorithms) - if diags.HasError() { - return nil, fmt.Errorf("mapping phase1 encryption_algorithms: %w", core.DiagsToError(diags)) - } - phase1.EncryptionAlgorithms = list - } else { - phase1.EncryptionAlgorithms = types.ListNull(types.StringType) - } - if len(apiTunnel.Phase1.IntegrityAlgorithms) > 0 { - list, diags := types.ListValueFrom(ctx, types.StringType, apiTunnel.Phase1.IntegrityAlgorithms) - if diags.HasError() { - return nil, fmt.Errorf("mapping phase1 integrity_algorithms: %w", core.DiagsToError(diags)) - } - phase1.IntegrityAlgorithms = list - } else { - phase1.IntegrityAlgorithms = types.ListNull(types.StringType) - } - if apiTunnel.Phase1.RekeyTime != nil { - phase1.RekeyTime = types.Int32Value(*apiTunnel.Phase1.RekeyTime) - } else { - phase1.RekeyTime = types.Int32Null() + phase1, err := mapPhase1(ctx, apiTunnel.Phase1) + if err != nil { + return nil, err } tunnel.Phase1 = phase1 - phase2 := &Phase2Model{} - if len(apiTunnel.Phase2.DhGroups) > 0 { - list, diags := types.ListValueFrom(ctx, types.StringType, apiTunnel.Phase2.DhGroups) - if diags.HasError() { - return nil, fmt.Errorf("mapping phase2 dh_groups: %w", core.DiagsToError(diags)) - } - phase2.DhGroups = list - } else { - phase2.DhGroups = types.ListNull(types.StringType) - } - if len(apiTunnel.Phase2.EncryptionAlgorithms) > 0 { - list, diags := types.ListValueFrom(ctx, types.StringType, apiTunnel.Phase2.EncryptionAlgorithms) - if diags.HasError() { - return nil, fmt.Errorf("mapping phase2 encryption_algorithms: %w", core.DiagsToError(diags)) - } - phase2.EncryptionAlgorithms = list - } else { - phase2.EncryptionAlgorithms = types.ListNull(types.StringType) - } - if len(apiTunnel.Phase2.IntegrityAlgorithms) > 0 { - list, diags := types.ListValueFrom(ctx, types.StringType, apiTunnel.Phase2.IntegrityAlgorithms) - if diags.HasError() { - return nil, fmt.Errorf("mapping phase2 integrity_algorithms: %w", core.DiagsToError(diags)) - } - phase2.IntegrityAlgorithms = list - } else { - phase2.IntegrityAlgorithms = types.ListNull(types.StringType) - } - if apiTunnel.Phase2.RekeyTime != nil { - phase2.RekeyTime = types.Int32Value(*apiTunnel.Phase2.RekeyTime) - } else { - phase2.RekeyTime = types.Int32Null() - } - if apiTunnel.Phase2.StartAction != nil { - phase2.StartAction = types.StringValue(string(*apiTunnel.Phase2.StartAction)) - } else { - phase2.StartAction = types.StringNull() - } - if apiTunnel.Phase2.DpdAction != nil { - phase2.DpdAction = types.StringValue(string(*apiTunnel.Phase2.DpdAction)) - } else { - phase2.DpdAction = types.StringNull() + phase2, err := mapPhase2(ctx, apiTunnel.Phase2) + if err != nil { + return nil, err } tunnel.Phase2 = phase2 @@ -1186,3 +1119,90 @@ func mapTunnel(ctx context.Context, apiTunnel *vpn.TunnelConfiguration, currentT return tunnel, nil } + +type BasePhaseFields struct { + DhGroups []vpn.PhaseDhGroupsInner + EncryptionAlgorithms []vpn.PhaseEncryptionAlgorithmsInner + IntegrityAlgorithms []vpn.PhaseIntegrityAlgorithmsInner + RekeyTime *int32 +} + +func mapBasePhase(ctx context.Context, apiPhase BasePhaseFields) (phase BasePhaseModel, err error) { + if len(apiPhase.DhGroups) > 0 { + list, diags := types.ListValueFrom(ctx, types.StringType, apiPhase.DhGroups) + if diags.HasError() { + err = fmt.Errorf("mapping base phase dh_groups: %w", core.DiagsToError(diags)) + return + } + phase.DhGroups = list + } else { + phase.DhGroups = types.ListNull(types.StringType) + } + if len(apiPhase.EncryptionAlgorithms) > 0 { + list, diags := types.ListValueFrom(ctx, types.StringType, apiPhase.EncryptionAlgorithms) + if diags.HasError() { + err = fmt.Errorf("mapping base phase encryption_algorithms: %w", core.DiagsToError(diags)) + return + } + phase.EncryptionAlgorithms = list + } else { + phase.EncryptionAlgorithms = types.ListNull(types.StringType) + } + if len(apiPhase.IntegrityAlgorithms) > 0 { + list, diags := types.ListValueFrom(ctx, types.StringType, apiPhase.IntegrityAlgorithms) + if diags.HasError() { + err = fmt.Errorf("mapping base phase integrity_algorithms: %w", core.DiagsToError(diags)) + return + } + phase.IntegrityAlgorithms = list + } else { + phase.IntegrityAlgorithms = types.ListNull(types.StringType) + } + if apiPhase.RekeyTime != nil { + phase.RekeyTime = types.Int32Value(*apiPhase.RekeyTime) + } else { + phase.RekeyTime = types.Int32Null() + } + return +} + +func mapPhase1(ctx context.Context, apiPhase1 vpn.TunnelConfigurationPhase1) (*Phase1Model, error) { + basePhase, err := mapBasePhase(ctx, BasePhaseFields{ + DhGroups: apiPhase1.DhGroups, + EncryptionAlgorithms: apiPhase1.EncryptionAlgorithms, + IntegrityAlgorithms: apiPhase1.IntegrityAlgorithms, + RekeyTime: apiPhase1.RekeyTime, + }) + if err != nil { + return nil, err + } + return &Phase1Model{ + BasePhaseModel: basePhase, + }, nil +} + +func mapPhase2(ctx context.Context, apiPhase2 vpn.TunnelConfigurationPhase2) (*Phase2Model, error) { + basePhase, err := mapBasePhase(ctx, BasePhaseFields{ + DhGroups: apiPhase2.DhGroups, + EncryptionAlgorithms: apiPhase2.EncryptionAlgorithms, + IntegrityAlgorithms: apiPhase2.IntegrityAlgorithms, + RekeyTime: apiPhase2.RekeyTime, + }) + if err != nil { + return nil, err + } + phase2 := &Phase2Model{ + BasePhaseModel: basePhase, + } + if apiPhase2.StartAction != nil { + phase2.StartAction = types.StringValue(string(*apiPhase2.StartAction)) + } else { + phase2.StartAction = types.StringNull() + } + if apiPhase2.DpdAction != nil { + phase2.DpdAction = types.StringValue(string(*apiPhase2.DpdAction)) + } else { + phase2.DpdAction = types.StringNull() + } + return phase2, nil +} diff --git a/stackit/internal/services/vpn/connection/resource_test.go b/stackit/internal/services/vpn/connection/resource_test.go index a1bf3c2f3..a3b6dd19e 100644 --- a/stackit/internal/services/vpn/connection/resource_test.go +++ b/stackit/internal/services/vpn/connection/resource_test.go @@ -20,6 +20,187 @@ var ( 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"}, + 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), + 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{ + 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.ListNull(types.StringType), + Tunnel1: fixtureTunnelModel(), + Tunnel2: fixtureTunnelModel(func(m *TunnelModel) { + m.RemoteAddress = types.StringValue("203.0.113.2") + }), + Labels: types.MapNull(types.StringType), + } + 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", + }, + 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", + }, + 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 @@ -29,46 +210,16 @@ func TestMapFields(t *testing.T) { }{ { description: "basic_connection", + input: fixtureConnectionResponse(), + expected: fixtureModel(), + isValid: true, + }, + { + description: "minimal_connection", input: &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"}, - Tunnel1: 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(), - }, - }, - Tunnel2: vpn.TunnelConfiguration{ - RemoteAddress: "203.0.113.2", - 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(), - }, - }, + Id: new("connection-id"), + Tunnel1: vpn.TunnelConfiguration{}, + Tunnel2: vpn.TunnelConfiguration{}, }, expected: Model{ ID: types.StringValue(fmt.Sprintf("%s,%s,%s,%s", projectId, region, gatewayId, "connection-id")), @@ -76,73 +227,45 @@ func TestMapFields(t *testing.T) { ProjectID: types.StringValue(projectId), Region: types.StringValue(region), GatewayID: types.StringValue(gatewayId), - DisplayName: types.StringValue("test-connection"), + DisplayName: types.StringValue(""), 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"), - }), + RemoteSubnet: types.ListNull(types.StringType), + LocalSubnet: types.ListNull(types.StringType), StaticRoutes: types.ListNull(types.StringType), Tunnel1: &TunnelModel{ PreSharedKeyWoVersion: types.Int64Value(1), - RemoteAddress: types.StringValue("203.0.113.1"), + RemoteAddress: types.StringValue(""), Phase1: &Phase1Model{ - 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), + BasePhaseModel: BasePhaseModel{ + DhGroups: types.ListNull(types.StringType), + EncryptionAlgorithms: types.ListNull(types.StringType), + IntegrityAlgorithms: types.ListNull(types.StringType), + }, }, Phase2: &Phase2Model{ - 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(3600), - StartAction: types.StringValue("start"), - DpdAction: types.StringValue("restart"), + BasePhaseModel: BasePhaseModel{ + DhGroups: types.ListNull(types.StringType), + EncryptionAlgorithms: types.ListNull(types.StringType), + IntegrityAlgorithms: types.ListNull(types.StringType), + }, }, }, Tunnel2: &TunnelModel{ PreSharedKeyWoVersion: types.Int64Value(1), - RemoteAddress: types.StringValue("203.0.113.2"), + RemoteAddress: types.StringValue(""), Phase1: &Phase1Model{ - 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), + BasePhaseModel: BasePhaseModel{ + DhGroups: types.ListNull(types.StringType), + EncryptionAlgorithms: types.ListNull(types.StringType), + IntegrityAlgorithms: types.ListNull(types.StringType), + }, }, Phase2: &Phase2Model{ - 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(3600), - StartAction: types.StringValue("start"), - DpdAction: types.StringValue("restart"), + BasePhaseModel: BasePhaseModel{ + DhGroups: types.ListNull(types.StringType), + EncryptionAlgorithms: types.ListNull(types.StringType), + IntegrityAlgorithms: types.ListNull(types.StringType), + }, }, }, Labels: types.MapNull(types.StringType), @@ -151,349 +274,43 @@ func TestMapFields(t *testing.T) { }, { description: "connection_with_static_routes_and_bgp", - input: &vpn.ConnectionResponse{ - Id: new("conn-id-2"), - DisplayName: "bgp-connection", - Enabled: new(false), - StaticRoutes: []string{"10.0.0.0/8"}, - Tunnel1: vpn.TunnelConfiguration{ - RemoteAddress: "203.0.113.10", - Phase1: vpn.TunnelConfigurationPhase1{ - EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256gcm16"}, - IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_384"}, - }, - Phase2: vpn.TunnelConfigurationPhase2{ - EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256gcm16"}, - IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_384"}, - StartAction: vpn.TUNNELCONFIGURATIONPHASE2ALLOFSTARTACTION_NONE.Ptr(), - DpdAction: vpn.TUNNELCONFIGURATIONPHASE2ALLOFDPDACTION_CLEAR.Ptr(), - }, - Peering: &vpn.PeeringConfig{ - LocalAddress: new("169.254.0.1"), - RemoteAddress: new("169.254.0.2"), - }, - Bgp: &vpn.BGPTunnelConfig{ - RemoteAsn: 65000, - }, - }, - Tunnel2: vpn.TunnelConfiguration{ - RemoteAddress: "203.0.113.11", - Phase1: vpn.TunnelConfigurationPhase1{ - EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256gcm16"}, - IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_384"}, - }, - Phase2: vpn.TunnelConfigurationPhase2{ - EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256gcm16"}, - IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_384"}, - StartAction: vpn.TUNNELCONFIGURATIONPHASE2ALLOFSTARTACTION_NONE.Ptr(), - DpdAction: vpn.TUNNELCONFIGURATIONPHASE2ALLOFDPDACTION_CLEAR.Ptr(), - }, - }, - }, - expected: Model{ - ID: types.StringValue(fmt.Sprintf("%s,%s,%s,%s", projectId, region, gatewayId, "conn-id-2")), - ConnectionID: types.StringValue("conn-id-2"), - ProjectID: types.StringValue(projectId), - Region: types.StringValue(region), - GatewayID: types.StringValue(gatewayId), - DisplayName: types.StringValue("bgp-connection"), - Enabled: types.BoolValue(false), - RemoteSubnet: types.ListNull(types.StringType), - LocalSubnet: types.ListNull(types.StringType), - StaticRoutes: types.ListValueMust(types.StringType, []attr.Value{ + 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"), - }), - Tunnel1: &TunnelModel{ - PreSharedKeyWoVersion: types.Int64Value(1), - RemoteAddress: types.StringValue("203.0.113.10"), - Phase1: &Phase1Model{ - DhGroups: types.ListNull(types.StringType), - EncryptionAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("aes256gcm16")}), - IntegrityAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("sha2_384")}), - RekeyTime: types.Int32Null(), - }, - Phase2: &Phase2Model{ - DhGroups: types.ListNull(types.StringType), - EncryptionAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("aes256gcm16")}), - IntegrityAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("sha2_384")}), - RekeyTime: types.Int32Null(), - StartAction: types.StringValue("none"), - DpdAction: types.StringValue("clear"), - }, - Peering: &PeeringConfigModel{ - LocalAddress: types.StringValue("169.254.0.1"), - RemoteAddress: types.StringValue("169.254.0.2"), - }, - Bgp: &BGPTunnelConfigModel{ - RemoteAsn: types.Int64Value(65000), - }, - }, - Tunnel2: &TunnelModel{ - PreSharedKeyWoVersion: types.Int64Value(1), - RemoteAddress: types.StringValue("203.0.113.11"), - Phase1: &Phase1Model{ - DhGroups: types.ListNull(types.StringType), - EncryptionAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("aes256gcm16")}), - IntegrityAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("sha2_384")}), - RekeyTime: types.Int32Null(), - }, - Phase2: &Phase2Model{ - DhGroups: types.ListNull(types.StringType), - EncryptionAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("aes256gcm16")}), - IntegrityAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("sha2_384")}), - RekeyTime: types.Int32Null(), - StartAction: types.StringValue("none"), - DpdAction: types.StringValue("clear"), - }, - }, - Labels: types.MapNull(types.StringType), - }, + }) + m.Tunnel1.Bgp = &BGPTunnelConfigModel{ + RemoteAsn: types.Int64Value(65000), + } + }), isValid: true, }, { description: "multiple_static_routes", - input: &vpn.ConnectionResponse{ - Id: new("conn-id-3"), - DisplayName: "static-routes-connection", - StaticRoutes: []string{"10.0.0.0/8", "172.16.0.0/12"}, - Tunnel1: vpn.TunnelConfiguration{ - RemoteAddress: "1.2.3.4", - Phase1: vpn.TunnelConfigurationPhase1{ - EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256"}, - IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_256"}, - }, - Phase2: vpn.TunnelConfigurationPhase2{ - EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256"}, - IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_256"}, - }, - }, - Tunnel2: vpn.TunnelConfiguration{ - RemoteAddress: "5.6.7.8", - Phase1: vpn.TunnelConfigurationPhase1{ - EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256"}, - IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_256"}, - }, - Phase2: vpn.TunnelConfigurationPhase2{ - EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256"}, - IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_256"}, - }, - }, - }, - expected: Model{ - ID: types.StringValue(fmt.Sprintf("%s,%s,%s,%s", projectId, region, gatewayId, "conn-id-3")), - ConnectionID: types.StringValue("conn-id-3"), - ProjectID: types.StringValue(projectId), - Region: types.StringValue(region), - GatewayID: types.StringValue(gatewayId), - DisplayName: types.StringValue("static-routes-connection"), - Enabled: types.BoolValue(true), - RemoteSubnet: types.ListNull(types.StringType), - LocalSubnet: types.ListNull(types.StringType), - StaticRoutes: types.ListValueMust(types.StringType, []attr.Value{ + 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"), - }), - Tunnel1: &TunnelModel{ - PreSharedKeyWoVersion: types.Int64Value(1), - RemoteAddress: types.StringValue("1.2.3.4"), - Phase1: &Phase1Model{ - DhGroups: types.ListNull(types.StringType), - EncryptionAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("aes256")}), - IntegrityAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("sha2_256")}), - RekeyTime: types.Int32Null(), - }, - Phase2: &Phase2Model{ - DhGroups: types.ListNull(types.StringType), - EncryptionAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("aes256")}), - IntegrityAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("sha2_256")}), - RekeyTime: types.Int32Null(), - StartAction: types.StringNull(), - DpdAction: types.StringNull(), - }, - }, - Tunnel2: &TunnelModel{ - PreSharedKeyWoVersion: types.Int64Value(1), - RemoteAddress: types.StringValue("5.6.7.8"), - Phase1: &Phase1Model{ - DhGroups: types.ListNull(types.StringType), - EncryptionAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("aes256")}), - IntegrityAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("sha2_256")}), - RekeyTime: types.Int32Null(), - }, - Phase2: &Phase2Model{ - DhGroups: types.ListNull(types.StringType), - EncryptionAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("aes256")}), - IntegrityAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("sha2_256")}), - RekeyTime: types.Int32Null(), - StartAction: types.StringNull(), - DpdAction: types.StringNull(), - }, - }, - Labels: types.MapNull(types.StringType), - }, + }) + }), isValid: true, }, { description: "empty_labels", - input: &vpn.ConnectionResponse{ - Id: new("conn-id-4"), - DisplayName: "empty-labels-connection", - Labels: &map[string]string{}, - Tunnel1: vpn.TunnelConfiguration{ - RemoteAddress: "1.2.3.4", - Phase1: vpn.TunnelConfigurationPhase1{ - EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256"}, - IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_256"}, - }, - Phase2: vpn.TunnelConfigurationPhase2{ - EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256"}, - IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_256"}, - }, - }, - Tunnel2: vpn.TunnelConfiguration{ - RemoteAddress: "5.6.7.8", - Phase1: vpn.TunnelConfigurationPhase1{ - EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256"}, - IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_256"}, - }, - Phase2: vpn.TunnelConfigurationPhase2{ - EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256"}, - IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_256"}, - }, - }, - }, - expected: Model{ - ID: types.StringValue(fmt.Sprintf("%s,%s,%s,%s", projectId, region, gatewayId, "conn-id-4")), - ConnectionID: types.StringValue("conn-id-4"), - ProjectID: types.StringValue(projectId), - Region: types.StringValue(region), - GatewayID: types.StringValue(gatewayId), - DisplayName: types.StringValue("empty-labels-connection"), - Enabled: types.BoolValue(true), - RemoteSubnet: types.ListNull(types.StringType), - LocalSubnet: types.ListNull(types.StringType), - StaticRoutes: types.ListNull(types.StringType), - Tunnel1: &TunnelModel{ - PreSharedKeyWoVersion: types.Int64Value(1), - RemoteAddress: types.StringValue("1.2.3.4"), - Phase1: &Phase1Model{ - DhGroups: types.ListNull(types.StringType), - EncryptionAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("aes256")}), - IntegrityAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("sha2_256")}), - RekeyTime: types.Int32Null(), - }, - Phase2: &Phase2Model{ - DhGroups: types.ListNull(types.StringType), - EncryptionAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("aes256")}), - IntegrityAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("sha2_256")}), - RekeyTime: types.Int32Null(), - StartAction: types.StringNull(), - DpdAction: types.StringNull(), - }, - }, - Tunnel2: &TunnelModel{ - PreSharedKeyWoVersion: types.Int64Value(1), - RemoteAddress: types.StringValue("5.6.7.8"), - Phase1: &Phase1Model{ - DhGroups: types.ListNull(types.StringType), - EncryptionAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("aes256")}), - IntegrityAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("sha2_256")}), - RekeyTime: types.Int32Null(), - }, - Phase2: &Phase2Model{ - DhGroups: types.ListNull(types.StringType), - EncryptionAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("aes256")}), - IntegrityAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("sha2_256")}), - RekeyTime: types.Int32Null(), - StartAction: types.StringNull(), - DpdAction: types.StringNull(), - }, - }, - Labels: types.MapNull(types.StringType), - }, - isValid: true, - }, - { - description: "asymmetric_phase_fields", - input: &vpn.ConnectionResponse{ - Id: new("conn-id-5"), - DisplayName: "asymmetric-connection", - Tunnel1: vpn.TunnelConfiguration{ - RemoteAddress: "1.2.3.4", - Phase1: vpn.TunnelConfigurationPhase1{ - EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256"}, - IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_256"}, - RekeyTime: new(int32(7200)), - }, - Phase2: vpn.TunnelConfigurationPhase2{ - EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256"}, - IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_256"}, - RekeyTime: new(int32(1800)), - StartAction: vpn.TUNNELCONFIGURATIONPHASE2ALLOFSTARTACTION_NONE.Ptr(), - DpdAction: vpn.TUNNELCONFIGURATIONPHASE2ALLOFDPDACTION_CLEAR.Ptr(), - }, - }, - Tunnel2: vpn.TunnelConfiguration{ - RemoteAddress: "5.6.7.8", - Phase1: vpn.TunnelConfigurationPhase1{ - EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256"}, - IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_256"}, - }, - Phase2: vpn.TunnelConfigurationPhase2{ - EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256"}, - IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_256"}, - }, - }, - }, - expected: Model{ - ID: types.StringValue(fmt.Sprintf("%s,%s,%s,%s", projectId, region, gatewayId, "conn-id-5")), - ConnectionID: types.StringValue("conn-id-5"), - ProjectID: types.StringValue(projectId), - Region: types.StringValue(region), - GatewayID: types.StringValue(gatewayId), - DisplayName: types.StringValue("asymmetric-connection"), - Enabled: types.BoolValue(true), - RemoteSubnet: types.ListNull(types.StringType), - LocalSubnet: types.ListNull(types.StringType), - StaticRoutes: types.ListNull(types.StringType), - Tunnel1: &TunnelModel{ - PreSharedKeyWoVersion: types.Int64Value(1), - RemoteAddress: types.StringValue("1.2.3.4"), - Phase1: &Phase1Model{ - DhGroups: types.ListNull(types.StringType), - EncryptionAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("aes256")}), - IntegrityAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("sha2_256")}), - RekeyTime: types.Int32Value(7200), - }, - Phase2: &Phase2Model{ - DhGroups: types.ListNull(types.StringType), - EncryptionAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("aes256")}), - IntegrityAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("sha2_256")}), - RekeyTime: types.Int32Value(1800), - StartAction: types.StringValue("none"), - DpdAction: types.StringValue("clear"), - }, - }, - Tunnel2: &TunnelModel{ - PreSharedKeyWoVersion: types.Int64Value(1), - RemoteAddress: types.StringValue("5.6.7.8"), - Phase1: &Phase1Model{ - DhGroups: types.ListNull(types.StringType), - EncryptionAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("aes256")}), - IntegrityAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("sha2_256")}), - RekeyTime: types.Int32Null(), - }, - Phase2: &Phase2Model{ - DhGroups: types.ListNull(types.StringType), - EncryptionAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("aes256")}), - IntegrityAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("sha2_256")}), - RekeyTime: types.Int32Null(), - StartAction: types.StringNull(), - DpdAction: types.StringNull(), - }, - }, - Labels: types.MapNull(types.StringType), - }, + input: fixtureConnectionResponse(func(m *vpn.ConnectionResponse) { + m.Labels = &map[string]string{} + }), + expected: fixtureModel(func(m *Model) { + m.Labels = types.MapNull(types.StringType) + }), isValid: true, }, { @@ -552,167 +369,21 @@ func TestToCreatePayload(t *testing.T) { }{ { description: "basic_connection", - input: &Model{ - DisplayName: types.StringValue("test-connection"), - Enabled: types.BoolValue(true), - RemoteSubnet: types.ListNull(types.StringType), - LocalSubnet: types.ListNull(types.StringType), - StaticRoutes: types.ListNull(types.StringType), - Tunnel1: &TunnelModel{ - RemoteAddress: types.StringValue("203.0.113.1"), - PreSharedKeyWo: types.StringValue("secret123-at-least-20-chars"), - Phase1: &Phase1Model{ - DhGroups: types.ListNull(types.StringType), - EncryptionAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("aes256")}), - IntegrityAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("sha2_384")}), - RekeyTime: types.Int32Null(), - }, - Phase2: &Phase2Model{ - DhGroups: types.ListNull(types.StringType), - EncryptionAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("aes256")}), - IntegrityAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("sha2_384")}), - RekeyTime: types.Int32Null(), - StartAction: types.StringNull(), - DpdAction: types.StringNull(), - }, - }, - Tunnel2: &TunnelModel{ - RemoteAddress: types.StringValue("203.0.113.2"), - PreSharedKeyWo: types.StringValue("secret456-at-least-20-chars"), - Phase1: &Phase1Model{ - DhGroups: types.ListNull(types.StringType), - EncryptionAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("aes256")}), - IntegrityAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("sha2_384")}), - RekeyTime: types.Int32Null(), - }, - Phase2: &Phase2Model{ - DhGroups: types.ListNull(types.StringType), - EncryptionAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("aes256")}), - IntegrityAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("sha2_384")}), - RekeyTime: types.Int32Null(), - StartAction: types.StringNull(), - DpdAction: types.StringNull(), - }, - }, - }, - expected: &vpn.CreateGatewayConnectionPayload{ - DisplayName: "test-connection", - Tunnel1: vpn.TunnelConfiguration{ - PreSharedKey: new("secret123-at-least-20-chars"), - RemoteAddress: "203.0.113.1", - Phase1: vpn.TunnelConfigurationPhase1{ - DhGroups: nil, - EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256"}, - IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_384"}, - }, - Phase2: vpn.TunnelConfigurationPhase2{ - DhGroups: nil, - EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256"}, - IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_384"}, - }, - }, - Tunnel2: vpn.TunnelConfiguration{ - PreSharedKey: new("secret456-at-least-20-chars"), - RemoteAddress: "203.0.113.2", - Phase1: vpn.TunnelConfigurationPhase1{ - DhGroups: nil, - EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256"}, - IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_384"}, - }, - Phase2: vpn.TunnelConfigurationPhase2{ - DhGroups: nil, - EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256"}, - IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_384"}, - }, - }, - Enabled: new(true), - Labels: &map[string]string{}, - }, - isValid: true, + input: new(fixtureModel(func(m *Model) { + m.Tunnel1.PreSharedKeyWo = types.StringValue("secret123-at-least-20-chars") + m.Tunnel2.PreSharedKeyWo = types.StringValue("secret456-at-least-20-chars") + })), + expected: fixtureCreatePayload(), + isValid: true, }, { - description: "with_phase2_fields", + description: "minimal_create", input: &Model{ - DisplayName: types.StringValue("test"), - Enabled: types.BoolValue(true), - RemoteSubnet: types.ListNull(types.StringType), - LocalSubnet: types.ListNull(types.StringType), - StaticRoutes: types.ListNull(types.StringType), - Tunnel1: &TunnelModel{ - RemoteAddress: types.StringValue("1.2.3.4"), - PreSharedKeyWo: types.StringValue("super-secret-key-at-least-20"), - PreSharedKeyWoVersion: types.Int64Null(), - Phase1: &Phase1Model{ - DhGroups: types.ListNull(types.StringType), - EncryptionAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("aes256")}), - IntegrityAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("sha2_384")}), - RekeyTime: types.Int32Value(7200), - }, - Phase2: &Phase2Model{ - DhGroups: types.ListNull(types.StringType), - EncryptionAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("aes256")}), - IntegrityAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("sha2_384")}), - RekeyTime: types.Int32Value(1800), - StartAction: types.StringValue("none"), - DpdAction: types.StringValue("clear"), - }, - }, - Tunnel2: &TunnelModel{ - RemoteAddress: types.StringValue("5.6.7.8"), - PreSharedKeyWo: types.StringValue("super-secret-key-at-least-20"), - PreSharedKeyWoVersion: types.Int64Null(), - Phase1: &Phase1Model{ - DhGroups: types.ListNull(types.StringType), - EncryptionAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("aes256")}), - IntegrityAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("sha2_384")}), - RekeyTime: types.Int32Null(), - }, - Phase2: &Phase2Model{ - DhGroups: types.ListNull(types.StringType), - EncryptionAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("aes256")}), - IntegrityAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("sha2_384")}), - RekeyTime: types.Int32Null(), - StartAction: types.StringNull(), - DpdAction: types.StringNull(), - }, - }, + Tunnel1: &TunnelModel{}, + Tunnel2: &TunnelModel{}, }, expected: &vpn.CreateGatewayConnectionPayload{ - DisplayName: "test", - Tunnel1: vpn.TunnelConfiguration{ - PreSharedKey: new("super-secret-key-at-least-20"), - RemoteAddress: "1.2.3.4", - Phase1: vpn.TunnelConfigurationPhase1{ - DhGroups: nil, - EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256"}, - IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_384"}, - RekeyTime: new(int32(7200)), - }, - Phase2: vpn.TunnelConfigurationPhase2{ - DhGroups: nil, - EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256"}, - IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_384"}, - RekeyTime: new(int32(1800)), - StartAction: vpn.TUNNELCONFIGURATIONPHASE2ALLOFSTARTACTION_NONE.Ptr(), - DpdAction: vpn.TUNNELCONFIGURATIONPHASE2ALLOFDPDACTION_CLEAR.Ptr(), - }, - }, - Tunnel2: vpn.TunnelConfiguration{ - PreSharedKey: new("super-secret-key-at-least-20"), - RemoteAddress: "5.6.7.8", - Phase1: vpn.TunnelConfigurationPhase1{ - DhGroups: nil, - EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256"}, - IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_384"}, - }, - Phase2: vpn.TunnelConfigurationPhase2{ - DhGroups: nil, - EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256"}, - IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_384"}, - }, - }, - Labels: &map[string]string{}, - Enabled: new(true), + Labels: &map[string]string{}, }, isValid: true, }, @@ -752,161 +423,21 @@ func TestToUpdatePayload(t *testing.T) { }{ { description: "basic_update", - input: &Model{ - DisplayName: types.StringValue("updated-connection"), - Enabled: types.BoolValue(false), - RemoteSubnet: types.ListNull(types.StringType), - LocalSubnet: types.ListNull(types.StringType), - StaticRoutes: types.ListNull(types.StringType), - Tunnel1: &TunnelModel{ - RemoteAddress: types.StringValue("203.0.113.1"), - PreSharedKeyWo: types.StringValue("secret123-at-least-20-chars"), - Phase1: &Phase1Model{ - DhGroups: types.ListNull(types.StringType), - EncryptionAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("aes256")}), - IntegrityAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("sha2_384")}), - RekeyTime: types.Int32Null(), - }, - Phase2: &Phase2Model{ - DhGroups: types.ListNull(types.StringType), - EncryptionAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("aes256")}), - IntegrityAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("sha2_384")}), - RekeyTime: types.Int32Null(), - StartAction: types.StringNull(), - DpdAction: types.StringNull(), - }, - }, - Tunnel2: &TunnelModel{ - RemoteAddress: types.StringValue("203.0.113.2"), - PreSharedKeyWo: types.StringValue("secret456-at-least-20-chars"), - Phase1: &Phase1Model{ - DhGroups: types.ListNull(types.StringType), - EncryptionAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("aes256")}), - IntegrityAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("sha2_384")}), - RekeyTime: types.Int32Null(), - }, - Phase2: &Phase2Model{ - DhGroups: types.ListNull(types.StringType), - EncryptionAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("aes256")}), - IntegrityAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("sha2_384")}), - RekeyTime: types.Int32Null(), - StartAction: types.StringNull(), - DpdAction: types.StringNull(), - }, - }, - }, - expected: &vpn.UpdateGatewayConnectionPayload{ - DisplayName: "updated-connection", - Tunnel1: vpn.TunnelConfiguration{ - PreSharedKey: new("secret123-at-least-20-chars"), - RemoteAddress: "203.0.113.1", - Phase1: vpn.TunnelConfigurationPhase1{ - DhGroups: nil, - EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256"}, - IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_384"}, - }, - Phase2: vpn.TunnelConfigurationPhase2{ - DhGroups: nil, - EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256"}, - IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_384"}, - }, - }, - Tunnel2: vpn.TunnelConfiguration{ - PreSharedKey: new("secret456-at-least-20-chars"), - RemoteAddress: "203.0.113.2", - Phase1: vpn.TunnelConfigurationPhase1{ - DhGroups: nil, - EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256"}, - IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_384"}, - }, - Phase2: vpn.TunnelConfigurationPhase2{ - DhGroups: nil, - EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256"}, - IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_384"}, - }, - }, - Labels: &map[string]string{}, - Enabled: new(false), - }, - isValid: true, + input: new(fixtureModel(func(m *Model) { + m.Tunnel1.PreSharedKeyWo = types.StringValue("secret123-at-least-20-chars") + m.Tunnel2.PreSharedKeyWo = types.StringValue("secret456-at-least-20-chars") + })), + expected: fixtureUpdatePayload(), + isValid: true, }, { - description: "update_without_psk", + description: "minimal_update", input: &Model{ - DisplayName: types.StringValue("updated-connection"), - Enabled: types.BoolValue(false), - RemoteSubnet: types.ListNull(types.StringType), - LocalSubnet: types.ListNull(types.StringType), - StaticRoutes: types.ListNull(types.StringType), - Tunnel1: &TunnelModel{ - RemoteAddress: types.StringValue("203.0.113.1"), - PreSharedKeyWo: types.StringNull(), - Phase1: &Phase1Model{ - DhGroups: types.ListNull(types.StringType), - EncryptionAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("aes256")}), - IntegrityAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("sha2_384")}), - RekeyTime: types.Int32Null(), - }, - Phase2: &Phase2Model{ - DhGroups: types.ListNull(types.StringType), - EncryptionAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("aes256")}), - IntegrityAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("sha2_384")}), - RekeyTime: types.Int32Null(), - StartAction: types.StringNull(), - DpdAction: types.StringNull(), - }, - }, - Tunnel2: &TunnelModel{ - RemoteAddress: types.StringValue("203.0.113.2"), - PreSharedKeyWo: types.StringNull(), - Phase1: &Phase1Model{ - DhGroups: types.ListNull(types.StringType), - EncryptionAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("aes256")}), - IntegrityAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("sha2_384")}), - RekeyTime: types.Int32Null(), - }, - Phase2: &Phase2Model{ - DhGroups: types.ListNull(types.StringType), - EncryptionAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("aes256")}), - IntegrityAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("sha2_384")}), - RekeyTime: types.Int32Null(), - StartAction: types.StringNull(), - DpdAction: types.StringNull(), - }, - }, + Tunnel1: &TunnelModel{}, + Tunnel2: &TunnelModel{}, }, expected: &vpn.UpdateGatewayConnectionPayload{ - DisplayName: "updated-connection", - Tunnel1: vpn.TunnelConfiguration{ - PreSharedKey: nil, - RemoteAddress: "203.0.113.1", - Phase1: vpn.TunnelConfigurationPhase1{ - DhGroups: nil, - EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256"}, - IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_384"}, - }, - Phase2: vpn.TunnelConfigurationPhase2{ - DhGroups: nil, - EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256"}, - IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_384"}, - }, - }, - Tunnel2: vpn.TunnelConfiguration{ - PreSharedKey: nil, - RemoteAddress: "203.0.113.2", - Phase1: vpn.TunnelConfigurationPhase1{ - DhGroups: nil, - EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256"}, - IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_384"}, - }, - Phase2: vpn.TunnelConfigurationPhase2{ - DhGroups: nil, - EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256"}, - IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_384"}, - }, - }, - Labels: &map[string]string{}, - Enabled: new(false), + Labels: &map[string]string{}, }, isValid: true, }, @@ -946,75 +477,22 @@ func TestToTunnelConfiguration(t *testing.T) { }{ { description: "valid_tunnel", - input: &TunnelModel{ - RemoteAddress: types.StringValue("203.0.113.1"), - PreSharedKeyWo: types.StringValue("secret123-at-least-20-chars"), - PreSharedKeyWoVersion: types.Int64Null(), - Phase1: &Phase1Model{ - DhGroups: types.ListNull(types.StringType), - EncryptionAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("aes256")}), - IntegrityAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("sha2_384")}), - RekeyTime: types.Int32Null(), - }, - Phase2: &Phase2Model{ - DhGroups: types.ListNull(types.StringType), - EncryptionAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("aes256")}), - IntegrityAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("sha2_384")}), - RekeyTime: types.Int32Null(), - StartAction: types.StringNull(), - DpdAction: types.StringNull(), - }, - }, - isValid: true, + input: fixtureTunnelModel(), + isValid: true, }, { description: "tunnel_with_bgp", - input: &TunnelModel{ - RemoteAddress: types.StringValue("203.0.113.1"), - PreSharedKeyWo: types.StringValue("secret123-at-least-20-chars"), - PreSharedKeyWoVersion: types.Int64Null(), - Phase1: &Phase1Model{ - DhGroups: types.ListNull(types.StringType), - EncryptionAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("aes256")}), - IntegrityAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("sha2_384")}), - RekeyTime: types.Int32Null(), - }, - Phase2: &Phase2Model{ - DhGroups: types.ListNull(types.StringType), - EncryptionAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("aes256")}), - IntegrityAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("sha2_384")}), - RekeyTime: types.Int32Null(), - StartAction: types.StringNull(), - DpdAction: types.StringNull(), - }, - Bgp: &BGPTunnelConfigModel{ + input: fixtureTunnelModel(func(m *TunnelModel) { + m.Bgp = &BGPTunnelConfigModel{ RemoteAsn: types.Int64Value(65000), - }, - }, + } + }), isValid: true, }, { - description: "tunnel_without_psk", - input: &TunnelModel{ - RemoteAddress: types.StringValue("203.0.113.1"), - PreSharedKeyWo: types.StringNull(), - PreSharedKeyWoVersion: types.Int64Null(), - Phase1: &Phase1Model{ - DhGroups: types.ListNull(types.StringType), - EncryptionAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("aes256")}), - IntegrityAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("sha2_384")}), - RekeyTime: types.Int32Null(), - }, - Phase2: &Phase2Model{ - DhGroups: types.ListNull(types.StringType), - EncryptionAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("aes256")}), - IntegrityAlgorithms: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("sha2_384")}), - RekeyTime: types.Int32Null(), - StartAction: types.StringNull(), - DpdAction: types.StringNull(), - }, - }, - isValid: true, + description: "empty_tunnel", + input: &TunnelModel{}, + isValid: true, }, { description: "nil_tunnel", From 49dfbf3bba4e22c29d420c9ae7ba321e62fa5523 Mon Sep 17 00:00:00 2001 From: Manuel Vaas Date: Fri, 19 Jun 2026 13:48:54 +0200 Subject: [PATCH 09/28] fixed linter issues --- .../services/vpn/connection/datasource.go | 10 +++--- .../services/vpn/connection/resource.go | 35 +++++++++++-------- 2 files changed, 26 insertions(+), 19 deletions(-) diff --git a/stackit/internal/services/vpn/connection/datasource.go b/stackit/internal/services/vpn/connection/datasource.go index fec4e7641..bf5d71b78 100644 --- a/stackit/internal/services/vpn/connection/datasource.go +++ b/stackit/internal/services/vpn/connection/datasource.go @@ -384,13 +384,13 @@ func mapDataSourceFields(ctx context.Context, conn *vpn.ConnectionResponse, mode model.StaticRoutes = types.ListNull(types.StringType) } - tunnel1, err := mapDataSourceTunnel(ctx, &conn.Tunnel1, model.Tunnel1) + tunnel1, err := mapDataSourceTunnel(ctx, &conn.Tunnel1) if err != nil { return fmt.Errorf("mapping tunnel1: %w", err) } model.Tunnel1 = tunnel1 - tunnel2, err := mapDataSourceTunnel(ctx, &conn.Tunnel2, model.Tunnel2) + tunnel2, err := mapDataSourceTunnel(ctx, &conn.Tunnel2) if err != nil { return fmt.Errorf("mapping tunnel2: %w", err) } @@ -405,17 +405,17 @@ func mapDataSourceFields(ctx context.Context, conn *vpn.ConnectionResponse, mode return nil } -func mapDataSourceTunnel(ctx context.Context, apiTunnel *vpn.TunnelConfiguration, currentTunnel *DataSourceTunnelModel) (*DataSourceTunnelModel, error) { +func mapDataSourceTunnel(ctx context.Context, apiTunnel *vpn.TunnelConfiguration) (*DataSourceTunnelModel, error) { tunnel := &DataSourceTunnelModel{ RemoteAddress: types.StringValue(string(apiTunnel.RemoteAddress)), } - phase1, err := mapPhase1(ctx, apiTunnel.Phase1) + phase1, err := mapPhase1(ctx, &apiTunnel.Phase1) if err != nil { return nil, err } tunnel.Phase1 = phase1 - phase2, err := mapPhase2(ctx, apiTunnel.Phase2) + phase2, err := mapPhase2(ctx, &apiTunnel.Phase2) if err != nil { return nil, err } diff --git a/stackit/internal/services/vpn/connection/resource.go b/stackit/internal/services/vpn/connection/resource.go index c78b61c3f..61fb51d9c 100644 --- a/stackit/internal/services/vpn/connection/resource.go +++ b/stackit/internal/services/vpn/connection/resource.go @@ -1077,13 +1077,13 @@ func mapTunnel(ctx context.Context, apiTunnel *vpn.TunnelConfiguration, currentT RemoteAddress: types.StringValue(string(apiTunnel.RemoteAddress)), PreSharedKey: currentTunnel.PreSharedKey, } - phase1, err := mapPhase1(ctx, apiTunnel.Phase1) + phase1, err := mapPhase1(ctx, &apiTunnel.Phase1) if err != nil { return nil, err } tunnel.Phase1 = phase1 - phase2, err := mapPhase2(ctx, apiTunnel.Phase2) + phase2, err := mapPhase2(ctx, &apiTunnel.Phase2) if err != nil { return nil, err } @@ -1127,12 +1127,15 @@ type BasePhaseFields struct { RekeyTime *int32 } -func mapBasePhase(ctx context.Context, apiPhase BasePhaseFields) (phase BasePhaseModel, err error) { +func mapBasePhase(ctx context.Context, apiPhase *BasePhaseFields) (phase BasePhaseModel, err error) { + if apiPhase == nil { + return phase, fmt.Errorf("api phase can not be nil") + } + if len(apiPhase.DhGroups) > 0 { list, diags := types.ListValueFrom(ctx, types.StringType, apiPhase.DhGroups) if diags.HasError() { - err = fmt.Errorf("mapping base phase dh_groups: %w", core.DiagsToError(diags)) - return + return phase, fmt.Errorf("mapping base phase dh_groups: %w", core.DiagsToError(diags)) } phase.DhGroups = list } else { @@ -1141,8 +1144,7 @@ func mapBasePhase(ctx context.Context, apiPhase BasePhaseFields) (phase BasePhas if len(apiPhase.EncryptionAlgorithms) > 0 { list, diags := types.ListValueFrom(ctx, types.StringType, apiPhase.EncryptionAlgorithms) if diags.HasError() { - err = fmt.Errorf("mapping base phase encryption_algorithms: %w", core.DiagsToError(diags)) - return + return phase, fmt.Errorf("mapping base phase encryption_algorithms: %w", core.DiagsToError(diags)) } phase.EncryptionAlgorithms = list } else { @@ -1151,8 +1153,7 @@ func mapBasePhase(ctx context.Context, apiPhase BasePhaseFields) (phase BasePhas if len(apiPhase.IntegrityAlgorithms) > 0 { list, diags := types.ListValueFrom(ctx, types.StringType, apiPhase.IntegrityAlgorithms) if diags.HasError() { - err = fmt.Errorf("mapping base phase integrity_algorithms: %w", core.DiagsToError(diags)) - return + return phase, fmt.Errorf("mapping base phase integrity_algorithms: %w", core.DiagsToError(diags)) } phase.IntegrityAlgorithms = list } else { @@ -1163,11 +1164,14 @@ func mapBasePhase(ctx context.Context, apiPhase BasePhaseFields) (phase BasePhas } else { phase.RekeyTime = types.Int32Null() } - return + return phase, nil } -func mapPhase1(ctx context.Context, apiPhase1 vpn.TunnelConfigurationPhase1) (*Phase1Model, error) { - basePhase, err := mapBasePhase(ctx, BasePhaseFields{ +func mapPhase1(ctx context.Context, apiPhase1 *vpn.TunnelConfigurationPhase1) (*Phase1Model, error) { + if apiPhase1 == nil { + return nil, fmt.Errorf("phase can not be nil") + } + basePhase, err := mapBasePhase(ctx, &BasePhaseFields{ DhGroups: apiPhase1.DhGroups, EncryptionAlgorithms: apiPhase1.EncryptionAlgorithms, IntegrityAlgorithms: apiPhase1.IntegrityAlgorithms, @@ -1181,8 +1185,11 @@ func mapPhase1(ctx context.Context, apiPhase1 vpn.TunnelConfigurationPhase1) (*P }, nil } -func mapPhase2(ctx context.Context, apiPhase2 vpn.TunnelConfigurationPhase2) (*Phase2Model, error) { - basePhase, err := mapBasePhase(ctx, BasePhaseFields{ +func mapPhase2(ctx context.Context, apiPhase2 *vpn.TunnelConfigurationPhase2) (*Phase2Model, error) { + if apiPhase2 == nil { + return nil, fmt.Errorf("phase can not be nil") + } + basePhase, err := mapBasePhase(ctx, &BasePhaseFields{ DhGroups: apiPhase2.DhGroups, EncryptionAlgorithms: apiPhase2.EncryptionAlgorithms, IntegrityAlgorithms: apiPhase2.IntegrityAlgorithms, From cea878324440df221be032967c716add7d91ee88 Mon Sep 17 00:00:00 2001 From: Manuel Vaas Date: Fri, 19 Jun 2026 14:04:55 +0200 Subject: [PATCH 10/28] fixed nil pointer --- stackit/internal/services/vpn/connection/resource.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/stackit/internal/services/vpn/connection/resource.go b/stackit/internal/services/vpn/connection/resource.go index 61fb51d9c..717e9bea7 100644 --- a/stackit/internal/services/vpn/connection/resource.go +++ b/stackit/internal/services/vpn/connection/resource.go @@ -1075,8 +1075,8 @@ func mapFields(ctx context.Context, conn *vpn.ConnectionResponse, model *Model, func mapTunnel(ctx context.Context, apiTunnel *vpn.TunnelConfiguration, currentTunnel *TunnelModel) (*TunnelModel, error) { tunnel := &TunnelModel{ RemoteAddress: types.StringValue(string(apiTunnel.RemoteAddress)), - PreSharedKey: currentTunnel.PreSharedKey, } + phase1, err := mapPhase1(ctx, &apiTunnel.Phase1) if err != nil { return nil, err @@ -1112,6 +1112,7 @@ func mapTunnel(ctx context.Context, apiTunnel *vpn.TunnelConfiguration, currentT // could be nil for Read after a terraform import if currentTunnel != nil { + tunnel.PreSharedKey = currentTunnel.PreSharedKey tunnel.PreSharedKeyWoVersion = currentTunnel.PreSharedKeyWoVersion } else { tunnel.PreSharedKeyWoVersion = types.Int64Null() From e2dbed41fc4cc190184df7e50745553a501eddc8 Mon Sep 17 00:00:00 2001 From: Manuel Vaas <34416897+Manuelvaas@users.noreply.github.com> Date: Mon, 22 Jun 2026 14:13:24 +0200 Subject: [PATCH 11/28] Update stackit/internal/services/vpn/connection/resource.go MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Ruben Hönle --- stackit/internal/services/vpn/connection/resource.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stackit/internal/services/vpn/connection/resource.go b/stackit/internal/services/vpn/connection/resource.go index 717e9bea7..0d02d81c6 100644 --- a/stackit/internal/services/vpn/connection/resource.go +++ b/stackit/internal/services/vpn/connection/resource.go @@ -171,7 +171,7 @@ func (r *vpnConnectionResource) Metadata(_ context.Context, req resource.Metadat func tunnelSchema(rootAttribute string) schema.SingleNestedAttribute { return schema.SingleNestedAttribute{ Description: tunnelSchemaDescriptions["tunnel"], - MarkdownDescription: fmt.Sprintf("%s \n\n-> **Note:** 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).", tunnelSchemaDescriptions["tunnel"]), + MarkdownDescription: fmt.Sprintf("%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).", tunnelSchemaDescriptions["tunnel"]), Required: true, Attributes: map[string]schema.Attribute{ "pre_shared_key": schema.StringAttribute{ From 97354b55b39735e0cb7467b2a59323806ddd8563 Mon Sep 17 00:00:00 2001 From: Manuel Vaas Date: Mon, 22 Jun 2026 14:16:28 +0200 Subject: [PATCH 12/28] renamed pv and sv variable --- .../services/vpn/connection/resource.go | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/stackit/internal/services/vpn/connection/resource.go b/stackit/internal/services/vpn/connection/resource.go index 0d02d81c6..2873af24b 100644 --- a/stackit/internal/services/vpn/connection/resource.go +++ b/stackit/internal/services/vpn/connection/resource.go @@ -648,17 +648,17 @@ func (r *vpnConnectionResource) Update(ctx context.Context, req resource.UpdateR // tunnel1 PSK rotation if !tfutils.IsUndefined(model.Tunnel1.PreSharedKeyWoVersion) { - pv := model.Tunnel1.PreSharedKeyWoVersion.ValueInt64() - sv := stateModel.Tunnel1.PreSharedKeyWoVersion.ValueInt64() - if pv < sv { + planVersion := model.Tunnel1.PreSharedKeyWoVersion.ValueInt64() + stateVersion := stateModel.Tunnel1.PreSharedKeyWoVersion.ValueInt64() + if planVersion < stateVersion { resp.Diagnostics.AddAttributeError( path.Root("tunnel1").AtName("pre_shared_key_wo_version"), "Version must not decrease", - fmt.Sprintf("`pre_shared_key_wo_version` must be incremented to rotate the pre-shared key, got %d (current: %d).", pv, sv), + fmt.Sprintf("`pre_shared_key_wo_version` must be incremented to rotate the pre-shared key, got %d (current: %d).", planVersion, stateVersion), ) return } - if pv > sv { + if planVersion > stateVersion { // Secret must be read from Config, not Plan — write-only values are always null in plan. model.Tunnel1.PreSharedKeyWo = configModel.Tunnel1.PreSharedKeyWo } @@ -666,17 +666,17 @@ func (r *vpnConnectionResource) Update(ctx context.Context, req resource.UpdateR // tunnel2 PSK rotation if !tfutils.IsUndefined(model.Tunnel2.PreSharedKeyWoVersion) { - pv := model.Tunnel2.PreSharedKeyWoVersion.ValueInt64() - sv := stateModel.Tunnel2.PreSharedKeyWoVersion.ValueInt64() - if pv < sv { + planVersion := model.Tunnel2.PreSharedKeyWoVersion.ValueInt64() + stateVersion := stateModel.Tunnel2.PreSharedKeyWoVersion.ValueInt64() + if planVersion < stateVersion { resp.Diagnostics.AddAttributeError( path.Root("tunnel2").AtName("pre_shared_key_wo_version"), "Version must not decrease", - fmt.Sprintf("`pre_shared_key_wo_version` must be incremented to rotate the pre-shared key, got %d (current: %d).", pv, sv), + fmt.Sprintf("`pre_shared_key_wo_version` must be incremented to rotate the pre-shared key, got %d (current: %d).", planVersion, stateVersion), ) return } - if pv > sv { + if planVersion > stateVersion { // Secret must be read from Config, not Plan — write-only values are always null in plan. model.Tunnel2.PreSharedKeyWo = configModel.Tunnel2.PreSharedKeyWo } From de6b892f0c1d91cc4870996b6ebcc8890a4458ac Mon Sep 17 00:00:00 2001 From: Manuel Vaas Date: Mon, 22 Jun 2026 14:21:40 +0200 Subject: [PATCH 13/28] Renamed toTunnelConfiguration -> toTunnelPayload --- stackit/internal/services/vpn/connection/resource.go | 6 +++--- stackit/internal/services/vpn/connection/resource_test.go | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/stackit/internal/services/vpn/connection/resource.go b/stackit/internal/services/vpn/connection/resource.go index 2873af24b..884146af3 100644 --- a/stackit/internal/services/vpn/connection/resource.go +++ b/stackit/internal/services/vpn/connection/resource.go @@ -817,12 +817,12 @@ type connectionFields struct { } func toConnectionFields(ctx context.Context, model *Model) (*connectionFields, error) { - tunnel1, err := toTunnelConfiguration(model.Tunnel1) + tunnel1, err := toTunnelPayload(model.Tunnel1) if err != nil { return nil, fmt.Errorf("converting tunnel1: %w", err) } - tunnel2, err := toTunnelConfiguration(model.Tunnel2) + tunnel2, err := toTunnelPayload(model.Tunnel2) if err != nil { return nil, fmt.Errorf("converting tunnel2: %w", err) } @@ -870,7 +870,7 @@ func toConnectionFields(ctx context.Context, model *Model) (*connectionFields, e return fields, nil } -func toTunnelConfiguration(tunnel *TunnelModel) (*vpn.TunnelConfiguration, error) { +func toTunnelPayload(tunnel *TunnelModel) (*vpn.TunnelConfiguration, error) { if tunnel == nil { return nil, fmt.Errorf("nil tunnel model") } diff --git a/stackit/internal/services/vpn/connection/resource_test.go b/stackit/internal/services/vpn/connection/resource_test.go index a3b6dd19e..b7336b764 100644 --- a/stackit/internal/services/vpn/connection/resource_test.go +++ b/stackit/internal/services/vpn/connection/resource_test.go @@ -503,7 +503,7 @@ func TestToTunnelConfiguration(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - config, err := toTunnelConfiguration(tt.input) + config, err := toTunnelPayload(tt.input) if !tt.isValid && err == nil { t.Fatalf("expected error, got none") From f63797dfe8008c0e4d7b2206cfed1f5c18800e31 Mon Sep 17 00:00:00 2001 From: Manuel Vaas Date: Mon, 22 Jun 2026 14:31:41 +0200 Subject: [PATCH 14/28] refactored mapTunnel function --- .../services/vpn/connection/resource.go | 40 +++++++++---------- 1 file changed, 19 insertions(+), 21 deletions(-) diff --git a/stackit/internal/services/vpn/connection/resource.go b/stackit/internal/services/vpn/connection/resource.go index 884146af3..7ecd1accd 100644 --- a/stackit/internal/services/vpn/connection/resource.go +++ b/stackit/internal/services/vpn/connection/resource.go @@ -1051,17 +1051,15 @@ func mapFields(ctx context.Context, conn *vpn.ConnectionResponse, model *Model, model.StaticRoutes = types.ListNull(types.StringType) } - tunnel1, err := mapTunnel(ctx, &conn.Tunnel1, model.Tunnel1) + err := mapTunnel(ctx, &conn.Tunnel1, model.Tunnel1) if err != nil { return fmt.Errorf("mapping tunnel1: %w", err) } - model.Tunnel1 = tunnel1 - tunnel2, err := mapTunnel(ctx, &conn.Tunnel2, model.Tunnel2) + err = mapTunnel(ctx, &conn.Tunnel2, model.Tunnel2) if err != nil { return fmt.Errorf("mapping tunnel2: %w", err) } - model.Tunnel2 = tunnel2 labels, err := tfutils.MapLabels(ctx, conn.Labels, model.Labels) if err != nil { @@ -1072,22 +1070,26 @@ func mapFields(ctx context.Context, conn *vpn.ConnectionResponse, model *Model, return nil } -func mapTunnel(ctx context.Context, apiTunnel *vpn.TunnelConfiguration, currentTunnel *TunnelModel) (*TunnelModel, error) { - tunnel := &TunnelModel{ - RemoteAddress: types.StringValue(string(apiTunnel.RemoteAddress)), +func mapTunnel(ctx context.Context, apiTunnel *vpn.TunnelConfiguration, tfTunnel *TunnelModel) error { + if tfTunnel == nil { + tfTunnel = &TunnelModel{ + PreSharedKeyWoVersion: types.Int64Null(), + } } + tfTunnel.RemoteAddress = types.StringValue(string(apiTunnel.RemoteAddress)) + phase1, err := mapPhase1(ctx, &apiTunnel.Phase1) if err != nil { - return nil, err + return err } - tunnel.Phase1 = phase1 + tfTunnel.Phase1 = phase1 phase2, err := mapPhase2(ctx, &apiTunnel.Phase2) if err != nil { - return nil, err + return err } - tunnel.Phase2 = phase2 + tfTunnel.Phase2 = phase2 if apiTunnel.Peering != nil { peering := &PeeringConfigModel{} @@ -1101,24 +1103,20 @@ func mapTunnel(ctx context.Context, apiTunnel *vpn.TunnelConfiguration, currentT } else { peering.RemoteAddress = types.StringNull() } - tunnel.Peering = peering + tfTunnel.Peering = peering + } else { + tfTunnel.Peering = nil } if apiTunnel.Bgp != nil { - tunnel.Bgp = &BGPTunnelConfigModel{ + tfTunnel.Bgp = &BGPTunnelConfigModel{ RemoteAsn: types.Int64Value(int64(apiTunnel.Bgp.RemoteAsn)), } - } - - // could be nil for Read after a terraform import - if currentTunnel != nil { - tunnel.PreSharedKey = currentTunnel.PreSharedKey - tunnel.PreSharedKeyWoVersion = currentTunnel.PreSharedKeyWoVersion } else { - tunnel.PreSharedKeyWoVersion = types.Int64Null() + tfTunnel.Bgp = nil } - return tunnel, nil + return nil } type BasePhaseFields struct { From cfb94f269819e5c030c1a1bd87288756964f0dc5 Mon Sep 17 00:00:00 2001 From: Manuel Vaas Date: Tue, 23 Jun 2026 12:02:43 +0200 Subject: [PATCH 15/28] interface for phases --- .../services/vpn/connection/resource.go | 58 +++++++++---------- 1 file changed, 26 insertions(+), 32 deletions(-) diff --git a/stackit/internal/services/vpn/connection/resource.go b/stackit/internal/services/vpn/connection/resource.go index 7ecd1accd..3b37b4a79 100644 --- a/stackit/internal/services/vpn/connection/resource.go +++ b/stackit/internal/services/vpn/connection/resource.go @@ -1119,20 +1119,20 @@ func mapTunnel(ctx context.Context, apiTunnel *vpn.TunnelConfiguration, tfTunnel return nil } -type BasePhaseFields struct { - DhGroups []vpn.PhaseDhGroupsInner - EncryptionAlgorithms []vpn.PhaseEncryptionAlgorithmsInner - IntegrityAlgorithms []vpn.PhaseIntegrityAlgorithmsInner - RekeyTime *int32 +type BasePhaseFields interface { + GetDhGroupsOk() ([]vpn.PhaseDhGroupsInner, bool) + GetEncryptionAlgorithmsOk() ([]vpn.PhaseEncryptionAlgorithmsInner, bool) + GetIntegrityAlgorithmsOk() ([]vpn.PhaseIntegrityAlgorithmsInner, bool) + GetRekeyTimeOk() (*int32, bool) } -func mapBasePhase(ctx context.Context, apiPhase *BasePhaseFields) (phase BasePhaseModel, err error) { +func mapBasePhase(ctx context.Context, apiPhase BasePhaseFields) (phase BasePhaseModel, err error) { if apiPhase == nil { return phase, fmt.Errorf("api phase can not be nil") } - if len(apiPhase.DhGroups) > 0 { - list, diags := types.ListValueFrom(ctx, types.StringType, apiPhase.DhGroups) + if dhGroups, _ := apiPhase.GetDhGroupsOk(); len(dhGroups) > 0 { + list, diags := types.ListValueFrom(ctx, types.StringType, dhGroups) if diags.HasError() { return phase, fmt.Errorf("mapping base phase dh_groups: %w", core.DiagsToError(diags)) } @@ -1140,8 +1140,9 @@ func mapBasePhase(ctx context.Context, apiPhase *BasePhaseFields) (phase BasePha } else { phase.DhGroups = types.ListNull(types.StringType) } - if len(apiPhase.EncryptionAlgorithms) > 0 { - list, diags := types.ListValueFrom(ctx, types.StringType, apiPhase.EncryptionAlgorithms) + + if encryptionAlgorithms, _ := apiPhase.GetEncryptionAlgorithmsOk(); len(encryptionAlgorithms) > 0 { + list, diags := types.ListValueFrom(ctx, types.StringType, encryptionAlgorithms) if diags.HasError() { return phase, fmt.Errorf("mapping base phase encryption_algorithms: %w", core.DiagsToError(diags)) } @@ -1149,8 +1150,9 @@ func mapBasePhase(ctx context.Context, apiPhase *BasePhaseFields) (phase BasePha } else { phase.EncryptionAlgorithms = types.ListNull(types.StringType) } - if len(apiPhase.IntegrityAlgorithms) > 0 { - list, diags := types.ListValueFrom(ctx, types.StringType, apiPhase.IntegrityAlgorithms) + + if integrityAlgorithms, _ := apiPhase.GetIntegrityAlgorithmsOk(); len(integrityAlgorithms) > 0 { + list, diags := types.ListValueFrom(ctx, types.StringType, integrityAlgorithms) if diags.HasError() { return phase, fmt.Errorf("mapping base phase integrity_algorithms: %w", core.DiagsToError(diags)) } @@ -1158,27 +1160,19 @@ func mapBasePhase(ctx context.Context, apiPhase *BasePhaseFields) (phase BasePha } else { phase.IntegrityAlgorithms = types.ListNull(types.StringType) } - if apiPhase.RekeyTime != nil { - phase.RekeyTime = types.Int32Value(*apiPhase.RekeyTime) - } else { - phase.RekeyTime = types.Int32Null() - } + + rekeyTime, _ := apiPhase.GetRekeyTimeOk() + phase.RekeyTime = types.Int32PointerValue(rekeyTime) + return phase, nil } func mapPhase1(ctx context.Context, apiPhase1 *vpn.TunnelConfigurationPhase1) (*Phase1Model, error) { - if apiPhase1 == nil { - return nil, fmt.Errorf("phase can not be nil") - } - basePhase, err := mapBasePhase(ctx, &BasePhaseFields{ - DhGroups: apiPhase1.DhGroups, - EncryptionAlgorithms: apiPhase1.EncryptionAlgorithms, - IntegrityAlgorithms: apiPhase1.IntegrityAlgorithms, - RekeyTime: apiPhase1.RekeyTime, - }) + basePhase, err := mapBasePhase(ctx, apiPhase1) if err != nil { return nil, err } + return &Phase1Model{ BasePhaseModel: basePhase, }, nil @@ -1188,27 +1182,27 @@ func mapPhase2(ctx context.Context, apiPhase2 *vpn.TunnelConfigurationPhase2) (* if apiPhase2 == nil { return nil, fmt.Errorf("phase can not be nil") } - basePhase, err := mapBasePhase(ctx, &BasePhaseFields{ - DhGroups: apiPhase2.DhGroups, - EncryptionAlgorithms: apiPhase2.EncryptionAlgorithms, - IntegrityAlgorithms: apiPhase2.IntegrityAlgorithms, - RekeyTime: apiPhase2.RekeyTime, - }) + + basePhase, err := mapBasePhase(ctx, apiPhase2) if err != nil { return nil, err } + phase2 := &Phase2Model{ BasePhaseModel: basePhase, } + if apiPhase2.StartAction != nil { phase2.StartAction = types.StringValue(string(*apiPhase2.StartAction)) } else { phase2.StartAction = types.StringNull() } + if apiPhase2.DpdAction != nil { phase2.DpdAction = types.StringValue(string(*apiPhase2.DpdAction)) } else { phase2.DpdAction = types.StringNull() } + return phase2, nil } From adb55c9e7df995eff7e9aaba4b9f6ab829ee318e Mon Sep 17 00:00:00 2001 From: Manuel Vaas Date: Tue, 23 Jun 2026 16:50:51 +0200 Subject: [PATCH 16/28] interface for connection payloads --- .../services/vpn/connection/resource.go | 90 ++++++++----------- 1 file changed, 38 insertions(+), 52 deletions(-) diff --git a/stackit/internal/services/vpn/connection/resource.go b/stackit/internal/services/vpn/connection/resource.go index 3b37b4a79..7e11c30b4 100644 --- a/stackit/internal/services/vpn/connection/resource.go +++ b/stackit/internal/services/vpn/connection/resource.go @@ -766,21 +766,13 @@ func toCreatePayload(ctx context.Context, model *Model) (*vpn.CreateGatewayConne return nil, fmt.Errorf("nil model") } - fields, err := toConnectionFields(ctx, model) + payload := &vpn.CreateGatewayConnectionPayload{} + err := toConnectionPayload(ctx, model, payload) if err != nil { return nil, err } - return &vpn.CreateGatewayConnectionPayload{ - DisplayName: fields.displayName, - Tunnel1: fields.tunnel1, - Tunnel2: fields.tunnel2, - Enabled: fields.enabled, - RemoteSubnets: fields.remoteSubnets, - LocalSubnets: fields.localSubnets, - StaticRoutes: fields.staticRoutes, - Labels: &fields.labels, - }, nil + return payload, nil } func toUpdatePayload(ctx context.Context, model *Model) (*vpn.UpdateGatewayConnectionPayload, error) { @@ -788,86 +780,80 @@ func toUpdatePayload(ctx context.Context, model *Model) (*vpn.UpdateGatewayConne return nil, fmt.Errorf("nil model") } - fields, err := toConnectionFields(ctx, model) + payload := &vpn.UpdateGatewayConnectionPayload{} + err := toConnectionPayload(ctx, model, payload) if err != nil { return nil, err } - return &vpn.UpdateGatewayConnectionPayload{ - DisplayName: fields.displayName, - Tunnel1: fields.tunnel1, - Tunnel2: fields.tunnel2, - Enabled: fields.enabled, - RemoteSubnets: fields.remoteSubnets, - LocalSubnets: fields.localSubnets, - StaticRoutes: fields.staticRoutes, - Labels: &fields.labels, - }, nil + return payload, nil } -type connectionFields struct { - displayName string - tunnel1 vpn.TunnelConfiguration - tunnel2 vpn.TunnelConfiguration - enabled *bool - remoteSubnets []string - localSubnets []string - staticRoutes []string - labels map[string]string +type connectionFields interface { + SetDisplayName(string) + SetTunnel1(vpn.TunnelConfiguration) + SetTunnel2(vpn.TunnelConfiguration) + SetEnabled(bool) + SetRemoteSubnets([]string) + SetLocalSubnets([]string) + SetStaticRoutes([]string) + SetLabels(map[string]string) } -func toConnectionFields(ctx context.Context, model *Model) (*connectionFields, error) { +func toConnectionPayload(ctx context.Context, model *Model, payload connectionFields) error { + if payload == nil { + return fmt.Errorf("payload can not be nil") + } + tunnel1, err := toTunnelPayload(model.Tunnel1) - if err != nil { - return nil, fmt.Errorf("converting tunnel1: %w", err) + if err != nil && tunnel1 != nil { + return fmt.Errorf("converting tunnel1: %w", err) } + payload.SetTunnel1(*tunnel1) tunnel2, err := toTunnelPayload(model.Tunnel2) - if err != nil { - return nil, fmt.Errorf("converting tunnel2: %w", err) + if err != nil && tunnel2 != nil { + return fmt.Errorf("converting tunnel2: %w", err) } + payload.SetTunnel2(*tunnel2) - fields := &connectionFields{ - displayName: model.DisplayName.ValueString(), - tunnel1: *tunnel1, - tunnel2: *tunnel2, - } + payload.SetDisplayName(model.DisplayName.ValueString()) if !tfutils.IsUndefined(model.Enabled) { - enabled := model.Enabled.ValueBool() - fields.enabled = &enabled + payload.SetEnabled(model.Enabled.ValueBool()) } if !tfutils.IsUndefined(model.RemoteSubnet) { remoteSubnets, err := tfutils.ListValueToStringSlice(model.RemoteSubnet) if err != nil { - return nil, fmt.Errorf("converting remote_subnet: %w", err) + return fmt.Errorf("converting remote_subnet: %w", err) } - fields.remoteSubnets = remoteSubnets + payload.SetRemoteSubnets(remoteSubnets) } if !tfutils.IsUndefined(model.LocalSubnet) { localSubnets, err := tfutils.ListValueToStringSlice(model.LocalSubnet) if err != nil { - return nil, fmt.Errorf("converting local_subnet: %w", err) + return fmt.Errorf("converting local_subnet: %w", err) } - fields.localSubnets = localSubnets + payload.SetLocalSubnets(localSubnets) } if !tfutils.IsUndefined(model.StaticRoutes) { staticRoutes, err := tfutils.ListValueToStringSlice(model.StaticRoutes) if err != nil { - return nil, fmt.Errorf("converting static_routes: %w", err) + return fmt.Errorf("converting static_routes: %w", err) } - fields.staticRoutes = staticRoutes + payload.SetStaticRoutes(staticRoutes) } - fields.labels, err = tfutils.LabelsToPayload(ctx, model.Labels) + labels, err := tfutils.LabelsToPayload(ctx, model.Labels) if err != nil { - return nil, err + return err } + payload.SetLabels(labels) - return fields, nil + return nil } func toTunnelPayload(tunnel *TunnelModel) (*vpn.TunnelConfiguration, error) { From 73e6903906fe588d9e228deab0401f123670249a Mon Sep 17 00:00:00 2001 From: Manuel Vaas Date: Tue, 23 Jun 2026 17:01:31 +0200 Subject: [PATCH 17/28] move description to schema --- docs/resources/vpn_connection.md | 8 +- .../services/vpn/connection/datasource.go | 93 ++++++------------ .../services/vpn/connection/resource.go | 97 ++++++------------- 3 files changed, 63 insertions(+), 135 deletions(-) diff --git a/docs/resources/vpn_connection.md b/docs/resources/vpn_connection.md index fe73a36fc..c1317b8eb 100644 --- a/docs/resources/vpn_connection.md +++ b/docs/resources/vpn_connection.md @@ -68,12 +68,12 @@ import { - `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 tunnel. +- `tunnel1` (Attributes) Configuration for the IPsec tunnel1 --> **Note:** 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 tunnel. +~> 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 --> **Note:** 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)) +~> 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 diff --git a/stackit/internal/services/vpn/connection/datasource.go b/stackit/internal/services/vpn/connection/datasource.go index bf5d71b78..c9289459b 100644 --- a/stackit/internal/services/vpn/connection/datasource.go +++ b/stackit/internal/services/vpn/connection/datasource.go @@ -52,41 +52,6 @@ type DataSourceModel struct { Labels types.Map `tfsdk:"labels"` } -var dataSourceSchemaDescriptions = map[string]string{ - "id": "Terraform's internal resource identifier. Structured as \"`project_id`,`region`,`gateway_id`,`connection_id`\".", - "project_id": "STACKIT project ID.", - "region": "STACKIT region.", - "gateway_id": "The UUID of the parent VPN gateway.", - "connection_id": "The server-generated UUID of the VPN connection.", - "display_name": "A user-friendly name for the connection.", - "enabled": "Whether this connection is enabled.", - "remote_subnet": "List of remote IPv4 CIDRs accessible via this connection.", - "local_subnet": "List of local IPv4 CIDRs to route through this connection.", - "static_routes": "List of static routes (IPv4 CIDRs) for route-based VPN.", - "labels": "Map of custom labels.", -} - -var dataSourceTunnelSchemaDescriptions = map[string]string{ - "remote_address": "Remote peer IPv4 address for this tunnel.", - "phase1": "IKE Phase 1 configuration.", - "phase1_dh_groups": "Diffie-Hellman groups.", - "phase1_encryption_algorithms": "Encryption algorithms.", - "phase1_integrity_algorithms": "Integrity/hash algorithms.", - "phase1_rekey_time": "IKE re-keying time in seconds.", - "phase2": "IKE Phase 2 configuration.", - "phase2_dh_groups": "Diffie-Hellman groups for PFS.", - "phase2_encryption_algorithms": "Encryption algorithms.", - "phase2_integrity_algorithms": "Integrity/hash algorithms.", - "phase2_rekey_time": "Child SA re-keying time in seconds.", - "phase2_start_action": "Start action (none or start).", - "phase2_dpd_action": "DPD timeout action (clear or restart).", - "peering": "Tunnel interface peering configuration.", - "peering_local_address": "Local tunnel interface IPv4 address.", - "peering_remote_address": "Remote tunnel interface IPv4 address.", - "bgp": "BGP configuration for this tunnel.", - "bgp_remote_asn": "Remote AS number.", -} - type vpnConnectionDataSource struct { client *vpn.APIClient providerData core.ProviderData @@ -120,87 +85,87 @@ func (d *vpnConnectionDataSource) Schema(_ context.Context, _ datasource.SchemaR Computed: true, Attributes: map[string]schema.Attribute{ "remote_address": schema.StringAttribute{ - Description: dataSourceTunnelSchemaDescriptions["remote_address"], + Description: "Remote peer IPv4 address for this tunnel.", Computed: true, }, "phase1": schema.SingleNestedAttribute{ - Description: dataSourceTunnelSchemaDescriptions["phase1"], + Description: "IKE Phase 1 configuration.", Computed: true, Attributes: map[string]schema.Attribute{ "dh_groups": schema.ListAttribute{ - Description: dataSourceTunnelSchemaDescriptions["phase1_dh_groups"], + Description: "Diffie-Hellman groups.", Computed: true, ElementType: types.StringType, }, "encryption_algorithms": schema.ListAttribute{ - Description: dataSourceTunnelSchemaDescriptions["phase1_encryption_algorithms"], + Description: "Encryption algorithms.", Computed: true, ElementType: types.StringType, }, "integrity_algorithms": schema.ListAttribute{ - Description: dataSourceTunnelSchemaDescriptions["phase1_integrity_algorithms"], + Description: "Integrity/hash algorithms.", Computed: true, ElementType: types.StringType, }, "rekey_time": schema.Int32Attribute{ - Description: dataSourceTunnelSchemaDescriptions["phase1_rekey_time"], + Description: "IKE re-keying time in seconds.", Computed: true, }, }, }, "phase2": schema.SingleNestedAttribute{ - Description: dataSourceTunnelSchemaDescriptions["phase2"], + Description: "IKE Phase 2 configuration.", Computed: true, Attributes: map[string]schema.Attribute{ "dh_groups": schema.ListAttribute{ - Description: dataSourceTunnelSchemaDescriptions["phase2_dh_groups"], + Description: "Diffie-Hellman groups for PFS.", Computed: true, ElementType: types.StringType, }, "encryption_algorithms": schema.ListAttribute{ - Description: dataSourceTunnelSchemaDescriptions["phase2_encryption_algorithms"], + Description: "Encryption algorithms.", Computed: true, ElementType: types.StringType, }, "integrity_algorithms": schema.ListAttribute{ - Description: dataSourceTunnelSchemaDescriptions["phase2_integrity_algorithms"], + Description: "Integrity/hash algorithms.", Computed: true, ElementType: types.StringType, }, "rekey_time": schema.Int32Attribute{ - Description: dataSourceTunnelSchemaDescriptions["phase2_rekey_time"], + Description: "Child SA re-keying time in seconds.", Computed: true, }, "start_action": schema.StringAttribute{ - Description: dataSourceTunnelSchemaDescriptions["phase2_start_action"], + Description: "Start action (none or start).", Computed: true, }, "dpd_action": schema.StringAttribute{ - Description: dataSourceTunnelSchemaDescriptions["phase2_dpd_action"], + Description: "DPD timeout action (clear or restart).", Computed: true, }, }, }, "peering": schema.SingleNestedAttribute{ - Description: dataSourceTunnelSchemaDescriptions["peering"], + Description: "Tunnel interface peering configuration.", Computed: true, Attributes: map[string]schema.Attribute{ "local_address": schema.StringAttribute{ - Description: dataSourceTunnelSchemaDescriptions["peering_local_address"], + Description: "Local tunnel interface IPv4 address.", Computed: true, }, "remote_address": schema.StringAttribute{ - Description: dataSourceTunnelSchemaDescriptions["peering_remote_address"], + Description: "Remote tunnel interface IPv4 address.", Computed: true, }, }, }, "bgp": schema.SingleNestedAttribute{ - Description: dataSourceTunnelSchemaDescriptions["bgp"], + Description: "BGP configuration for this tunnel.", Computed: true, Attributes: map[string]schema.Attribute{ "remote_asn": schema.Int64Attribute{ - Description: dataSourceTunnelSchemaDescriptions["bgp_remote_asn"], + Description: "Remote AS number.", Computed: true, }, }, @@ -212,11 +177,11 @@ func (d *vpnConnectionDataSource) Schema(_ context.Context, _ datasource.SchemaR Description: fmt.Sprintf("VPN Connection data source schema. %s", core.DatasourceRegionFallbackDocstring), Attributes: map[string]schema.Attribute{ "id": schema.StringAttribute{ - Description: dataSourceSchemaDescriptions["id"], + Description: "Terraform's internal resource identifier. Structured as \"`project_id`,`region`,`gateway_id`,`connection_id`\".", Computed: true, }, "project_id": schema.StringAttribute{ - Description: dataSourceSchemaDescriptions["project_id"], + Description: "STACKIT project ID.", Required: true, Validators: []validator.String{ validate.UUID(), @@ -224,11 +189,11 @@ func (d *vpnConnectionDataSource) Schema(_ context.Context, _ datasource.SchemaR }, }, "region": schema.StringAttribute{ - Description: dataSourceSchemaDescriptions["region"], + Description: "STACKIT region.", Computed: true, }, "gateway_id": schema.StringAttribute{ - Description: dataSourceSchemaDescriptions["gateway_id"], + Description: "The UUID of the parent VPN gateway.", Required: true, Validators: []validator.String{ validate.UUID(), @@ -236,7 +201,7 @@ func (d *vpnConnectionDataSource) Schema(_ context.Context, _ datasource.SchemaR }, }, "connection_id": schema.StringAttribute{ - Description: dataSourceSchemaDescriptions["connection_id"], + Description: "The server-generated UUID of the VPN connection.", Required: true, Validators: []validator.String{ validate.UUID(), @@ -244,32 +209,32 @@ func (d *vpnConnectionDataSource) Schema(_ context.Context, _ datasource.SchemaR }, }, "display_name": schema.StringAttribute{ - Description: dataSourceSchemaDescriptions["display_name"], + Description: "A user-friendly name for the connection.", Computed: true, }, "enabled": schema.BoolAttribute{ - Description: dataSourceSchemaDescriptions["enabled"], + Description: "Whether this connection is enabled.", Computed: true, }, "remote_subnet": schema.ListAttribute{ - Description: dataSourceSchemaDescriptions["remote_subnet"], + Description: "List of remote IPv4 CIDRs accessible via this connection.", Computed: true, ElementType: types.StringType, }, "local_subnet": schema.ListAttribute{ - Description: dataSourceSchemaDescriptions["local_subnet"], + Description: "List of local IPv4 CIDRs to route through this connection.", Computed: true, ElementType: types.StringType, }, "static_routes": schema.ListAttribute{ - Description: dataSourceSchemaDescriptions["static_routes"], + Description: "List of static routes (IPv4 CIDRs) for route-based VPN.", Computed: true, ElementType: types.StringType, }, "tunnel1": tunnelSchema, "tunnel2": tunnelSchema, "labels": schema.MapAttribute{ - Description: dataSourceSchemaDescriptions["labels"], + Description: "Map of custom labels.", Computed: true, ElementType: types.StringType, }, diff --git a/stackit/internal/services/vpn/connection/resource.go b/stackit/internal/services/vpn/connection/resource.go index 7e11c30b4..90205b065 100644 --- a/stackit/internal/services/vpn/connection/resource.go +++ b/stackit/internal/services/vpn/connection/resource.go @@ -103,43 +103,6 @@ var ( dpdActionValues = sdkUtils.EnumSliceToStringSlice(vpn.AllowedTunnelConfigurationPhase2AllOfDpdActionEnumValues) ) -var schemaDescriptions = map[string]string{ - "id": "Terraform's internal resource identifier. Structured as \"`project_id`,`region`,`gateway_id`,`connection_id`\".", - "connection_id": "The server-generated UUID of the VPN connection.", - "project_id": "STACKIT project ID.", - "region": "STACKIT region.", - "gateway_id": "The UUID of the parent VPN gateway.", - "display_name": "A user-friendly name for the connection. Must start and end with an alphanumeric character, may contain hyphens, and be 1-63 characters long.", - "enabled": "Whether this connection is enabled. Defaults to true.", - "remote_subnet": "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.", - "local_subnet": "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.", - "static_routes": "List of static routes (IPv4 CIDRs) for route-based VPN. Mandatory for ROUTE_BASED gateways.", - "tunnel1": "Configuration for the first IPsec tunnel.", - "tunnel2": "Configuration for the second IPsec tunnel.", - "labels": "Map of custom labels.", -} - -var tunnelSchemaDescriptions = map[string]string{ - "tunnel": "Configuration for the IPsec tunnel.", - "pre_shared_key": "Pre-shared key for the IPsec tunnel. Minimum 20 characters. Write-only argument `pre_shared_key_wo` should be preferred.", - "pre_shared_key_wo": "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": "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.", - "remote_address": "Remote IPv4 address for the tunnel endpoint.", - "phase1_dh_groups": fmt.Sprintf("Diffie-Hellman groups for key exchange. %s", tfutils.FormatPossibleValues(dhGroupValues...)), - "phase1_encryption_algorithms": fmt.Sprintf("Encryption algorithms for Phase 1. %s", tfutils.FormatPossibleValues(encryptionAlgorithmValues...)), - "phase1_integrity_algorithms": fmt.Sprintf("Integrity algorithms for Phase 1. %s", tfutils.FormatPossibleValues(integrityAlgorithmValues...)), - "phase1_rekey_time": "Time to schedule an IKE re-keying in seconds. Range: 900-28800. Default: 14400.", - "phase2_dh_groups": fmt.Sprintf("Diffie-Hellman groups for Phase 2. %s", tfutils.FormatPossibleValues(dhGroupValues...)), - "phase2_encryption_algorithms": fmt.Sprintf("Encryption algorithms for Phase 2. %s", tfutils.FormatPossibleValues(encryptionAlgorithmValues...)), - "phase2_integrity_algorithms": fmt.Sprintf("Integrity algorithms for Phase 2. %s", tfutils.FormatPossibleValues(integrityAlgorithmValues...)), - "phase2_rekey_time": "Time to schedule a Child SA re-keying in seconds. Range: 900-3600. Default: 3600.", - "phase2_start_action": fmt.Sprintf("Action to perform after loading the connection configuration. Default: 'start'. %s", tfutils.FormatPossibleValues(startActionValues...)), - "phase2_dpd_action": fmt.Sprintf("Action to perform on DPD timeout. Default: 'restart'. %s", tfutils.FormatPossibleValues(dpdActionValues...)), - "peering_local_address": "Local tunnel interface IPv4 address.", - "peering_remote_address": "Remote tunnel interface IPv4 address.", - "bgp_remote_asn": "Remote ASN for BGP peering (private ASN range, 64512-4294967294).", -} - type vpnConnectionResource struct { client *vpn.APIClient providerData core.ProviderData @@ -170,12 +133,12 @@ func (r *vpnConnectionResource) Metadata(_ context.Context, req resource.Metadat func tunnelSchema(rootAttribute string) schema.SingleNestedAttribute { return schema.SingleNestedAttribute{ - Description: tunnelSchemaDescriptions["tunnel"], - MarkdownDescription: fmt.Sprintf("%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).", tunnelSchemaDescriptions["tunnel"]), + 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: tunnelSchemaDescriptions["pre_shared_key"], + Description: "Pre-shared key for the IPsec tunnel. Minimum 20 characters. Write-only argument `pre_shared_key_wo` should be preferred.", Optional: true, Validators: []validator.String{ stringvalidator.LengthAtLeast(20), @@ -183,7 +146,7 @@ func tunnelSchema(rootAttribute string) schema.SingleNestedAttribute { }, }, "pre_shared_key_wo": schema.StringAttribute{ - Description: tunnelSchemaDescriptions["pre_shared_key_wo"], + 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, Validators: []validator.String{ @@ -195,14 +158,14 @@ func tunnelSchema(rootAttribute string) schema.SingleNestedAttribute { }, }, "pre_shared_key_wo_version": schema.Int64Attribute{ - Description: tunnelSchemaDescriptions["pre_shared_key_wo_version"], + 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")), }, }, "remote_address": schema.StringAttribute{ - Description: tunnelSchemaDescriptions["remote_address"], + Description: "Remote IPv4 address for the tunnel endpoint.", Required: true, Validators: []validator.String{ validate.IP(true), @@ -212,7 +175,7 @@ func tunnelSchema(rootAttribute string) schema.SingleNestedAttribute { Required: true, Attributes: map[string]schema.Attribute{ "dh_groups": schema.ListAttribute{ - Description: tunnelSchemaDescriptions["phase1_dh_groups"], + Description: fmt.Sprintf("Diffie-Hellman groups for key exchange. %s", tfutils.FormatPossibleValues(dhGroupValues...)), Optional: true, ElementType: types.StringType, Validators: []validator.List{ @@ -222,7 +185,7 @@ func tunnelSchema(rootAttribute string) schema.SingleNestedAttribute { }, }, "encryption_algorithms": schema.ListAttribute{ - Description: tunnelSchemaDescriptions["phase1_encryption_algorithms"], + Description: fmt.Sprintf("Encryption algorithms for Phase 1. %s", tfutils.FormatPossibleValues(encryptionAlgorithmValues...)), Required: true, ElementType: types.StringType, Validators: []validator.List{ @@ -232,7 +195,7 @@ func tunnelSchema(rootAttribute string) schema.SingleNestedAttribute { }, }, "integrity_algorithms": schema.ListAttribute{ - Description: tunnelSchemaDescriptions["phase1_integrity_algorithms"], + Description: fmt.Sprintf("Integrity algorithms for Phase 1. %s", tfutils.FormatPossibleValues(integrityAlgorithmValues...)), Required: true, ElementType: types.StringType, Validators: []validator.List{ @@ -242,7 +205,7 @@ func tunnelSchema(rootAttribute string) schema.SingleNestedAttribute { }, }, "rekey_time": schema.Int32Attribute{ - Description: tunnelSchemaDescriptions["phase1_rekey_time"], + Description: "Time to schedule an IKE re-keying in seconds. Range: 900-28800. Default: 14400.", Optional: true, Computed: true, Validators: []validator.Int32{ @@ -255,7 +218,7 @@ func tunnelSchema(rootAttribute string) schema.SingleNestedAttribute { Required: true, Attributes: map[string]schema.Attribute{ "dh_groups": schema.ListAttribute{ - Description: tunnelSchemaDescriptions["phase2_dh_groups"], + Description: fmt.Sprintf("Diffie-Hellman groups for Phase 2. %s", tfutils.FormatPossibleValues(dhGroupValues...)), Optional: true, ElementType: types.StringType, Validators: []validator.List{ @@ -265,7 +228,7 @@ func tunnelSchema(rootAttribute string) schema.SingleNestedAttribute { }, }, "encryption_algorithms": schema.ListAttribute{ - Description: tunnelSchemaDescriptions["phase2_encryption_algorithms"], + Description: fmt.Sprintf("Encryption algorithms for Phase 2. %s", tfutils.FormatPossibleValues(encryptionAlgorithmValues...)), Required: true, ElementType: types.StringType, Validators: []validator.List{ @@ -275,7 +238,7 @@ func tunnelSchema(rootAttribute string) schema.SingleNestedAttribute { }, }, "integrity_algorithms": schema.ListAttribute{ - Description: tunnelSchemaDescriptions["phase2_integrity_algorithms"], + Description: fmt.Sprintf("Integrity algorithms for Phase 2. %s", tfutils.FormatPossibleValues(integrityAlgorithmValues...)), Required: true, ElementType: types.StringType, Validators: []validator.List{ @@ -285,7 +248,7 @@ func tunnelSchema(rootAttribute string) schema.SingleNestedAttribute { }, }, "rekey_time": schema.Int32Attribute{ - Description: tunnelSchemaDescriptions["phase2_rekey_time"], + Description: "Time to schedule a Child SA re-keying in seconds. Range: 900-3600. Default: 3600.", Optional: true, Computed: true, Validators: []validator.Int32{ @@ -293,7 +256,7 @@ func tunnelSchema(rootAttribute string) schema.SingleNestedAttribute { }, }, "start_action": schema.StringAttribute{ - Description: tunnelSchemaDescriptions["phase2_start_action"], + Description: fmt.Sprintf("Action to perform after loading the connection configuration. Default: 'start'. %s", tfutils.FormatPossibleValues(startActionValues...)), Optional: true, Computed: true, Validators: []validator.String{ @@ -301,7 +264,7 @@ func tunnelSchema(rootAttribute string) schema.SingleNestedAttribute { }, }, "dpd_action": schema.StringAttribute{ - Description: tunnelSchemaDescriptions["phase2_dpd_action"], + Description: fmt.Sprintf("Action to perform on DPD timeout. Default: 'restart'. %s", tfutils.FormatPossibleValues(dpdActionValues...)), Optional: true, Computed: true, Validators: []validator.String{ @@ -314,14 +277,14 @@ func tunnelSchema(rootAttribute string) schema.SingleNestedAttribute { Optional: true, Attributes: map[string]schema.Attribute{ "local_address": schema.StringAttribute{ - Description: tunnelSchemaDescriptions["peering_local_address"], + Description: "Local tunnel interface IPv4 address.", Required: true, Validators: []validator.String{ validate.IP(true), }, }, "remote_address": schema.StringAttribute{ - Description: tunnelSchemaDescriptions["peering_remote_address"], + Description: "Remote tunnel interface IPv4 address.", Required: true, Validators: []validator.String{ validate.IP(true), @@ -333,7 +296,7 @@ func tunnelSchema(rootAttribute string) schema.SingleNestedAttribute { Optional: true, Attributes: map[string]schema.Attribute{ "remote_asn": schema.Int64Attribute{ - Description: tunnelSchemaDescriptions["bgp_remote_asn"], + Description: "Remote ASN for BGP peering (private ASN range, 64512-4294967294).", Required: true, Validators: []validator.Int64{ int64validator.Between(64512, 4294967294), @@ -350,14 +313,14 @@ func (r *vpnConnectionResource) Schema(_ context.Context, _ resource.SchemaReque Description: fmt.Sprintf("VPN Connection resource schema. %s", core.ResourceRegionFallbackDocstring), Attributes: map[string]schema.Attribute{ "id": schema.StringAttribute{ - Description: schemaDescriptions["id"], + 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: schemaDescriptions["connection_id"], + Description: "The server-generated UUID of the VPN connection.", Computed: true, Validators: []validator.String{ validate.UUID(), @@ -368,7 +331,7 @@ func (r *vpnConnectionResource) Schema(_ context.Context, _ resource.SchemaReque }, }, "project_id": schema.StringAttribute{ - Description: schemaDescriptions["project_id"], + Description: "STACKIT project ID.", Required: true, PlanModifiers: []planmodifier.String{ stringplanmodifier.RequiresReplace(), @@ -379,7 +342,7 @@ func (r *vpnConnectionResource) Schema(_ context.Context, _ resource.SchemaReque }, }, "region": schema.StringAttribute{ - Description: schemaDescriptions["region"], + Description: "STACKIT region.", Optional: true, Computed: true, PlanModifiers: []planmodifier.String{ @@ -387,7 +350,7 @@ func (r *vpnConnectionResource) Schema(_ context.Context, _ resource.SchemaReque }, }, "gateway_id": schema.StringAttribute{ - Description: schemaDescriptions["gateway_id"], + Description: "The UUID of the parent VPN gateway.", Required: true, PlanModifiers: []planmodifier.String{ stringplanmodifier.RequiresReplace(), @@ -398,7 +361,7 @@ func (r *vpnConnectionResource) Schema(_ context.Context, _ resource.SchemaReque }, }, "display_name": schema.StringAttribute{ - Description: schemaDescriptions["display_name"], + 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( @@ -408,13 +371,13 @@ func (r *vpnConnectionResource) Schema(_ context.Context, _ resource.SchemaReque }, }, "enabled": schema.BoolAttribute{ - Description: schemaDescriptions["enabled"], + Description: "Whether this connection is enabled. Defaults to true.", Optional: true, Computed: true, Default: booldefault.StaticBool(true), }, "remote_subnet": schema.ListAttribute{ - Description: schemaDescriptions["remote_subnet"], + 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, @@ -424,7 +387,7 @@ func (r *vpnConnectionResource) Schema(_ context.Context, _ resource.SchemaReque }, }, "local_subnet": schema.ListAttribute{ - Description: schemaDescriptions["local_subnet"], + 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, @@ -434,7 +397,7 @@ func (r *vpnConnectionResource) Schema(_ context.Context, _ resource.SchemaReque }, }, "static_routes": schema.ListAttribute{ - Description: schemaDescriptions["static_routes"], + 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{})), @@ -446,7 +409,7 @@ func (r *vpnConnectionResource) Schema(_ context.Context, _ resource.SchemaReque "tunnel1": tunnelSchema("tunnel1"), "tunnel2": tunnelSchema("tunnel2"), "labels": schema.MapAttribute{ - Description: schemaDescriptions["labels"], + Description: "Map of custom labels.", Optional: true, ElementType: types.StringType, Validators: validate.LabelValidators(), From 4344a8ce88886c8e441e3991e2ca17cd6dfd3b2a Mon Sep 17 00:00:00 2001 From: Manuel Vaas Date: Tue, 23 Jun 2026 17:24:49 +0200 Subject: [PATCH 18/28] interface for connectionResponse --- .../services/vpn/connection/resource.go | 61 ++++++++++--------- 1 file changed, 31 insertions(+), 30 deletions(-) diff --git a/stackit/internal/services/vpn/connection/resource.go b/stackit/internal/services/vpn/connection/resource.go index 90205b065..79a3f03e8 100644 --- a/stackit/internal/services/vpn/connection/resource.go +++ b/stackit/internal/services/vpn/connection/resource.go @@ -521,13 +521,7 @@ func (r *vpnConnectionResource) Create(ctx context.Context, req resource.CreateR return } - connResp, err := r.client.DefaultAPI.GetGatewayConnection(ctx, projectId, region, gatewayId, connectionId).Execute() - if err != nil { - core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating VPN connection", fmt.Sprintf("Reading created connection: %v", err)) - return - } - - err = mapFields(ctx, connResp, &model, region) + err = mapFields(ctx, createResp, &model, region) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating VPN connection", fmt.Sprintf("Processing API payload: %v", err)) return @@ -662,7 +656,7 @@ func (r *vpnConnectionResource) Update(ctx context.Context, req resource.UpdateR return } - _, err = r.client.DefaultAPI.UpdateGatewayConnection(ctx, projectId, region, gatewayId, connectionId).UpdateGatewayConnectionPayload(*payload).Execute() + 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 @@ -670,12 +664,6 @@ func (r *vpnConnectionResource) Update(ctx context.Context, req resource.UpdateR ctx = core.LogResponse(ctx) - connResp, err := r.client.DefaultAPI.GetGatewayConnection(ctx, projectId, region, gatewayId, connectionId).Execute() - if err != nil { - core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating VPN connection", fmt.Sprintf("Reading updated connection: %v", err)) - return - } - err = mapFields(ctx, connResp, &model, region) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating VPN connection", fmt.Sprintf("Processing API payload: %v", err)) @@ -942,7 +930,19 @@ func toTunnelPayload(tunnel *TunnelModel) (*vpn.TunnelConfiguration, error) { return config, nil } -func mapFields(ctx context.Context, conn *vpn.ConnectionResponse, model *Model, region string) error { +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) +} + +func mapFields(ctx context.Context, conn connectionResponse, model *Model, region string) error { if conn == nil { return fmt.Errorf("response input is nil") } @@ -951,8 +951,8 @@ func mapFields(ctx context.Context, conn *vpn.ConnectionResponse, model *Model, } var connectionId string - if conn.Id != nil { - connectionId = *conn.Id + if respConnectionId, _ := conn.GetIdOk(); respConnectionId != nil { + connectionId = *respConnectionId } else if model.ConnectionID.ValueString() != "" { connectionId = model.ConnectionID.ValueString() } else { @@ -961,17 +961,17 @@ func mapFields(ctx context.Context, conn *vpn.ConnectionResponse, model *Model, model.ID = tfutils.BuildInternalTerraformId(model.ProjectID.ValueString(), region, model.GatewayID.ValueString(), connectionId) model.ConnectionID = types.StringValue(connectionId) - model.DisplayName = types.StringValue(conn.DisplayName) + model.DisplayName = types.StringValue(conn.GetDisplayName()) model.Region = types.StringValue(region) - if conn.Enabled != nil { - model.Enabled = types.BoolValue(*conn.Enabled) + if enabled, _ := conn.GetEnabledOk(); enabled != nil { + model.Enabled = types.BoolValue(*enabled) } else { model.Enabled = types.BoolValue(true) } - if conn.RemoteSubnets != nil { - list, diags := types.ListValueFrom(ctx, types.StringType, conn.RemoteSubnets) + 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)) } @@ -980,8 +980,8 @@ func mapFields(ctx context.Context, conn *vpn.ConnectionResponse, model *Model, model.RemoteSubnet = types.ListNull(types.StringType) } - if conn.LocalSubnets != nil { - list, diags := types.ListValueFrom(ctx, types.StringType, conn.LocalSubnets) + 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)) } @@ -990,8 +990,8 @@ func mapFields(ctx context.Context, conn *vpn.ConnectionResponse, model *Model, model.LocalSubnet = types.ListNull(types.StringType) } - if conn.StaticRoutes != nil { - list, diags := types.ListValueFrom(ctx, types.StringType, conn.StaticRoutes) + 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)) } @@ -1000,17 +1000,18 @@ func mapFields(ctx context.Context, conn *vpn.ConnectionResponse, model *Model, model.StaticRoutes = types.ListNull(types.StringType) } - err := mapTunnel(ctx, &conn.Tunnel1, model.Tunnel1) + err := mapTunnel(ctx, conn.GetTunnel1(), model.Tunnel1) if err != nil { return fmt.Errorf("mapping tunnel1: %w", err) } - err = mapTunnel(ctx, &conn.Tunnel2, model.Tunnel2) + err = mapTunnel(ctx, conn.GetTunnel2(), model.Tunnel2) if err != nil { return fmt.Errorf("mapping tunnel2: %w", err) } - labels, err := tfutils.MapLabels(ctx, conn.Labels, model.Labels) + respLabels, _ := conn.GetLabelsOk() + labels, err := tfutils.MapLabels(ctx, respLabels, model.Labels) if err != nil { return fmt.Errorf("mapping labels: %w", err) } @@ -1019,7 +1020,7 @@ func mapFields(ctx context.Context, conn *vpn.ConnectionResponse, model *Model, return nil } -func mapTunnel(ctx context.Context, apiTunnel *vpn.TunnelConfiguration, tfTunnel *TunnelModel) error { +func mapTunnel(ctx context.Context, apiTunnel vpn.TunnelConfiguration, tfTunnel *TunnelModel) error { if tfTunnel == nil { tfTunnel = &TunnelModel{ PreSharedKeyWoVersion: types.Int64Null(), From 9e71ffc9a6e808cc0832a96f1528b10d96aecf67 Mon Sep 17 00:00:00 2001 From: Manuel Vaas Date: Tue, 23 Jun 2026 17:33:58 +0200 Subject: [PATCH 19/28] removed unused parts in create function --- .../internal/services/vpn/connection/resource.go | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/stackit/internal/services/vpn/connection/resource.go b/stackit/internal/services/vpn/connection/resource.go index 79a3f03e8..f689eb250 100644 --- a/stackit/internal/services/vpn/connection/resource.go +++ b/stackit/internal/services/vpn/connection/resource.go @@ -505,22 +505,6 @@ func (r *vpnConnectionResource) Create(ctx context.Context, req resource.CreateR ctx = core.LogResponse(ctx) - if createResp.Id == nil { - core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating VPN connection", "Got empty connection id") - return - } - connectionId := *createResp.Id - - ctx = tfutils.SetAndLogStateFields(ctx, &resp.Diagnostics, &resp.State, map[string]any{ - "project_id": projectId, - "region": region, - "gateway_id": gatewayId, - "connection_id": connectionId, - }) - if resp.Diagnostics.HasError() { - return - } - err = mapFields(ctx, createResp, &model, region) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating VPN connection", fmt.Sprintf("Processing API payload: %v", err)) From 528a7b0bf8112cd9515fc5e3e9443afe2a5b10eb Mon Sep 17 00:00:00 2001 From: Manuel Vaas Date: Wed, 24 Jun 2026 11:14:00 +0200 Subject: [PATCH 20/28] resolved comments --- .../services/vpn/connection/datasource.go | 74 +++-- .../services/vpn/connection/resource.go | 306 ++++++++---------- .../services/vpn/connection/resource_test.go | 44 ++- 3 files changed, 221 insertions(+), 203 deletions(-) diff --git a/stackit/internal/services/vpn/connection/datasource.go b/stackit/internal/services/vpn/connection/datasource.go index c9289459b..1100fa14b 100644 --- a/stackit/internal/services/vpn/connection/datasource.go +++ b/stackit/internal/services/vpn/connection/datasource.go @@ -291,7 +291,7 @@ func (d *vpnConnectionDataSource) Read(ctx context.Context, req datasource.ReadR }) } -func mapDataSourceFields(ctx context.Context, conn *vpn.ConnectionResponse, model *DataSourceModel, region string) error { +func mapDataSourceFields(ctx context.Context, conn connectionResponse, model *DataSourceModel, region string) error { if conn == nil { return fmt.Errorf("response input is nil") } @@ -300,8 +300,8 @@ func mapDataSourceFields(ctx context.Context, conn *vpn.ConnectionResponse, mode } var connectionId string - if conn.Id != nil { - connectionId = *conn.Id + if respConnectionId, _ := conn.GetIdOk(); respConnectionId != nil { + connectionId = *respConnectionId } else if model.ConnectionID.ValueString() != "" { connectionId = model.ConnectionID.ValueString() } else { @@ -310,17 +310,17 @@ func mapDataSourceFields(ctx context.Context, conn *vpn.ConnectionResponse, mode model.ID = tfutils.BuildInternalTerraformId(model.ProjectID.ValueString(), region, model.GatewayID.ValueString(), connectionId) model.ConnectionID = types.StringValue(connectionId) - model.DisplayName = types.StringValue(conn.DisplayName) + model.DisplayName = types.StringValue(conn.GetDisplayName()) model.Region = types.StringValue(region) - if conn.Enabled != nil { - model.Enabled = types.BoolValue(*conn.Enabled) + if enabled, _ := conn.GetEnabledOk(); enabled != nil { + model.Enabled = types.BoolValue(*enabled) } else { model.Enabled = types.BoolValue(true) } - if conn.RemoteSubnets != nil { - list, diags := types.ListValueFrom(ctx, types.StringType, conn.RemoteSubnets) + 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)) } @@ -329,8 +329,8 @@ func mapDataSourceFields(ctx context.Context, conn *vpn.ConnectionResponse, mode model.RemoteSubnet = types.ListNull(types.StringType) } - if conn.LocalSubnets != nil { - list, diags := types.ListValueFrom(ctx, types.StringType, conn.LocalSubnets) + 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)) } @@ -339,8 +339,8 @@ func mapDataSourceFields(ctx context.Context, conn *vpn.ConnectionResponse, mode model.LocalSubnet = types.ListNull(types.StringType) } - if conn.StaticRoutes != nil { - list, diags := types.ListValueFrom(ctx, types.StringType, conn.StaticRoutes) + 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)) } @@ -349,19 +349,18 @@ func mapDataSourceFields(ctx context.Context, conn *vpn.ConnectionResponse, mode model.StaticRoutes = types.ListNull(types.StringType) } - tunnel1, err := mapDataSourceTunnel(ctx, &conn.Tunnel1) + err := mapDataSourceTunnel(ctx, conn.GetTunnel1(), model.Tunnel1) if err != nil { return fmt.Errorf("mapping tunnel1: %w", err) } - model.Tunnel1 = tunnel1 - tunnel2, err := mapDataSourceTunnel(ctx, &conn.Tunnel2) + err = mapDataSourceTunnel(ctx, conn.GetTunnel2(), model.Tunnel2) if err != nil { return fmt.Errorf("mapping tunnel2: %w", err) } - model.Tunnel2 = tunnel2 - labels, err := tfutils.MapLabels(ctx, conn.Labels, model.Labels) + respLabels, _ := conn.GetLabelsOk() + labels, err := tfutils.MapLabels(ctx, respLabels, model.Labels) if err != nil { return fmt.Errorf("mapping labels: %w", err) } @@ -370,21 +369,38 @@ func mapDataSourceFields(ctx context.Context, conn *vpn.ConnectionResponse, mode return nil } -func mapDataSourceTunnel(ctx context.Context, apiTunnel *vpn.TunnelConfiguration) (*DataSourceTunnelModel, error) { - tunnel := &DataSourceTunnelModel{ - RemoteAddress: types.StringValue(string(apiTunnel.RemoteAddress)), +func mapDataSourceTunnel(ctx context.Context, apiTunnel vpn.TunnelConfiguration, tfTunnel *DataSourceTunnelModel) error { + if tfTunnel == nil { + tfTunnel = &DataSourceTunnelModel{} } - phase1, err := mapPhase1(ctx, &apiTunnel.Phase1) + + tfTunnel.RemoteAddress = types.StringValue(string(apiTunnel.RemoteAddress)) + + basePhase1, err := mapBasePhase(ctx, &apiTunnel.Phase1) if err != nil { - return nil, err + return err + } + tfTunnel.Phase1 = &Phase1Model{ + BasePhaseModel: basePhase1, } - tunnel.Phase1 = phase1 - phase2, err := mapPhase2(ctx, &apiTunnel.Phase2) + basePhase2, err := mapBasePhase(ctx, &apiTunnel.Phase2) if err != nil { - return nil, err + return err + } + tfTunnel.Phase2 = &Phase2Model{ + BasePhaseModel: basePhase2, + } + if apiTunnel.Phase2.StartAction != nil { + tfTunnel.Phase2.StartAction = types.StringValue(string(*apiTunnel.Phase2.StartAction)) + } else { + tfTunnel.Phase2.StartAction = types.StringNull() + } + if apiTunnel.Phase2.DpdAction != nil { + tfTunnel.Phase2.DpdAction = types.StringValue(string(*apiTunnel.Phase2.DpdAction)) + } else { + tfTunnel.Phase2.DpdAction = types.StringNull() } - tunnel.Phase2 = phase2 if apiTunnel.Peering != nil { peering := &PeeringConfigModel{} @@ -398,14 +414,14 @@ func mapDataSourceTunnel(ctx context.Context, apiTunnel *vpn.TunnelConfiguration } else { peering.RemoteAddress = types.StringNull() } - tunnel.Peering = peering + tfTunnel.Peering = peering } if apiTunnel.Bgp != nil { - tunnel.Bgp = &BGPTunnelConfigModel{ + tfTunnel.Bgp = &BGPTunnelConfigModel{ RemoteAsn: types.Int64Value(int64(apiTunnel.Bgp.RemoteAsn)), } } - return tunnel, nil + return nil } diff --git a/stackit/internal/services/vpn/connection/resource.go b/stackit/internal/services/vpn/connection/resource.go index f689eb250..1b3a6b3a7 100644 --- a/stackit/internal/services/vpn/connection/resource.go +++ b/stackit/internal/services/vpn/connection/resource.go @@ -95,6 +95,41 @@ type Model struct { Labels types.Map `tfsdk:"labels"` } +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) @@ -587,40 +622,16 @@ func (r *vpnConnectionResource) Update(ctx context.Context, req resource.UpdateR return } - // tunnel1 PSK rotation - if !tfutils.IsUndefined(model.Tunnel1.PreSharedKeyWoVersion) { - planVersion := model.Tunnel1.PreSharedKeyWoVersion.ValueInt64() - stateVersion := stateModel.Tunnel1.PreSharedKeyWoVersion.ValueInt64() - if planVersion < stateVersion { - resp.Diagnostics.AddAttributeError( - path.Root("tunnel1").AtName("pre_shared_key_wo_version"), - "Version must not decrease", - fmt.Sprintf("`pre_shared_key_wo_version` must be incremented to rotate the pre-shared key, got %d (current: %d).", planVersion, stateVersion), - ) - return - } - if planVersion > stateVersion { - // Secret must be read from Config, not Plan — write-only values are always null in plan. - model.Tunnel1.PreSharedKeyWo = configModel.Tunnel1.PreSharedKeyWo - } + err := pskRotationOnUpdate(resp, "tunnel1", model.Tunnel1, stateModel.Tunnel1.PreSharedKeyWoVersion.ValueInt64(), configModel.Tunnel1.PreSharedKeyWo) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Tunnel1 PSK Rotation", err.Error()) + return } - // tunnel2 PSK rotation - if !tfutils.IsUndefined(model.Tunnel2.PreSharedKeyWoVersion) { - planVersion := model.Tunnel2.PreSharedKeyWoVersion.ValueInt64() - stateVersion := stateModel.Tunnel2.PreSharedKeyWoVersion.ValueInt64() - if planVersion < stateVersion { - resp.Diagnostics.AddAttributeError( - path.Root("tunnel2").AtName("pre_shared_key_wo_version"), - "Version must not decrease", - fmt.Sprintf("`pre_shared_key_wo_version` must be incremented to rotate the pre-shared key, got %d (current: %d).", planVersion, stateVersion), - ) - return - } - if planVersion > stateVersion { - // Secret must be read from Config, not Plan — write-only values are always null in plan. - model.Tunnel2.PreSharedKeyWo = configModel.Tunnel2.PreSharedKeyWo - } + err = pskRotationOnUpdate(resp, "tunnel2", model.Tunnel2, stateModel.Tunnel2.PreSharedKeyWoVersion.ValueInt64(), configModel.Tunnel2.PreSharedKeyWo) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Tunnel2 PSK Rotation", err.Error()) + return } ctx = core.InitProviderContext(ctx) @@ -696,6 +707,28 @@ func (r *vpnConnectionResource) Delete(ctx context.Context, req resource.DeleteR tflog.Info(ctx, "VPN connection deleted") } +func pskRotationOnUpdate(resp *resource.UpdateResponse, rootAttribute string, modelTunnel *TunnelModel, currentKeyVersion int64, preSharedKey types.String) error { + if resp == nil || modelTunnel == nil { + return fmt.Errorf("pskRotationOnUpdate: arguments can not be nil") + } + + if !tfutils.IsUndefined(modelTunnel.PreSharedKeyWoVersion) { + newKeyVersion := modelTunnel.PreSharedKeyWoVersion.ValueInt64() + if newKeyVersion < currentKeyVersion { + resp.Diagnostics.AddAttributeError( + path.Root(rootAttribute).AtName("pre_shared_key_wo_version"), + "Version must not decrease", + fmt.Sprintf("`pre_shared_key_wo_version` must be incremented to rotate the pre-shared key, got %d (current: %d).", newKeyVersion, currentKeyVersion), + ) + } + if newKeyVersion > currentKeyVersion { + // Secret must be read from Config, not Plan — write-only values are always null in plan. + modelTunnel.PreSharedKeyWo = preSharedKey + } + } + return nil +} + func toCreatePayload(ctx context.Context, model *Model) (*vpn.CreateGatewayConnectionPayload, error) { if model == nil { return nil, fmt.Errorf("nil model") @@ -724,18 +757,7 @@ func toUpdatePayload(ctx context.Context, model *Model) (*vpn.UpdateGatewayConne return payload, nil } -type connectionFields interface { - SetDisplayName(string) - SetTunnel1(vpn.TunnelConfiguration) - SetTunnel2(vpn.TunnelConfiguration) - SetEnabled(bool) - SetRemoteSubnets([]string) - SetLocalSubnets([]string) - SetStaticRoutes([]string) - SetLabels(map[string]string) -} - -func toConnectionPayload(ctx context.Context, model *Model, payload connectionFields) error { +func toConnectionPayload(ctx context.Context, model *Model, payload connectionPayload) error { if payload == nil { return fmt.Errorf("payload can not be nil") } @@ -808,90 +830,31 @@ func toTunnelPayload(tunnel *TunnelModel) (*vpn.TunnelConfiguration, error) { if tunnel.Phase1 != nil { phase1 := vpn.TunnelConfigurationPhase1{} - - if !tfutils.IsUndefined(tunnel.Phase1.DhGroups) { - dhGroups, err := tfutils.ListValueToStringSlice(tunnel.Phase1.DhGroups) - if err != nil { - return nil, fmt.Errorf("converting phase1 dh_groups: %w", err) - } - dhGroupsInner := []vpn.PhaseDhGroupsInner{} - for _, item := range dhGroups { - dhGroupsInner = append(dhGroupsInner, vpn.PhaseDhGroupsInner(item)) - } - phase1.DhGroups = dhGroupsInner - } - - encAlgs, err := tfutils.ListValueToStringSlice(tunnel.Phase1.EncryptionAlgorithms) + err := toBasePhasePayload(&tunnel.Phase1.BasePhaseModel, &phase1) if err != nil { - return nil, fmt.Errorf("converting phase1 encryption_algorithms: %w", err) + return nil, err } - encAlgsInner := []vpn.PhaseEncryptionAlgorithmsInner{} - for _, item := range encAlgs { - encAlgsInner = append(encAlgsInner, vpn.PhaseEncryptionAlgorithmsInner(item)) - } - phase1.EncryptionAlgorithms = encAlgsInner - - intAlgs, err := tfutils.ListValueToStringSlice(tunnel.Phase1.IntegrityAlgorithms) - if err != nil { - return nil, fmt.Errorf("converting phase1 integrity_algorithms: %w", err) - } - intAlgsInner := []vpn.PhaseIntegrityAlgorithmsInner{} - for _, item := range intAlgs { - intAlgsInner = append(intAlgsInner, vpn.PhaseIntegrityAlgorithmsInner(item)) - } - phase1.IntegrityAlgorithms = intAlgsInner - - if !tfutils.IsUndefined(tunnel.Phase1.RekeyTime) { - rekeyTime := tunnel.Phase1.RekeyTime.ValueInt32() - phase1.RekeyTime = &rekeyTime - } - config.Phase1 = phase1 } if tunnel.Phase2 != nil { phase2 := vpn.TunnelConfigurationPhase2{} - if !tfutils.IsUndefined(tunnel.Phase2.DhGroups) { - dhGroups, err := tfutils.ListValueToStringSlice(tunnel.Phase2.DhGroups) - if err != nil { - return nil, fmt.Errorf("converting phase2 dh_groups: %w", err) - } - dhGroupsInner := []vpn.PhaseDhGroupsInner{} - for _, item := range dhGroups { - dhGroupsInner = append(dhGroupsInner, vpn.PhaseDhGroupsInner(item)) - } - phase2.DhGroups = dhGroupsInner - } - encAlgs, err := tfutils.ListValueToStringSlice(tunnel.Phase2.EncryptionAlgorithms) - if err != nil { - return nil, fmt.Errorf("converting phase2 encryption_algorithms: %w", err) - } - encAlgsInner := []vpn.PhaseEncryptionAlgorithmsInner{} - for _, item := range encAlgs { - encAlgsInner = append(encAlgsInner, vpn.PhaseEncryptionAlgorithmsInner(item)) - } - phase2.EncryptionAlgorithms = encAlgsInner - intAlgs, err := tfutils.ListValueToStringSlice(tunnel.Phase2.IntegrityAlgorithms) + + err := toBasePhasePayload(&tunnel.Phase2.BasePhaseModel, &phase2) if err != nil { - return nil, fmt.Errorf("converting phase2 integrity_algorithms: %w", err) - } - intAlgsInner := []vpn.PhaseIntegrityAlgorithmsInner{} - for _, item := range intAlgs { - intAlgsInner = append(intAlgsInner, vpn.PhaseIntegrityAlgorithmsInner(item)) - } - phase2.IntegrityAlgorithms = intAlgsInner - if !tfutils.IsUndefined(tunnel.Phase2.RekeyTime) { - rekeyTime := tunnel.Phase2.RekeyTime.ValueInt32() - phase2.RekeyTime = &rekeyTime + return nil, err } + if !tfutils.IsUndefined(tunnel.Phase2.StartAction) { startAction := tunnel.Phase2.StartAction.ValueString() phase2.StartAction = vpn.TunnelConfigurationPhase2AllOfStartAction(startAction).Ptr() } + if !tfutils.IsUndefined(tunnel.Phase2.DpdAction) { dpdAction := tunnel.Phase2.DpdAction.ValueString() phase2.DpdAction = vpn.TunnelConfigurationPhase2AllOfDpdAction(dpdAction).Ptr() } + config.Phase2 = phase2 } @@ -914,16 +877,46 @@ func toTunnelPayload(tunnel *TunnelModel) (*vpn.TunnelConfiguration, error) { return config, nil } -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) +func toBasePhasePayload(phaseModel *BasePhaseModel, phasePayload BasePhasePayload) error { + if phaseModel != nil { + if !tfutils.IsUndefined(phaseModel.DhGroups) { + dhGroups, err := tfutils.ListValueToStringSlice(phaseModel.DhGroups) + if err != nil { + return fmt.Errorf("converting phase dh_groups: %w", err) + } + dhGroupsInner := []vpn.PhaseDhGroupsInner{} + for _, item := range dhGroups { + dhGroupsInner = append(dhGroupsInner, vpn.PhaseDhGroupsInner(item)) + } + phasePayload.SetDhGroups(dhGroupsInner) + } + + encAlgs, err := tfutils.ListValueToStringSlice(phaseModel.EncryptionAlgorithms) + if err != nil { + return fmt.Errorf("converting phase encryption_algorithms: %w", err) + } + encAlgsInner := []vpn.PhaseEncryptionAlgorithmsInner{} + for _, item := range encAlgs { + encAlgsInner = append(encAlgsInner, vpn.PhaseEncryptionAlgorithmsInner(item)) + } + phasePayload.SetEncryptionAlgorithms(encAlgsInner) + + intAlgs, err := tfutils.ListValueToStringSlice(phaseModel.IntegrityAlgorithms) + if err != nil { + return fmt.Errorf("converting phase integrity_algorithms: %w", err) + } + intAlgsInner := []vpn.PhaseIntegrityAlgorithmsInner{} + for _, item := range intAlgs { + intAlgsInner = append(intAlgsInner, vpn.PhaseIntegrityAlgorithmsInner(item)) + } + phasePayload.SetIntegrityAlgorithms(intAlgsInner) + + if !tfutils.IsUndefined(phaseModel.RekeyTime) { + rekeyTime := phaseModel.RekeyTime.ValueInt32() + phasePayload.SetRekeyTime(rekeyTime) + } + } + return nil } func mapFields(ctx context.Context, conn connectionResponse, model *Model, region string) error { @@ -1013,17 +1006,31 @@ func mapTunnel(ctx context.Context, apiTunnel vpn.TunnelConfiguration, tfTunnel tfTunnel.RemoteAddress = types.StringValue(string(apiTunnel.RemoteAddress)) - phase1, err := mapPhase1(ctx, &apiTunnel.Phase1) + basePhase1, err := mapBasePhase(ctx, &apiTunnel.Phase1) if err != nil { return err } - tfTunnel.Phase1 = phase1 + tfTunnel.Phase1 = &Phase1Model{ + BasePhaseModel: basePhase1, + } - phase2, err := mapPhase2(ctx, &apiTunnel.Phase2) + basePhase2, err := mapBasePhase(ctx, &apiTunnel.Phase2) if err != nil { return err } - tfTunnel.Phase2 = phase2 + tfTunnel.Phase2 = &Phase2Model{ + BasePhaseModel: basePhase2, + } + if apiTunnel.Phase2.StartAction != nil { + tfTunnel.Phase2.StartAction = types.StringValue(string(*apiTunnel.Phase2.StartAction)) + } else { + tfTunnel.Phase2.StartAction = types.StringNull() + } + if apiTunnel.Phase2.DpdAction != nil { + tfTunnel.Phase2.DpdAction = types.StringValue(string(*apiTunnel.Phase2.DpdAction)) + } else { + tfTunnel.Phase2.DpdAction = types.StringNull() + } if apiTunnel.Peering != nil { peering := &PeeringConfigModel{} @@ -1053,14 +1060,7 @@ func mapTunnel(ctx context.Context, apiTunnel vpn.TunnelConfiguration, tfTunnel return nil } -type BasePhaseFields interface { - GetDhGroupsOk() ([]vpn.PhaseDhGroupsInner, bool) - GetEncryptionAlgorithmsOk() ([]vpn.PhaseEncryptionAlgorithmsInner, bool) - GetIntegrityAlgorithmsOk() ([]vpn.PhaseIntegrityAlgorithmsInner, bool) - GetRekeyTimeOk() (*int32, bool) -} - -func mapBasePhase(ctx context.Context, apiPhase BasePhaseFields) (phase BasePhaseModel, err error) { +func mapBasePhase(ctx context.Context, apiPhase BasePhasePayload) (phase BasePhaseModel, err error) { if apiPhase == nil { return phase, fmt.Errorf("api phase can not be nil") } @@ -1100,43 +1100,3 @@ func mapBasePhase(ctx context.Context, apiPhase BasePhaseFields) (phase BasePhas return phase, nil } - -func mapPhase1(ctx context.Context, apiPhase1 *vpn.TunnelConfigurationPhase1) (*Phase1Model, error) { - basePhase, err := mapBasePhase(ctx, apiPhase1) - if err != nil { - return nil, err - } - - return &Phase1Model{ - BasePhaseModel: basePhase, - }, nil -} - -func mapPhase2(ctx context.Context, apiPhase2 *vpn.TunnelConfigurationPhase2) (*Phase2Model, error) { - if apiPhase2 == nil { - return nil, fmt.Errorf("phase can not be nil") - } - - basePhase, err := mapBasePhase(ctx, apiPhase2) - if err != nil { - return nil, err - } - - phase2 := &Phase2Model{ - BasePhaseModel: basePhase, - } - - if apiPhase2.StartAction != nil { - phase2.StartAction = types.StringValue(string(*apiPhase2.StartAction)) - } else { - phase2.StartAction = types.StringNull() - } - - if apiPhase2.DpdAction != nil { - phase2.DpdAction = types.StringValue(string(*apiPhase2.DpdAction)) - } else { - phase2.DpdAction = types.StringNull() - } - - return phase2, nil -} diff --git a/stackit/internal/services/vpn/connection/resource_test.go b/stackit/internal/services/vpn/connection/resource_test.go index b7336b764..055ccea40 100644 --- a/stackit/internal/services/vpn/connection/resource_test.go +++ b/stackit/internal/services/vpn/connection/resource_test.go @@ -8,6 +8,7 @@ import ( "github.com/google/go-cmp/cmp" "github.com/google/uuid" "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/types" vpn "github.com/stackitcloud/stackit-sdk-go/services/vpn/v1api" @@ -468,7 +469,6 @@ func TestToUpdatePayload(t *testing.T) { }) } } - func TestToTunnelConfiguration(t *testing.T) { tests := []struct { description string @@ -536,3 +536,45 @@ func TestToTunnelConfiguration(t *testing.T) { }) } } + +func TestPskRotationOnUpdate(t *testing.T) { + type args struct { + resp *resource.UpdateResponse + rootAttribute string + modelTunnel *TunnelModel + currentKeyVersion int64 + preSharedKey types.String + } + + tests := []struct { + description string + input args + expected *TunnelModel + isValid bool + }{ + { + description: "no_args", + input: args{}, + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + err := pskRotationOnUpdate(tt.input.resp, tt.input.rootAttribute, tt.input.modelTunnel, tt.input.currentKeyVersion, tt.input.preSharedKey) + + 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 { + diff := cmp.Diff(tt.expected, tt.input.modelTunnel) + if diff != "" { + t.Fatalf("Data does not match (-want +got):\n%s", diff) + } + } + }) + } +} From c322d90edd7f6a737b079ada6b1f58bd9a501c31 Mon Sep 17 00:00:00 2001 From: Manuel Vaas Date: Wed, 24 Jun 2026 12:06:47 +0200 Subject: [PATCH 21/28] Add testing --- .../vpn/connection/datasource_test.go | 81 ++++++++++++- .../services/vpn/connection/resource.go | 10 +- .../services/vpn/connection/resource_test.go | 112 ++++++++++++++++-- 3 files changed, 186 insertions(+), 17 deletions(-) diff --git a/stackit/internal/services/vpn/connection/datasource_test.go b/stackit/internal/services/vpn/connection/datasource_test.go index 33ef68fad..757b2cfdf 100644 --- a/stackit/internal/services/vpn/connection/datasource_test.go +++ b/stackit/internal/services/vpn/connection/datasource_test.go @@ -8,6 +8,7 @@ import ( "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" ) @@ -46,8 +47,10 @@ func fixtureDataSourceModel(mods ...func(m *DataSourceModel)) DataSourceModel { LocalSubnet: types.ListValueMust(types.StringType, []attr.Value{ types.StringValue("192.168.0.0/24"), }), - StaticRoutes: types.ListNull(types.StringType), - Tunnel1: fixtureDataSourceTunnelModel(), + StaticRoutes: types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("123.45.67.89"), + }), + Tunnel1: fixtureDataSourceTunnelModel(), Tunnel2: fixtureDataSourceTunnelModel(func(m *DataSourceTunnelModel) { m.RemoteAddress = types.StringValue("203.0.113.2") }), @@ -72,6 +75,60 @@ func TestMapDataSourceFields(t *testing.T) { expected: fixtureDataSourceModel(), isValid: true, }, + { + description: "minimal_connection", + input: &vpn.ConnectionResponse{ + Id: new("connection-id"), + }, + expected: DataSourceModel{ + 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.BoolValue(true), + RemoteSubnet: basetypes.NewListNull(basetypes.StringType{}), + LocalSubnet: basetypes.NewListNull(basetypes.StringType{}), + StaticRoutes: basetypes.NewListNull(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{}), + }, + }, + }, + Labels: basetypes.NewMapNull(basetypes.StringType{}), + }, + isValid: true, + }, { description: "connection_with_static_routes_and_bgp", input: fixtureConnectionResponse(func(m *vpn.ConnectionResponse) { @@ -113,10 +170,25 @@ func TestMapDataSourceFields(t *testing.T) { }), 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, - expected: DataSourceModel{}, isValid: false, }, { @@ -125,8 +197,7 @@ func TestMapDataSourceFields(t *testing.T) { Id: nil, DisplayName: "test-connection", }, - expected: DataSourceModel{}, - isValid: false, + isValid: false, }, } for _, tt := range tests { diff --git a/stackit/internal/services/vpn/connection/resource.go b/stackit/internal/services/vpn/connection/resource.go index 1b3a6b3a7..41c2a6661 100644 --- a/stackit/internal/services/vpn/connection/resource.go +++ b/stackit/internal/services/vpn/connection/resource.go @@ -707,13 +707,13 @@ func (r *vpnConnectionResource) Delete(ctx context.Context, req resource.DeleteR tflog.Info(ctx, "VPN connection deleted") } -func pskRotationOnUpdate(resp *resource.UpdateResponse, rootAttribute string, modelTunnel *TunnelModel, currentKeyVersion int64, preSharedKey types.String) error { - if resp == nil || modelTunnel == nil { +func pskRotationOnUpdate(resp *resource.UpdateResponse, rootAttribute string, tunnelModel *TunnelModel, currentKeyVersion int64, preSharedKey types.String) error { + if resp == nil || tunnelModel == nil { return fmt.Errorf("pskRotationOnUpdate: arguments can not be nil") } - if !tfutils.IsUndefined(modelTunnel.PreSharedKeyWoVersion) { - newKeyVersion := modelTunnel.PreSharedKeyWoVersion.ValueInt64() + if !tfutils.IsUndefined(tunnelModel.PreSharedKeyWoVersion) { + newKeyVersion := tunnelModel.PreSharedKeyWoVersion.ValueInt64() if newKeyVersion < currentKeyVersion { resp.Diagnostics.AddAttributeError( path.Root(rootAttribute).AtName("pre_shared_key_wo_version"), @@ -723,7 +723,7 @@ func pskRotationOnUpdate(resp *resource.UpdateResponse, rootAttribute string, mo } if newKeyVersion > currentKeyVersion { // Secret must be read from Config, not Plan — write-only values are always null in plan. - modelTunnel.PreSharedKeyWo = preSharedKey + tunnelModel.PreSharedKeyWo = preSharedKey } } return nil diff --git a/stackit/internal/services/vpn/connection/resource_test.go b/stackit/internal/services/vpn/connection/resource_test.go index 055ccea40..6abfb347c 100644 --- a/stackit/internal/services/vpn/connection/resource_test.go +++ b/stackit/internal/services/vpn/connection/resource_test.go @@ -52,6 +52,7 @@ func fixtureConnectionResponse(mods ...func(m *vpn.ConnectionResponse)) *vpn.Con 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" @@ -118,8 +119,10 @@ func fixtureModel(mods ...func(m *Model)) Model { LocalSubnet: types.ListValueMust(types.StringType, []attr.Value{ types.StringValue("192.168.0.0/24"), }), - StaticRoutes: types.ListNull(types.StringType), - Tunnel1: fixtureTunnelModel(), + StaticRoutes: types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("123.45.67.89"), + }), + Tunnel1: fixtureTunnelModel(), Tunnel2: fixtureTunnelModel(func(m *TunnelModel) { m.RemoteAddress = types.StringValue("203.0.113.2") }), @@ -165,6 +168,9 @@ func fixtureCreatePayload(mods ...func(m *vpn.CreateGatewayConnectionPayload)) * 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") @@ -188,6 +194,9 @@ func fixtureUpdatePayload(mods ...func(m *vpn.UpdateGatewayConnectionPayload)) * 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") @@ -314,6 +323,22 @@ func TestMapFields(t *testing.T) { }), 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, @@ -442,6 +467,24 @@ func TestToUpdatePayload(t *testing.T) { }, isValid: true, }, + { + description: "peering", + input: new(fixtureModel(func(m *Model) { + m.Tunnel1.Peering = &PeeringConfigModel{ + LocalAddress: types.StringValue("123.45.67.89"), + RemoteAddress: types.StringValue("98.76.54.32"), + } + })), + 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: "nil_model", input: nil, @@ -469,6 +512,7 @@ func TestToUpdatePayload(t *testing.T) { }) } } + func TestToTunnelConfiguration(t *testing.T) { tests := []struct { description string @@ -541,7 +585,7 @@ func TestPskRotationOnUpdate(t *testing.T) { type args struct { resp *resource.UpdateResponse rootAttribute string - modelTunnel *TunnelModel + tunnelModel *TunnelModel currentKeyVersion int64 preSharedKey types.String } @@ -550,8 +594,54 @@ func TestPskRotationOnUpdate(t *testing.T) { description string input args expected *TunnelModel + tfError bool isValid bool }{ + { + description: "default", + input: args{ + resp: &resource.UpdateResponse{}, + rootAttribute: "tunnel1", + tunnelModel: &TunnelModel{}, + currentKeyVersion: 0, + preSharedKey: types.StringValue("foo-bar"), + }, + expected: &TunnelModel{}, + tfError: false, + isValid: true, + }, + { + description: "upgrade_key", + input: args{ + resp: &resource.UpdateResponse{}, + rootAttribute: "tunnel1", + tunnelModel: &TunnelModel{ + PreSharedKeyWoVersion: types.Int64Value(1), + }, + currentKeyVersion: 0, + preSharedKey: types.StringValue("foo-bar"), + }, + expected: &TunnelModel{ + PreSharedKeyWoVersion: types.Int64Value(1), + PreSharedKeyWo: types.StringValue("foo-bar"), + }, + tfError: false, + isValid: true, + }, + { + description: "downgrade_key", + input: args{ + resp: &resource.UpdateResponse{}, + rootAttribute: "tunnel1", + tunnelModel: &TunnelModel{ + PreSharedKeyWoVersion: types.Int64Value(0), + }, + currentKeyVersion: 1, + preSharedKey: types.StringValue("foo-bar"), + }, + isValid: true, + tfError: true, + }, { description: "no_args", input: args{}, @@ -561,7 +651,7 @@ func TestPskRotationOnUpdate(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - err := pskRotationOnUpdate(tt.input.resp, tt.input.rootAttribute, tt.input.modelTunnel, tt.input.currentKeyVersion, tt.input.preSharedKey) + err := pskRotationOnUpdate(tt.input.resp, tt.input.rootAttribute, tt.input.tunnelModel, tt.input.currentKeyVersion, tt.input.preSharedKey) if !tt.isValid && err == nil { t.Fatalf("expected error, got none") @@ -570,9 +660,17 @@ func TestPskRotationOnUpdate(t *testing.T) { t.Fatalf("expected no error, got %v", err) } if tt.isValid { - diff := cmp.Diff(tt.expected, tt.input.modelTunnel) - if diff != "" { - t.Fatalf("Data does not match (-want +got):\n%s", diff) + if tt.tfError && !tt.input.resp.Diagnostics.HasError() { + t.Fatalf("expected tf error, got none") + } + if !tt.tfError && tt.input.resp.Diagnostics.HasError() { + t.Fatalf("expected no tf error, got %v", tt.input.resp.Diagnostics.Errors()) + } + if !tt.tfError { + modelDiff := cmp.Diff(tt.expected, tt.input.tunnelModel) + if modelDiff != "" { + t.Fatalf("Data does not match (-want +got):\n%s", modelDiff) + } } } }) From 1694ce40c6e856145a410713fbb2f823e36e2c5c Mon Sep 17 00:00:00 2001 From: Manuel Vaas Date: Wed, 24 Jun 2026 13:55:17 +0200 Subject: [PATCH 22/28] removed manual enabled override --- stackit/internal/services/vpn/connection/resource.go | 2 +- stackit/internal/services/vpn/connection/resource_test.go | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/stackit/internal/services/vpn/connection/resource.go b/stackit/internal/services/vpn/connection/resource.go index 41c2a6661..ff048479c 100644 --- a/stackit/internal/services/vpn/connection/resource.go +++ b/stackit/internal/services/vpn/connection/resource.go @@ -944,7 +944,7 @@ func mapFields(ctx context.Context, conn connectionResponse, model *Model, regio if enabled, _ := conn.GetEnabledOk(); enabled != nil { model.Enabled = types.BoolValue(*enabled) } else { - model.Enabled = types.BoolValue(true) + model.Enabled = types.BoolNull() } if remoteSubnets, _ := conn.GetRemoteSubnetsOk(); remoteSubnets != nil { diff --git a/stackit/internal/services/vpn/connection/resource_test.go b/stackit/internal/services/vpn/connection/resource_test.go index 6abfb347c..0fff94ed3 100644 --- a/stackit/internal/services/vpn/connection/resource_test.go +++ b/stackit/internal/services/vpn/connection/resource_test.go @@ -238,7 +238,6 @@ func TestMapFields(t *testing.T) { Region: types.StringValue(region), GatewayID: types.StringValue(gatewayId), DisplayName: types.StringValue(""), - Enabled: types.BoolValue(true), RemoteSubnet: types.ListNull(types.StringType), LocalSubnet: types.ListNull(types.StringType), StaticRoutes: types.ListNull(types.StringType), From 9a582c0d2412088944b46676dbac76e466a470ff Mon Sep 17 00:00:00 2001 From: Manuel Vaas Date: Wed, 24 Jun 2026 15:04:16 +0200 Subject: [PATCH 23/28] fixed linter issue --- .../services/vpn/connection/datasource.go | 13 +++++++++---- .../internal/services/vpn/connection/resource.go | 15 +++++++++------ 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/stackit/internal/services/vpn/connection/datasource.go b/stackit/internal/services/vpn/connection/datasource.go index 1100fa14b..fbf02d1f8 100644 --- a/stackit/internal/services/vpn/connection/datasource.go +++ b/stackit/internal/services/vpn/connection/datasource.go @@ -349,12 +349,14 @@ func mapDataSourceFields(ctx context.Context, conn connectionResponse, model *Da model.StaticRoutes = types.ListNull(types.StringType) } - err := mapDataSourceTunnel(ctx, conn.GetTunnel1(), model.Tunnel1) + tunnel1 := conn.GetTunnel1() + err := mapDataSourceTunnel(ctx, &tunnel1, model.Tunnel1) if err != nil { return fmt.Errorf("mapping tunnel1: %w", err) } - err = mapDataSourceTunnel(ctx, conn.GetTunnel2(), model.Tunnel2) + tunnel2 := conn.GetTunnel2() + err = mapDataSourceTunnel(ctx, &tunnel2, model.Tunnel2) if err != nil { return fmt.Errorf("mapping tunnel2: %w", err) } @@ -369,9 +371,12 @@ func mapDataSourceFields(ctx context.Context, conn connectionResponse, model *Da return nil } -func mapDataSourceTunnel(ctx context.Context, apiTunnel vpn.TunnelConfiguration, tfTunnel *DataSourceTunnelModel) error { +func mapDataSourceTunnel(ctx context.Context, apiTunnel *vpn.TunnelConfiguration, tfTunnel *DataSourceTunnelModel) error { + if apiTunnel == nil { + return fmt.Errorf("apiTunnel can not be nil") + } if tfTunnel == nil { - tfTunnel = &DataSourceTunnelModel{} + return fmt.Errorf("tfTunnel can not be nil") } tfTunnel.RemoteAddress = types.StringValue(string(apiTunnel.RemoteAddress)) diff --git a/stackit/internal/services/vpn/connection/resource.go b/stackit/internal/services/vpn/connection/resource.go index ff048479c..23376e250 100644 --- a/stackit/internal/services/vpn/connection/resource.go +++ b/stackit/internal/services/vpn/connection/resource.go @@ -977,12 +977,14 @@ func mapFields(ctx context.Context, conn connectionResponse, model *Model, regio model.StaticRoutes = types.ListNull(types.StringType) } - err := mapTunnel(ctx, conn.GetTunnel1(), model.Tunnel1) + tunnel1 := conn.GetTunnel1() + err := mapTunnel(ctx, &tunnel1, model.Tunnel1) if err != nil { return fmt.Errorf("mapping tunnel1: %w", err) } - err = mapTunnel(ctx, conn.GetTunnel2(), model.Tunnel2) + tunnel2 := conn.GetTunnel2() + err = mapTunnel(ctx, &tunnel2, model.Tunnel2) if err != nil { return fmt.Errorf("mapping tunnel2: %w", err) } @@ -997,11 +999,12 @@ func mapFields(ctx context.Context, conn connectionResponse, model *Model, regio return nil } -func mapTunnel(ctx context.Context, apiTunnel vpn.TunnelConfiguration, tfTunnel *TunnelModel) error { +func mapTunnel(ctx context.Context, apiTunnel *vpn.TunnelConfiguration, tfTunnel *TunnelModel) error { + if apiTunnel == nil { + return fmt.Errorf("apiTunnel can not be nil") + } if tfTunnel == nil { - tfTunnel = &TunnelModel{ - PreSharedKeyWoVersion: types.Int64Null(), - } + return fmt.Errorf("tfTunnel can not be nil") } tfTunnel.RemoteAddress = types.StringValue(string(apiTunnel.RemoteAddress)) From 1de193bf8859d8f2017952cb10a4445df2c3e6fe Mon Sep 17 00:00:00 2001 From: Manuel Vaas Date: Thu, 25 Jun 2026 07:17:18 +0200 Subject: [PATCH 24/28] fix tunnel --- .../services/vpn/connection/datasource.go | 16 ++++++++++++---- .../internal/services/vpn/connection/resource.go | 10 ++++++++++ 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/stackit/internal/services/vpn/connection/datasource.go b/stackit/internal/services/vpn/connection/datasource.go index fbf02d1f8..f456cd4b3 100644 --- a/stackit/internal/services/vpn/connection/datasource.go +++ b/stackit/internal/services/vpn/connection/datasource.go @@ -80,8 +80,8 @@ func (d *vpnConnectionDataSource) Metadata(_ context.Context, req datasource.Met resp.TypeName = req.ProviderTypeName + "_vpn_connection" } -func (d *vpnConnectionDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { - tunnelSchema := schema.SingleNestedAttribute{ +func DataSourceTunnelSchema() schema.SingleNestedAttribute { + return schema.SingleNestedAttribute{ Computed: true, Attributes: map[string]schema.Attribute{ "remote_address": schema.StringAttribute{ @@ -172,7 +172,9 @@ func (d *vpnConnectionDataSource) Schema(_ context.Context, _ datasource.SchemaR }, }, } +} +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{ @@ -231,8 +233,8 @@ func (d *vpnConnectionDataSource) Schema(_ context.Context, _ datasource.SchemaR Computed: true, ElementType: types.StringType, }, - "tunnel1": tunnelSchema, - "tunnel2": tunnelSchema, + "tunnel1": DataSourceTunnelSchema(), + "tunnel2": DataSourceTunnelSchema(), "labels": schema.MapAttribute{ Description: "Map of custom labels.", Computed: true, @@ -350,12 +352,18 @@ func mapDataSourceFields(ctx context.Context, conn connectionResponse, model *Da } tunnel1 := conn.GetTunnel1() + if model.Tunnel1 == nil { + model.Tunnel1 = &DataSourceTunnelModel{} + } err := mapDataSourceTunnel(ctx, &tunnel1, model.Tunnel1) if err != nil { return fmt.Errorf("mapping tunnel1: %w", err) } tunnel2 := conn.GetTunnel2() + if model.Tunnel1 == nil { + model.Tunnel2 = &DataSourceTunnelModel{} + } err = mapDataSourceTunnel(ctx, &tunnel2, model.Tunnel2) if err != nil { return fmt.Errorf("mapping tunnel2: %w", err) diff --git a/stackit/internal/services/vpn/connection/resource.go b/stackit/internal/services/vpn/connection/resource.go index 23376e250..f10d6397b 100644 --- a/stackit/internal/services/vpn/connection/resource.go +++ b/stackit/internal/services/vpn/connection/resource.go @@ -978,12 +978,22 @@ func mapFields(ctx context.Context, conn connectionResponse, model *Model, regio } tunnel1 := conn.GetTunnel1() + if model.Tunnel1 == nil { + model.Tunnel1 = &TunnelModel{ + PreSharedKeyWoVersion: types.Int64Null(), + } + } 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 = &TunnelModel{ + PreSharedKeyWoVersion: types.Int64Null(), + } + } err = mapTunnel(ctx, &tunnel2, model.Tunnel2) if err != nil { return fmt.Errorf("mapping tunnel2: %w", err) From cc30e9c1ea9f947e12e819c5a1355bb068486b15 Mon Sep 17 00:00:00 2001 From: Manuel Vaas Date: Thu, 25 Jun 2026 07:48:30 +0200 Subject: [PATCH 25/28] fix tunnel2 nil check in datasource --- stackit/internal/services/vpn/connection/datasource.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stackit/internal/services/vpn/connection/datasource.go b/stackit/internal/services/vpn/connection/datasource.go index f456cd4b3..f9264ab37 100644 --- a/stackit/internal/services/vpn/connection/datasource.go +++ b/stackit/internal/services/vpn/connection/datasource.go @@ -361,7 +361,7 @@ func mapDataSourceFields(ctx context.Context, conn connectionResponse, model *Da } tunnel2 := conn.GetTunnel2() - if model.Tunnel1 == nil { + if model.Tunnel2 == nil { model.Tunnel2 = &DataSourceTunnelModel{} } err = mapDataSourceTunnel(ctx, &tunnel2, model.Tunnel2) From 073bddbdf272520c76b5f2e6cd5fe1c6b9e2e167 Mon Sep 17 00:00:00 2001 From: Manuel Vaas Date: Thu, 25 Jun 2026 08:45:25 +0200 Subject: [PATCH 26/28] removed key version from tunnelmodel init --- stackit/internal/services/vpn/connection/resource.go | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/stackit/internal/services/vpn/connection/resource.go b/stackit/internal/services/vpn/connection/resource.go index f10d6397b..704b54f3e 100644 --- a/stackit/internal/services/vpn/connection/resource.go +++ b/stackit/internal/services/vpn/connection/resource.go @@ -979,9 +979,7 @@ func mapFields(ctx context.Context, conn connectionResponse, model *Model, regio tunnel1 := conn.GetTunnel1() if model.Tunnel1 == nil { - model.Tunnel1 = &TunnelModel{ - PreSharedKeyWoVersion: types.Int64Null(), - } + model.Tunnel1 = &TunnelModel{} } err := mapTunnel(ctx, &tunnel1, model.Tunnel1) if err != nil { @@ -990,9 +988,7 @@ func mapFields(ctx context.Context, conn connectionResponse, model *Model, regio tunnel2 := conn.GetTunnel2() if model.Tunnel2 == nil { - model.Tunnel2 = &TunnelModel{ - PreSharedKeyWoVersion: types.Int64Null(), - } + model.Tunnel2 = &TunnelModel{} } err = mapTunnel(ctx, &tunnel2, model.Tunnel2) if err != nil { From 1056ccdaa849e03620b127758f8f59df8e9f035d Mon Sep 17 00:00:00 2001 From: Ruben Hoenle Date: Thu, 25 Jun 2026 17:30:53 +0200 Subject: [PATCH 27/28] feat(vpn): add proper handling for write-only fields --- .../services/vpn/connection/datasource.go | 149 +--- .../vpn/connection/datasource_test.go | 72 +- .../services/vpn/connection/resource.go | 700 +++++++++--------- .../services/vpn/connection/resource_test.go | 637 +++++++++++----- .../services/vpn/testdata/connection-max.tf | 6 +- .../services/vpn/testdata/connection-min.tf | 10 +- stackit/internal/services/vpn/vpn_acc_test.go | 8 +- stackit/internal/services/vpn/vpn_test.go | 84 +++ 8 files changed, 933 insertions(+), 733 deletions(-) create mode 100644 stackit/internal/services/vpn/vpn_test.go diff --git a/stackit/internal/services/vpn/connection/datasource.go b/stackit/internal/services/vpn/connection/datasource.go index f9264ab37..420a81559 100644 --- a/stackit/internal/services/vpn/connection/datasource.go +++ b/stackit/internal/services/vpn/connection/datasource.go @@ -15,11 +15,9 @@ import ( "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" - tfutils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" - - "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" ) @@ -37,19 +35,9 @@ type DataSourceTunnelModel struct { } type DataSourceModel 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"` - Tunnel1 *DataSourceTunnelModel `tfsdk:"tunnel1"` - Tunnel2 *DataSourceTunnelModel `tfsdk:"tunnel2"` - Labels types.Map `tfsdk:"labels"` + CommonModel + Tunnel1 *DataSourceTunnelModel `tfsdk:"tunnel1"` + Tunnel2 *DataSourceTunnelModel `tfsdk:"tunnel2"` } type vpnConnectionDataSource struct { @@ -294,68 +282,16 @@ func (d *vpnConnectionDataSource) Read(ctx context.Context, req datasource.ReadR } func mapDataSourceFields(ctx context.Context, conn connectionResponse, model *DataSourceModel, 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) - - if enabled, _ := conn.GetEnabledOk(); enabled != nil { - model.Enabled = types.BoolValue(*enabled) - } else { - model.Enabled = types.BoolValue(true) - } - - 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 - } else { - model.RemoteSubnet = 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 - } else { - model.LocalSubnet = 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 - } else { - model.StaticRoutes = types.ListNull(types.StringType) + err := mapCommonFields(ctx, conn, &model.CommonModel, region) + if err != nil { + return err } tunnel1 := conn.GetTunnel1() if model.Tunnel1 == nil { model.Tunnel1 = &DataSourceTunnelModel{} } - err := mapDataSourceTunnel(ctx, &tunnel1, model.Tunnel1) + err = mapTunnel(ctx, &tunnel1, model.Tunnel1) if err != nil { return fmt.Errorf("mapping tunnel1: %w", err) } @@ -364,77 +300,10 @@ func mapDataSourceFields(ctx context.Context, conn connectionResponse, model *Da if model.Tunnel2 == nil { model.Tunnel2 = &DataSourceTunnelModel{} } - err = mapDataSourceTunnel(ctx, &tunnel2, model.Tunnel2) + err = mapTunnel(ctx, &tunnel2, model.Tunnel2) if err != nil { return fmt.Errorf("mapping tunnel2: %w", err) } - 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 mapDataSourceTunnel(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(string(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, - } - if apiTunnel.Phase2.StartAction != nil { - tfTunnel.Phase2.StartAction = types.StringValue(string(*apiTunnel.Phase2.StartAction)) - } else { - tfTunnel.Phase2.StartAction = types.StringNull() - } - if apiTunnel.Phase2.DpdAction != nil { - tfTunnel.Phase2.DpdAction = types.StringValue(string(*apiTunnel.Phase2.DpdAction)) - } else { - tfTunnel.Phase2.DpdAction = types.StringNull() - } - - if apiTunnel.Peering != nil { - peering := &PeeringConfigModel{} - if apiTunnel.Peering.LocalAddress != nil { - peering.LocalAddress = types.StringValue(*apiTunnel.Peering.LocalAddress) - } else { - peering.LocalAddress = types.StringNull() - } - if apiTunnel.Peering.RemoteAddress != nil { - peering.RemoteAddress = types.StringValue(*apiTunnel.Peering.RemoteAddress) - } else { - peering.RemoteAddress = types.StringNull() - } - tfTunnel.Peering = peering - } - - if apiTunnel.Bgp != nil { - tfTunnel.Bgp = &BGPTunnelConfigModel{ - RemoteAsn: types.Int64Value(int64(apiTunnel.Bgp.RemoteAsn)), - } - } - return nil } diff --git a/stackit/internal/services/vpn/connection/datasource_test.go b/stackit/internal/services/vpn/connection/datasource_test.go index 757b2cfdf..2eba462c3 100644 --- a/stackit/internal/services/vpn/connection/datasource_test.go +++ b/stackit/internal/services/vpn/connection/datasource_test.go @@ -34,27 +34,29 @@ func fixtureDataSourceTunnelModel(mods ...func(m *DataSourceTunnelModel)) *DataS func fixtureDataSourceModel(mods ...func(m *DataSourceModel)) DataSourceModel { resp := DataSourceModel{ - 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"), - }), + 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") }), - Labels: types.MapNull(types.StringType), } for _, mod := range mods { mod(&resp) @@ -81,16 +83,19 @@ func TestMapDataSourceFields(t *testing.T) { Id: new("connection-id"), }, expected: DataSourceModel{ - 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.BoolValue(true), - RemoteSubnet: basetypes.NewListNull(basetypes.StringType{}), - LocalSubnet: basetypes.NewListNull(basetypes.StringType{}), - StaticRoutes: basetypes.NewListNull(basetypes.StringType{}), + 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{ @@ -125,7 +130,6 @@ func TestMapDataSourceFields(t *testing.T) { }, }, }, - Labels: basetypes.NewMapNull(basetypes.StringType{}), }, isValid: true, }, @@ -203,11 +207,13 @@ func TestMapDataSourceFields(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { state := &DataSourceModel{ - ProjectID: types.StringValue(projectId), - Region: types.StringValue(region), - GatewayID: types.StringValue(gatewayId), - Tunnel1: &DataSourceTunnelModel{}, - Tunnel2: &DataSourceTunnelModel{}, + 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) diff --git a/stackit/internal/services/vpn/connection/resource.go b/stackit/internal/services/vpn/connection/resource.go index 704b54f3e..f07240410 100644 --- a/stackit/internal/services/vpn/connection/resource.go +++ b/stackit/internal/services/vpn/connection/resource.go @@ -69,17 +69,14 @@ type BGPTunnelConfigModel struct { } type TunnelModel struct { - PreSharedKey types.String `tfsdk:"pre_shared_key"` - PreSharedKeyWo types.String `tfsdk:"pre_shared_key_wo"` - PreSharedKeyWoVersion types.Int64 `tfsdk:"pre_shared_key_wo_version"` - RemoteAddress types.String `tfsdk:"remote_address"` - Phase1 *Phase1Model `tfsdk:"phase1"` - Phase2 *Phase2Model `tfsdk:"phase2"` - Peering *PeeringConfigModel `tfsdk:"peering"` - Bgp *BGPTunnelConfigModel `tfsdk:"bgp"` + 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"` } -type Model struct { +// 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"` @@ -90,11 +87,16 @@ type Model struct { RemoteSubnet types.List `tfsdk:"remote_subnet"` LocalSubnet types.List `tfsdk:"local_subnet"` StaticRoutes types.List `tfsdk:"static_routes"` - Tunnel1 *TunnelModel `tfsdk:"tunnel1"` - Tunnel2 *TunnelModel `tfsdk:"tunnel2"` 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) @@ -157,6 +159,7 @@ func (r *vpnConnectionResource) Configure(ctx context.Context, req resource.Conf if resp.Diagnostics.HasError() { return } + r.client = apiClient r.providerData = providerData tflog.Info(ctx, "VPN client configured") @@ -166,184 +169,189 @@ func (r *vpnConnectionResource) Metadata(_ context.Context, req resource.Metadat resp.TypeName = req.ProviderTypeName + "_vpn_connection" } -func tunnelSchema(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, - Validators: []validator.String{ - stringvalidator.LengthAtLeast(20), - stringvalidator.PreferWriteOnlyAttribute(path.MatchRoot(rootAttribute).AtName("pre_shared_key_wo")), +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, - Validators: []validator.String{ - stringvalidator.LengthAtLeast(20), - stringvalidator.ExactlyOneOf( - path.MatchRelative().AtParent().AtName("pre_shared_key"), - path.MatchRelative().AtParent().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")), + "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), + "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...), - ), + "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...), - ), + "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...), - ), + "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), + "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...), - ), + "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...), - ), + "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...), - ), + "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), + "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...), + "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...), + "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), + "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), + "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), + "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), + }, }, }, }, }, - }, + } } -} -func (r *vpnConnectionResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { resp.Schema = schema.Schema{ Description: fmt.Sprintf("VPN Connection resource schema. %s", core.ResourceRegionFallbackDocstring), Attributes: map[string]schema.Attribute{ @@ -501,32 +509,34 @@ func (r *vpnConnectionResource) ImportState(ctx context.Context, req resource.Im } func (r *vpnConnectionResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { // nolint:gocritic // function signature required by Terraform - var model Model - diags := req.Plan.Get(ctx, &model) + // 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 } - model.Tunnel1.PreSharedKeyWo = configModel.Tunnel1.PreSharedKeyWo - model.Tunnel2.PreSharedKeyWo = configModel.Tunnel2.PreSharedKeyWo ctx = core.InitProviderContext(ctx) - projectId := model.ProjectID.ValueString() - region := r.providerData.GetRegionWithOverride(model.Region) - gatewayId := model.GatewayID.ValueString() + 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, &model) + 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 @@ -540,13 +550,13 @@ func (r *vpnConnectionResource) Create(ctx context.Context, req resource.CreateR ctx = core.LogResponse(ctx) - err = mapFields(ctx, createResp, &model, region) + 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, model) + diags = resp.State.Set(ctx, planModel) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return @@ -575,8 +585,7 @@ func (r *vpnConnectionResource) Read(ctx context.Context, req resource.ReadReque connResp, err := r.client.DefaultAPI.GetGatewayConnection(ctx, projectId, region, gatewayId, connectionId).Execute() if err != nil { - var oapiErr *oapierror.GenericOpenAPIError - if errors.As(err, &oapiErr) && oapiErr.StatusCode == http.StatusNotFound { + if oapiErr, ok := errors.AsType[*oapierror.GenericOpenAPIError](err); ok && oapiErr.StatusCode == http.StatusNotFound { resp.State.RemoveResource(ctx) return } @@ -586,7 +595,7 @@ func (r *vpnConnectionResource) Read(ctx context.Context, req resource.ReadReque ctx = core.LogResponse(ctx) - err = mapFields(ctx, connResp, &model, region) + 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 @@ -601,20 +610,16 @@ func (r *vpnConnectionResource) Read(ctx context.Context, req resource.ReadReque } func (r *vpnConnectionResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform - var model Model - diags := req.Plan.Get(ctx, &model) - resp.Diagnostics.Append(diags...) - if resp.Diagnostics.HasError() { - return - } - - var configModel Model - diags = req.Config.Get(ctx, &configModel) + // 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...) @@ -622,30 +627,28 @@ func (r *vpnConnectionResource) Update(ctx context.Context, req resource.UpdateR return } - err := pskRotationOnUpdate(resp, "tunnel1", model.Tunnel1, stateModel.Tunnel1.PreSharedKeyWoVersion.ValueInt64(), configModel.Tunnel1.PreSharedKeyWo) - if err != nil { - core.LogAndAddError(ctx, &resp.Diagnostics, "Tunnel1 PSK Rotation", err.Error()) - return - } - - err = pskRotationOnUpdate(resp, "tunnel2", model.Tunnel2, stateModel.Tunnel2.PreSharedKeyWoVersion.ValueInt64(), configModel.Tunnel2.PreSharedKeyWo) - if err != nil { - core.LogAndAddError(ctx, &resp.Diagnostics, "Tunnel2 PSK Rotation", err.Error()) + // 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 := model.ProjectID.ValueString() - region := r.providerData.GetRegionWithOverride(model.Region) - gatewayId := model.GatewayID.ValueString() - connectionId := model.ConnectionID.ValueString() + 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, &model) + 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 @@ -659,13 +662,13 @@ func (r *vpnConnectionResource) Update(ctx context.Context, req resource.UpdateR ctx = core.LogResponse(ctx) - err = mapFields(ctx, connResp, &model, region) + 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, model) + diags = resp.State.Set(ctx, planModel) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return @@ -694,8 +697,7 @@ func (r *vpnConnectionResource) Delete(ctx context.Context, req resource.DeleteR err := r.client.DefaultAPI.DeleteGatewayConnection(ctx, projectId, region, gatewayId, connectionId).Execute() if err != nil { - var oapiErr *oapierror.GenericOpenAPIError - if errors.As(err, &oapiErr) && oapiErr.StatusCode == http.StatusNotFound { + if oapiErr, ok := errors.AsType[*oapierror.GenericOpenAPIError](err); ok && oapiErr.StatusCode == http.StatusNotFound { resp.State.RemoveResource(ctx) return } @@ -707,71 +709,106 @@ func (r *vpnConnectionResource) Delete(ctx context.Context, req resource.DeleteR tflog.Info(ctx, "VPN connection deleted") } -func pskRotationOnUpdate(resp *resource.UpdateResponse, rootAttribute string, tunnelModel *TunnelModel, currentKeyVersion int64, preSharedKey types.String) error { - if resp == nil || tunnelModel == nil { - return fmt.Errorf("pskRotationOnUpdate: arguments can not be nil") - } - - if !tfutils.IsUndefined(tunnelModel.PreSharedKeyWoVersion) { - newKeyVersion := tunnelModel.PreSharedKeyWoVersion.ValueInt64() - if newKeyVersion < currentKeyVersion { - resp.Diagnostics.AddAttributeError( - path.Root(rootAttribute).AtName("pre_shared_key_wo_version"), - "Version must not decrease", - fmt.Sprintf("`pre_shared_key_wo_version` must be incremented to rotate the pre-shared key, got %d (current: %d).", newKeyVersion, currentKeyVersion), - ) - } - if newKeyVersion > currentKeyVersion { - // Secret must be read from Config, not Plan — write-only values are always null in plan. - tunnelModel.PreSharedKeyWo = preSharedKey - } +func toCreatePayload(ctx context.Context, planModel, configModel *Model) (*vpn.CreateGatewayConnectionPayload, error) { + if planModel == nil { + return nil, fmt.Errorf("nil plan model") } - return nil -} - -func toCreatePayload(ctx context.Context, model *Model) (*vpn.CreateGatewayConnectionPayload, error) { - if model == nil { - return nil, fmt.Errorf("nil model") + if configModel == nil { + return nil, fmt.Errorf("nil config model") } payload := &vpn.CreateGatewayConnectionPayload{} - err := toConnectionPayload(ctx, model, payload) + 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, model *Model) (*vpn.UpdateGatewayConnectionPayload, error) { - if model == nil { - return nil, fmt.Errorf("nil model") +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, model, payload) + 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 { - if payload == nil { - return fmt.Errorf("payload can not be nil") - } - tunnel1, err := toTunnelPayload(model.Tunnel1) - if err != nil && tunnel1 != nil { + if err != nil { return fmt.Errorf("converting tunnel1: %w", err) } + payload.SetTunnel1(*tunnel1) tunnel2, err := toTunnelPayload(model.Tunnel2) - if err != nil && tunnel2 != nil { + if err != nil { return fmt.Errorf("converting tunnel2: %w", err) } + payload.SetTunnel2(*tunnel2) payload.SetDisplayName(model.DisplayName.ValueString()) @@ -822,12 +859,6 @@ func toTunnelPayload(tunnel *TunnelModel) (*vpn.TunnelConfiguration, error) { RemoteAddress: tunnel.RemoteAddress.ValueString(), } - if !tfutils.IsUndefined(tunnel.PreSharedKeyWo) { - config.PreSharedKey = tunnel.PreSharedKeyWo.ValueStringPointer() - } else if !tfutils.IsUndefined(tunnel.PreSharedKey) { - config.PreSharedKey = tunnel.PreSharedKey.ValueStringPointer() - } - if tunnel.Phase1 != nil { phase1 := vpn.TunnelConfigurationPhase1{} err := toBasePhasePayload(&tunnel.Phase1.BasePhaseModel, &phase1) @@ -838,39 +869,29 @@ func toTunnelPayload(tunnel *TunnelModel) (*vpn.TunnelConfiguration, error) { } if tunnel.Phase2 != nil { - phase2 := vpn.TunnelConfigurationPhase2{} + 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 } - if !tfutils.IsUndefined(tunnel.Phase2.StartAction) { - startAction := tunnel.Phase2.StartAction.ValueString() - phase2.StartAction = vpn.TunnelConfigurationPhase2AllOfStartAction(startAction).Ptr() - } - - if !tfutils.IsUndefined(tunnel.Phase2.DpdAction) { - dpdAction := tunnel.Phase2.DpdAction.ValueString() - phase2.DpdAction = vpn.TunnelConfigurationPhase2AllOfDpdAction(dpdAction).Ptr() - } - config.Phase2 = phase2 } if tunnel.Peering != nil { - localAddr := tunnel.Peering.LocalAddress.ValueString() - remoteAddr := tunnel.Peering.RemoteAddress.ValueString() config.Peering = &vpn.PeeringConfig{ - LocalAddress: &localAddr, - RemoteAddress: &remoteAddr, + LocalAddress: new(tunnel.Peering.LocalAddress.ValueString()), + RemoteAddress: new(tunnel.Peering.RemoteAddress.ValueString()), } } if tunnel.Bgp != nil { - remoteAsn := tunnel.Bgp.RemoteAsn.ValueInt64() config.Bgp = &vpn.BGPTunnelConfig{ - RemoteAsn: remoteAsn, + RemoteAsn: tunnel.Bgp.RemoteAsn.ValueInt64(), } } @@ -878,48 +899,45 @@ func toTunnelPayload(tunnel *TunnelModel) (*vpn.TunnelConfiguration, error) { } func toBasePhasePayload(phaseModel *BasePhaseModel, phasePayload BasePhasePayload) error { - if phaseModel != nil { - if !tfutils.IsUndefined(phaseModel.DhGroups) { - dhGroups, err := tfutils.ListValueToStringSlice(phaseModel.DhGroups) - if err != nil { - return fmt.Errorf("converting phase dh_groups: %w", err) - } - dhGroupsInner := []vpn.PhaseDhGroupsInner{} - for _, item := range dhGroups { - dhGroupsInner = append(dhGroupsInner, vpn.PhaseDhGroupsInner(item)) - } - phasePayload.SetDhGroups(dhGroupsInner) - } + if phaseModel == nil { + return nil + } - encAlgs, err := tfutils.ListValueToStringSlice(phaseModel.EncryptionAlgorithms) + if !tfutils.IsUndefined(phaseModel.DhGroups) { + dhGroups, err := tfutils.ListValueToStringSlice(phaseModel.DhGroups) if err != nil { - return fmt.Errorf("converting phase encryption_algorithms: %w", err) - } - encAlgsInner := []vpn.PhaseEncryptionAlgorithmsInner{} - for _, item := range encAlgs { - encAlgsInner = append(encAlgsInner, vpn.PhaseEncryptionAlgorithmsInner(item)) + return fmt.Errorf("converting phase dh_groups: %w", err) } - phasePayload.SetEncryptionAlgorithms(encAlgsInner) + phasePayload.SetDhGroups(tfutils.Map(dhGroups, func(t string) vpn.PhaseDhGroupsInner { + return vpn.PhaseDhGroupsInner(t) + })) + } - intAlgs, err := tfutils.ListValueToStringSlice(phaseModel.IntegrityAlgorithms) - if err != nil { - return fmt.Errorf("converting phase integrity_algorithms: %w", err) - } - intAlgsInner := []vpn.PhaseIntegrityAlgorithmsInner{} - for _, item := range intAlgs { - intAlgsInner = append(intAlgsInner, vpn.PhaseIntegrityAlgorithmsInner(item)) - } - phasePayload.SetIntegrityAlgorithms(intAlgsInner) + 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) + })) - if !tfutils.IsUndefined(phaseModel.RekeyTime) { - rekeyTime := phaseModel.RekeyTime.ValueInt32() - phasePayload.SetRekeyTime(rekeyTime) - } + 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 mapFields(ctx context.Context, conn connectionResponse, model *Model, region string) error { +func mapCommonFields(ctx context.Context, conn connectionResponse, model *CommonModel, region string) error { if conn == nil { return fmt.Errorf("response input is nil") } @@ -941,47 +959,57 @@ func mapFields(ctx context.Context, conn connectionResponse, model *Model, regio model.DisplayName = types.StringValue(conn.GetDisplayName()) model.Region = types.StringValue(region) - if enabled, _ := conn.GetEnabledOk(); enabled != nil { - model.Enabled = types.BoolValue(*enabled) - } else { - model.Enabled = types.BoolNull() - } + 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 - } else { - model.RemoteSubnet = types.ListNull(types.StringType) } + 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 - } else { - model.LocalSubnet = types.ListNull(types.StringType) } + 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 - } else { - model.StaticRoutes = types.ListNull(types.StringType) + } + + 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) + err = mapTunnel(ctx, &tunnel1, &model.Tunnel1.DataSourceTunnelModel) if err != nil { return fmt.Errorf("mapping tunnel1: %w", err) } @@ -990,22 +1018,15 @@ func mapFields(ctx context.Context, conn connectionResponse, model *Model, regio if model.Tunnel2 == nil { model.Tunnel2 = &TunnelModel{} } - err = mapTunnel(ctx, &tunnel2, model.Tunnel2) + err = mapTunnel(ctx, &tunnel2, &model.Tunnel2.DataSourceTunnelModel) if err != nil { return fmt.Errorf("mapping tunnel2: %w", err) } - 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 mapTunnel(ctx context.Context, apiTunnel *vpn.TunnelConfiguration, tfTunnel *TunnelModel) error { +func mapTunnel(ctx context.Context, apiTunnel *vpn.TunnelConfiguration, tfTunnel *DataSourceTunnelModel) error { if apiTunnel == nil { return fmt.Errorf("apiTunnel can not be nil") } @@ -1013,12 +1034,13 @@ func mapTunnel(ctx context.Context, apiTunnel *vpn.TunnelConfiguration, tfTunnel return fmt.Errorf("tfTunnel can not be nil") } - tfTunnel.RemoteAddress = types.StringValue(string(apiTunnel.RemoteAddress)) + tfTunnel.RemoteAddress = types.StringValue(apiTunnel.RemoteAddress) basePhase1, err := mapBasePhase(ctx, &apiTunnel.Phase1) if err != nil { return err } + tfTunnel.Phase1 = &Phase1Model{ BasePhaseModel: basePhase1, } @@ -1027,43 +1049,26 @@ func mapTunnel(ctx context.Context, apiTunnel *vpn.TunnelConfiguration, tfTunnel if err != nil { return err } + tfTunnel.Phase2 = &Phase2Model{ BasePhaseModel: basePhase2, - } - if apiTunnel.Phase2.StartAction != nil { - tfTunnel.Phase2.StartAction = types.StringValue(string(*apiTunnel.Phase2.StartAction)) - } else { - tfTunnel.Phase2.StartAction = types.StringNull() - } - if apiTunnel.Phase2.DpdAction != nil { - tfTunnel.Phase2.DpdAction = types.StringValue(string(*apiTunnel.Phase2.DpdAction)) - } else { - tfTunnel.Phase2.DpdAction = types.StringNull() + StartAction: types.StringPointerValue((*string)(apiTunnel.Phase2.StartAction)), + DpdAction: types.StringPointerValue((*string)(apiTunnel.Phase2.DpdAction)), } + tfTunnel.Peering = nil if apiTunnel.Peering != nil { - peering := &PeeringConfigModel{} - if apiTunnel.Peering.LocalAddress != nil { - peering.LocalAddress = types.StringValue(*apiTunnel.Peering.LocalAddress) - } else { - peering.LocalAddress = types.StringNull() + tfTunnel.Peering = &PeeringConfigModel{ + LocalAddress: types.StringPointerValue(apiTunnel.Peering.LocalAddress), + RemoteAddress: types.StringPointerValue(apiTunnel.Peering.RemoteAddress), } - if apiTunnel.Peering.RemoteAddress != nil { - peering.RemoteAddress = types.StringValue(*apiTunnel.Peering.RemoteAddress) - } else { - peering.RemoteAddress = types.StringNull() - } - tfTunnel.Peering = peering - } else { - tfTunnel.Peering = nil } + tfTunnel.Bgp = nil if apiTunnel.Bgp != nil { tfTunnel.Bgp = &BGPTunnelConfigModel{ - RemoteAsn: types.Int64Value(int64(apiTunnel.Bgp.RemoteAsn)), + RemoteAsn: types.Int64Value(apiTunnel.Bgp.RemoteAsn), } - } else { - tfTunnel.Bgp = nil } return nil @@ -1074,34 +1079,31 @@ func mapBasePhase(ctx context.Context, apiPhase BasePhasePayload) (phase BasePha return phase, fmt.Errorf("api phase can not be nil") } - if dhGroups, _ := apiPhase.GetDhGroupsOk(); len(dhGroups) > 0 { + 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 - } else { - phase.DhGroups = types.ListNull(types.StringType) } - if encryptionAlgorithms, _ := apiPhase.GetEncryptionAlgorithmsOk(); len(encryptionAlgorithms) > 0 { + 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 - } else { - phase.EncryptionAlgorithms = types.ListNull(types.StringType) } - if integrityAlgorithms, _ := apiPhase.GetIntegrityAlgorithmsOk(); len(integrityAlgorithms) > 0 { + 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 - } else { - phase.IntegrityAlgorithms = types.ListNull(types.StringType) } rekeyTime, _ := apiPhase.GetRekeyTimeOk() diff --git a/stackit/internal/services/vpn/connection/resource_test.go b/stackit/internal/services/vpn/connection/resource_test.go index 0fff94ed3..40ca8a9d4 100644 --- a/stackit/internal/services/vpn/connection/resource_test.go +++ b/stackit/internal/services/vpn/connection/resource_test.go @@ -8,7 +8,6 @@ import ( "github.com/google/go-cmp/cmp" "github.com/google/uuid" "github.com/hashicorp/terraform-plugin-framework/attr" - "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/types" vpn "github.com/stackitcloud/stackit-sdk-go/services/vpn/v1api" @@ -86,16 +85,18 @@ func fixtureBasePhaseModel(mods ...func(m *BasePhaseModel)) BasePhaseModel { func fixtureTunnelModel(mods ...func(m *TunnelModel)) *TunnelModel { resp := &TunnelModel{ PreSharedKeyWoVersion: types.Int64Value(1), - 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"), + 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 { @@ -106,27 +107,29 @@ func fixtureTunnelModel(mods ...func(m *TunnelModel)) *TunnelModel { func fixtureModel(mods ...func(m *Model)) Model { resp := Model{ - 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"), - }), + 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") }), - Labels: types.MapNull(types.StringType), } for _, mod := range mods { mod(&resp) @@ -232,52 +235,58 @@ func TestMapFields(t *testing.T) { Tunnel2: vpn.TunnelConfiguration{}, }, expected: Model{ - 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), + 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), - RemoteAddress: types.StringValue(""), - Phase1: &Phase1Model{ - BasePhaseModel: BasePhaseModel{ - DhGroups: types.ListNull(types.StringType), - EncryptionAlgorithms: types.ListNull(types.StringType), - IntegrityAlgorithms: types.ListNull(types.StringType), + 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), + 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), - RemoteAddress: types.StringValue(""), - Phase1: &Phase1Model{ - BasePhaseModel: BasePhaseModel{ - DhGroups: types.ListNull(types.StringType), - EncryptionAlgorithms: types.ListNull(types.StringType), - IntegrityAlgorithms: types.ListNull(types.StringType), + 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), + Phase2: &Phase2Model{ + BasePhaseModel: BasePhaseModel{ + DhGroups: types.ListNull(types.StringType), + EncryptionAlgorithms: types.ListNull(types.StringType), + IntegrityAlgorithms: types.ListNull(types.StringType), + }, }, }, }, - Labels: types.MapNull(types.StringType), }, isValid: true, }, @@ -357,9 +366,11 @@ func TestMapFields(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { state := &Model{ - ProjectID: types.StringValue(projectId), - Region: types.StringValue(region), - GatewayID: types.StringValue(gatewayId), + CommonModel: CommonModel{ + ProjectID: types.StringValue(projectId), + Region: types.StringValue(region), + GatewayID: types.StringValue(gatewayId), + }, Tunnel1: &TunnelModel{ PreSharedKeyWoVersion: types.Int64Value(1), }, @@ -368,7 +379,7 @@ func TestMapFields(t *testing.T) { }, } - err := mapFields(context.Background(), tt.input, state, region) + err := mapResourceFields(context.Background(), tt.input, state, region) if !tt.isValid && err == nil { t.Fatalf("expected error, got none") @@ -386,26 +397,135 @@ func TestMapFields(t *testing.T) { } func TestToCreatePayload(t *testing.T) { + type args struct { + planModel *Model + configModel *Model + } + tests := []struct { description string - input *Model + args args expected *vpn.CreateGatewayConnectionPayload isValid bool }{ + // NOTE: Before diving into these tests read the comments in the function implementation :) { - description: "basic_connection", - input: new(fixtureModel(func(m *Model) { - m.Tunnel1.PreSharedKeyWo = types.StringValue("secret123-at-least-20-chars") - m.Tunnel2.PreSharedKeyWo = types.StringValue("secret456-at-least-20-chars") - })), - expected: fixtureCreatePayload(), - isValid: true, + 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", - input: &Model{ - Tunnel1: &TunnelModel{}, - Tunnel2: &TunnelModel{}, + args: args{ + planModel: &Model{ + Tunnel1: &TunnelModel{}, + Tunnel2: &TunnelModel{}, + }, + configModel: &Model{ + Tunnel1: &TunnelModel{}, + Tunnel2: &TunnelModel{}, + }, }, expected: &vpn.CreateGatewayConnectionPayload{ Labels: &map[string]string{}, @@ -413,15 +533,27 @@ func TestToCreatePayload(t *testing.T) { isValid: true, }, { - description: "nil_model", - input: nil, - expected: nil, - isValid: false, + 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.input) + payload, err := toCreatePayload(context.Background(), tt.args.planModel, tt.args.configModel) if !tt.isValid && err == nil { t.Fatalf("Should have failed") @@ -440,26 +572,196 @@ func TestToCreatePayload(t *testing.T) { } func TestToUpdatePayload(t *testing.T) { + type args struct { + planModel *Model + stateModel *Model + configModel *Model + } + tests := []struct { description string - input *Model + args args expected *vpn.UpdateGatewayConnectionPayload isValid bool }{ + // NOTE: Before diving into these tests read the comments in the function implementation :) { - description: "basic_update", - input: new(fixtureModel(func(m *Model) { - m.Tunnel1.PreSharedKeyWo = types.StringValue("secret123-at-least-20-chars") - m.Tunnel2.PreSharedKeyWo = types.StringValue("secret456-at-least-20-chars") - })), - expected: fixtureUpdatePayload(), - isValid: true, + 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", - input: &Model{ - Tunnel1: &TunnelModel{}, - Tunnel2: &TunnelModel{}, + args: args{ + planModel: &Model{ + Tunnel1: &TunnelModel{}, + Tunnel2: &TunnelModel{}, + }, + stateModel: &Model{}, + configModel: &Model{ + Tunnel1: &TunnelModel{}, + Tunnel2: &TunnelModel{}, + }, }, expected: &vpn.UpdateGatewayConnectionPayload{ Labels: &map[string]string{}, @@ -468,12 +770,19 @@ func TestToUpdatePayload(t *testing.T) { }, { description: "peering", - input: new(fixtureModel(func(m *Model) { - m.Tunnel1.Peering = &PeeringConfigModel{ - LocalAddress: types.StringValue("123.45.67.89"), - RemoteAddress: types.StringValue("98.76.54.32"), - } - })), + 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"), @@ -485,16 +794,40 @@ func TestToUpdatePayload(t *testing.T) { isValid: true, }, { - description: "nil_model", - input: nil, - expected: nil, - isValid: false, + 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.input) + payload, err := toUpdatePayload(context.Background(), tt.args.planModel, tt.args.stateModel, tt.args.configModel) if !tt.isValid && err == nil { t.Fatalf("Should have failed") @@ -579,99 +912,3 @@ func TestToTunnelConfiguration(t *testing.T) { }) } } - -func TestPskRotationOnUpdate(t *testing.T) { - type args struct { - resp *resource.UpdateResponse - rootAttribute string - tunnelModel *TunnelModel - currentKeyVersion int64 - preSharedKey types.String - } - - tests := []struct { - description string - input args - expected *TunnelModel - tfError bool - isValid bool - }{ - { - description: "default", - input: args{ - resp: &resource.UpdateResponse{}, - rootAttribute: "tunnel1", - tunnelModel: &TunnelModel{}, - currentKeyVersion: 0, - preSharedKey: types.StringValue("foo-bar"), - }, - expected: &TunnelModel{}, - tfError: false, - isValid: true, - }, - { - description: "upgrade_key", - input: args{ - resp: &resource.UpdateResponse{}, - rootAttribute: "tunnel1", - tunnelModel: &TunnelModel{ - PreSharedKeyWoVersion: types.Int64Value(1), - }, - currentKeyVersion: 0, - preSharedKey: types.StringValue("foo-bar"), - }, - expected: &TunnelModel{ - PreSharedKeyWoVersion: types.Int64Value(1), - PreSharedKeyWo: types.StringValue("foo-bar"), - }, - tfError: false, - isValid: true, - }, - { - description: "downgrade_key", - input: args{ - resp: &resource.UpdateResponse{}, - rootAttribute: "tunnel1", - tunnelModel: &TunnelModel{ - PreSharedKeyWoVersion: types.Int64Value(0), - }, - currentKeyVersion: 1, - preSharedKey: types.StringValue("foo-bar"), - }, - isValid: true, - tfError: true, - }, - { - description: "no_args", - input: args{}, - isValid: false, - }, - } - - for _, tt := range tests { - t.Run(tt.description, func(t *testing.T) { - err := pskRotationOnUpdate(tt.input.resp, tt.input.rootAttribute, tt.input.tunnelModel, tt.input.currentKeyVersion, tt.input.preSharedKey) - - 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 tt.tfError && !tt.input.resp.Diagnostics.HasError() { - t.Fatalf("expected tf error, got none") - } - if !tt.tfError && tt.input.resp.Diagnostics.HasError() { - t.Fatalf("expected no tf error, got %v", tt.input.resp.Diagnostics.Errors()) - } - if !tt.tfError { - modelDiff := cmp.Diff(tt.expected, tt.input.tunnelModel) - if modelDiff != "" { - t.Fatalf("Data does not match (-want +got):\n%s", modelDiff) - } - } - } - }) - } -} diff --git a/stackit/internal/services/vpn/testdata/connection-max.tf b/stackit/internal/services/vpn/testdata/connection-max.tf index 599f0bce0..2fc8374eb 100644 --- a/stackit/internal/services/vpn/testdata/connection-max.tf +++ b/stackit/internal/services/vpn/testdata/connection-max.tf @@ -24,7 +24,8 @@ resource "stackit_vpn_connection" "connection" { local_subnet = [var.local_subnet] tunnel1 = { - remote_address = var.tunnel1_remote_address + 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 @@ -54,7 +55,8 @@ resource "stackit_vpn_connection" "connection" { } tunnel2 = { - remote_address = var.tunnel2_remote_address + 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 diff --git a/stackit/internal/services/vpn/testdata/connection-min.tf b/stackit/internal/services/vpn/testdata/connection-min.tf index 23953c2d8..e7e095c79 100644 --- a/stackit/internal/services/vpn/testdata/connection-min.tf +++ b/stackit/internal/services/vpn/testdata/connection-min.tf @@ -11,8 +11,9 @@ resource "stackit_vpn_connection" "connection" { display_name = var.connection_display_name tunnel1 = { - remote_address = var.tunnel1_remote_address - pre_shared_key_wo = var.tunnel1_psk + 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"] @@ -28,8 +29,9 @@ resource "stackit_vpn_connection" "connection" { } tunnel2 = { - remote_address = var.tunnel2_remote_address - pre_shared_key_wo = var.tunnel2_psk + 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"] diff --git a/stackit/internal/services/vpn/vpn_acc_test.go b/stackit/internal/services/vpn/vpn_acc_test.go index 01bd65e2e..3fa9693ba 100644 --- a/stackit/internal/services/vpn/vpn_acc_test.go +++ b/stackit/internal/services/vpn/vpn_acc_test.go @@ -598,7 +598,7 @@ func TestAccVpnConnectionResourceMin(t *testing.T) { }, ImportState: true, ImportStateVerify: true, - ImportStateVerifyIgnore: []string{"tunnel1.pre_shared_key_wo", "tunnel2.pre_shared_key_wo"}, + ImportStateVerifyIgnore: []string{"tunnel1.pre_shared_key", "tunnel2.pre_shared_key"}, }, }, }) @@ -942,8 +942,7 @@ func testAccCheckVpnResourcesDestroy(s *terraform.State) error { } err := client.DefaultAPI.DeleteGatewayConnection(ctx, testutil.ProjectId, testutil.Region, *gateway.Id, *conn.Id).Execute() if err != nil { - var oapiErr *oapierror.GenericOpenAPIError - if errors.As(err, &oapiErr) && (oapiErr.StatusCode == http.StatusNotFound || oapiErr.StatusCode == http.StatusGone) { + 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) @@ -952,8 +951,7 @@ func testAccCheckVpnResourcesDestroy(s *terraform.State) error { err = client.DefaultAPI.DeleteGateway(ctx, testutil.ProjectId, testutil.Region, *gateway.Id).Execute() if err != nil { - var oapiErr *oapierror.GenericOpenAPIError - if errors.As(err, &oapiErr) && (oapiErr.StatusCode == http.StatusNotFound || oapiErr.StatusCode == http.StatusGone) { + 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) 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"), + }, + }, + }) +} From da2b1f51c4cc5ebc3d310afedceb23f1b411caab Mon Sep 17 00:00:00 2001 From: Ruben Hoenle Date: Fri, 26 Jun 2026 14:38:34 +0200 Subject: [PATCH 28/28] generate docs --- docs/resources/vpn_connection.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/resources/vpn_connection.md b/docs/resources/vpn_connection.md index c1317b8eb..eb60caf37 100644 --- a/docs/resources/vpn_connection.md +++ b/docs/resources/vpn_connection.md @@ -102,8 +102,8 @@ 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) 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, [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` (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. @@ -167,8 +167,8 @@ 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) 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, [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` (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.