From b646beafb5d8ed83240db7c675a1bdb8deba9c4f Mon Sep 17 00:00:00 2001 From: Steffen Koenig Date: Wed, 29 Jul 2026 20:10:21 +0200 Subject: [PATCH 1/4] feat(vpn): add BGP filter resources and gateway/connection SDK updates Reflects upcoming stackit-sdk-go vpn changes (stackitcloud/stackit-sdk-go#9324, pinned by commit since the SDK PR isn't tagged/released yet): - New stackit_vpn_bgp_filter and stackit_vpn_bgp_filter_rule resources and data sources, supporting the new gateway-scoped BGP route filtering API. - stackit_vpn_gateway gains an optional network_config block (predefined_network_prefix, routing_table_id). - stackit_vpn_connection's tunnel bgp block gains inbound_filter_id, linking a tunnel's BGP session to a stackit_vpn_bgp_filter, with tri-state set/clear/leave-untouched handling on update. - integrity_algorithms now accepts sha2_512 instead of sha1 (SDK enum change, no code change needed since values are derived dynamically). go.mod is pinned to the SDK PR's commit as a pseudo-version with a TODO(vpn-sdk-pin) marker to re-pin once the SDK PR merges and is tagged. --- docs/data-sources/vpn_bgp_filter.md | 36 + docs/data-sources/vpn_bgp_filter_rule.md | 62 ++ docs/data-sources/vpn_connection.md | 2 + docs/data-sources/vpn_gateway.md | 10 + docs/resources/vpn_bgp_filter.md | 53 ++ docs/resources/vpn_bgp_filter_rule.md | 88 +++ docs/resources/vpn_connection.md | 16 +- docs/resources/vpn_gateway.md | 14 + .../stackit_vpn_bgp_filter/data-source.tf | 5 + .../data-source.tf | 6 + .../import-by-string-id.tf | 5 + .../stackit_vpn_bgp_filter/resource.tf | 5 + .../import-by-string-id.tf | 5 + .../stackit_vpn_bgp_filter_rule/resource.tf | 15 + .../resources/stackit_vpn_gateway/resource.tf | 4 + go.mod | 2 +- go.sum | 2 + .../services/vpn/bgp_filter/datasource.go | 144 ++++ .../services/vpn/bgp_filter/resource.go | 391 ++++++++++ .../services/vpn/bgp_filter/resource_test.go | 116 +++ .../vpn/bgp_filter_rule/datasource.go | 206 ++++++ .../services/vpn/bgp_filter_rule/resource.go | 692 ++++++++++++++++++ .../vpn/bgp_filter_rule/resource_test.go | 281 +++++++ .../services/vpn/connection/datasource.go | 4 + .../services/vpn/connection/resource.go | 48 +- .../services/vpn/connection/resource_test.go | 211 +++++- .../services/vpn/gateway/datasource.go | 15 + .../internal/services/vpn/gateway/resource.go | 93 ++- .../services/vpn/gateway/resource_test.go | 145 ++++ .../services/vpn/testdata/bgp-filter-rule.tf | 21 + .../services/vpn/testdata/bgp-filter.tf | 8 + .../services/vpn/testdata/gateway-max.tf | 5 + stackit/internal/services/vpn/vpn_acc_test.go | 244 +++++- stackit/provider.go | 6 + 34 files changed, 2948 insertions(+), 12 deletions(-) create mode 100644 docs/data-sources/vpn_bgp_filter.md create mode 100644 docs/data-sources/vpn_bgp_filter_rule.md create mode 100644 docs/resources/vpn_bgp_filter.md create mode 100644 docs/resources/vpn_bgp_filter_rule.md create mode 100644 examples/data-sources/stackit_vpn_bgp_filter/data-source.tf create mode 100644 examples/data-sources/stackit_vpn_bgp_filter_rule/data-source.tf create mode 100644 examples/resources/stackit_vpn_bgp_filter/import-by-string-id.tf create mode 100644 examples/resources/stackit_vpn_bgp_filter/resource.tf create mode 100644 examples/resources/stackit_vpn_bgp_filter_rule/import-by-string-id.tf create mode 100644 examples/resources/stackit_vpn_bgp_filter_rule/resource.tf create mode 100644 stackit/internal/services/vpn/bgp_filter/datasource.go create mode 100644 stackit/internal/services/vpn/bgp_filter/resource.go create mode 100644 stackit/internal/services/vpn/bgp_filter/resource_test.go create mode 100644 stackit/internal/services/vpn/bgp_filter_rule/datasource.go create mode 100644 stackit/internal/services/vpn/bgp_filter_rule/resource.go create mode 100644 stackit/internal/services/vpn/bgp_filter_rule/resource_test.go create mode 100644 stackit/internal/services/vpn/testdata/bgp-filter-rule.tf create mode 100644 stackit/internal/services/vpn/testdata/bgp-filter.tf diff --git a/docs/data-sources/vpn_bgp_filter.md b/docs/data-sources/vpn_bgp_filter.md new file mode 100644 index 000000000..a92b0cb29 --- /dev/null +++ b/docs/data-sources/vpn_bgp_filter.md @@ -0,0 +1,36 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_vpn_bgp_filter Data Source - stackit" +subcategory: "" +description: |- + VPN BGP filter 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_bgp_filter (Data Source) + +VPN BGP filter 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_bgp_filter" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + gateway_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + filter_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} +``` + + +## Schema + +### Required + +- `filter_id` (String) The server-generated UUID of the BGP filter. +- `gateway_id` (String) The UUID of the parent VPN gateway. +- `project_id` (String) STACKIT project ID associated with the BGP filter. + +### Read-Only + +- `display_name` (String) A user-friendly name for the filter. +- `id` (String) Terraform's internal resource identifier. Structured as "`project_id`,`region`,`gateway_id`,`filter_id`". +- `region` (String) STACKIT region name the resource is located in. diff --git a/docs/data-sources/vpn_bgp_filter_rule.md b/docs/data-sources/vpn_bgp_filter_rule.md new file mode 100644 index 000000000..2b147f3c0 --- /dev/null +++ b/docs/data-sources/vpn_bgp_filter_rule.md @@ -0,0 +1,62 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_vpn_bgp_filter_rule Data Source - stackit" +subcategory: "" +description: |- + VPN BGP filter rule 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_bgp_filter_rule (Data Source) + +VPN BGP filter rule 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_bgp_filter_rule" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + gateway_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + filter_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + rule_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} +``` + + +## Schema + +### Required + +- `filter_id` (String) The UUID of the parent `stackit_vpn_bgp_filter`. +- `gateway_id` (String) The UUID of the parent VPN gateway. +- `project_id` (String) STACKIT project ID associated with the BGP filter rule. +- `rule_id` (String) The server-generated UUID of the rule. + +### Read-Only + +- `action` (String) The action to take if the route matches all criteria. +- `id` (String) Terraform's internal resource identifier. Structured as "`project_id`,`region`,`gateway_id`,`filter_id`,`rule_id`". +- `match` (Attributes) Matching criteria. (see [below for nested schema](#nestedatt--match)) +- `region` (String) STACKIT region name the resource is located in. +- `sequence` (Number) The evaluation order of the rule. +- `set` (Attributes) BGP attributes applied when `action` is `PERMIT`. (see [below for nested schema](#nestedatt--set)) + + +### Nested Schema for `match` + +Read-Only: + +- `as_path_contains_any` (List of Number) Matches if the AS-PATH contains any one of the listed ASNs. +- `communities` (List of String) Matches if the route carries any one of these BGP standard communities. +- `first_asn` (Number) Matches if the first ASN in the AS-PATH equals this ASN. +- `max_prefix_length` (Number) Maximum subnet mask length for matched prefixes. +- `min_prefix_length` (Number) Minimum subnet mask length for matched prefixes. +- `peer` (String) Matches the exact IPv4 address of the BGP neighbor that advertised the route. +- `prefixes` (List of String) List of IPv4 networks to match. + + + +### Nested Schema for `set` + +Read-Only: + +- `local_preference` (Number) BGP LOCAL_PREF set on the route. diff --git a/docs/data-sources/vpn_connection.md b/docs/data-sources/vpn_connection.md index 1dd3db8b8..ec8789648 100644 --- a/docs/data-sources/vpn_connection.md +++ b/docs/data-sources/vpn_connection.md @@ -58,6 +58,7 @@ Read-Only: Read-Only: +- `inbound_filter_id` (String) UUID of the `stackit_vpn_bgp_filter` applied for inbound route filtering on this tunnel's BGP peering session, if any. - `remote_asn` (Number) Remote AS number. @@ -111,6 +112,7 @@ Read-Only: Read-Only: +- `inbound_filter_id` (String) UUID of the `stackit_vpn_bgp_filter` applied for inbound route filtering on this tunnel's BGP peering session, if any. - `remote_asn` (Number) Remote AS number. diff --git a/docs/data-sources/vpn_gateway.md b/docs/data-sources/vpn_gateway.md index ee514ae05..43d3ebf51 100644 --- a/docs/data-sources/vpn_gateway.md +++ b/docs/data-sources/vpn_gateway.md @@ -34,6 +34,7 @@ data "stackit_vpn_gateway" "example" { - `display_name` (String) A user-friendly name for the VPN gateway. - `id` (String) Terraform's internal resource identifier. Structured as "`project_id`,`region`,`gateway_id`". - `labels` (Map of String) Map of custom labels (key-value string pairs). +- `network_config` (Attributes) Network configuration for the VPN gateway. (see [below for nested schema](#nestedatt--network_config)) - `plan_id` (String) The service plan identifier (e.g. `p500`). For guidance on finding available plans, see [List available service plans](https://docs.stackit.cloud/products/network/connectivity-hybrid-multi-cloud/vpn/getting-started/gateway-create/#list-available-service-plans). - `region` (String) STACKIT region name the resource is located in. If not defined, the provider region is used. - `routing_type` (String) Routing architecture. Possible values are: `POLICY_BASED`, `ROUTE_BASED`, `BGP_ROUTE_BASED`. @@ -54,3 +55,12 @@ Read-Only: - `local_asn` (Number) Local ASN for BGP (private ASN range, 64512-4294967294). - `override_advertised_routes` (List of String) List of IPv4 CIDRs to advertise via BGP. If omitted, SNA network ranges are advertised. + + + +### Nested Schema for `network_config` + +Read-Only: + +- `predefined_network_prefix` (List of String) The IPv4 network prefix (CIDR notation) allocated for the VPN gateway. Must have a prefix length of /28 or larger. Cannot be changed after the gateway is created. +- `routing_table_id` (String) Custom routing table ID for the VPN gateway. If omitted, a default routing table is assigned. diff --git a/docs/resources/vpn_bgp_filter.md b/docs/resources/vpn_bgp_filter.md new file mode 100644 index 000000000..7e68eb68a --- /dev/null +++ b/docs/resources/vpn_bgp_filter.md @@ -0,0 +1,53 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_vpn_bgp_filter Resource - stackit" +subcategory: "" +description: |- + VPN BGP filter resource schema. A named BGP route filter attached to a VPN gateway. A filter holds an ordered set of rules (see stackit_vpn_bgp_filter_rule); a route is evaluated against each rule in sequence order and the first match decides the outcome. An implicit deny is applied after the last rule, so an empty filter denies every route. Uses the default_region specified in the provider configuration as a fallback in case no region is defined on resource level. +--- + +# stackit_vpn_bgp_filter (Resource) + +VPN BGP filter resource schema. A named BGP route filter attached to a VPN gateway. A filter holds an ordered set of rules (see `stackit_vpn_bgp_filter_rule`); a route is evaluated against each rule in sequence order and the first match decides the outcome. An implicit deny is applied after the last rule, so an empty filter denies every route. 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_bgp_filter" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + gateway_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + display_name = "example-bgp-filter" +} +``` + + +## Schema + +### Required + +- `display_name` (String) A user-friendly name for the filter. Display only - not enforced unique across a gateway. +- `gateway_id` (String) The UUID of the parent VPN gateway. +- `project_id` (String) STACKIT project ID associated with the BGP filter. + +### Optional + +- `region` (String) STACKIT region name the resource is located in. If not defined, the provider region is used. + +### Read-Only + +- `filter_id` (String) The server-generated UUID of the BGP filter. +- `id` (String) Terraform's internal resource identifier. Structured as "`project_id`,`region`,`gateway_id`,`filter_id`". + +## Import + +Import is supported using the following syntax: + +In Terraform v1.5.0 and later, the [` + "`" + `import` + "`" + ` block](https://developer.hashicorp.com/terraform/language/import) can be used with the ` + "`" + `id` + "`" + ` attribute, for example: + +```terraform +# Only use the import statement, if you want to import an existing VPN BGP filter +import { + to = stackit_vpn_bgp_filter.example + id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx,eu01,xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx,xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} +``` diff --git a/docs/resources/vpn_bgp_filter_rule.md b/docs/resources/vpn_bgp_filter_rule.md new file mode 100644 index 000000000..193ed3d89 --- /dev/null +++ b/docs/resources/vpn_bgp_filter_rule.md @@ -0,0 +1,88 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_vpn_bgp_filter_rule Resource - stackit" +subcategory: "" +description: |- + VPN BGP filter rule resource schema. A single rule within a stackit_vpn_bgp_filter. All non-empty fields within match are AND-combined. Rules within a filter are evaluated in sequence order (lower first); the first matching rule decides the outcome. An implicit deny follows the last rule. A filter may hold at most 10 rules. Uses the default_region specified in the provider configuration as a fallback in case no region is defined on resource level. +--- + +# stackit_vpn_bgp_filter_rule (Resource) + +VPN BGP filter rule resource schema. A single rule within a `stackit_vpn_bgp_filter`. All non-empty fields within `match` are AND-combined. Rules within a filter are evaluated in `sequence` order (lower first); the first matching rule decides the outcome. An implicit deny follows the last rule. A filter may hold at most 10 rules. 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_bgp_filter_rule" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + gateway_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + filter_id = stackit_vpn_bgp_filter.example.filter_id + action = "PERMIT" + + match = { + prefixes = ["10.0.0.0/16"] + max_prefix_length = 24 + } + + set = { + local_preference = 150 + } +} +``` + + +## Schema + +### Required + +- `action` (String) The action to take if the route matches all criteria. Possible values are: `PERMIT`, `DENY`. +- `filter_id` (String) The UUID of the parent `stackit_vpn_bgp_filter`. +- `gateway_id` (String) The UUID of the parent VPN gateway. +- `project_id` (String) STACKIT project ID associated with the BGP filter rule. + +### Optional + +- `match` (Attributes) Optional matching criteria. If omitted entirely, the rule acts as match-all. All non-empty fields in this block must match (logical AND). (see [below for nested schema](#nestedatt--match)) +- `region` (String) STACKIT region name the resource is located in. If not defined, the provider region is used. +- `sequence` (Number) The evaluation order of the rule. Lower numbers are evaluated first. Must be unique within a filter. If omitted on creation, the server auto-assigns the next value. +- `set` (Attributes) Optional BGP attributes to apply when `action` is `PERMIT`. Ignored for `DENY` rules. (see [below for nested schema](#nestedatt--set)) + +### Read-Only + +- `id` (String) Terraform's internal resource identifier. Structured as "`project_id`,`region`,`gateway_id`,`filter_id`,`rule_id`". +- `rule_id` (String) The server-generated UUID of the rule. + + +### Nested Schema for `match` + +Optional: + +- `as_path_contains_any` (List of Number) Matches if the AS-PATH contains any one of the listed ASNs (logical OR within the list). +- `communities` (List of String) Matches if the route carries any one of these BGP standard communities. Format is `asn:value` per RFC 1997. +- `first_asn` (Number) Matches if the first ASN (immediate neighbor) in the AS-PATH equals this ASN. +- `max_prefix_length` (Number) Maximum subnet mask length for matched prefixes. +- `min_prefix_length` (Number) Minimum subnet mask length for matched prefixes. +- `peer` (String) Matches the exact IPv4 address of the BGP neighbor that advertised the route. +- `prefixes` (List of String) List of IPv4 networks to match. A route's prefix matches if it equals one of these (subject to min/max prefix length refinement). + + + +### Nested Schema for `set` + +Optional: + +- `local_preference` (Number) BGP LOCAL_PREF to set on the route. Higher values are preferred during best-path selection. Default BGP LOCAL_PREF is 100. + +## Import + +Import is supported using the following syntax: + +In Terraform v1.5.0 and later, the [` + "`" + `import` + "`" + ` block](https://developer.hashicorp.com/terraform/language/import) can be used with the ` + "`" + `id` + "`" + ` attribute, for example: + +```terraform +# Only use the import statement, if you want to import an existing VPN BGP filter rule +import { + to = stackit_vpn_bgp_filter_rule.example + id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx,eu01,xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx,xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx,xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} +``` diff --git a/docs/resources/vpn_connection.md b/docs/resources/vpn_connection.md index 90ce83f14..f28727af3 100644 --- a/docs/resources/vpn_connection.md +++ b/docs/resources/vpn_connection.md @@ -106,7 +106,7 @@ Optional: 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`. +- `integrity_algorithms` (List of String) Integrity algorithms for Phase 1. Possible values are: `sha2_256`, `sha2_384`, `sha2_512`. Optional: @@ -120,7 +120,7 @@ Optional: 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`. +- `integrity_algorithms` (List of String) Integrity algorithms for Phase 2. Possible values are: `sha2_256`, `sha2_384`, `sha2_512`. Optional: @@ -137,6 +137,10 @@ Required: - `remote_asn` (Number) Remote ASN for BGP peering (private ASN range, 64512-4294967294). +Optional: + +- `inbound_filter_id` (String) UUID of a `stackit_vpn_bgp_filter` to apply for inbound route filtering on this tunnel's BGP peering session. If omitted, no inbound filtering is applied. + ### Nested Schema for `tunnel1.peering` @@ -171,7 +175,7 @@ Optional: 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`. +- `integrity_algorithms` (List of String) Integrity algorithms for Phase 1. Possible values are: `sha2_256`, `sha2_384`, `sha2_512`. Optional: @@ -185,7 +189,7 @@ Optional: 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`. +- `integrity_algorithms` (List of String) Integrity algorithms for Phase 2. Possible values are: `sha2_256`, `sha2_384`, `sha2_512`. Optional: @@ -202,6 +206,10 @@ Required: - `remote_asn` (Number) Remote ASN for BGP peering (private ASN range, 64512-4294967294). +Optional: + +- `inbound_filter_id` (String) UUID of a `stackit_vpn_bgp_filter` to apply for inbound route filtering on this tunnel's BGP peering session. If omitted, no inbound filtering is applied. + ### Nested Schema for `tunnel2.peering` diff --git a/docs/resources/vpn_gateway.md b/docs/resources/vpn_gateway.md index bbd557e1b..a94d3eb2d 100644 --- a/docs/resources/vpn_gateway.md +++ b/docs/resources/vpn_gateway.md @@ -23,6 +23,10 @@ resource "stackit_vpn_gateway" "example" { tunnel1 = "eu01-1" tunnel2 = "eu01-2" } + + network_config = { + predefined_network_prefix = ["10.20.0.0/28"] + } } ``` @@ -41,6 +45,7 @@ resource "stackit_vpn_gateway" "example" { - `bgp` (Attributes) BGP configuration. Only applicable when routing_type is BGP_ROUTE_BASED. (see [below for nested schema](#nestedatt--bgp)) - `labels` (Map of String) Map of custom labels (key-value string pairs). +- `network_config` (Attributes) Network configuration for the VPN gateway. (see [below for nested schema](#nestedatt--network_config)) - `region` (String) STACKIT region name the resource is located in. If not defined, the provider region is used. ### Read-Only @@ -68,6 +73,15 @@ Optional: - `override_advertised_routes` (List of String) List of IPv4 CIDRs to advertise via BGP. If omitted, SNA network ranges are advertised. + + +### Nested Schema for `network_config` + +Optional: + +- `predefined_network_prefix` (List of String) The IPv4 network prefix (CIDR notation) allocated for the VPN gateway. Must have a prefix length of /28 or larger. Cannot be changed after the gateway is created. +- `routing_table_id` (String) Custom routing table ID for the VPN gateway. If omitted, a default routing table is assigned. + ## Import Import is supported using the following syntax: diff --git a/examples/data-sources/stackit_vpn_bgp_filter/data-source.tf b/examples/data-sources/stackit_vpn_bgp_filter/data-source.tf new file mode 100644 index 000000000..5373681f4 --- /dev/null +++ b/examples/data-sources/stackit_vpn_bgp_filter/data-source.tf @@ -0,0 +1,5 @@ +data "stackit_vpn_bgp_filter" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + gateway_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + filter_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} diff --git a/examples/data-sources/stackit_vpn_bgp_filter_rule/data-source.tf b/examples/data-sources/stackit_vpn_bgp_filter_rule/data-source.tf new file mode 100644 index 000000000..410f6c7d3 --- /dev/null +++ b/examples/data-sources/stackit_vpn_bgp_filter_rule/data-source.tf @@ -0,0 +1,6 @@ +data "stackit_vpn_bgp_filter_rule" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + gateway_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + filter_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + rule_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} diff --git a/examples/resources/stackit_vpn_bgp_filter/import-by-string-id.tf b/examples/resources/stackit_vpn_bgp_filter/import-by-string-id.tf new file mode 100644 index 000000000..1e6df72db --- /dev/null +++ b/examples/resources/stackit_vpn_bgp_filter/import-by-string-id.tf @@ -0,0 +1,5 @@ +# Only use the import statement, if you want to import an existing VPN BGP filter +import { + to = stackit_vpn_bgp_filter.example + id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx,eu01,xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx,xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} diff --git a/examples/resources/stackit_vpn_bgp_filter/resource.tf b/examples/resources/stackit_vpn_bgp_filter/resource.tf new file mode 100644 index 000000000..16e893157 --- /dev/null +++ b/examples/resources/stackit_vpn_bgp_filter/resource.tf @@ -0,0 +1,5 @@ +resource "stackit_vpn_bgp_filter" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + gateway_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + display_name = "example-bgp-filter" +} diff --git a/examples/resources/stackit_vpn_bgp_filter_rule/import-by-string-id.tf b/examples/resources/stackit_vpn_bgp_filter_rule/import-by-string-id.tf new file mode 100644 index 000000000..01b910d2a --- /dev/null +++ b/examples/resources/stackit_vpn_bgp_filter_rule/import-by-string-id.tf @@ -0,0 +1,5 @@ +# Only use the import statement, if you want to import an existing VPN BGP filter rule +import { + to = stackit_vpn_bgp_filter_rule.example + id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx,eu01,xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx,xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx,xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} diff --git a/examples/resources/stackit_vpn_bgp_filter_rule/resource.tf b/examples/resources/stackit_vpn_bgp_filter_rule/resource.tf new file mode 100644 index 000000000..4b330608f --- /dev/null +++ b/examples/resources/stackit_vpn_bgp_filter_rule/resource.tf @@ -0,0 +1,15 @@ +resource "stackit_vpn_bgp_filter_rule" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + gateway_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + filter_id = stackit_vpn_bgp_filter.example.filter_id + action = "PERMIT" + + match = { + prefixes = ["10.0.0.0/16"] + max_prefix_length = 24 + } + + set = { + local_preference = 150 + } +} diff --git a/examples/resources/stackit_vpn_gateway/resource.tf b/examples/resources/stackit_vpn_gateway/resource.tf index 08c918668..addf5b356 100644 --- a/examples/resources/stackit_vpn_gateway/resource.tf +++ b/examples/resources/stackit_vpn_gateway/resource.tf @@ -8,4 +8,8 @@ resource "stackit_vpn_gateway" "example" { tunnel1 = "eu01-1" tunnel2 = "eu01-2" } + + network_config = { + predefined_network_prefix = ["10.20.0.0/28"] + } } diff --git a/go.mod b/go.mod index f530b0b66..d9a737e14 100644 --- a/go.mod +++ b/go.mod @@ -48,7 +48,7 @@ require ( github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex v1.16.1 github.com/stackitcloud/stackit-sdk-go/services/telemetrylink v0.4.0 github.com/stackitcloud/stackit-sdk-go/services/telemetryrouter v0.4.0 - github.com/stackitcloud/stackit-sdk-go/services/vpn v0.14.0 + github.com/stackitcloud/stackit-sdk-go/services/vpn v0.14.1-0.20260728105652-71775a9e99ee // TODO(vpn-sdk-pin): re-pin to tagged release once stackitcloud/stackit-sdk-go#9324 merges github.com/teambition/rrule-go v1.8.2 golang.org/x/mod v0.38.0 ) diff --git a/go.sum b/go.sum index 78bb4a31f..85e48b938 100644 --- a/go.sum +++ b/go.sum @@ -744,6 +744,8 @@ github.com/stackitcloud/stackit-sdk-go/services/telemetryrouter v0.4.0 h1:tEKBl3 github.com/stackitcloud/stackit-sdk-go/services/telemetryrouter v0.4.0/go.mod h1:WUmgKtwpe90Yq3YbgNxc2clTTULVxCu0ha6lMTjUnII= github.com/stackitcloud/stackit-sdk-go/services/vpn v0.14.0 h1:LMgbzhPunuelsIsfyEj/5O/aYfNcg/eGHsnZ7AZOhYg= github.com/stackitcloud/stackit-sdk-go/services/vpn v0.14.0/go.mod h1:toIjQk1dhxdUFVyCWJJja0w/0nFpDid8MWX0ukQfvfo= +github.com/stackitcloud/stackit-sdk-go/services/vpn v0.14.1-0.20260728105652-71775a9e99ee h1:PSQAjD/gFEYFgVj+lI7CXb04u0LXm6uRSJx1DjXTIqg= +github.com/stackitcloud/stackit-sdk-go/services/vpn v0.14.1-0.20260728105652-71775a9e99ee/go.mod h1:toIjQk1dhxdUFVyCWJJja0w/0nFpDid8MWX0ukQfvfo= github.com/stbenjam/no-sprintf-host-port v0.3.1 h1:AyX7+dxI4IdLBPtDbsGAyqiTSLpCP9hWRrXQDU4Cm/g= github.com/stbenjam/no-sprintf-host-port v0.3.1/go.mod h1:ODbZesTCHMVKthBHskvUUexdcNHAQRXk9NpSsL8p/HQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= diff --git a/stackit/internal/services/vpn/bgp_filter/datasource.go b/stackit/internal/services/vpn/bgp_filter/datasource.go new file mode 100644 index 000000000..4d39d761a --- /dev/null +++ b/stackit/internal/services/vpn/bgp_filter/datasource.go @@ -0,0 +1,144 @@ +package bgpfilter + +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-log/tflog" + + "github.com/stackitcloud/stackit-sdk-go/core/oapierror" + vpn "github.com/stackitcloud/stackit-sdk-go/services/vpn/v1api" + + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/vpn/utils" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" +) + +var ( + _ datasource.DataSource = &bgpFilterDataSource{} + _ datasource.DataSourceWithConfigure = &bgpFilterDataSource{} +) + +type bgpFilterDataSource struct { + client *vpn.APIClient + providerData core.ProviderData +} + +func NewVPNBGPFilterDataSource() datasource.DataSource { + return &bgpFilterDataSource{} +} + +func (d *bgpFilterDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + var ok bool + d.providerData, ok = conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics) + if !ok { + return + } + + d.client = utils.ConfigureClient(ctx, &d.providerData, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "VPN client configured") +} + +func (d *bgpFilterDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_vpn_bgp_filter" +} + +func (d *bgpFilterDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: fmt.Sprintf("VPN BGP filter data source schema. %s", core.DatasourceRegionFallbackDocstring), + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "Terraform's internal resource identifier. Structured as \"`project_id`,`region`,`gateway_id`,`filter_id`\".", + Computed: true, + }, + "project_id": schema.StringAttribute{ + Description: "STACKIT project ID associated with the BGP filter.", + Required: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "region": schema.StringAttribute{ + Description: "STACKIT region name the resource is located in.", + Computed: true, + }, + "gateway_id": schema.StringAttribute{ + Description: "The UUID of the parent VPN gateway.", + Required: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "filter_id": schema.StringAttribute{ + Description: "The server-generated UUID of the BGP filter.", + Required: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "display_name": schema.StringAttribute{ + Description: "A user-friendly name for the filter.", + Computed: true, + }, + }, + } +} + +func (d *bgpFilterDataSource) 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() + filterId := model.FilterId.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, "filter_id", filterId) + + filterResp, err := d.client.DefaultAPI.GetGatewayBGPFilter(ctx, projectId, region, gatewayId, filterId).Execute() + if err != nil { + if oapiErr, ok := errors.AsType[*oapierror.GenericOpenAPIError](err); ok && oapiErr.StatusCode == http.StatusNotFound { + resp.State.RemoveResource(ctx) + return + } + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading VPN BGP filter", fmt.Sprintf("Calling API: %v", err)) + return + } + ctx = core.LogResponse(ctx) + + err = mapFields(filterResp, &model, region) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading VPN BGP filter", 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 BGP filter read", map[string]any{ + "filter_id": filterId, + }) +} diff --git a/stackit/internal/services/vpn/bgp_filter/resource.go b/stackit/internal/services/vpn/bgp_filter/resource.go new file mode 100644 index 000000000..387ec7393 --- /dev/null +++ b/stackit/internal/services/vpn/bgp_filter/resource.go @@ -0,0 +1,391 @@ +package bgpfilter + +import ( + "context" + "errors" + "fmt" + "net/http" + "regexp" + "strings" + + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/stackitcloud/stackit-sdk-go/core/oapierror" + vpn "github.com/stackitcloud/stackit-sdk-go/services/vpn/v1api" + + "github.com/stackitcloud/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 = &bgpFilterResource{} + _ resource.ResourceWithConfigure = &bgpFilterResource{} + _ resource.ResourceWithImportState = &bgpFilterResource{} + _ resource.ResourceWithModifyPlan = &bgpFilterResource{} +) + +// Model is shared by the resource and the data source - the BGPFilter API only has plain, +// always-returned fields (no write-only values), so there is no need for a separate split +// like the one used for stackit_vpn_connection. +type Model struct { + Id types.String `tfsdk:"id"` // needed by TF + ProjectId types.String `tfsdk:"project_id"` + Region types.String `tfsdk:"region"` + GatewayId types.String `tfsdk:"gateway_id"` + FilterId types.String `tfsdk:"filter_id"` + DisplayName types.String `tfsdk:"display_name"` +} + +type bgpFilterResource struct { + client *vpn.APIClient + providerData core.ProviderData +} + +func NewVPNBGPFilterResource() resource.Resource { + return &bgpFilterResource{} +} + +func (r *bgpFilterResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + var ok bool + r.providerData, ok = conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics) + if !ok { + return + } + + apiClient := utils.ConfigureClient(ctx, &r.providerData, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + r.client = apiClient + tflog.Info(ctx, "VPN client configured") +} + +func (r *bgpFilterResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_vpn_bgp_filter" +} + +func (r *bgpFilterResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: fmt.Sprintf("VPN BGP filter resource schema. A named BGP route filter attached to a VPN gateway. "+ + "A filter holds an ordered set of rules (see `stackit_vpn_bgp_filter_rule`); a route is evaluated against "+ + "each rule in sequence order and the first match decides the outcome. An implicit deny is applied after "+ + "the last rule, so an empty filter denies every route. %s", core.ResourceRegionFallbackDocstring), + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "Terraform's internal resource identifier. Structured as \"`project_id`,`region`,`gateway_id`,`filter_id`\".", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "filter_id": schema.StringAttribute{ + Description: "The server-generated UUID of the BGP filter.", + Computed: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "project_id": schema.StringAttribute{ + Description: "STACKIT project ID associated with the BGP filter.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "region": schema.StringAttribute{ + Description: "STACKIT region name the resource is located in. If not defined, the provider region is used.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "gateway_id": schema.StringAttribute{ + Description: "The UUID of the parent VPN gateway.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "display_name": schema.StringAttribute{ + Description: "A user-friendly name for the filter. Display only - not enforced unique across a gateway.", + 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", + ), + }, + }, + }, + } +} + +func (r *bgpFilterResource) 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 *bgpFilterResource) 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 BGP filter", + fmt.Sprintf("Expected import identifier with format: [project_id],[region],[gateway_id],[filter_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], + "filter_id": idParts[3], + }) + tflog.Info(ctx, "VPN BGP filter state imported") +} + +func (r *bgpFilterResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { // nolint:gocritic // function signature required by Terraform + var model Model + diags := req.Plan.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + ctx = core.InitProviderContext(ctx) + + projectId := model.ProjectId.ValueString() + gatewayId := model.GatewayId.ValueString() + region := r.providerData.GetRegionWithOverride(model.Region) + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "gateway_id", gatewayId) + ctx = tflog.SetField(ctx, "region", region) + + payload := toCreatePayload(&model) + + createResp, err := r.client.DefaultAPI.CreateGatewayBGPFilter(ctx, projectId, region, gatewayId).CreateGatewayBGPFilterPayload(*payload).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating VPN BGP filter", fmt.Sprintf("Calling API: %v", err)) + return + } + + ctx = core.LogResponse(ctx) + + err = mapFields(createResp, &model, region) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating VPN BGP filter", 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 BGP filter created") +} + +func (r *bgpFilterResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { // nolint:gocritic // function signature required by Terraform + var model Model + diags := req.State.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + ctx = core.InitProviderContext(ctx) + + projectId := model.ProjectId.ValueString() + gatewayId := model.GatewayId.ValueString() + filterId := model.FilterId.ValueString() + region := r.providerData.GetRegionWithOverride(model.Region) + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "gateway_id", gatewayId) + ctx = tflog.SetField(ctx, "filter_id", filterId) + ctx = tflog.SetField(ctx, "region", region) + + filterResp, err := r.client.DefaultAPI.GetGatewayBGPFilter(ctx, projectId, region, gatewayId, filterId).Execute() + if err != nil { + if oapiErr, ok := errors.AsType[*oapierror.GenericOpenAPIError](err); ok && oapiErr.StatusCode == http.StatusNotFound { + resp.State.RemoveResource(ctx) + return + } + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading VPN BGP filter", err.Error()) + return + } + + ctx = core.LogResponse(ctx) + + err = mapFields(filterResp, &model, region) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading VPN BGP filter", 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 BGP filter read") +} + +func (r *bgpFilterResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform + var model Model + diags := req.Plan.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + ctx = core.InitProviderContext(ctx) + + projectId := model.ProjectId.ValueString() + gatewayId := model.GatewayId.ValueString() + filterId := model.FilterId.ValueString() + region := r.providerData.GetRegionWithOverride(model.Region) + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "gateway_id", gatewayId) + ctx = tflog.SetField(ctx, "filter_id", filterId) + ctx = tflog.SetField(ctx, "region", region) + + payload := toUpdatePayload(&model) + + updateResp, err := r.client.DefaultAPI.UpdateGatewayBGPFilter(ctx, projectId, region, gatewayId, filterId).UpdateGatewayBGPFilterPayload(*payload).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating VPN BGP filter", err.Error()) + return + } + + ctx = core.LogResponse(ctx) + + err = mapFields(updateResp, &model, region) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating VPN BGP filter", 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 BGP filter updated") +} + +func (r *bgpFilterResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { // nolint:gocritic // function signature required by Terraform + var model Model + diags := req.State.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + ctx = core.InitProviderContext(ctx) + + projectId := model.ProjectId.ValueString() + gatewayId := model.GatewayId.ValueString() + filterId := model.FilterId.ValueString() + region := r.providerData.GetRegionWithOverride(model.Region) + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "gateway_id", gatewayId) + ctx = tflog.SetField(ctx, "filter_id", filterId) + ctx = tflog.SetField(ctx, "region", region) + + err := r.client.DefaultAPI.DeleteGatewayBGPFilter(ctx, projectId, region, gatewayId, filterId).Execute() + if err != nil { + if oapiErr, ok := errors.AsType[*oapierror.GenericOpenAPIError](err); ok && oapiErr.StatusCode == http.StatusNotFound { + resp.State.RemoveResource(ctx) + return + } + // Deleting a filter still referenced by a connection's tunnel bgp.inbound_filter_id fails with + // a RESOURCE_IN_USE API error - surfaced here like any other API error. + core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting VPN BGP filter", fmt.Sprintf("Calling API: %v", err)) + return + } + + ctx = core.LogResponse(ctx) + tflog.Info(ctx, "VPN BGP filter deleted") +} + +func toCreatePayload(model *Model) *vpn.CreateGatewayBGPFilterPayload { + return &vpn.CreateGatewayBGPFilterPayload{ + DisplayName: model.DisplayName.ValueString(), + } +} + +func toUpdatePayload(model *Model) *vpn.UpdateGatewayBGPFilterPayload { + return &vpn.UpdateGatewayBGPFilterPayload{ + DisplayName: model.DisplayName.ValueString(), + } +} + +func mapFields(filter *vpn.BGPFilter, model *Model, region string) error { + if filter == nil { + return fmt.Errorf("response input is nil") + } + if model == nil { + return fmt.Errorf("model input is nil") + } + + var filterId string + if model.FilterId.ValueString() != "" { + filterId = model.FilterId.ValueString() + } else if filter.Id != nil { + filterId = *filter.Id + } else { + return fmt.Errorf("filter id not present") + } + + model.Id = tfutils.BuildInternalTerraformId(model.ProjectId.ValueString(), region, model.GatewayId.ValueString(), filterId) + model.FilterId = types.StringValue(filterId) + model.Region = types.StringValue(region) + model.DisplayName = types.StringValue(filter.DisplayName) + + return nil +} diff --git a/stackit/internal/services/vpn/bgp_filter/resource_test.go b/stackit/internal/services/vpn/bgp_filter/resource_test.go new file mode 100644 index 000000000..5917bd100 --- /dev/null +++ b/stackit/internal/services/vpn/bgp_filter/resource_test.go @@ -0,0 +1,116 @@ +package bgpfilter + +import ( + "fmt" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/uuid" + "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 + state Model + input *vpn.BGPFilter + expected Model + isValid bool + }{ + { + description: "default_ok", + state: Model{ + ProjectId: types.StringValue(projectId), + GatewayId: types.StringValue(gatewayId), + }, + input: &vpn.BGPFilter{ + Id: new("filter-id"), + DisplayName: "test-filter", + }, + expected: Model{ + Id: types.StringValue(fmt.Sprintf("%s,%s,%s,%s", projectId, region, gatewayId, "filter-id")), + ProjectId: types.StringValue(projectId), + Region: types.StringValue(region), + GatewayId: types.StringValue(gatewayId), + FilterId: types.StringValue("filter-id"), + DisplayName: types.StringValue("test-filter"), + }, + isValid: true, + }, + { + description: "nil_response", + state: Model{}, + input: nil, + expected: Model{}, + isValid: false, + }, + { + description: "nil_filter_id", + state: Model{ + ProjectId: types.StringValue(projectId), + GatewayId: types.StringValue(gatewayId), + }, + input: &vpn.BGPFilter{ + Id: nil, + DisplayName: "test-filter", + }, + expected: Model{}, + isValid: false, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + state := tt.state + err := mapFields(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) { + model := &Model{ + DisplayName: types.StringValue("test-filter"), + } + expected := &vpn.CreateGatewayBGPFilterPayload{ + DisplayName: "test-filter", + } + + payload := toCreatePayload(model) + + if diff := cmp.Diff(expected, payload); diff != "" { + t.Fatalf("Data does not match (-want +got):\n%s", diff) + } +} + +func TestToUpdatePayload(t *testing.T) { + model := &Model{ + DisplayName: types.StringValue("updated-filter"), + } + expected := &vpn.UpdateGatewayBGPFilterPayload{ + DisplayName: "updated-filter", + } + + payload := toUpdatePayload(model) + + if diff := cmp.Diff(expected, payload); diff != "" { + t.Fatalf("Data does not match (-want +got):\n%s", diff) + } +} diff --git a/stackit/internal/services/vpn/bgp_filter_rule/datasource.go b/stackit/internal/services/vpn/bgp_filter_rule/datasource.go new file mode 100644 index 000000000..10269139a --- /dev/null +++ b/stackit/internal/services/vpn/bgp_filter_rule/datasource.go @@ -0,0 +1,206 @@ +package bgpfilterrule + +import ( + "context" + "errors" + "fmt" + "net/http" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + + "github.com/stackitcloud/stackit-sdk-go/core/oapierror" + vpn "github.com/stackitcloud/stackit-sdk-go/services/vpn/v1api" + + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/vpn/utils" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" +) + +var ( + _ datasource.DataSource = &bgpFilterRuleDataSource{} + _ datasource.DataSourceWithConfigure = &bgpFilterRuleDataSource{} +) + +type bgpFilterRuleDataSource struct { + client *vpn.APIClient + providerData core.ProviderData +} + +func NewVPNBGPFilterRuleDataSource() datasource.DataSource { + return &bgpFilterRuleDataSource{} +} + +func (d *bgpFilterRuleDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + var ok bool + d.providerData, ok = conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics) + if !ok { + return + } + + d.client = utils.ConfigureClient(ctx, &d.providerData, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "VPN client configured") +} + +func (d *bgpFilterRuleDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_vpn_bgp_filter_rule" +} + +func (d *bgpFilterRuleDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: fmt.Sprintf("VPN BGP filter rule data source schema. %s", core.DatasourceRegionFallbackDocstring), + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "Terraform's internal resource identifier. Structured as \"`project_id`,`region`,`gateway_id`,`filter_id`,`rule_id`\".", + Computed: true, + }, + "project_id": schema.StringAttribute{ + Description: "STACKIT project ID associated with the BGP filter rule.", + Required: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "region": schema.StringAttribute{ + Description: "STACKIT region name the resource is located in.", + Computed: true, + }, + "gateway_id": schema.StringAttribute{ + Description: "The UUID of the parent VPN gateway.", + Required: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "filter_id": schema.StringAttribute{ + Description: "The UUID of the parent `stackit_vpn_bgp_filter`.", + Required: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "rule_id": schema.StringAttribute{ + Description: "The server-generated UUID of the rule.", + Required: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "action": schema.StringAttribute{ + Description: "The action to take if the route matches all criteria.", + Computed: true, + }, + "sequence": schema.Int32Attribute{ + Description: "The evaluation order of the rule.", + Computed: true, + }, + "match": schema.SingleNestedAttribute{ + Description: "Matching criteria.", + Computed: true, + Attributes: map[string]schema.Attribute{ + "as_path_contains_any": schema.ListAttribute{ + Description: "Matches if the AS-PATH contains any one of the listed ASNs.", + Computed: true, + ElementType: types.Int64Type, + }, + "communities": schema.ListAttribute{ + Description: "Matches if the route carries any one of these BGP standard communities.", + Computed: true, + ElementType: types.StringType, + }, + "first_asn": schema.Int64Attribute{ + Description: "Matches if the first ASN in the AS-PATH equals this ASN.", + Computed: true, + }, + "max_prefix_length": schema.Int32Attribute{ + Description: "Maximum subnet mask length for matched prefixes.", + Computed: true, + }, + "min_prefix_length": schema.Int32Attribute{ + Description: "Minimum subnet mask length for matched prefixes.", + Computed: true, + }, + "peer": schema.StringAttribute{ + Description: "Matches the exact IPv4 address of the BGP neighbor that advertised the route.", + Computed: true, + }, + "prefixes": schema.ListAttribute{ + Description: "List of IPv4 networks to match.", + Computed: true, + ElementType: types.StringType, + }, + }, + }, + "set": schema.SingleNestedAttribute{ + Description: "BGP attributes applied when `action` is `PERMIT`.", + Computed: true, + Attributes: map[string]schema.Attribute{ + "local_preference": schema.Int32Attribute{ + Description: "BGP LOCAL_PREF set on the route.", + Computed: true, + }, + }, + }, + }, + } +} + +func (d *bgpFilterRuleDataSource) 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() + filterId := model.FilterId.ValueString() + ruleId := model.RuleId.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, "filter_id", filterId) + ctx = tflog.SetField(ctx, "rule_id", ruleId) + + ruleResp, err := d.client.DefaultAPI.GetGatewayBGPFilterRule(ctx, projectId, region, gatewayId, filterId, ruleId).Execute() + if err != nil { + if oapiErr, ok := errors.AsType[*oapierror.GenericOpenAPIError](err); ok && oapiErr.StatusCode == http.StatusNotFound { + resp.State.RemoveResource(ctx) + return + } + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading VPN BGP filter rule", fmt.Sprintf("Calling API: %v", err)) + return + } + ctx = core.LogResponse(ctx) + + err = mapFields(ctx, ruleResp, &model, region) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading VPN BGP filter rule", 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 BGP filter rule read", map[string]any{ + "rule_id": ruleId, + }) +} diff --git a/stackit/internal/services/vpn/bgp_filter_rule/resource.go b/stackit/internal/services/vpn/bgp_filter_rule/resource.go new file mode 100644 index 000000000..1b8896359 --- /dev/null +++ b/stackit/internal/services/vpn/bgp_filter_rule/resource.go @@ -0,0 +1,692 @@ +package bgpfilterrule + +import ( + "context" + "errors" + "fmt" + "net/http" + "regexp" + "strings" + + "github.com/hashicorp/terraform-plugin-framework-validators/int32validator" + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/int32planmodifier" + "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" + sdkUtils "github.com/stackitcloud/stackit-sdk-go/core/utils" + 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 = &bgpFilterRuleResource{} + _ resource.ResourceWithConfigure = &bgpFilterRuleResource{} + _ resource.ResourceWithImportState = &bgpFilterRuleResource{} + _ resource.ResourceWithModifyPlan = &bgpFilterRuleResource{} + + actionValues = sdkUtils.EnumSliceToStringSlice(vpn.AllowedBGPFilterRuleActionEnumValues) +) + +type MatchModel struct { + AsPathContainsAny types.List `tfsdk:"as_path_contains_any"` + Communities types.List `tfsdk:"communities"` + FirstAsn types.Int64 `tfsdk:"first_asn"` + MaxPrefixLength types.Int32 `tfsdk:"max_prefix_length"` + MinPrefixLength types.Int32 `tfsdk:"min_prefix_length"` + Peer types.String `tfsdk:"peer"` + Prefixes types.List `tfsdk:"prefixes"` +} + +type SetModel struct { + LocalPreference types.Int32 `tfsdk:"local_preference"` +} + +// Model is shared by the resource and the data source - the BGPFilterRule API only has plain, +// always-returned fields (no write-only values), so there is no need for a separate split +// like the one used for stackit_vpn_connection. +type Model struct { + Id types.String `tfsdk:"id"` // needed by TF + ProjectId types.String `tfsdk:"project_id"` + Region types.String `tfsdk:"region"` + GatewayId types.String `tfsdk:"gateway_id"` + FilterId types.String `tfsdk:"filter_id"` + RuleId types.String `tfsdk:"rule_id"` + Action types.String `tfsdk:"action"` + Sequence types.Int32 `tfsdk:"sequence"` + Match *MatchModel `tfsdk:"match"` + Set *SetModel `tfsdk:"set"` +} + +type bgpFilterRuleResource struct { + client *vpn.APIClient + providerData core.ProviderData +} + +func NewVPNBGPFilterRuleResource() resource.Resource { + return &bgpFilterRuleResource{} +} + +func (r *bgpFilterRuleResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + var ok bool + r.providerData, ok = conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics) + if !ok { + return + } + + apiClient := utils.ConfigureClient(ctx, &r.providerData, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + r.client = apiClient + tflog.Info(ctx, "VPN client configured") +} + +func (r *bgpFilterRuleResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_vpn_bgp_filter_rule" +} + +func (r *bgpFilterRuleResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: fmt.Sprintf("VPN BGP filter rule resource schema. A single rule within a `stackit_vpn_bgp_filter`. "+ + "All non-empty fields within `match` are AND-combined. Rules within a filter are evaluated in `sequence` "+ + "order (lower first); the first matching rule decides the outcome. An implicit deny follows the last rule. "+ + "A filter may hold at most 10 rules. %s", core.ResourceRegionFallbackDocstring), + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "Terraform's internal resource identifier. Structured as \"`project_id`,`region`,`gateway_id`,`filter_id`,`rule_id`\".", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "rule_id": schema.StringAttribute{ + Description: "The server-generated UUID of the rule.", + Computed: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "project_id": schema.StringAttribute{ + Description: "STACKIT project ID associated with the BGP filter rule.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "region": schema.StringAttribute{ + Description: "STACKIT region name the resource is located in. If not defined, the provider region is used.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "gateway_id": schema.StringAttribute{ + Description: "The UUID of the parent VPN gateway.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "filter_id": schema.StringAttribute{ + Description: "The UUID of the parent `stackit_vpn_bgp_filter`.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "action": schema.StringAttribute{ + Description: fmt.Sprintf("The action to take if the route matches all criteria. %s", tfutils.FormatPossibleValues(actionValues...)), + Required: true, + Validators: []validator.String{ + stringvalidator.OneOf(actionValues...), + }, + }, + "sequence": schema.Int32Attribute{ + Description: "The evaluation order of the rule. Lower numbers are evaluated first. Must be unique within a filter. If omitted on creation, the server auto-assigns the next value.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Int32{ + int32planmodifier.UseStateForUnknown(), + }, + }, + "match": schema.SingleNestedAttribute{ + Description: "Optional matching criteria. If omitted entirely, the rule acts as match-all. All non-empty fields in this block must match (logical AND).", + Optional: true, + Attributes: map[string]schema.Attribute{ + "as_path_contains_any": schema.ListAttribute{ + Description: "Matches if the AS-PATH contains any one of the listed ASNs (logical OR within the list).", + Optional: true, + ElementType: types.Int64Type, + }, + "communities": schema.ListAttribute{ + Description: "Matches if the route carries any one of these BGP standard communities. Format is `asn:value` per RFC 1997.", + Optional: true, + ElementType: types.StringType, + Validators: []validator.List{ + listvalidator.ValueStringsAre( + stringvalidator.RegexMatches(regexp.MustCompile(`^\d+:\d+$`), "must be in the format \"asn:value\""), + ), + }, + }, + "first_asn": schema.Int64Attribute{ + Description: "Matches if the first ASN (immediate neighbor) in the AS-PATH equals this ASN.", + Optional: true, + }, + "max_prefix_length": schema.Int32Attribute{ + Description: "Maximum subnet mask length for matched prefixes.", + Optional: true, + Validators: []validator.Int32{ + int32validator.Between(0, 32), + }, + }, + "min_prefix_length": schema.Int32Attribute{ + Description: "Minimum subnet mask length for matched prefixes.", + Optional: true, + Validators: []validator.Int32{ + int32validator.Between(0, 32), + }, + }, + "peer": schema.StringAttribute{ + Description: "Matches the exact IPv4 address of the BGP neighbor that advertised the route.", + Optional: true, + Validators: []validator.String{ + validate.IP(false), + }, + }, + "prefixes": schema.ListAttribute{ + Description: "List of IPv4 networks to match. A route's prefix matches if it equals one of these (subject to min/max prefix length refinement).", + Optional: true, + ElementType: types.StringType, + Validators: []validator.List{ + listvalidator.ValueStringsAre(validate.CIDR()), + }, + }, + }, + }, + "set": schema.SingleNestedAttribute{ + Description: "Optional BGP attributes to apply when `action` is `PERMIT`. Ignored for `DENY` rules.", + Optional: true, + Attributes: map[string]schema.Attribute{ + "local_preference": schema.Int32Attribute{ + Description: "BGP LOCAL_PREF to set on the route. Higher values are preferred during best-path selection. Default BGP LOCAL_PREF is 100.", + Optional: true, + }, + }, + }, + }, + } +} + +func (r *bgpFilterRuleResource) 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 *bgpFilterRuleResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + idParts := strings.Split(req.ID, core.Separator) + + if len(idParts) != 5 || idParts[0] == "" || idParts[1] == "" || idParts[2] == "" || idParts[3] == "" || idParts[4] == "" { + core.LogAndAddError(ctx, &resp.Diagnostics, + "Error importing VPN BGP filter rule", + fmt.Sprintf("Expected import identifier with format: [project_id],[region],[gateway_id],[filter_id],[rule_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], + "filter_id": idParts[3], + "rule_id": idParts[4], + }) + tflog.Info(ctx, "VPN BGP filter rule state imported") +} + +func (r *bgpFilterRuleResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { // nolint:gocritic // function signature required by Terraform + var model Model + diags := req.Plan.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + ctx = core.InitProviderContext(ctx) + + projectId := model.ProjectId.ValueString() + gatewayId := model.GatewayId.ValueString() + filterId := model.FilterId.ValueString() + region := r.providerData.GetRegionWithOverride(model.Region) + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "gateway_id", gatewayId) + ctx = tflog.SetField(ctx, "filter_id", filterId) + ctx = tflog.SetField(ctx, "region", region) + + payload, err := toCreatePayload(&model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating VPN BGP filter rule", fmt.Sprintf("Creating API payload: %v", err)) + return + } + + createResp, err := r.client.DefaultAPI.CreateGatewayBGPFilterRule(ctx, projectId, region, gatewayId, filterId).CreateGatewayBGPFilterRulePayload(*payload).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating VPN BGP filter rule", fmt.Sprintf("Calling API: %v", err)) + return + } + + ctx = core.LogResponse(ctx) + + err = mapFields(ctx, createResp, &model, region) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating VPN BGP filter rule", 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 BGP filter rule created") +} + +func (r *bgpFilterRuleResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { // nolint:gocritic // function signature required by Terraform + var model Model + diags := req.State.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + ctx = core.InitProviderContext(ctx) + + projectId := model.ProjectId.ValueString() + gatewayId := model.GatewayId.ValueString() + filterId := model.FilterId.ValueString() + ruleId := model.RuleId.ValueString() + region := r.providerData.GetRegionWithOverride(model.Region) + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "gateway_id", gatewayId) + ctx = tflog.SetField(ctx, "filter_id", filterId) + ctx = tflog.SetField(ctx, "rule_id", ruleId) + ctx = tflog.SetField(ctx, "region", region) + + ruleResp, err := r.client.DefaultAPI.GetGatewayBGPFilterRule(ctx, projectId, region, gatewayId, filterId, ruleId).Execute() + if err != nil { + if oapiErr, ok := errors.AsType[*oapierror.GenericOpenAPIError](err); ok && oapiErr.StatusCode == http.StatusNotFound { + resp.State.RemoveResource(ctx) + return + } + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading VPN BGP filter rule", err.Error()) + return + } + + ctx = core.LogResponse(ctx) + + err = mapFields(ctx, ruleResp, &model, region) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading VPN BGP filter rule", 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 BGP filter rule read") +} + +func (r *bgpFilterRuleResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform + var model Model + diags := req.Plan.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + ctx = core.InitProviderContext(ctx) + + projectId := model.ProjectId.ValueString() + gatewayId := model.GatewayId.ValueString() + filterId := model.FilterId.ValueString() + ruleId := model.RuleId.ValueString() + region := r.providerData.GetRegionWithOverride(model.Region) + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "gateway_id", gatewayId) + ctx = tflog.SetField(ctx, "filter_id", filterId) + ctx = tflog.SetField(ctx, "rule_id", ruleId) + ctx = tflog.SetField(ctx, "region", region) + + payload, err := toUpdatePayload(&model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating VPN BGP filter rule", fmt.Sprintf("Creating API payload: %v", err)) + return + } + + updateResp, err := r.client.DefaultAPI.UpdateGatewayBGPFilterRule(ctx, projectId, region, gatewayId, filterId, ruleId).UpdateGatewayBGPFilterRulePayload(*payload).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating VPN BGP filter rule", err.Error()) + return + } + + ctx = core.LogResponse(ctx) + + err = mapFields(ctx, updateResp, &model, region) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating VPN BGP filter rule", 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 BGP filter rule updated") +} + +func (r *bgpFilterRuleResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { // nolint:gocritic // function signature required by Terraform + var model Model + diags := req.State.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + ctx = core.InitProviderContext(ctx) + + projectId := model.ProjectId.ValueString() + gatewayId := model.GatewayId.ValueString() + filterId := model.FilterId.ValueString() + ruleId := model.RuleId.ValueString() + region := r.providerData.GetRegionWithOverride(model.Region) + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "gateway_id", gatewayId) + ctx = tflog.SetField(ctx, "filter_id", filterId) + ctx = tflog.SetField(ctx, "rule_id", ruleId) + ctx = tflog.SetField(ctx, "region", region) + + err := r.client.DefaultAPI.DeleteGatewayBGPFilterRule(ctx, projectId, region, gatewayId, filterId, ruleId).Execute() + if err != nil { + if oapiErr, ok := errors.AsType[*oapierror.GenericOpenAPIError](err); ok && oapiErr.StatusCode == http.StatusNotFound { + resp.State.RemoveResource(ctx) + return + } + core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting VPN BGP filter rule", fmt.Sprintf("Calling API: %v", err)) + return + } + + ctx = core.LogResponse(ctx) + tflog.Info(ctx, "VPN BGP filter rule deleted") +} + +// matchPayload is implemented (via pointer receiver) by CreateGatewayBGPFilterRulePayloadMatch and +// UpdateGatewayBGPFilterRulePayloadMatch - the generated SDK creates a distinct match type per +// payload even though their shape is identical, so a shared interface lets fillMatchPayload work +// for both without duplicating the field-by-field conversion. +type matchPayload interface { + SetAsPathContainsAny([]int64) + SetCommunities([]string) + SetFirstASN(int64) + SetMaxPrefixLength(int32) + SetMinPrefixLength(int32) + SetPeer(string) + SetPrefixes([]string) +} + +// setPayload is implemented (via pointer receiver) by CreateGatewayBGPFilterRulePayloadSet and +// UpdateGatewayBGPFilterRulePayloadSet, mirroring matchPayload above. +type setPayload interface { + SetLocalPreference(int32) +} + +func fillMatchPayload(model *MatchModel, payload matchPayload) error { + if !tfutils.IsUndefined(model.AsPathContainsAny) { + asns, err := int64ListValueToSlice(model.AsPathContainsAny) + if err != nil { + return fmt.Errorf("converting match.as_path_contains_any: %w", err) + } + payload.SetAsPathContainsAny(asns) + } + + if !tfutils.IsUndefined(model.Communities) { + communities, err := tfutils.ListValueToStringSlice(model.Communities) + if err != nil { + return fmt.Errorf("converting match.communities: %w", err) + } + payload.SetCommunities(communities) + } + + if !tfutils.IsUndefined(model.FirstAsn) { + payload.SetFirstASN(model.FirstAsn.ValueInt64()) + } + + if !tfutils.IsUndefined(model.MaxPrefixLength) { + payload.SetMaxPrefixLength(model.MaxPrefixLength.ValueInt32()) + } + + if !tfutils.IsUndefined(model.MinPrefixLength) { + payload.SetMinPrefixLength(model.MinPrefixLength.ValueInt32()) + } + + if !tfutils.IsUndefined(model.Peer) { + payload.SetPeer(model.Peer.ValueString()) + } + + if !tfutils.IsUndefined(model.Prefixes) { + prefixes, err := tfutils.ListValueToStringSlice(model.Prefixes) + if err != nil { + return fmt.Errorf("converting match.prefixes: %w", err) + } + payload.SetPrefixes(prefixes) + } + + return nil +} + +func fillSetPayload(model *SetModel, payload setPayload) { + if !tfutils.IsUndefined(model.LocalPreference) { + payload.SetLocalPreference(model.LocalPreference.ValueInt32()) + } +} + +func int64ListValueToSlice(list types.List) ([]int64, error) { + result := []int64{} + for _, el := range list.Elements() { + elInt, ok := el.(types.Int64) + if !ok { + return result, fmt.Errorf("expected element to be of type %T, got %T", types.Int64{}, el) + } + result = append(result, elInt.ValueInt64()) + } + return result, nil +} + +func toCreatePayload(model *Model) (*vpn.CreateGatewayBGPFilterRulePayload, error) { + payload := &vpn.CreateGatewayBGPFilterRulePayload{ + Action: vpn.CreateGatewayBGPFilterRulePayloadAction(model.Action.ValueString()), + } + + // sequence is optional on create - if the user didn't set it, let the server auto-assign it. + if !tfutils.IsUndefined(model.Sequence) { + payload.Sequence = conversion.Int32ValueToPointer(model.Sequence) + } + + if model.Match != nil { + match := &vpn.CreateGatewayBGPFilterRulePayloadMatch{} + if err := fillMatchPayload(model.Match, match); err != nil { + return nil, err + } + payload.Match = match + } + + if model.Set != nil { + set := &vpn.CreateGatewayBGPFilterRulePayloadSet{} + fillSetPayload(model.Set, set) + payload.Set = set + } + + return payload, nil +} + +func toUpdatePayload(model *Model) (*vpn.UpdateGatewayBGPFilterRulePayload, error) { + payload := &vpn.UpdateGatewayBGPFilterRulePayload{ + Action: vpn.UpdateGatewayBGPFilterRulePayloadAction(model.Action.ValueString()), + // sequence is required on every PUT by the API - model.Sequence is Optional+Computed with + // UseStateForUnknown, so it always carries a known value by the time an update runs. + Sequence: conversion.Int32ValueToPointer(model.Sequence), + } + + if model.Match != nil { + match := &vpn.UpdateGatewayBGPFilterRulePayloadMatch{} + if err := fillMatchPayload(model.Match, match); err != nil { + return nil, err + } + payload.Match = match + } + + if model.Set != nil { + set := &vpn.UpdateGatewayBGPFilterRulePayloadSet{} + fillSetPayload(model.Set, set) + payload.Set = set + } + + return payload, nil +} + +func mapFields(ctx context.Context, rule *vpn.BGPFilterRule, model *Model, region string) error { + if rule == nil { + return fmt.Errorf("response input is nil") + } + if model == nil { + return fmt.Errorf("model input is nil") + } + + var ruleId string + if model.RuleId.ValueString() != "" { + ruleId = model.RuleId.ValueString() + } else if rule.Id != nil { + ruleId = *rule.Id + } else { + return fmt.Errorf("rule id not present") + } + + model.Id = tfutils.BuildInternalTerraformId(model.ProjectId.ValueString(), region, model.GatewayId.ValueString(), model.FilterId.ValueString(), ruleId) + model.RuleId = types.StringValue(ruleId) + model.Region = types.StringValue(region) + model.Action = types.StringValue(string(rule.Action)) + + model.Sequence = types.Int32Null() + if sequence, ok := rule.GetSequenceOk(); ok && sequence != nil { + model.Sequence = types.Int32Value(*sequence) + } + + model.Match = nil + if match, ok := rule.GetMatchOk(); ok && match != nil { + matchModel := &MatchModel{ + FirstAsn: types.Int64Null(), + Peer: types.StringNull(), + } + + matchModel.AsPathContainsAny = types.ListNull(types.Int64Type) + if asns, ok := match.GetAsPathContainsAnyOk(); ok && asns != nil { + listVal, diags := types.ListValueFrom(ctx, types.Int64Type, asns) + if diags.HasError() { + return fmt.Errorf("mapping match.as_path_contains_any: %w", core.DiagsToError(diags)) + } + matchModel.AsPathContainsAny = listVal + } + + matchModel.Communities = types.ListNull(types.StringType) + if communities, ok := match.GetCommunitiesOk(); ok && communities != nil { + listVal, diags := types.ListValueFrom(ctx, types.StringType, communities) + if diags.HasError() { + return fmt.Errorf("mapping match.communities: %w", core.DiagsToError(diags)) + } + matchModel.Communities = listVal + } + + if firstAsn, ok := match.GetFirstASNOk(); ok && firstAsn != nil { + matchModel.FirstAsn = types.Int64Value(*firstAsn) + } + + matchModel.MaxPrefixLength = types.Int32PointerValue(func() *int32 { v, _ := match.GetMaxPrefixLengthOk(); return v }()) + matchModel.MinPrefixLength = types.Int32PointerValue(func() *int32 { v, _ := match.GetMinPrefixLengthOk(); return v }()) + + if peer, ok := match.GetPeerOk(); ok && peer != nil { + matchModel.Peer = types.StringValue(*peer) + } + + matchModel.Prefixes = types.ListNull(types.StringType) + if prefixes, ok := match.GetPrefixesOk(); ok && prefixes != nil { + listVal, diags := types.ListValueFrom(ctx, types.StringType, prefixes) + if diags.HasError() { + return fmt.Errorf("mapping match.prefixes: %w", core.DiagsToError(diags)) + } + matchModel.Prefixes = listVal + } + + model.Match = matchModel + } + + model.Set = nil + if set, ok := rule.GetSetOk(); ok && set != nil { + setModel := &SetModel{} + setModel.LocalPreference = types.Int32PointerValue(func() *int32 { v, _ := set.GetLocalPreferenceOk(); return v }()) + model.Set = setModel + } + + return nil +} diff --git a/stackit/internal/services/vpn/bgp_filter_rule/resource_test.go b/stackit/internal/services/vpn/bgp_filter_rule/resource_test.go new file mode 100644 index 000000000..459d7e315 --- /dev/null +++ b/stackit/internal/services/vpn/bgp_filter_rule/resource_test.go @@ -0,0 +1,281 @@ +package bgpfilterrule + +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() + filterId = uuid.NewString() + region = "eu01" +) + +func TestMapFields(t *testing.T) { + tests := []struct { + description string + state Model + input *vpn.BGPFilterRule + expected Model + isValid bool + }{ + { + description: "minimal_rule", + state: Model{ + ProjectId: types.StringValue(projectId), + GatewayId: types.StringValue(gatewayId), + FilterId: types.StringValue(filterId), + }, + input: &vpn.BGPFilterRule{ + Id: new("rule-id"), + Action: vpn.BGPFILTERRULEACTION_PERMIT, + }, + expected: Model{ + Id: types.StringValue(fmt.Sprintf("%s,%s,%s,%s,%s", projectId, region, gatewayId, filterId, "rule-id")), + ProjectId: types.StringValue(projectId), + Region: types.StringValue(region), + GatewayId: types.StringValue(gatewayId), + FilterId: types.StringValue(filterId), + RuleId: types.StringValue("rule-id"), + Action: types.StringValue("PERMIT"), + Sequence: types.Int32Null(), + }, + isValid: true, + }, + { + description: "full_rule", + state: Model{ + ProjectId: types.StringValue(projectId), + GatewayId: types.StringValue(gatewayId), + FilterId: types.StringValue(filterId), + }, + input: &vpn.BGPFilterRule{ + Id: new("rule-id"), + Action: vpn.BGPFILTERRULEACTION_PERMIT, + Sequence: new(int32(10)), + Match: &vpn.BGPFilterRuleMatch{ + AsPathContainsAny: []int64{65001, 65002}, + Communities: []string{"65000:100"}, + FirstASN: new(int64(65001)), + MaxPrefixLength: new(int32(24)), + MinPrefixLength: new(int32(16)), + Peer: new("192.0.2.1"), + Prefixes: []string{"10.0.0.0/16"}, + }, + Set: &vpn.BGPFilterRuleSet{ + LocalPreference: new(int32(150)), + }, + }, + expected: Model{ + Id: types.StringValue(fmt.Sprintf("%s,%s,%s,%s,%s", projectId, region, gatewayId, filterId, "rule-id")), + ProjectId: types.StringValue(projectId), + Region: types.StringValue(region), + GatewayId: types.StringValue(gatewayId), + FilterId: types.StringValue(filterId), + RuleId: types.StringValue("rule-id"), + Action: types.StringValue("PERMIT"), + Sequence: types.Int32Value(10), + Match: &MatchModel{ + AsPathContainsAny: types.ListValueMust(types.Int64Type, []attr.Value{ + types.Int64Value(65001), + types.Int64Value(65002), + }), + Communities: types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("65000:100"), + }), + FirstAsn: types.Int64Value(65001), + MaxPrefixLength: types.Int32Value(24), + MinPrefixLength: types.Int32Value(16), + Peer: types.StringValue("192.0.2.1"), + Prefixes: types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("10.0.0.0/16"), + }), + }, + Set: &SetModel{ + LocalPreference: types.Int32Value(150), + }, + }, + isValid: true, + }, + { + description: "nil_response", + state: Model{}, + input: nil, + expected: Model{}, + isValid: false, + }, + { + description: "nil_rule_id", + state: Model{ + ProjectId: types.StringValue(projectId), + }, + input: &vpn.BGPFilterRule{ + Id: nil, + Action: vpn.BGPFILTERRULEACTION_DENY, + }, + expected: Model{}, + isValid: false, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + state := tt.state + 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.CreateGatewayBGPFilterRulePayload + }{ + { + description: "minimal - sequence omitted", + input: &Model{ + Action: types.StringValue("PERMIT"), + Sequence: types.Int32Null(), + }, + expected: &vpn.CreateGatewayBGPFilterRulePayload{ + Action: vpn.CREATEGATEWAYBGPFILTERRULEPAYLOADACTION_PERMIT, + }, + }, + { + description: "sequence set explicitly", + input: &Model{ + Action: types.StringValue("DENY"), + Sequence: types.Int32Value(20), + }, + expected: &vpn.CreateGatewayBGPFilterRulePayload{ + Action: vpn.CREATEGATEWAYBGPFILTERRULEPAYLOADACTION_DENY, + Sequence: new(int32(20)), + }, + }, + { + description: "with match and set", + input: &Model{ + Action: types.StringValue("PERMIT"), + Sequence: types.Int32Null(), + Match: &MatchModel{ + AsPathContainsAny: types.ListValueMust(types.Int64Type, []attr.Value{ + types.Int64Value(65001), + }), + Communities: types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("65000:100"), + }), + FirstAsn: types.Int64Value(65001), + MaxPrefixLength: types.Int32Value(24), + MinPrefixLength: types.Int32Value(16), + Peer: types.StringValue("192.0.2.1"), + Prefixes: types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("10.0.0.0/16"), + }), + }, + Set: &SetModel{ + LocalPreference: types.Int32Value(150), + }, + }, + expected: &vpn.CreateGatewayBGPFilterRulePayload{ + Action: vpn.CREATEGATEWAYBGPFILTERRULEPAYLOADACTION_PERMIT, + Match: &vpn.CreateGatewayBGPFilterRulePayloadMatch{ + AsPathContainsAny: []int64{65001}, + Communities: []string{"65000:100"}, + FirstASN: new(int64(65001)), + MaxPrefixLength: new(int32(24)), + MinPrefixLength: new(int32(16)), + Peer: new("192.0.2.1"), + Prefixes: []string{"10.0.0.0/16"}, + }, + Set: &vpn.CreateGatewayBGPFilterRulePayloadSet{ + LocalPreference: new(int32(150)), + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + payload, err := toCreatePayload(tt.input) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if diff := cmp.Diff(tt.expected, payload); 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.UpdateGatewayBGPFilterRulePayload + }{ + { + description: "sequence always sent even when unchanged", + input: &Model{ + Action: types.StringValue("PERMIT"), + Sequence: types.Int32Value(10), + }, + expected: &vpn.UpdateGatewayBGPFilterRulePayload{ + Action: vpn.UPDATEGATEWAYBGPFILTERRULEPAYLOADACTION_PERMIT, + Sequence: new(int32(10)), + }, + }, + { + description: "with match and set", + input: &Model{ + Action: types.StringValue("DENY"), + Sequence: types.Int32Value(30), + Match: &MatchModel{ + Peer: types.StringValue("192.0.2.2"), + }, + Set: &SetModel{ + LocalPreference: types.Int32Value(200), + }, + }, + expected: &vpn.UpdateGatewayBGPFilterRulePayload{ + Action: vpn.UPDATEGATEWAYBGPFILTERRULEPAYLOADACTION_DENY, + Sequence: new(int32(30)), + Match: &vpn.UpdateGatewayBGPFilterRulePayloadMatch{ + Peer: new("192.0.2.2"), + }, + Set: &vpn.UpdateGatewayBGPFilterRulePayloadSet{ + LocalPreference: new(int32(200)), + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + payload, err := toUpdatePayload(tt.input) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if diff := cmp.Diff(tt.expected, payload); diff != "" { + t.Fatalf("Data does not match (-want +got):\n%s", diff) + } + }) + } +} diff --git a/stackit/internal/services/vpn/connection/datasource.go b/stackit/internal/services/vpn/connection/datasource.go index 9473c84c6..bf3a5ab74 100644 --- a/stackit/internal/services/vpn/connection/datasource.go +++ b/stackit/internal/services/vpn/connection/datasource.go @@ -157,6 +157,10 @@ func (d *vpnConnectionDataSource) Schema(_ context.Context, _ datasource.SchemaR Description: "Remote AS number.", Computed: true, }, + "inbound_filter_id": schema.StringAttribute{ + Description: "UUID of the `stackit_vpn_bgp_filter` applied for inbound route filtering on this tunnel's BGP peering session, if any.", + Computed: true, + }, }, }, }, diff --git a/stackit/internal/services/vpn/connection/resource.go b/stackit/internal/services/vpn/connection/resource.go index 2b319e3f0..4877449a5 100644 --- a/stackit/internal/services/vpn/connection/resource.go +++ b/stackit/internal/services/vpn/connection/resource.go @@ -68,7 +68,8 @@ type PeeringConfigModel struct { } type BGPTunnelConfigModel struct { - RemoteAsn types.Int64 `tfsdk:"remote_asn"` + RemoteAsn types.Int64 `tfsdk:"remote_asn"` + InboundFilterId types.String `tfsdk:"inbound_filter_id"` } type TunnelModel struct { @@ -353,6 +354,14 @@ func (r *vpnConnectionResource) Schema(_ context.Context, _ resource.SchemaReque int64validator.Between(64512, 4294967294), }, }, + "inbound_filter_id": schema.StringAttribute{ + Description: "UUID of a `stackit_vpn_bgp_filter` to apply for inbound route filtering on this tunnel's BGP peering session. If omitted, no inbound filtering is applied.", + Optional: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, }, }, }, @@ -761,6 +770,19 @@ func toCreatePayload(ctx context.Context, planModel, configModel *Model) (*vpn.C payload.Tunnel1.PreSharedKey = getPresharedKey(planModel.Tunnel1, configModel.Tunnel1) payload.Tunnel2.PreSharedKey = getPresharedKey(planModel.Tunnel2, configModel.Tunnel2) + // inline function to allow re-using the logic for tunnel1 & tunnel2 without being too confusing. + // On create there is no prior state to compare against - either the value is set, or it's left + // untouched (omitted from the payload). + setInboundFilterId := func(planTunnelModel *TunnelModel, bgpPayload *vpn.BGPTunnelConfig) { + if bgpPayload == nil || planTunnelModel.Bgp == nil || tfutils.IsUndefined(planTunnelModel.Bgp.InboundFilterId) { + return + } + bgpPayload.SetInboundFilterId(planTunnelModel.Bgp.InboundFilterId.ValueString()) + } + + setInboundFilterId(planModel.Tunnel1, payload.Tunnel1.Bgp) + setInboundFilterId(planModel.Tunnel2, payload.Tunnel2.Bgp) + return payload, nil } @@ -807,6 +829,26 @@ func toUpdatePayload(ctx context.Context, planModel, stateModel, configModel *Mo payload.Tunnel1.PreSharedKey = getPresharedKey(planModel.Tunnel1, stateModel.Tunnel1, configModel.Tunnel1) payload.Tunnel2.PreSharedKey = getPresharedKey(planModel.Tunnel2, stateModel.Tunnel2, configModel.Tunnel2) + // inline function to allow re-using the logic for tunnel1 & tunnel2 without being too confusing. + // inbound_filter_id is nullable server-side: if the plan has a value, send it; if the plan has no + // value but state previously had one, the user removed it - send an explicit null to clear it; + // otherwise it was never set, so leave the payload field untouched (omitted). + setInboundFilterId := func(planTunnelModel, stateTunnelModel *TunnelModel, bgpPayload *vpn.BGPTunnelConfig) { + if bgpPayload == nil { + return + } + if planTunnelModel.Bgp != nil && !tfutils.IsUndefined(planTunnelModel.Bgp.InboundFilterId) { + bgpPayload.SetInboundFilterId(planTunnelModel.Bgp.InboundFilterId.ValueString()) + return + } + if stateTunnelModel.Bgp != nil && !tfutils.IsUndefined(stateTunnelModel.Bgp.InboundFilterId) { + bgpPayload.SetInboundFilterIdNil() + } + } + + setInboundFilterId(planModel.Tunnel1, stateModel.Tunnel1, payload.Tunnel1.Bgp) + setInboundFilterId(planModel.Tunnel2, stateModel.Tunnel2, payload.Tunnel2.Bgp) + return payload, nil } @@ -1082,8 +1124,10 @@ func mapTunnel(ctx context.Context, apiTunnel *vpn.TunnelConfiguration, tfTunnel tfTunnel.Bgp = nil if apiTunnel.Bgp != nil { + inboundFilterId, _ := apiTunnel.Bgp.GetInboundFilterIdOk() tfTunnel.Bgp = &BGPTunnelConfigModel{ - RemoteAsn: types.Int64Value(apiTunnel.Bgp.RemoteAsn), + RemoteAsn: types.Int64Value(apiTunnel.Bgp.RemoteAsn), + InboundFilterId: types.StringPointerValue(inboundFilterId), } } diff --git a/stackit/internal/services/vpn/connection/resource_test.go b/stackit/internal/services/vpn/connection/resource_test.go index 309f0e440..65ded6410 100644 --- a/stackit/internal/services/vpn/connection/resource_test.go +++ b/stackit/internal/services/vpn/connection/resource_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/types" @@ -348,6 +349,36 @@ func TestMapFields(t *testing.T) { }), isValid: true, }, + { + description: "bgp_with_inbound_filter_id", + input: fixtureConnectionResponse(func(m *vpn.ConnectionResponse) { + bgp := &vpn.BGPTunnelConfig{RemoteAsn: 65000} + bgp.SetInboundFilterId("filter-id") + m.Tunnel1.Bgp = bgp + }), + expected: fixtureModel(func(m *Model) { + m.Tunnel1.Bgp = &BGPTunnelConfigModel{ + RemoteAsn: types.Int64Value(65000), + InboundFilterId: types.StringValue("filter-id"), + } + }), + isValid: true, + }, + { + description: "bgp_with_explicit_null_inbound_filter_id", + input: fixtureConnectionResponse(func(m *vpn.ConnectionResponse) { + bgp := &vpn.BGPTunnelConfig{RemoteAsn: 65000} + bgp.SetInboundFilterIdNil() + m.Tunnel1.Bgp = bgp + }), + expected: fixtureModel(func(m *Model) { + m.Tunnel1.Bgp = &BGPTunnelConfigModel{ + RemoteAsn: types.Int64Value(65000), + InboundFilterId: types.StringNull(), + } + }), + isValid: true, + }, { description: "nil_response", input: nil, @@ -516,6 +547,63 @@ func TestToCreatePayload(t *testing.T) { }), isValid: true, }, + { + description: "inbound_filter_id set on create", + args: args{ + planModel: new(fixtureModel(func(m *Model) { + m.Tunnel1.PreSharedKey = types.StringNull() + m.Tunnel1.PreSharedKeyWo = types.StringNull() + m.Tunnel1.PreSharedKeyWoVersion = types.Int64Null() + m.Tunnel1.Bgp = &BGPTunnelConfigModel{ + RemoteAsn: types.Int64Value(65000), + InboundFilterId: types.StringValue("filter-id"), + } + + 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 + bgp := &vpn.BGPTunnelConfig{RemoteAsn: 65000} + bgp.SetInboundFilterId("filter-id") + m.Tunnel1.Bgp = bgp + }), + isValid: true, + }, + { + description: "inbound_filter_id omitted on create leaves it unset", + args: args{ + planModel: new(fixtureModel(func(m *Model) { + m.Tunnel1.PreSharedKey = types.StringNull() + m.Tunnel1.PreSharedKeyWo = types.StringNull() + m.Tunnel1.PreSharedKeyWoVersion = types.Int64Null() + m.Tunnel1.Bgp = &BGPTunnelConfigModel{ + RemoteAsn: types.Int64Value(65000), + } + + 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 + m.Tunnel1.Bgp = &vpn.BGPTunnelConfig{RemoteAsn: 65000} + }), + isValid: true, + }, { description: "minimal_create", args: args{ @@ -563,7 +651,7 @@ func TestToCreatePayload(t *testing.T) { t.Fatalf("Should not have failed: %v", err) } if tt.isValid { - diff := cmp.Diff(tt.expected, payload) + diff := cmp.Diff(tt.expected, payload, cmpopts.IgnoreUnexported(vpn.NullableString{})) if diff != "" { t.Fatalf("Data does not match (-want +got):\n%s", diff) } @@ -751,6 +839,125 @@ func TestToUpdatePayload(t *testing.T) { }), isValid: true, }, + { + description: "inbound_filter_id unchanged on update", + args: args{ + planModel: new(fixtureModel(func(m *Model) { + m.Tunnel1.PreSharedKey = types.StringNull() + m.Tunnel1.PreSharedKeyWo = types.StringNull() + m.Tunnel1.PreSharedKeyWoVersion = types.Int64Null() + m.Tunnel1.Bgp = &BGPTunnelConfigModel{ + RemoteAsn: types.Int64Value(65000), + InboundFilterId: types.StringValue("filter-id"), + } + + m.Tunnel2.PreSharedKey = types.StringNull() + m.Tunnel2.PreSharedKeyWo = types.StringNull() + m.Tunnel2.PreSharedKeyWoVersion = types.Int64Null() + })), + stateModel: &Model{ + Tunnel1: &TunnelModel{ + DataSourceTunnelModel: DataSourceTunnelModel{ + Bgp: &BGPTunnelConfigModel{ + RemoteAsn: types.Int64Value(65000), + InboundFilterId: types.StringValue("filter-id"), + }, + }, + }, + Tunnel2: &TunnelModel{}, + }, + configModel: &Model{ + Tunnel1: &TunnelModel{}, + Tunnel2: &TunnelModel{}, + }, + }, + expected: fixtureUpdatePayload(func(m *vpn.UpdateGatewayConnectionPayload) { + m.Tunnel1.PreSharedKey = nil + m.Tunnel2.PreSharedKey = nil + bgp := &vpn.BGPTunnelConfig{RemoteAsn: 65000} + bgp.SetInboundFilterId("filter-id") + m.Tunnel1.Bgp = bgp + }), + isValid: true, + }, + { + description: "inbound_filter_id cleared on update", + args: args{ + planModel: new(fixtureModel(func(m *Model) { + m.Tunnel1.PreSharedKey = types.StringNull() + m.Tunnel1.PreSharedKeyWo = types.StringNull() + m.Tunnel1.PreSharedKeyWoVersion = types.Int64Null() + m.Tunnel1.Bgp = &BGPTunnelConfigModel{ + RemoteAsn: types.Int64Value(65000), + InboundFilterId: types.StringNull(), + } + + m.Tunnel2.PreSharedKey = types.StringNull() + m.Tunnel2.PreSharedKeyWo = types.StringNull() + m.Tunnel2.PreSharedKeyWoVersion = types.Int64Null() + })), + stateModel: &Model{ + Tunnel1: &TunnelModel{ + DataSourceTunnelModel: DataSourceTunnelModel{ + Bgp: &BGPTunnelConfigModel{ + RemoteAsn: types.Int64Value(65000), + InboundFilterId: types.StringValue("filter-id"), + }, + }, + }, + Tunnel2: &TunnelModel{}, + }, + configModel: &Model{ + Tunnel1: &TunnelModel{}, + Tunnel2: &TunnelModel{}, + }, + }, + expected: fixtureUpdatePayload(func(m *vpn.UpdateGatewayConnectionPayload) { + m.Tunnel1.PreSharedKey = nil + m.Tunnel2.PreSharedKey = nil + bgp := &vpn.BGPTunnelConfig{RemoteAsn: 65000} + bgp.SetInboundFilterIdNil() + m.Tunnel1.Bgp = bgp + }), + isValid: true, + }, + { + description: "inbound_filter_id never set stays untouched on update", + args: args{ + planModel: new(fixtureModel(func(m *Model) { + m.Tunnel1.PreSharedKey = types.StringNull() + m.Tunnel1.PreSharedKeyWo = types.StringNull() + m.Tunnel1.PreSharedKeyWoVersion = types.Int64Null() + m.Tunnel1.Bgp = &BGPTunnelConfigModel{ + RemoteAsn: types.Int64Value(65000), + } + + m.Tunnel2.PreSharedKey = types.StringNull() + m.Tunnel2.PreSharedKeyWo = types.StringNull() + m.Tunnel2.PreSharedKeyWoVersion = types.Int64Null() + })), + stateModel: &Model{ + Tunnel1: &TunnelModel{ + DataSourceTunnelModel: DataSourceTunnelModel{ + Bgp: &BGPTunnelConfigModel{ + RemoteAsn: types.Int64Value(65000), + }, + }, + }, + Tunnel2: &TunnelModel{}, + }, + configModel: &Model{ + Tunnel1: &TunnelModel{}, + Tunnel2: &TunnelModel{}, + }, + }, + expected: fixtureUpdatePayload(func(m *vpn.UpdateGatewayConnectionPayload) { + m.Tunnel1.PreSharedKey = nil + m.Tunnel2.PreSharedKey = nil + m.Tunnel1.Bgp = &vpn.BGPTunnelConfig{RemoteAsn: 65000} + }), + isValid: true, + }, { description: "minimal_update", args: args{ @@ -837,7 +1044,7 @@ func TestToUpdatePayload(t *testing.T) { t.Fatalf("Should not have failed: %v", err) } if tt.isValid { - diff := cmp.Diff(tt.expected, payload) + diff := cmp.Diff(tt.expected, payload, cmpopts.IgnoreUnexported(vpn.NullableString{})) if diff != "" { t.Fatalf("Data does not match (-want +got):\n%s", diff) } diff --git a/stackit/internal/services/vpn/gateway/datasource.go b/stackit/internal/services/vpn/gateway/datasource.go index eebfd3904..4da180264 100644 --- a/stackit/internal/services/vpn/gateway/datasource.go +++ b/stackit/internal/services/vpn/gateway/datasource.go @@ -123,6 +123,21 @@ func (d *vpnGatewayDataSource) Schema(_ context.Context, _ datasource.SchemaRequ }, }, }, + "network_config": schema.SingleNestedAttribute{ + Description: schemaDescriptions["network_config"], + Computed: true, + Attributes: map[string]schema.Attribute{ + "predefined_network_prefix": schema.ListAttribute{ + Description: schemaDescriptions["network_config_predefined_network_prefix"], + Computed: true, + ElementType: types.StringType, + }, + "routing_table_id": schema.StringAttribute{ + Description: schemaDescriptions["network_config_routing_table_id"], + Computed: true, + }, + }, + }, "labels": schema.MapAttribute{ Description: schemaDescriptions["labels"], Computed: true, diff --git a/stackit/internal/services/vpn/gateway/resource.go b/stackit/internal/services/vpn/gateway/resource.go index 8a93bbef9..97174537f 100644 --- a/stackit/internal/services/vpn/gateway/resource.go +++ b/stackit/internal/services/vpn/gateway/resource.go @@ -16,6 +16,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/resource/schema/listdefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/listplanmodifier" "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" @@ -54,6 +55,11 @@ type BGPGatewayConfigModel struct { OverrideAdvertisedRoutes types.List `tfsdk:"override_advertised_routes"` } +type NetworkConfigModel struct { + PredefinedNetworkPrefix types.List `tfsdk:"predefined_network_prefix"` + RoutingTableId types.String `tfsdk:"routing_table_id"` +} + type Model struct { Id types.String `tfsdk:"id"` // needed by TF GatewayId types.String `tfsdk:"gateway_id"` @@ -64,6 +70,7 @@ type Model struct { RoutingType types.String `tfsdk:"routing_type"` AvailabilityZones *AvailabilityZonesModel `tfsdk:"availability_zones"` Bgp *BGPGatewayConfigModel `tfsdk:"bgp"` + NetworkConfig *NetworkConfigModel `tfsdk:"network_config"` Labels types.Map `tfsdk:"labels"` } @@ -81,7 +88,10 @@ var schemaDescriptions = map[string]string{ "bgp": fmt.Sprintf("BGP configuration. Only applicable when routing_type is %s.", vpn.ROUTINGTYPE_BGP_ROUTE_BASED), "bgp_local_asn": "Local ASN for BGP (private ASN range, 64512-4294967294).", "bgp_override_advertised_routes": "List of IPv4 CIDRs to advertise via BGP. If omitted, SNA network ranges are advertised.", - "labels": "Map of custom labels (key-value string pairs).", + "network_config": "Network configuration for the VPN gateway.", + "network_config_predefined_network_prefix": "The IPv4 network prefix (CIDR notation) allocated for the VPN gateway. Must have a prefix length of /28 or larger. Cannot be changed after the gateway is created.", + "network_config_routing_table_id": "Custom routing table ID for the VPN gateway. If omitted, a default routing table is assigned.", + "labels": "Map of custom labels (key-value string pairs).", } type gatewayResource struct { @@ -215,6 +225,35 @@ func (r *gatewayResource) Schema(_ context.Context, _ resource.SchemaRequest, re }, }, }, + "network_config": schema.SingleNestedAttribute{ + Description: schemaDescriptions["network_config"], + Optional: true, + Attributes: map[string]schema.Attribute{ + "predefined_network_prefix": schema.ListAttribute{ + Description: schemaDescriptions["network_config_predefined_network_prefix"], + Optional: true, + ElementType: types.StringType, + Validators: []validator.List{ + listvalidator.ValueStringsAre(validate.CIDR()), + }, + PlanModifiers: []planmodifier.List{ + listplanmodifier.RequiresReplace(), + }, + }, + "routing_table_id": schema.StringAttribute{ + Description: schemaDescriptions["network_config_routing_table_id"], + Optional: true, + Computed: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + }, + }, "labels": schema.MapAttribute{ Description: schemaDescriptions["labels"], Optional: true, @@ -525,6 +564,14 @@ func toCreatePayload(ctx context.Context, model *Model) (*vpn.CreateGatewayPaylo payload.Bgp = bgpConfig } + if model.NetworkConfig != nil { + networkConfig, err := toNetworkConfigPayload(model.NetworkConfig) + if err != nil { + return nil, err + } + payload.NetworkConfig = networkConfig + } + labels, err := tfutils.LabelsToPayload(ctx, model.Labels) if err != nil { return nil, err @@ -566,6 +613,14 @@ func toUpdatePayload(ctx context.Context, model *Model) (*vpn.UpdateGatewayPaylo payload.Bgp = bgpConfig } + if model.NetworkConfig != nil { + networkConfig, err := toNetworkConfigPayload(model.NetworkConfig) + if err != nil { + return nil, err + } + payload.NetworkConfig = networkConfig + } + labels, err := tfutils.LabelsToPayload(ctx, model.Labels) if err != nil { return nil, err @@ -575,6 +630,24 @@ func toUpdatePayload(ctx context.Context, model *Model) (*vpn.UpdateGatewayPaylo return payload, nil } +func toNetworkConfigPayload(model *NetworkConfigModel) (*vpn.NetworkConfig, error) { + networkConfig := &vpn.NetworkConfig{} + + if !tfutils.IsUndefined(model.PredefinedNetworkPrefix) { + prefixes, err := tfutils.ListValueToStringSlice(model.PredefinedNetworkPrefix) + if err != nil { + return nil, fmt.Errorf("converting network_config.predefined_network_prefix: %w", err) + } + networkConfig.PredefinedNetworkPrefix = prefixes + } + + if !tfutils.IsUndefined(model.RoutingTableId) { + networkConfig.RoutingTableId = model.RoutingTableId.ValueStringPointer() + } + + return networkConfig, nil +} + func mapFields(ctx context.Context, gateway *vpn.GatewayResponse, model *Model, region string) error { if gateway == nil { return fmt.Errorf("response input is nil") @@ -617,6 +690,24 @@ func mapFields(ctx context.Context, gateway *vpn.GatewayResponse, model *Model, model.Bgp = bgpModel } + model.NetworkConfig = nil + if gateway.NetworkConfig != nil { + networkConfigModel := &NetworkConfigModel{ + RoutingTableId: types.StringPointerValue(gateway.NetworkConfig.RoutingTableId), + } + + networkConfigModel.PredefinedNetworkPrefix = types.ListNull(types.StringType) + if gateway.NetworkConfig.PredefinedNetworkPrefix != nil { + listVal, diags := types.ListValueFrom(ctx, types.StringType, gateway.NetworkConfig.PredefinedNetworkPrefix) + if diags.HasError() { + return fmt.Errorf("mapping network_config.predefined_network_prefix: %w", core.DiagsToError(diags)) + } + networkConfigModel.PredefinedNetworkPrefix = listVal + } + + model.NetworkConfig = networkConfigModel + } + labels, err := tfutils.MapLabels(ctx, gateway.Labels, model.Labels) if err != nil { return fmt.Errorf("mapping labels: %w", err) diff --git a/stackit/internal/services/vpn/gateway/resource_test.go b/stackit/internal/services/vpn/gateway/resource_test.go index 54571eb9d..5a6eab2d5 100644 --- a/stackit/internal/services/vpn/gateway/resource_test.go +++ b/stackit/internal/services/vpn/gateway/resource_test.go @@ -159,6 +159,91 @@ func TestMapFields(t *testing.T) { }, isValid: true, }, + { + description: "with_network_config", + args: args{ + state: Model{ + ProjectId: types.StringValue(projectId), + }, + input: &vpn.GatewayResponse{ + Id: new("gateway-id"), + DisplayName: "test-gateway", + PlanId: "p500", + RoutingType: vpn.ROUTINGTYPE_ROUTE_BASED, + AvailabilityZones: vpn.GatewayAvailabilityZones{ + Tunnel1: "eu01-1", + Tunnel2: "eu01-2", + }, + NetworkConfig: &vpn.NetworkConfig{ + PredefinedNetworkPrefix: []string{"10.20.0.0/28"}, + RoutingTableId: new("routing-table-id"), + }, + }, + }, + expected: Model{ + Id: types.StringValue(fmt.Sprintf("%s,%s,%s", projectId, region, "gateway-id")), + ProjectId: types.StringValue(projectId), + Region: types.StringValue(region), + GatewayId: types.StringValue("gateway-id"), + DisplayName: types.StringValue("test-gateway"), + PlanId: types.StringValue("p500"), + RoutingType: types.StringValue("ROUTE_BASED"), + AvailabilityZones: &AvailabilityZonesModel{ + Tunnel1: types.StringValue("eu01-1"), + Tunnel2: types.StringValue("eu01-2"), + }, + NetworkConfig: &NetworkConfigModel{ + PredefinedNetworkPrefix: types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("10.20.0.0/28"), + }), + RoutingTableId: types.StringValue("routing-table-id"), + }, + Labels: types.MapNull(types.StringType), + }, + isValid: true, + }, + { + description: "network_config_without_routing_table_id", + args: args{ + state: Model{ + ProjectId: types.StringValue(projectId), + }, + input: &vpn.GatewayResponse{ + Id: new("gateway-id"), + DisplayName: "test-gateway", + PlanId: "p500", + RoutingType: vpn.ROUTINGTYPE_ROUTE_BASED, + AvailabilityZones: vpn.GatewayAvailabilityZones{ + Tunnel1: "eu01-1", + Tunnel2: "eu01-2", + }, + NetworkConfig: &vpn.NetworkConfig{ + PredefinedNetworkPrefix: []string{"10.20.0.0/28"}, + }, + }, + }, + expected: Model{ + Id: types.StringValue(fmt.Sprintf("%s,%s,%s", projectId, region, "gateway-id")), + ProjectId: types.StringValue(projectId), + Region: types.StringValue(region), + GatewayId: types.StringValue("gateway-id"), + DisplayName: types.StringValue("test-gateway"), + PlanId: types.StringValue("p500"), + RoutingType: types.StringValue("ROUTE_BASED"), + AvailabilityZones: &AvailabilityZonesModel{ + Tunnel1: types.StringValue("eu01-1"), + Tunnel2: types.StringValue("eu01-2"), + }, + NetworkConfig: &NetworkConfigModel{ + PredefinedNetworkPrefix: types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("10.20.0.0/28"), + }), + RoutingTableId: types.StringNull(), + }, + Labels: types.MapNull(types.StringType), + }, + isValid: true, + }, { description: "nil_response", args: args{ @@ -268,6 +353,39 @@ func TestToCreatePayload(t *testing.T) { }, isValid: true, }, + { + description: "with_network_config", + input: &Model{ + DisplayName: types.StringValue("test-gateway"), + PlanId: types.StringValue("p500"), + RoutingType: types.StringValue("ROUTE_BASED"), + AvailabilityZones: &AvailabilityZonesModel{ + Tunnel1: types.StringValue("eu01-1"), + Tunnel2: types.StringValue("eu01-2"), + }, + NetworkConfig: &NetworkConfigModel{ + PredefinedNetworkPrefix: types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("10.20.0.0/28"), + }), + RoutingTableId: types.StringValue("routing-table-id"), + }, + }, + expected: &vpn.CreateGatewayPayload{ + DisplayName: "test-gateway", + PlanId: "p500", + RoutingType: vpn.RoutingType("ROUTE_BASED"), + AvailabilityZones: vpn.CreateGatewayPayloadAvailabilityZones{ + Tunnel1: "eu01-1", + Tunnel2: "eu01-2", + }, + NetworkConfig: &vpn.NetworkConfig{ + PredefinedNetworkPrefix: []string{"10.20.0.0/28"}, + RoutingTableId: new("routing-table-id"), + }, + Labels: &map[string]string{}, + }, + isValid: true, + }, { description: "nil_model", input: nil, @@ -359,6 +477,33 @@ func TestToUpdatePayload(t *testing.T) { }, isValid: true, }, + { + description: "with_network_config", + input: &Model{ + DisplayName: types.StringValue("test-gateway"), + PlanId: types.StringValue("p500"), + AvailabilityZones: &AvailabilityZonesModel{ + Tunnel1: types.StringValue("eu01-1"), + Tunnel2: types.StringValue("eu01-2"), + }, + NetworkConfig: &NetworkConfigModel{ + RoutingTableId: types.StringValue("routing-table-id"), + }, + }, + expected: &vpn.UpdateGatewayPayload{ + DisplayName: "test-gateway", + PlanId: "p500", + AvailabilityZones: vpn.UpdateGatewayPayloadAvailabilityZones{ + Tunnel1: "eu01-1", + Tunnel2: "eu01-2", + }, + NetworkConfig: &vpn.NetworkConfig{ + RoutingTableId: new("routing-table-id"), + }, + Labels: &map[string]string{}, + }, + isValid: true, + }, { description: "nil_model", input: nil, diff --git a/stackit/internal/services/vpn/testdata/bgp-filter-rule.tf b/stackit/internal/services/vpn/testdata/bgp-filter-rule.tf new file mode 100644 index 000000000..880cd5a8e --- /dev/null +++ b/stackit/internal/services/vpn/testdata/bgp-filter-rule.tf @@ -0,0 +1,21 @@ +variable "rule_action" {} +variable "rule_peer" {} +variable "rule_prefix" {} +variable "rule_local_preference" {} + +resource "stackit_vpn_bgp_filter_rule" "rule" { + project_id = stackit_vpn_bgp_filter.filter.project_id + region = stackit_vpn_bgp_filter.filter.region + gateway_id = stackit_vpn_bgp_filter.filter.gateway_id + filter_id = stackit_vpn_bgp_filter.filter.filter_id + action = var.rule_action + + match = { + peer = var.rule_peer + prefixes = [var.rule_prefix] + } + + set = { + local_preference = var.rule_local_preference + } +} diff --git a/stackit/internal/services/vpn/testdata/bgp-filter.tf b/stackit/internal/services/vpn/testdata/bgp-filter.tf new file mode 100644 index 000000000..5ca7c4d4c --- /dev/null +++ b/stackit/internal/services/vpn/testdata/bgp-filter.tf @@ -0,0 +1,8 @@ +variable "filter_display_name" {} + +resource "stackit_vpn_bgp_filter" "filter" { + 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.filter_display_name +} diff --git a/stackit/internal/services/vpn/testdata/gateway-max.tf b/stackit/internal/services/vpn/testdata/gateway-max.tf index 486122d01..2afe4a0e2 100644 --- a/stackit/internal/services/vpn/testdata/gateway-max.tf +++ b/stackit/internal/services/vpn/testdata/gateway-max.tf @@ -9,6 +9,7 @@ variable "local_asn" {} variable "override_advertised_routes" {} variable "label_key" {} variable "label_value" {} +variable "network_config_prefix" {} resource "stackit_vpn_gateway" "gateway" { project_id = var.project_id @@ -27,6 +28,10 @@ resource "stackit_vpn_gateway" "gateway" { override_advertised_routes = var.override_advertised_routes } + network_config = { + predefined_network_prefix = [var.network_config_prefix] + } + labels = var.label_key == "" ? {} : { (var.label_key) = var.label_value } diff --git a/stackit/internal/services/vpn/vpn_acc_test.go b/stackit/internal/services/vpn/vpn_acc_test.go index 18946237d..f579f4d4e 100644 --- a/stackit/internal/services/vpn/vpn_acc_test.go +++ b/stackit/internal/services/vpn/vpn_acc_test.go @@ -34,6 +34,12 @@ var connectionMinConfig string //go:embed testdata/connection-max.tf var connectionMaxConfig string +//go:embed testdata/bgp-filter.tf +var bgpFilterConfig string + +//go:embed testdata/bgp-filter-rule.tf +var bgpFilterRuleConfig string + var gatewayMinVars = config.Variables{ "project_id": config.StringVariable(testutil.ProjectId), "display_name": config.StringVariable("vpn-gw-acc-test-" + acctest.RandStringFromCharSet(8, acctest.CharSetAlpha)), @@ -63,6 +69,7 @@ var gatewayMaxVars = config.Variables{ "override_advertised_routes": config.ListVariable(config.StringVariable("10.0.0.0/16"), config.StringVariable("192.168.0.0/24")), "label_key": config.StringVariable("env"), "label_value": config.StringVariable("test"), + "network_config_prefix": config.StringVariable("10.20.0.0/28"), } var gatewayMaxVarsUpdated = func() config.Variables { @@ -145,6 +152,39 @@ var connectionMaxVarsPskRotated = func() config.Variables { return rotated }() +var bgpFilterVars = func() config.Variables { + vars := make(config.Variables, len(gatewayMinVars)+1) + maps.Copy(vars, gatewayMinVars) + vars["filter_display_name"] = config.StringVariable("vpn-bgp-filter-acc-test-" + acctest.RandStringFromCharSet(8, acctest.CharSetAlpha)) + return vars +}() + +var bgpFilterVarsUpdated = func() config.Variables { + updated := make(config.Variables, len(bgpFilterVars)) + maps.Copy(updated, bgpFilterVars) + updated["filter_display_name"] = config.StringVariable("vpn-bgp-filter-acc-test-updated-" + acctest.RandStringFromCharSet(8, acctest.CharSetAlpha)) + return updated +}() + +var bgpFilterRuleVars = func() config.Variables { + vars := make(config.Variables, len(bgpFilterVars)+4) + maps.Copy(vars, bgpFilterVars) + vars["rule_action"] = config.StringVariable("PERMIT") + vars["rule_peer"] = config.StringVariable("192.0.2.1") + vars["rule_prefix"] = config.StringVariable("10.0.0.0/16") + vars["rule_local_preference"] = config.IntegerVariable(150) + return vars +}() + +var bgpFilterRuleVarsUpdated = func() config.Variables { + updated := make(config.Variables, len(bgpFilterRuleVars)) + maps.Copy(updated, bgpFilterRuleVars) + // keep action=PERMIT so local_preference is not silently ignored/dropped server-side + updated["rule_prefix"] = config.StringVariable("172.16.0.0/12") + updated["rule_local_preference"] = config.IntegerVariable(200) + return updated +}() + func TestAccVpnGatewayResourceMin(t *testing.T) { resource.Test(t, resource.TestCase{ ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, @@ -286,6 +326,8 @@ func TestAccVpnGatewayResourceMax(t *testing.T) { resource.TestCheckResourceAttr("stackit_vpn_gateway.gateway", "bgp.local_asn", testutil.ConvertConfigVariable(gatewayMaxVars["local_asn"])), testutil.CheckListAttr("stackit_vpn_gateway.gateway", "bgp.override_advertised_routes", gatewayMaxVars["override_advertised_routes"]), resource.TestCheckResourceAttr("stackit_vpn_gateway.gateway", "labels."+testutil.ConvertConfigVariable(gatewayMaxVars["label_key"]), testutil.ConvertConfigVariable(gatewayMaxVars["label_value"])), + resource.TestCheckResourceAttr("stackit_vpn_gateway.gateway", "network_config.predefined_network_prefix.0", testutil.ConvertConfigVariable(gatewayMaxVars["network_config_prefix"])), + resource.TestCheckResourceAttrSet("stackit_vpn_gateway.gateway", "network_config.routing_table_id"), resource.TestCheckResourceAttrSet("stackit_vpn_gateway.gateway", "gateway_id"), ), }, @@ -313,6 +355,7 @@ func TestAccVpnGatewayResourceMax(t *testing.T) { resource.TestCheckResourceAttr("data.stackit_vpn_gateway.gateway", "bgp.local_asn", testutil.ConvertConfigVariable(gatewayMaxVars["local_asn"])), testutil.CheckListAttr("data.stackit_vpn_gateway.gateway", "bgp.override_advertised_routes", gatewayMaxVars["override_advertised_routes"]), resource.TestCheckResourceAttr("data.stackit_vpn_gateway.gateway", "labels."+testutil.ConvertConfigVariable(gatewayMaxVars["label_key"]), testutil.ConvertConfigVariable(gatewayMaxVars["label_value"])), + resource.TestCheckResourceAttr("data.stackit_vpn_gateway.gateway", "network_config.predefined_network_prefix.0", testutil.ConvertConfigVariable(gatewayMaxVars["network_config_prefix"])), resource.TestCheckResourceAttrSet("data.stackit_vpn_gateway.gateway", "gateway_id"), @@ -367,6 +410,7 @@ func TestAccVpnGatewayResourceMax(t *testing.T) { resource.TestCheckResourceAttr("stackit_vpn_gateway.gateway", "bgp.local_asn", testutil.ConvertConfigVariable(gatewayMaxVarsUpdated["local_asn"])), testutil.CheckListAttr("stackit_vpn_gateway.gateway", "bgp.override_advertised_routes", gatewayMaxVarsUpdated["override_advertised_routes"]), resource.TestCheckResourceAttr("stackit_vpn_gateway.gateway", "labels."+testutil.ConvertConfigVariable(gatewayMaxVarsUpdated["label_key"]), testutil.ConvertConfigVariable(gatewayMaxVarsUpdated["label_value"])), + resource.TestCheckResourceAttr("stackit_vpn_gateway.gateway", "network_config.predefined_network_prefix.0", testutil.ConvertConfigVariable(gatewayMaxVarsUpdated["network_config_prefix"])), resource.TestCheckResourceAttrSet("stackit_vpn_gateway.gateway", "gateway_id"), ), }, @@ -385,6 +429,7 @@ func TestAccVpnGatewayResourceMax(t *testing.T) { resource.TestCheckResourceAttr("stackit_vpn_gateway.gateway", "bgp.local_asn", testutil.ConvertConfigVariable(gatewayMaxVarsUpdated2["local_asn"])), testutil.CheckListAttr("stackit_vpn_gateway.gateway", "bgp.override_advertised_routes", gatewayMaxVarsUpdated2["override_advertised_routes"]), resource.TestCheckResourceAttr("stackit_vpn_gateway.gateway", "labels.#", "0"), + resource.TestCheckResourceAttr("stackit_vpn_gateway.gateway", "network_config.predefined_network_prefix.0", testutil.ConvertConfigVariable(gatewayMaxVarsUpdated2["network_config_prefix"])), resource.TestCheckResourceAttrSet("stackit_vpn_gateway.gateway", "gateway_id"), ), }, @@ -866,6 +911,164 @@ func TestAccVpnConnectionResourceMax(t *testing.T) { }) } +func TestAccVpnBgpFilterResource(t *testing.T) { + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, + CheckDestroy: testAccCheckVpnResourcesDestroy, + Steps: []resource.TestStep{ + // Creation + { + ConfigVariables: bgpFilterVars, + Config: fmt.Sprintf("%s\n%s\n%s", testutil.NewConfigBuilder().BuildProviderConfig(), gatewayMinConfig, bgpFilterConfig), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("stackit_vpn_bgp_filter.filter", "project_id", testutil.ProjectId), + resource.TestCheckResourceAttr("stackit_vpn_bgp_filter.filter", "region", testutil.Region), + resource.TestCheckResourceAttr("stackit_vpn_bgp_filter.filter", "display_name", testutil.ConvertConfigVariable(bgpFilterVars["filter_display_name"])), + resource.TestCheckResourceAttrSet("stackit_vpn_bgp_filter.filter", "filter_id"), + ), + }, + // Data source + { + ConfigVariables: bgpFilterVars, + Config: fmt.Sprintf(` + %s + %s + %s + + data "stackit_vpn_bgp_filter" "filter" { + project_id = stackit_vpn_bgp_filter.filter.project_id + gateway_id = stackit_vpn_bgp_filter.filter.gateway_id + filter_id = stackit_vpn_bgp_filter.filter.filter_id + } + `, + testutil.NewConfigBuilder().BuildProviderConfig(), gatewayMinConfig, bgpFilterConfig, + ), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("data.stackit_vpn_bgp_filter.filter", "project_id", testutil.ProjectId), + resource.TestCheckResourceAttr("data.stackit_vpn_bgp_filter.filter", "display_name", testutil.ConvertConfigVariable(bgpFilterVars["filter_display_name"])), + resource.TestCheckResourceAttrPair("data.stackit_vpn_bgp_filter.filter", "region", "stackit_vpn_bgp_filter.filter", "region"), + resource.TestCheckResourceAttrPair("data.stackit_vpn_bgp_filter.filter", "filter_id", "stackit_vpn_bgp_filter.filter", "filter_id"), + ), + }, + // Update + { + ConfigVariables: bgpFilterVarsUpdated, + Config: fmt.Sprintf("%s\n%s\n%s", testutil.NewConfigBuilder().BuildProviderConfig(), gatewayMinConfig, bgpFilterConfig), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("stackit_vpn_bgp_filter.filter", "display_name", testutil.ConvertConfigVariable(bgpFilterVarsUpdated["filter_display_name"])), + resource.TestCheckResourceAttrSet("stackit_vpn_bgp_filter.filter", "filter_id"), + ), + }, + // Import + { + ConfigVariables: bgpFilterVars, + ResourceName: "stackit_vpn_bgp_filter.filter", + ImportStateIdFunc: func(s *terraform.State) (string, error) { + r, ok := s.RootModule().Resources["stackit_vpn_bgp_filter.filter"] + if !ok { + return "", fmt.Errorf("couldn't find resource stackit_vpn_bgp_filter.filter") + } + gatewayId, ok := r.Primary.Attributes["gateway_id"] + if !ok { + return "", fmt.Errorf("couldn't find attribute gateway_id") + } + filterId, ok := r.Primary.Attributes["filter_id"] + if !ok { + return "", fmt.Errorf("couldn't find attribute filter_id") + } + return fmt.Sprintf("%s,%s,%s,%s", testutil.ProjectId, testutil.Region, gatewayId, filterId), nil + }, + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + +func TestAccVpnBgpFilterRuleResource(t *testing.T) { + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, + CheckDestroy: testAccCheckVpnResourcesDestroy, + Steps: []resource.TestStep{ + // Creation + { + ConfigVariables: bgpFilterRuleVars, + Config: fmt.Sprintf("%s\n%s\n%s\n%s", testutil.NewConfigBuilder().BuildProviderConfig(), gatewayMinConfig, bgpFilterConfig, bgpFilterRuleConfig), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("stackit_vpn_bgp_filter_rule.rule", "project_id", testutil.ProjectId), + resource.TestCheckResourceAttr("stackit_vpn_bgp_filter_rule.rule", "region", testutil.Region), + resource.TestCheckResourceAttr("stackit_vpn_bgp_filter_rule.rule", "action", testutil.ConvertConfigVariable(bgpFilterRuleVars["rule_action"])), + resource.TestCheckResourceAttr("stackit_vpn_bgp_filter_rule.rule", "match.peer", testutil.ConvertConfigVariable(bgpFilterRuleVars["rule_peer"])), + resource.TestCheckResourceAttr("stackit_vpn_bgp_filter_rule.rule", "match.prefixes.0", testutil.ConvertConfigVariable(bgpFilterRuleVars["rule_prefix"])), + resource.TestCheckResourceAttr("stackit_vpn_bgp_filter_rule.rule", "set.local_preference", testutil.ConvertConfigVariable(bgpFilterRuleVars["rule_local_preference"])), + resource.TestCheckResourceAttrSet("stackit_vpn_bgp_filter_rule.rule", "rule_id"), + resource.TestCheckResourceAttrSet("stackit_vpn_bgp_filter_rule.rule", "sequence"), + ), + }, + // Data source + { + ConfigVariables: bgpFilterRuleVars, + Config: fmt.Sprintf(` + %s + %s + %s + %s + + data "stackit_vpn_bgp_filter_rule" "rule" { + project_id = stackit_vpn_bgp_filter_rule.rule.project_id + gateway_id = stackit_vpn_bgp_filter_rule.rule.gateway_id + filter_id = stackit_vpn_bgp_filter_rule.rule.filter_id + rule_id = stackit_vpn_bgp_filter_rule.rule.rule_id + } + `, + testutil.NewConfigBuilder().BuildProviderConfig(), gatewayMinConfig, bgpFilterConfig, bgpFilterRuleConfig, + ), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("data.stackit_vpn_bgp_filter_rule.rule", "action", testutil.ConvertConfigVariable(bgpFilterRuleVars["rule_action"])), + resource.TestCheckResourceAttrPair("data.stackit_vpn_bgp_filter_rule.rule", "rule_id", "stackit_vpn_bgp_filter_rule.rule", "rule_id"), + resource.TestCheckResourceAttrPair("data.stackit_vpn_bgp_filter_rule.rule", "sequence", "stackit_vpn_bgp_filter_rule.rule", "sequence"), + ), + }, + // Update + { + ConfigVariables: bgpFilterRuleVarsUpdated, + Config: fmt.Sprintf("%s\n%s\n%s\n%s", testutil.NewConfigBuilder().BuildProviderConfig(), gatewayMinConfig, bgpFilterConfig, bgpFilterRuleConfig), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("stackit_vpn_bgp_filter_rule.rule", "match.prefixes.0", testutil.ConvertConfigVariable(bgpFilterRuleVarsUpdated["rule_prefix"])), + resource.TestCheckResourceAttr("stackit_vpn_bgp_filter_rule.rule", "set.local_preference", testutil.ConvertConfigVariable(bgpFilterRuleVarsUpdated["rule_local_preference"])), + resource.TestCheckResourceAttrSet("stackit_vpn_bgp_filter_rule.rule", "rule_id"), + ), + }, + // Import + { + ConfigVariables: bgpFilterRuleVars, + ResourceName: "stackit_vpn_bgp_filter_rule.rule", + ImportStateIdFunc: func(s *terraform.State) (string, error) { + r, ok := s.RootModule().Resources["stackit_vpn_bgp_filter_rule.rule"] + if !ok { + return "", fmt.Errorf("couldn't find resource stackit_vpn_bgp_filter_rule.rule") + } + gatewayId, ok := r.Primary.Attributes["gateway_id"] + if !ok { + return "", fmt.Errorf("couldn't find attribute gateway_id") + } + filterId, ok := r.Primary.Attributes["filter_id"] + if !ok { + return "", fmt.Errorf("couldn't find attribute filter_id") + } + ruleId, ok := r.Primary.Attributes["rule_id"] + if !ok { + return "", fmt.Errorf("couldn't find attribute rule_id") + } + return fmt.Sprintf("%s,%s,%s,%s,%s", testutil.ProjectId, testutil.Region, gatewayId, filterId, ruleId), nil + }, + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + func testAccCheckVpnResourcesDestroy(s *terraform.State) error { ctx := context.Background() client, err := vpn.NewAPIClient(testutil.NewConfigBuilder().BuildClientOptions(testutil.VpnCustomEndpoint, false)...) @@ -885,8 +1088,8 @@ func testAccCheckVpnResourcesDestroy(s *terraform.State) error { } 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]" + case "stackit_vpn_connection", "stackit_vpn_bgp_filter", "stackit_vpn_bgp_filter_rule": + // connection/BGP filter/BGP filter rule terraform ID: "[project_id],[region],[gateway_id],...]" parts := strings.Split(rs.Primary.ID, core.Separator) if len(parts) > 2 { gatewayId = parts[2] @@ -935,6 +1138,43 @@ func testAccCheckVpnResourcesDestroy(s *terraform.State) error { } } + // BGP filters (and their rules) must be deleted before the gateway, and while no connection + // still references them via tunnel bgp.inbound_filter_id (handled above). + filtersResp, err := client.DefaultAPI.ListGatewayBGPFilters(ctx, testutil.ProjectId, testutil.Region, *gateway.Id).Execute() + if err != nil { + return fmt.Errorf("listing BGP filters for gateway %s during CheckDestroy: %w", *gateway.Id, err) + } + for _, filter := range filtersResp.BgpFilters { + if filter.Id == nil { + continue + } + + rulesResp, err := client.DefaultAPI.ListGatewayBGPFilterRules(ctx, testutil.ProjectId, testutil.Region, *gateway.Id, *filter.Id).Execute() + if err != nil { + return fmt.Errorf("listing BGP filter rules for filter %s during CheckDestroy: %w", *filter.Id, err) + } + for _, rule := range rulesResp.Rules { + if rule.Id == nil { + continue + } + err := client.DefaultAPI.DeleteGatewayBGPFilterRule(ctx, testutil.ProjectId, testutil.Region, *gateway.Id, *filter.Id, *rule.Id).Execute() + if err != nil { + if oapiErr, ok := errors.AsType[*oapierror.GenericOpenAPIError](err); ok && (oapiErr.StatusCode == http.StatusNotFound || oapiErr.StatusCode == http.StatusGone) { + continue + } + return fmt.Errorf("destroying BGP filter rule %s during CheckDestroy: %w", *rule.Id, err) + } + } + + err = client.DefaultAPI.DeleteGatewayBGPFilter(ctx, testutil.ProjectId, testutil.Region, *gateway.Id, *filter.Id).Execute() + if err != nil { + if oapiErr, ok := errors.AsType[*oapierror.GenericOpenAPIError](err); ok && (oapiErr.StatusCode == http.StatusNotFound || oapiErr.StatusCode == http.StatusGone) { + continue + } + return fmt.Errorf("destroying BGP filter %s during CheckDestroy: %w", *filter.Id, err) + } + } + err = client.DefaultAPI.DeleteGateway(ctx, testutil.ProjectId, testutil.Region, *gateway.Id).Execute() if err != nil { if oapiErr, ok := errors.AsType[*oapierror.GenericOpenAPIError](err); ok && (oapiErr.StatusCode == http.StatusNotFound || oapiErr.StatusCode == http.StatusGone) { diff --git a/stackit/provider.go b/stackit/provider.go index f99c5eb3c..3ad9f1656 100644 --- a/stackit/provider.go +++ b/stackit/provider.go @@ -135,6 +135,8 @@ import ( telemetryRouterAccessToken "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/telemetryrouter/accesstoken" telemetryRouterDestination "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/telemetryrouter/destination" telemetryRouterInstance "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/telemetryrouter/instance" + vpnBgpFilter "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/vpn/bgp_filter" + vpnBgpFilterRule "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/vpn/bgp_filter_rule" vpnConnection "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/vpn/connection" vpnGateway "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/vpn/gateway" vpnGatewayStatus "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/vpn/gateway_status" @@ -782,6 +784,8 @@ func (p *Provider) DataSources(_ context.Context) []func() datasource.DataSource vpnGateway.NewVPNGatewayDataSource, vpnGatewayStatus.NewVPNGatewayStatusDataSource, vpnConnection.NewVPNConnectionDataSource, + vpnBgpFilter.NewVPNBGPFilterDataSource, + vpnBgpFilterRule.NewVPNBGPFilterRuleDataSource, } dataSources = append(dataSources, customRole.NewCustomRoleDataSources()...) dataSources = append(dataSources, iamRoleBindingsV1.NewRoleBindingsDatasources()...) @@ -890,6 +894,8 @@ func (p *Provider) Resources(_ context.Context) []func() resource.Resource { telemetryLink.NewTelemetryLinkResource, vpnConnection.NewVpnConnectionResource, vpnGateway.NewGatewayResource, + vpnBgpFilter.NewVPNBGPFilterResource, + vpnBgpFilterRule.NewVPNBGPFilterRuleResource, } resources = append(resources, roleAssignements.NewRoleAssignmentResources()...) resources = append(resources, customRole.NewCustomRoleResources()...) From a90c2c7d161fcd7fbd864d7c68fd13c714221f0c Mon Sep 17 00:00:00 2001 From: Steffen Koenig Date: Sat, 22 Aug 2026 00:05:27 +0200 Subject: [PATCH 2/4] chore(deps): pin vpn SDK to released v0.15.0 The vpn changes (BGP filter, network_config, inbound_filter_id) landed on stackit-sdk-go main and are now tagged as services/vpn/v0.15.0. Re-pin from the temporary commit pseudo-version to this release; no code changes needed since the tag points at the same content already implemented against. --- go.mod | 2 +- go.sum | 6 ++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/go.mod b/go.mod index d9a737e14..e09edfaa3 100644 --- a/go.mod +++ b/go.mod @@ -48,7 +48,7 @@ require ( github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex v1.16.1 github.com/stackitcloud/stackit-sdk-go/services/telemetrylink v0.4.0 github.com/stackitcloud/stackit-sdk-go/services/telemetryrouter v0.4.0 - github.com/stackitcloud/stackit-sdk-go/services/vpn v0.14.1-0.20260728105652-71775a9e99ee // TODO(vpn-sdk-pin): re-pin to tagged release once stackitcloud/stackit-sdk-go#9324 merges + github.com/stackitcloud/stackit-sdk-go/services/vpn v0.15.0 github.com/teambition/rrule-go v1.8.2 golang.org/x/mod v0.38.0 ) diff --git a/go.sum b/go.sum index 85e48b938..443c684e4 100644 --- a/go.sum +++ b/go.sum @@ -742,10 +742,8 @@ github.com/stackitcloud/stackit-sdk-go/services/telemetrylink v0.4.0 h1:RG+cZzvI github.com/stackitcloud/stackit-sdk-go/services/telemetrylink v0.4.0/go.mod h1:hgw8janWmDfP2bnuZensxqcAePr49BX5ug8Rq85o+h8= github.com/stackitcloud/stackit-sdk-go/services/telemetryrouter v0.4.0 h1:tEKBl3g7SIjZ8aa43nNjS1Rqr/IwxQ3Pr1MArYF0fno= github.com/stackitcloud/stackit-sdk-go/services/telemetryrouter v0.4.0/go.mod h1:WUmgKtwpe90Yq3YbgNxc2clTTULVxCu0ha6lMTjUnII= -github.com/stackitcloud/stackit-sdk-go/services/vpn v0.14.0 h1:LMgbzhPunuelsIsfyEj/5O/aYfNcg/eGHsnZ7AZOhYg= -github.com/stackitcloud/stackit-sdk-go/services/vpn v0.14.0/go.mod h1:toIjQk1dhxdUFVyCWJJja0w/0nFpDid8MWX0ukQfvfo= -github.com/stackitcloud/stackit-sdk-go/services/vpn v0.14.1-0.20260728105652-71775a9e99ee h1:PSQAjD/gFEYFgVj+lI7CXb04u0LXm6uRSJx1DjXTIqg= -github.com/stackitcloud/stackit-sdk-go/services/vpn v0.14.1-0.20260728105652-71775a9e99ee/go.mod h1:toIjQk1dhxdUFVyCWJJja0w/0nFpDid8MWX0ukQfvfo= +github.com/stackitcloud/stackit-sdk-go/services/vpn v0.15.0 h1:JpMJjWBa6fwNNcAHoq00v+8+DwMD0/fe4WPjvbHdY2o= +github.com/stackitcloud/stackit-sdk-go/services/vpn v0.15.0/go.mod h1:toIjQk1dhxdUFVyCWJJja0w/0nFpDid8MWX0ukQfvfo= github.com/stbenjam/no-sprintf-host-port v0.3.1 h1:AyX7+dxI4IdLBPtDbsGAyqiTSLpCP9hWRrXQDU4Cm/g= github.com/stbenjam/no-sprintf-host-port v0.3.1/go.mod h1:ODbZesTCHMVKthBHskvUUexdcNHAQRXk9NpSsL8p/HQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= From 0feb5ea6d730024ef0b6ca65cbcfc707ba8f07e6 Mon Sep 17 00:00:00 2001 From: Steffen Koenig Date: Sat, 22 Aug 2026 00:11:57 +0200 Subject: [PATCH 3/4] docs(vpn): regenerate to reflect sha1 remaining supported in v0.15.0 The released stackit-sdk-go v0.15.0 kept `sha1` in PhaseIntegrityAlgorithmsInner alongside the new `sha2_512`, unlike the draft PR commit this was originally implemented against (which dropped `sha1`). No Go code change is needed since the provider already derives its integrity_algorithms validator values dynamically from the SDK enum, but the previously generated docs were stale and needed a refresh. --- 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 f28727af3..8ff5d367a 100644 --- a/docs/resources/vpn_connection.md +++ b/docs/resources/vpn_connection.md @@ -106,7 +106,7 @@ Optional: 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: `sha2_256`, `sha2_384`, `sha2_512`. +- `integrity_algorithms` (List of String) Integrity algorithms for Phase 1. Possible values are: `sha1`, `sha2_256`, `sha2_384`, `sha2_512`. Optional: @@ -120,7 +120,7 @@ Optional: 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: `sha2_256`, `sha2_384`, `sha2_512`. +- `integrity_algorithms` (List of String) Integrity algorithms for Phase 2. Possible values are: `sha1`, `sha2_256`, `sha2_384`, `sha2_512`. Optional: @@ -175,7 +175,7 @@ Optional: 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: `sha2_256`, `sha2_384`, `sha2_512`. +- `integrity_algorithms` (List of String) Integrity algorithms for Phase 1. Possible values are: `sha1`, `sha2_256`, `sha2_384`, `sha2_512`. Optional: @@ -189,7 +189,7 @@ Optional: 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: `sha2_256`, `sha2_384`, `sha2_512`. +- `integrity_algorithms` (List of String) Integrity algorithms for Phase 2. Possible values are: `sha1`, `sha2_256`, `sha2_384`, `sha2_512`. Optional: From 4f931ca4a3c4417397d5ad0fecf416876d7769a4 Mon Sep 17 00:00:00 2001 From: Steffen Koenig Date: Sat, 22 Aug 2026 00:19:14 +0200 Subject: [PATCH 4/4] feat(vpn): warn when integrity_algorithms uses deprecated sha1 sha1 remains supported by the API but is deprecated. Add a schema description note and a plan-time diagnostic warning (on create/update) when a stackit_vpn_connection tunnel's phase1/phase2 integrity_algorithms includes sha1, pointing users toward sha2_256/sha2_384/sha2_512 instead. --- docs/resources/vpn_connection.md | 8 +-- .../services/vpn/connection/resource.go | 39 ++++++++++++- .../services/vpn/connection/resource_test.go | 58 +++++++++++++++++++ 3 files changed, 99 insertions(+), 6 deletions(-) diff --git a/docs/resources/vpn_connection.md b/docs/resources/vpn_connection.md index 8ff5d367a..b8e282215 100644 --- a/docs/resources/vpn_connection.md +++ b/docs/resources/vpn_connection.md @@ -106,7 +106,7 @@ Optional: 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`, `sha2_512`. +- `integrity_algorithms` (List of String) Integrity algorithms for Phase 1. Possible values are: `sha1`, `sha2_256`, `sha2_384`, `sha2_512`. `sha1` is deprecated and may be removed in a future API version; prefer `sha2_256`, `sha2_384`, or `sha2_512`. Optional: @@ -120,7 +120,7 @@ Optional: 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`, `sha2_512`. +- `integrity_algorithms` (List of String) Integrity algorithms for Phase 2. Possible values are: `sha1`, `sha2_256`, `sha2_384`, `sha2_512`. `sha1` is deprecated and may be removed in a future API version; prefer `sha2_256`, `sha2_384`, or `sha2_512`. Optional: @@ -175,7 +175,7 @@ Optional: 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`, `sha2_512`. +- `integrity_algorithms` (List of String) Integrity algorithms for Phase 1. Possible values are: `sha1`, `sha2_256`, `sha2_384`, `sha2_512`. `sha1` is deprecated and may be removed in a future API version; prefer `sha2_256`, `sha2_384`, or `sha2_512`. Optional: @@ -189,7 +189,7 @@ Optional: 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`, `sha2_512`. +- `integrity_algorithms` (List of String) Integrity algorithms for Phase 2. Possible values are: `sha1`, `sha2_256`, `sha2_384`, `sha2_512`. `sha1` is deprecated and may be removed in a future API version; prefer `sha2_256`, `sha2_384`, or `sha2_512`. Optional: diff --git a/stackit/internal/services/vpn/connection/resource.go b/stackit/internal/services/vpn/connection/resource.go index 4877449a5..94bc9e705 100644 --- a/stackit/internal/services/vpn/connection/resource.go +++ b/stackit/internal/services/vpn/connection/resource.go @@ -243,7 +243,7 @@ func (r *vpnConnectionResource) Schema(_ context.Context, _ resource.SchemaReque }, }, "integrity_algorithms": schema.ListAttribute{ - Description: fmt.Sprintf("Integrity algorithms for Phase 1. %s", tfutils.FormatPossibleValues(integrityAlgorithmValues...)), + Description: fmt.Sprintf("Integrity algorithms for Phase 1. %s `sha1` is deprecated and may be removed in a future API version; prefer `sha2_256`, `sha2_384`, or `sha2_512`.", tfutils.FormatPossibleValues(integrityAlgorithmValues...)), Required: true, ElementType: types.StringType, Validators: []validator.List{ @@ -287,7 +287,7 @@ func (r *vpnConnectionResource) Schema(_ context.Context, _ resource.SchemaReque }, }, "integrity_algorithms": schema.ListAttribute{ - Description: fmt.Sprintf("Integrity algorithms for Phase 2. %s", tfutils.FormatPossibleValues(integrityAlgorithmValues...)), + Description: fmt.Sprintf("Integrity algorithms for Phase 2. %s `sha1` is deprecated and may be removed in a future API version; prefer `sha2_256`, `sha2_384`, or `sha2_512`.", tfutils.FormatPossibleValues(integrityAlgorithmValues...)), Required: true, ElementType: types.StringType, Validators: []validator.List{ @@ -552,6 +552,10 @@ func (r *vpnConnectionResource) Create(ctx context.Context, req resource.CreateR return } + if modelUsesDeprecatedSha1IntegrityAlgorithm(&planModel) { + resp.Diagnostics.AddWarning("Deprecated integrity algorithm", "`sha1` is deprecated for `integrity_algorithms` and may be removed in a future API version. Use `sha2_256`, `sha2_384`, or `sha2_512` instead.") + } + ctx = core.InitProviderContext(ctx) projectId := planModel.ProjectID.ValueString() @@ -662,6 +666,10 @@ func (r *vpnConnectionResource) Update(ctx context.Context, req resource.UpdateR return } + if modelUsesDeprecatedSha1IntegrityAlgorithm(&planModel) { + resp.Diagnostics.AddWarning("Deprecated integrity algorithm", "`sha1` is deprecated for `integrity_algorithms` and may be removed in a future API version. Use `sha2_256`, `sha2_384`, or `sha2_512` instead.") + } + ctx = core.InitProviderContext(ctx) projectId := planModel.ProjectID.ValueString() @@ -956,6 +964,33 @@ func toTunnelPayload(tunnel *TunnelModel) (*vpn.TunnelConfiguration, error) { return config, nil } +// modelUsesDeprecatedSha1IntegrityAlgorithm reports whether any tunnel/phase in the model still +// configures the deprecated `sha1` integrity algorithm. `sha1` remains supported by the API but +// should be avoided in favor of `sha2_256`, `sha2_384`, or `sha2_512`. +func modelUsesDeprecatedSha1IntegrityAlgorithm(model *Model) bool { + usesSha1 := func(algorithms types.List) bool { + if tfutils.IsUndefined(algorithms) { + return false + } + for _, el := range algorithms.Elements() { + if s, ok := el.(types.String); ok && s.ValueString() == string(vpn.PHASEINTEGRITYALGORITHMSINNER_SHA1) { + return true + } + } + return false + } + + tunnelUsesSha1 := func(tunnel *TunnelModel) bool { + if tunnel == nil { + return false + } + return (tunnel.Phase1 != nil && usesSha1(tunnel.Phase1.IntegrityAlgorithms)) || + (tunnel.Phase2 != nil && usesSha1(tunnel.Phase2.IntegrityAlgorithms)) + } + + return tunnelUsesSha1(model.Tunnel1) || tunnelUsesSha1(model.Tunnel2) +} + func toBasePhasePayload(phaseModel *BasePhaseModel, phasePayload BasePhasePayload) error { if phaseModel == nil { return nil diff --git a/stackit/internal/services/vpn/connection/resource_test.go b/stackit/internal/services/vpn/connection/resource_test.go index 65ded6410..ec1e4ca2e 100644 --- a/stackit/internal/services/vpn/connection/resource_test.go +++ b/stackit/internal/services/vpn/connection/resource_test.go @@ -1120,3 +1120,61 @@ func TestToTunnelConfiguration(t *testing.T) { }) } } + +func TestModelUsesDeprecatedSha1IntegrityAlgorithm(t *testing.T) { + tests := []struct { + description string + input *Model + expected bool + }{ + { + description: "no_sha1", + input: new(fixtureModel()), + expected: false, + }, + { + description: "sha1_in_tunnel1_phase1", + input: new(fixtureModel(func(m *Model) { + m.Tunnel1.Phase1.IntegrityAlgorithms = types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("sha1"), + }) + })), + expected: true, + }, + { + description: "sha1_in_tunnel2_phase2", + input: new(fixtureModel(func(m *Model) { + m.Tunnel2.Phase2.IntegrityAlgorithms = types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("sha1"), + }) + })), + expected: true, + }, + { + description: "sha1_mixed_with_other_algorithms", + input: new(fixtureModel(func(m *Model) { + m.Tunnel1.Phase1.IntegrityAlgorithms = types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("sha2_256"), + types.StringValue("sha1"), + }) + })), + expected: true, + }, + { + description: "integrity_algorithms_undefined", + input: new(fixtureModel(func(m *Model) { + m.Tunnel1.Phase1.IntegrityAlgorithms = types.ListNull(types.StringType) + m.Tunnel2.Phase2.IntegrityAlgorithms = types.ListNull(types.StringType) + })), + expected: false, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + got := modelUsesDeprecatedSha1IntegrityAlgorithm(tt.input) + if got != tt.expected { + t.Fatalf("got %v, want %v", got, tt.expected) + } + }) + } +}