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 a34c592aa..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: @@ -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`, `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: @@ -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`, `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: @@ -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/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..94bc9e705 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 { @@ -242,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{ @@ -286,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{ @@ -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(), + }, + }, }, }, }, @@ -543,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() @@ -653,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() @@ -761,6 +778,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 +837,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 } @@ -914,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 @@ -1082,8 +1159,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..ec1e4ca2e 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) } @@ -913,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) + } + }) + } +} 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 6e0562557..b65166c03 100644 --- a/stackit/provider.go +++ b/stackit/provider.go @@ -139,6 +139,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" @@ -785,6 +787,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()...) @@ -898,6 +902,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()...)