diff --git a/docs/data-sources/alb.md b/docs/data-sources/alb.md new file mode 100644 index 000000000..34ab1703a --- /dev/null +++ b/docs/data-sources/alb.md @@ -0,0 +1,273 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_alb Data Source - stackit" +subcategory: "" +description: |- + Setting up supporting infrastructure + The example below creates the supporting infrastructure using the STACKIT Terraform provider, including the network, network interface, a public IP address and server resources. +--- + +# stackit_alb (Data Source) + +## Setting up supporting infrastructure + + +The example below creates the supporting infrastructure using the STACKIT Terraform provider, including the network, network interface, a public IP address and server resources. + +## Example Usage + +```terraform +data "stackit_alb" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "example-load-balancer" +} +``` + + +## Schema + +### Required + +- `name` (String) Application Load balancer name. +- `project_id` (String) STACKIT project ID to which the Application Load Balancer is associated. + +### Read-Only + +- `disable_target_security_group_assignment` (Boolean) Disable target security group assignemt to allow targets outside of the given network. Connectivity to targets need to be ensured by the customer, including routing and Security Groups (targetSecurityGroup can be assigned). Not changeable after creation. +- `errors` (Attributes Set) Reports all errors a Application Load Balancer has. (see [below for nested schema](#nestedatt--errors)) +- `external_address` (String) The external IP address where this Application Load Balancer is exposed. Not changeable after creation. +- `id` (String) Terraform's internal resource ID. It is structured as "`project_id`","region","`name`". +- `labels` (Map of String) Labels represent user-defined metadata as key-value pairs. Label count cannot exceed 64 per ALB. +- `listeners` (Attributes List) List of all listeners which will accept traffic. Limited to 20. (see [below for nested schema](#nestedatt--listeners)) +- `load_balancer_security_group` (Attributes) Security Group permitting network traffic from the LoadBalancer to the targets. Useful when disableTargetSecurityGroupAssignment=true to manually assign target security groups to targets. (see [below for nested schema](#nestedatt--load_balancer_security_group)) +- `networks` (Attributes Set) List of networks that listeners and targets reside in. (see [below for nested schema](#nestedatt--networks)) +- `options` (Attributes) Defines any optional functionality you want to have enabled on your Application Load Balancer. (see [below for nested schema](#nestedatt--options)) +- `plan_id` (String) Service Plan configures the size of the Application Load Balancer. Possible values are: `p10`.. This list can change in the future. Therefore, this is not an enum. +- `private_address` (String) +- `region` (String) The resource region. If not defined, the provider region is used. Possible values are: `eu01`, `eu02`. +- `status` (String) Enum: "STATUS_UNSPECIFIED" "STATUS_PENDING" "STATUS_READY" "STATUS_ERROR" "STATUS_TERMINATING" +- `target_pools` (Attributes List) List of all target pools which will be used in the Application Load Balancer. Limited to 20. (see [below for nested schema](#nestedatt--target_pools)) +- `target_security_group` (Attributes) Security Group that allows the targets to receive traffic from the LoadBalancer. Useful when disableTargetSecurityGroupAssignment=true to manually assign target security groups to targets. (see [below for nested schema](#nestedatt--target_security_group)) +- `version` (String) Application Load Balancer resource version. Used for concurrency safe updates. + + +### Nested Schema for `errors` + +Read-Only: + +- `description` (String) The error description contains additional helpful user information to fix the error state of the Application Load Balancer. For example the IP 45.135.247.139 does not exist in the project, then the description will report: Floating IP "45.135.247.139" could not be found. +- `type` (String) Enum: "TYPE_UNSPECIFIED" "TYPE_INTERNAL" "TYPE_QUOTA_SECGROUP_EXCEEDED" "TYPE_QUOTA_SECGROUPRULE_EXCEEDED" "TYPE_PORT_NOT_CONFIGURED" "TYPE_FIP_NOT_CONFIGURED" "TYPE_TARGET_NOT_ACTIVE" "TYPE_METRICS_MISCONFIGURED" "TYPE_LOGS_MISCONFIGURED" +The error type specifies which part of the Application Load Balancer encountered the error. I.e. the API will not check if a provided public IP is actually available in the project. Instead the Application Load Balancer with try to use the provided IP and if not available reports TYPE_FIP_NOT_CONFIGURED error. + + + +### Nested Schema for `listeners` + +Read-Only: + +- `http` (Attributes) Configuration for HTTP traffic. (see [below for nested schema](#nestedatt--listeners--http)) +- `https` (Attributes) Configuration for handling HTTPS traffic on this listener. (see [below for nested schema](#nestedatt--listeners--https)) +- `name` (String) Unique name for the listener +- `port` (Number) Port number on which the listener receives incoming traffic. +- `protocol` (String) Protocol is the highest network protocol we understand to load balance. Possible values are: `PROTOCOL_UNSPECIFIED`, `PROTOCOL_HTTP`, `PROTOCOL_HTTPS`. +- `waf_config_name` (String) Enable Web Application Firewall (WAF), referenced by name. See "Application Load Balancer - Web Application Firewall API" for more information. + + +### Nested Schema for `listeners.http` + +Read-Only: + +- `hosts` (Attributes List) Defines routing rules grouped by hostname. (see [below for nested schema](#nestedatt--listeners--http--hosts)) + + +### Nested Schema for `listeners.http.hosts` + +Read-Only: + +- `host` (String) Hostname to match. Supports wildcards (e.g. *.example.com). +- `rules` (Attributes List) Routing rules under the specified host, matched by path prefix. (see [below for nested schema](#nestedatt--listeners--http--hosts--rules)) + + +### Nested Schema for `listeners.http.hosts.rules` + +Read-Only: + +- `cookie_persistence` (Attributes) Routing persistence via cookies. (see [below for nested schema](#nestedatt--listeners--http--hosts--rules--cookie_persistence)) +- `headers` (Attributes Set) Headers for the rule. (see [below for nested schema](#nestedatt--listeners--http--hosts--rules--headers)) +- `path` (Attributes) Routing via path. (see [below for nested schema](#nestedatt--listeners--http--hosts--rules--path)) +- `query_parameters` (Attributes Set) Query parameters for the rule. (see [below for nested schema](#nestedatt--listeners--http--hosts--rules--query_parameters)) +- `target_pool` (String) Reference target pool by target pool name. +- `web_socket` (Boolean) If enabled, when client sends an HTTP request with and Upgrade header, indicating the desire to establish a Websocket connection, if backend server supports WebSocket, it responds with HTTP 101 status code, switching protocols from HTTP to WebSocket. Hence the client and the server can exchange data in real-time using one long-lived TCP connection. + + +### Nested Schema for `listeners.http.hosts.rules.cookie_persistence` + +Read-Only: + +- `name` (String) The name of the cookie to use. +- `ttl` (String) TTL specifies the time-to-live for the cookie. The default value is 0s, and it acts as a session cookie, expiring when the client session ends. + + + +### Nested Schema for `listeners.http.hosts.rules.headers` + +Read-Only: + +- `exact_match` (String) Exact match for the header value. +- `name` (String) Header name. + + + +### Nested Schema for `listeners.http.hosts.rules.path` + +Read-Only: + +- `exact_match` (String) Exact path match. Only a request path exactly equal to the value will match, e.g. '/foo' matches only '/foo', not '/foo/bar' or '/foobar'. +- `prefix` (String) Prefix path match. Only matches on full segment boundaries, e.g. '/foo' matches '/foo' and '/foo/bar' but NOT '/foobar'. + + + +### Nested Schema for `listeners.http.hosts.rules.query_parameters` + +Read-Only: + +- `exact_match` (String) Exact match for the query parameters value. +- `name` (String) Query parameter name. + + + + + + +### Nested Schema for `listeners.https` + +Read-Only: + +- `certificate_config` (Attributes) TLS termination certificate configuration. (see [below for nested schema](#nestedatt--listeners--https--certificate_config)) + + +### Nested Schema for `listeners.https.certificate_config` + +Read-Only: + +- `certificate_ids` (Set of String) Certificate IDs for TLS termination. + + + + + +### Nested Schema for `load_balancer_security_group` + +Read-Only: + +- `id` (String) ID of the security Group +- `name` (String) Name of the security Group + + + +### Nested Schema for `networks` + +Read-Only: + +- `network_id` (String) STACKIT network ID the Application Load Balancer and/or targets are in. +- `role` (String) The role defines how the Application Load Balancer is using the network. Possible values are: `ROLE_UNSPECIFIED`, `ROLE_LISTENERS_AND_TARGETS`, `ROLE_LISTENERS`, `ROLE_TARGETS`. + + + +### Nested Schema for `options` + +Read-Only: + +- `acl` (Set of String) Use this option to limit the IP ranges that can use the Application Load Balancer. +- `ephemeral_address` (Boolean) This option automates the handling of the external IP address for an Application Load Balancer. If set to true a new IP address will be automatically created. It will also be automatically deleted when the Load Balancer is deleted. +- `observability` (Attributes) We offer Load Balancer observability via STACKIT Observability or external solutions. (see [below for nested schema](#nestedatt--options--observability)) +- `private_network_only` (Boolean) Application Load Balancer is accessible only via a private network ip address. Not changeable after creation. + + +### Nested Schema for `options.observability` + +Read-Only: + +- `logs` (Attributes) Observability logs configuration. (see [below for nested schema](#nestedatt--options--observability--logs)) +- `metrics` (Attributes) Observability metrics configuration. (see [below for nested schema](#nestedatt--options--observability--metrics)) + + +### Nested Schema for `options.observability.logs` + +Read-Only: + +- `credentials_ref` (String) Credentials reference for logging. This reference is created via the observability create endpoint and the credential needs to contain the basic auth username and password for the logging solution the push URL points to. Then this enables monitoring via remote write for the Application Load Balancer. +- `push_url` (String) Credentials reference for logging. This reference is created via the observability create endpoint and the credential needs to contain the basic auth username and password for the logging solution the push URL points to. Then this enables monitoring via remote write for the Application Load Balancer. + + + +### Nested Schema for `options.observability.metrics` + +Read-Only: + +- `credentials_ref` (String) Credentials reference for metrics. This reference is created via the observability create endpoint and the credential needs to contain the basic auth username and password for the metrics solution the push URL points to. Then this enables monitoring via remote write for the Application Load Balancer. +- `push_url` (String) Credentials reference for metrics. This reference is created via the observability create endpoint and the credential needs to contain the basic auth username and password for the metrics solution the push URL points to. Then this enables monitoring via remote write for the Application Load Balancer. + + + + + +### Nested Schema for `target_pools` + +Read-Only: + +- `active_health_check` (Attributes) (see [below for nested schema](#nestedatt--target_pools--active_health_check)) +- `name` (String) Target pool name. +- `target_port` (Number) The number identifying the port where each target listens for traffic. +- `targets` (Attributes Set) List of all targets which will be used in the pool. Limited to 250. (see [below for nested schema](#nestedatt--target_pools--targets)) +- `tls_config` (Attributes) Configuration for TLS bridging. (see [below for nested schema](#nestedatt--target_pools--tls_config)) + + +### Nested Schema for `target_pools.active_health_check` + +Read-Only: + +- `healthy_threshold` (Number) Healthy threshold of the health checking. +- `http_health_checks` (Attributes) Options for the HTTP health checking. (see [below for nested schema](#nestedatt--target_pools--active_health_check--http_health_checks)) +- `interval` (String) Interval duration of health checking in seconds. +- `interval_jitter` (String) Interval duration threshold of the health checking in seconds. +- `timeout` (String) Active health checking timeout duration in seconds. +- `unhealthy_threshold` (Number) Unhealthy threshold of the health checking. + + +### Nested Schema for `target_pools.active_health_check.http_health_checks` + +Read-Only: + +- `ok_status` (Set of String) List of HTTP status codes that indicate a healthy response. +- `path` (String) Path to send the health check request to. + + + + +### Nested Schema for `target_pools.targets` + +Read-Only: + +- `display_name` (String) Target display name +- `ip` (String) Private target IP, which must by unique within a target pool. + + + +### Nested Schema for `target_pools.tls_config` + +Read-Only: + +- `custom_ca` (String) Specifies a custom Certificate Authority (CA). When provided, the target pool will trust certificates signed by this CA, in addition to any system-trusted CAs. This is useful for scenarios where the target pool needs to communicate with servers using self-signed or internally-issued certificates. Enabled needs to be set to true and skip validation to false for this option. +- `enabled` (Boolean) Enable TLS (Transport Layer Security) bridging for the connection between Application Load Balancer and targets in this pool. When enabled, public CAs are trusted. Can be used in tandem with the options either custom CA or skip validation or alone. +- `skip_certificate_validation` (Boolean) Bypass certificate validation for TLS bridging in this target pool. This option is insecure and can only be used with public CAs by setting enabled true. Meant to be used for testing purposes only! + + + + +### Nested Schema for `target_security_group` + +Read-Only: + +- `id` (String) ID of the security Group +- `name` (String) Name of the security Group diff --git a/docs/index.md b/docs/index.md index 4e9d3e2b2..3e9bb311b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -153,6 +153,7 @@ Note: AWS specific checks must be skipped as they do not work on STACKIT. For de ### Optional +- `alb_custom_endpoint` (String) Custom endpoint for the Application Load Balancer service - `authorization_custom_endpoint` (String) Custom endpoint for the Membership service - `cdn_custom_endpoint` (String) Custom endpoint for the CDN service - `credentials_path` (String) Path of JSON from where the credentials are read. Takes precedence over the env var `STACKIT_CREDENTIALS_PATH`. Default value is `~/.stackit/credentials.json`. diff --git a/docs/resources/alb.md b/docs/resources/alb.md new file mode 100644 index 000000000..f1755d1fc --- /dev/null +++ b/docs/resources/alb.md @@ -0,0 +1,497 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_alb Resource - stackit" +subcategory: "" +description: |- + Setting up supporting infrastructure + The example below creates the supporting infrastructure using the STACKIT Terraform provider, including the network, network interface, a public IP address and server resources. +--- + +# stackit_alb (Resource) + +## Setting up supporting infrastructure + + +The example below creates the supporting infrastructure using the STACKIT Terraform provider, including the network, network interface, a public IP address and server resources. + +## Example Usage + +```terraform +variable "project_id" { + description = "The STACKIT Project ID" + type = string + default = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} + +variable "image_id" { + description = "A valid Debian 12 Image ID available in all projects" + type = string + default = "939249d1-6f48-4ab7-929b-95170728311a" +} + +variable "availability_zone" { + description = "An availability zone" + type = string + default = "eu01-1" +} + +variable "machine_type" { + description = "The machine flavor with 2GB of RAM and 1 core" + type = string + default = "c2i.1" +} + +variable "label_key" { + description = "An optional label key" + type = string + default = "key" +} + +variable "label_value" { + description = "An optional label value" + type = string + default = "value" +} + +# Create a network +resource "stackit_network" "network" { + project_id = var.project_id + name = "example-network" + ipv4_nameservers = ["1.1.1.1"] + ipv4_prefix = "192.168.2.0/25" + routed = true +} + +# Create a network interface +resource "stackit_network_interface" "nic" { + project_id = var.project_id + network_id = stackit_network.network.network_id + lifecycle { + ignore_changes = [ + security_group_ids, + ] + } +} + +# Create a key pair for accessing the target server instance +resource "stackit_key_pair" "keypair" { + name = "example-key-pair" + public_key = chomp(file("path/to/id_rsa.pub")) +} + +# Create a target server instance +resource "stackit_server" "server" { + project_id = var.project_id + name = "example-server" + machine_type = var.machine_type + keypair_name = stackit_key_pair.keypair.name + availability_zone = var.availability_zone + + boot_volume = { + size = 20 + source_type = "image" + source_id = var.image_id + delete_on_termination = true + } + + network_interfaces = [ + stackit_network_interface.nic.network_interface_id + ] + + # Explicit dependencies to ensure ordering + depends_on = [ + stackit_network.network, + stackit_key_pair.keypair, + stackit_network_interface.nic + ] +} + +# Create example credentials for observability of the ALB +# Create real credentials in your stackit observability +resource "stackit_loadbalancer_observability_credential" "observability" { + project_id = var.project_id + display_name = "my-cred" + password = "password" + username = "username" +} + +# Create a Application Load Balancer +resource "stackit_alb" "example" { + project_id = var.project_id + region = "eu01" + name = "example-load-balancer" + plan_id = "p10" + // Hint: Automatically create an IP for the ALB lifecycle by setting ephemeral_address = true or use: + // external_address = "124.124.124.124" + labels = { + (var.label_key) = var.label_value + } + listeners = [{ + port = 443 + http = { + hosts = [{ + host = "*" + rules = [{ + target_pool = "my-target-pool" + web_socket = true + query_parameters = [{ + name = "my-query-key" + exact_match = "my-query-value" + }] + headers = [{ + name = "my-header-key" + exact_match = "my-header-value" + }] + path = { + prefix = "/path" + } + cookie_persistence = { + name = "my-cookie" + ttl = "60s" + } + }] + }] + } + https = { + certificate_config = { + certificate_ids = [ + # Currently no TF provider available, needs to be added with API + # https://docs.api.stackit.cloud/documentation/certificates/version/v2 + "name-v1-8c81bd317af8a03b8ef0851ccb074eb17d1ad589b540446244a5e593f78ef820" + ] + } + } + protocol = "PROTOCOL_HTTPS" + # Currently no TF provider available, needs to be added with API + # https://docs.api.stackit.cloud/documentation/alb-waf/version/v1alpha + waf_config_name = "my-waf-config" + } + ] + networks = [ + { + network_id = stackit_network.network.network_id + role = "ROLE_LISTENERS_AND_TARGETS" + } + ] + options = { + acl = ["123.123.123.123/24", "12.12.12.12/24"] + ephemeral_address = true + private_network_only = false + observability = { + logs = { + credentials_ref = stackit_loadbalancer_observability_credential.observability.credentials_ref + push_url = "https://logs.stackit.argus.eu01.stackit.cloud/instances//loki/api/v1/push" + } + metrics = { + credentials_ref = stackit_loadbalancer_observability_credential.observability.credentials_ref + push_url = "https://push.metrics.stackit.argus.eu01.stackit.cloud/instances//api/v1/receive" + } + } + } + target_pools = [ + { + name = "my-target-pool" + active_health_check = { + interval = "0.500s" + interval_jitter = "0.010s" + timeout = "1s" + healthy_threshold = "5" + unhealthy_threshold = "3" + http_health_checks = { + ok_status = ["200", "201"] + path = "/healthy" + } + } + target_port = 80 + targets = [ + { + display_name = "my-target" + ip = stackit_network_interface.nic.ipv4 + } + ] + tls_config = { + enabled = true + skip_certificate_validation = false + custom_ca = chomp(file("path/to/PEM_formated_CA")) + } + } + ] + disable_target_security_group_assignment = false # only needed if targets are not in the same network +} +``` + + +## Schema + +### Required + +- `listeners` (Attributes List) List of all listeners which will accept traffic. Limited to 20. (see [below for nested schema](#nestedatt--listeners)) +- `name` (String) Application Load balancer name. +- `networks` (Attributes Set) List of networks that listeners and targets reside in. (see [below for nested schema](#nestedatt--networks)) +- `plan_id` (String) Service Plan configures the size of the Application Load Balancer. Possible values are: `p10`.. This list can change in the future. Therefore, this is not an enum. +- `project_id` (String) STACKIT project ID to which the Application Load Balancer is associated. +- `target_pools` (Attributes List) List of all target pools which will be used in the Application Load Balancer. Limited to 20. (see [below for nested schema](#nestedatt--target_pools)) + +### Optional + +- `disable_target_security_group_assignment` (Boolean) Disable target security group assignemt to allow targets outside of the given network. Connectivity to targets need to be ensured by the customer, including routing and Security Groups (targetSecurityGroup can be assigned). Not changeable after creation. +- `external_address` (String) The external IP address where this Application Load Balancer is exposed. Not changeable after creation. +- `labels` (Map of String) Labels represent user-defined metadata as key-value pairs. Label count cannot exceed 64 per ALB. +- `options` (Attributes) Defines any optional functionality you want to have enabled on your Application Load Balancer. (see [below for nested schema](#nestedatt--options)) +- `region` (String) The resource region. If not defined, the provider region is used. Possible values are: `eu01`, `eu02`. + +### Read-Only + +- `errors` (Attributes Set) Reports all errors a Application Load Balancer has. (see [below for nested schema](#nestedatt--errors)) +- `id` (String) Terraform's internal resource ID. It is structured as "`project_id`","region","`name`". +- `load_balancer_security_group` (Attributes) Security Group permitting network traffic from the LoadBalancer to the targets. Useful when disableTargetSecurityGroupAssignment=true to manually assign target security groups to targets. (see [below for nested schema](#nestedatt--load_balancer_security_group)) +- `private_address` (String) +- `status` (String) Enum: "STATUS_UNSPECIFIED" "STATUS_PENDING" "STATUS_READY" "STATUS_ERROR" "STATUS_TERMINATING" +- `target_security_group` (Attributes) Security Group that allows the targets to receive traffic from the LoadBalancer. Useful when disableTargetSecurityGroupAssignment=true to manually assign target security groups to targets. (see [below for nested schema](#nestedatt--target_security_group)) +- `version` (String) Application Load Balancer resource version. Used for concurrency safe updates. + + +### Nested Schema for `listeners` + +Required: + +- `http` (Attributes) Configuration for HTTP traffic. (see [below for nested schema](#nestedatt--listeners--http)) +- `port` (Number) Port number on which the listener receives incoming traffic. +- `protocol` (String) Protocol is the highest network protocol we understand to load balance. Possible values are: `PROTOCOL_UNSPECIFIED`, `PROTOCOL_HTTP`, `PROTOCOL_HTTPS`. + +Optional: + +- `https` (Attributes) Configuration for handling HTTPS traffic on this listener. (see [below for nested schema](#nestedatt--listeners--https)) +- `waf_config_name` (String) Enable Web Application Firewall (WAF), referenced by name. See "Application Load Balancer - Web Application Firewall API" for more information. + +Read-Only: + +- `name` (String) Unique name for the listener + + +### Nested Schema for `listeners.http` + +Required: + +- `hosts` (Attributes List) Defines routing rules grouped by hostname. (see [below for nested schema](#nestedatt--listeners--http--hosts)) + + +### Nested Schema for `listeners.http.hosts` + +Required: + +- `host` (String) Hostname to match. Supports wildcards (e.g. *.example.com). +- `rules` (Attributes List) Routing rules under the specified host, matched by path prefix. (see [below for nested schema](#nestedatt--listeners--http--hosts--rules)) + + +### Nested Schema for `listeners.http.hosts.rules` + +Required: + +- `target_pool` (String) Reference target pool by target pool name. + +Optional: + +- `cookie_persistence` (Attributes) Routing persistence via cookies. (see [below for nested schema](#nestedatt--listeners--http--hosts--rules--cookie_persistence)) +- `headers` (Attributes Set) Headers for the rule. (see [below for nested schema](#nestedatt--listeners--http--hosts--rules--headers)) +- `path` (Attributes) Routing via path. (see [below for nested schema](#nestedatt--listeners--http--hosts--rules--path)) +- `query_parameters` (Attributes Set) Query parameters for the rule. (see [below for nested schema](#nestedatt--listeners--http--hosts--rules--query_parameters)) +- `web_socket` (Boolean) If enabled, when client sends an HTTP request with and Upgrade header, indicating the desire to establish a Websocket connection, if backend server supports WebSocket, it responds with HTTP 101 status code, switching protocols from HTTP to WebSocket. Hence the client and the server can exchange data in real-time using one long-lived TCP connection. + + +### Nested Schema for `listeners.http.hosts.rules.cookie_persistence` + +Required: + +- `name` (String) The name of the cookie to use. +- `ttl` (String) TTL specifies the time-to-live for the cookie. The default value is 0s, and it acts as a session cookie, expiring when the client session ends. + + + +### Nested Schema for `listeners.http.hosts.rules.headers` + +Required: + +- `name` (String) Header name. + +Optional: + +- `exact_match` (String) Exact match for the header value. + + + +### Nested Schema for `listeners.http.hosts.rules.path` + +Optional: + +- `exact_match` (String) Exact path match. Only a request path exactly equal to the value will match, e.g. '/foo' matches only '/foo', not '/foo/bar' or '/foobar'. +- `prefix` (String) Prefix path match. Only matches on full segment boundaries, e.g. '/foo' matches '/foo' and '/foo/bar' but NOT '/foobar'. + + + +### Nested Schema for `listeners.http.hosts.rules.query_parameters` + +Required: + +- `name` (String) Query parameter name. + +Optional: + +- `exact_match` (String) Exact match for the query parameters value. + + + + + + +### Nested Schema for `listeners.https` + +Required: + +- `certificate_config` (Attributes) TLS termination certificate configuration. (see [below for nested schema](#nestedatt--listeners--https--certificate_config)) + + +### Nested Schema for `listeners.https.certificate_config` + +Required: + +- `certificate_ids` (Set of String) Certificate IDs for TLS termination. + + + + + +### Nested Schema for `networks` + +Required: + +- `network_id` (String) STACKIT network ID the Application Load Balancer and/or targets are in. +- `role` (String) The role defines how the Application Load Balancer is using the network. Possible values are: `ROLE_UNSPECIFIED`, `ROLE_LISTENERS_AND_TARGETS`, `ROLE_LISTENERS`, `ROLE_TARGETS`. + + + +### Nested Schema for `target_pools` + +Required: + +- `name` (String) Target pool name. +- `target_port` (Number) The number identifying the port where each target listens for traffic. +- `targets` (Attributes Set) List of all targets which will be used in the pool. Limited to 250. (see [below for nested schema](#nestedatt--target_pools--targets)) + +Optional: + +- `active_health_check` (Attributes) (see [below for nested schema](#nestedatt--target_pools--active_health_check)) +- `tls_config` (Attributes) Configuration for TLS bridging. (see [below for nested schema](#nestedatt--target_pools--tls_config)) + + +### Nested Schema for `target_pools.targets` + +Required: + +- `ip` (String) Private target IP, which must by unique within a target pool. + +Optional: + +- `display_name` (String) Target display name + + + +### Nested Schema for `target_pools.active_health_check` + +Required: + +- `healthy_threshold` (Number) Healthy threshold of the health checking. +- `interval` (String) Interval duration of health checking in seconds. +- `interval_jitter` (String) Interval duration threshold of the health checking in seconds. +- `timeout` (String) Active health checking timeout duration in seconds. +- `unhealthy_threshold` (Number) Unhealthy threshold of the health checking. + +Optional: + +- `http_health_checks` (Attributes) Options for the HTTP health checking. (see [below for nested schema](#nestedatt--target_pools--active_health_check--http_health_checks)) + + +### Nested Schema for `target_pools.active_health_check.http_health_checks` + +Required: + +- `ok_status` (Set of String) List of HTTP status codes that indicate a healthy response. +- `path` (String) Path to send the health check request to. + + + + +### Nested Schema for `target_pools.tls_config` + +Optional: + +- `custom_ca` (String) Specifies a custom Certificate Authority (CA). When provided, the target pool will trust certificates signed by this CA, in addition to any system-trusted CAs. This is useful for scenarios where the target pool needs to communicate with servers using self-signed or internally-issued certificates. Enabled needs to be set to true and skip validation to false for this option. +- `enabled` (Boolean) Enable TLS (Transport Layer Security) bridging for the connection between Application Load Balancer and targets in this pool. When enabled, public CAs are trusted. Can be used in tandem with the options either custom CA or skip validation or alone. +- `skip_certificate_validation` (Boolean) Bypass certificate validation for TLS bridging in this target pool. This option is insecure and can only be used with public CAs by setting enabled true. Meant to be used for testing purposes only! + + + + +### Nested Schema for `options` + +Optional: + +- `acl` (Set of String) Use this option to limit the IP ranges that can use the Application Load Balancer. +- `ephemeral_address` (Boolean) This option automates the handling of the external IP address for an Application Load Balancer. If set to true a new IP address will be automatically created. It will also be automatically deleted when the Load Balancer is deleted. +- `observability` (Attributes) We offer Load Balancer observability via STACKIT Observability or external solutions. (see [below for nested schema](#nestedatt--options--observability)) +- `private_network_only` (Boolean) Application Load Balancer is accessible only via a private network ip address. Not changeable after creation. + + +### Nested Schema for `options.observability` + +Optional: + +- `logs` (Attributes) Observability logs configuration. (see [below for nested schema](#nestedatt--options--observability--logs)) +- `metrics` (Attributes) Observability metrics configuration. (see [below for nested schema](#nestedatt--options--observability--metrics)) + + +### Nested Schema for `options.observability.logs` + +Required: + +- `credentials_ref` (String) Credentials reference for logging. This reference is created via the observability create endpoint and the credential needs to contain the basic auth username and password for the logging solution the push URL points to. Then this enables monitoring via remote write for the Application Load Balancer. +- `push_url` (String) Credentials reference for logging. This reference is created via the observability create endpoint and the credential needs to contain the basic auth username and password for the logging solution the push URL points to. Then this enables monitoring via remote write for the Application Load Balancer. + + + +### Nested Schema for `options.observability.metrics` + +Required: + +- `credentials_ref` (String) Credentials reference for metrics. This reference is created via the observability create endpoint and the credential needs to contain the basic auth username and password for the metrics solution the push URL points to. Then this enables monitoring via remote write for the Application Load Balancer. +- `push_url` (String) Credentials reference for metrics. This reference is created via the observability create endpoint and the credential needs to contain the basic auth username and password for the metrics solution the push URL points to. Then this enables monitoring via remote write for the Application Load Balancer. + + + + + +### Nested Schema for `errors` + +Read-Only: + +- `description` (String) The error description contains additional helpful user information to fix the error state of the Application Load Balancer. For example the IP 45.135.247.139 does not exist in the project, then the description will report: Floating IP "45.135.247.139" could not be found. +- `type` (String) Enum: "TYPE_UNSPECIFIED" "TYPE_INTERNAL" "TYPE_QUOTA_SECGROUP_EXCEEDED" "TYPE_QUOTA_SECGROUPRULE_EXCEEDED" "TYPE_PORT_NOT_CONFIGURED" "TYPE_FIP_NOT_CONFIGURED" "TYPE_TARGET_NOT_ACTIVE" "TYPE_METRICS_MISCONFIGURED" "TYPE_LOGS_MISCONFIGURED" +The error type specifies which part of the Application Load Balancer encountered the error. I.e. the API will not check if a provided public IP is actually available in the project. Instead the Application Load Balancer with try to use the provided IP and if not available reports TYPE_FIP_NOT_CONFIGURED error. + + + +### Nested Schema for `load_balancer_security_group` + +Read-Only: + +- `id` (String) ID of the security Group +- `name` (String) Name of the security Group + + + +### Nested Schema for `target_security_group` + +Read-Only: + +- `id` (String) ID of the security Group +- `name` (String) Name of the security Group diff --git a/examples/data-sources/stackit_alb/data-source.tf b/examples/data-sources/stackit_alb/data-source.tf new file mode 100644 index 000000000..4f9e3c691 --- /dev/null +++ b/examples/data-sources/stackit_alb/data-source.tf @@ -0,0 +1,4 @@ +data "stackit_alb" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "example-load-balancer" +} \ No newline at end of file diff --git a/examples/resources/stackit_alb/resource.tf b/examples/resources/stackit_alb/resource.tf new file mode 100644 index 000000000..4829c307b --- /dev/null +++ b/examples/resources/stackit_alb/resource.tf @@ -0,0 +1,201 @@ +variable "project_id" { + description = "The STACKIT Project ID" + type = string + default = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} + +variable "image_id" { + description = "A valid Debian 12 Image ID available in all projects" + type = string + default = "939249d1-6f48-4ab7-929b-95170728311a" +} + +variable "availability_zone" { + description = "An availability zone" + type = string + default = "eu01-1" +} + +variable "machine_type" { + description = "The machine flavor with 2GB of RAM and 1 core" + type = string + default = "c2i.1" +} + +variable "label_key" { + description = "An optional label key" + type = string + default = "key" +} + +variable "label_value" { + description = "An optional label value" + type = string + default = "value" +} + +# Create a network +resource "stackit_network" "network" { + project_id = var.project_id + name = "example-network" + ipv4_nameservers = ["1.1.1.1"] + ipv4_prefix = "192.168.2.0/25" + routed = true +} + +# Create a network interface +resource "stackit_network_interface" "nic" { + project_id = var.project_id + network_id = stackit_network.network.network_id + lifecycle { + ignore_changes = [ + security_group_ids, + ] + } +} + +# Create a key pair for accessing the target server instance +resource "stackit_key_pair" "keypair" { + name = "example-key-pair" + public_key = chomp(file("path/to/id_rsa.pub")) +} + +# Create a target server instance +resource "stackit_server" "server" { + project_id = var.project_id + name = "example-server" + machine_type = var.machine_type + keypair_name = stackit_key_pair.keypair.name + availability_zone = var.availability_zone + + boot_volume = { + size = 20 + source_type = "image" + source_id = var.image_id + delete_on_termination = true + } + + network_interfaces = [ + stackit_network_interface.nic.network_interface_id + ] + + # Explicit dependencies to ensure ordering + depends_on = [ + stackit_network.network, + stackit_key_pair.keypair, + stackit_network_interface.nic + ] +} + +# Create example credentials for observability of the ALB +# Create real credentials in your stackit observability +resource "stackit_loadbalancer_observability_credential" "observability" { + project_id = var.project_id + display_name = "my-cred" + password = "password" + username = "username" +} + +# Create a Application Load Balancer +resource "stackit_alb" "example" { + project_id = var.project_id + region = "eu01" + name = "example-load-balancer" + plan_id = "p10" + // Hint: Automatically create an IP for the ALB lifecycle by setting ephemeral_address = true or use: + // external_address = "124.124.124.124" + labels = { + (var.label_key) = var.label_value + } + listeners = [{ + port = 443 + http = { + hosts = [{ + host = "*" + rules = [{ + target_pool = "my-target-pool" + web_socket = true + query_parameters = [{ + name = "my-query-key" + exact_match = "my-query-value" + }] + headers = [{ + name = "my-header-key" + exact_match = "my-header-value" + }] + path = { + prefix = "/path" + } + cookie_persistence = { + name = "my-cookie" + ttl = "60s" + } + }] + }] + } + https = { + certificate_config = { + certificate_ids = [ + # Currently no TF provider available, needs to be added with API + # https://docs.api.stackit.cloud/documentation/certificates/version/v2 + "name-v1-8c81bd317af8a03b8ef0851ccb074eb17d1ad589b540446244a5e593f78ef820" + ] + } + } + protocol = "PROTOCOL_HTTPS" + # Currently no TF provider available, needs to be added with API + # https://docs.api.stackit.cloud/documentation/alb-waf/version/v1alpha + waf_config_name = "my-waf-config" + } + ] + networks = [ + { + network_id = stackit_network.network.network_id + role = "ROLE_LISTENERS_AND_TARGETS" + } + ] + options = { + acl = ["123.123.123.123/24", "12.12.12.12/24"] + ephemeral_address = true + private_network_only = false + observability = { + logs = { + credentials_ref = stackit_loadbalancer_observability_credential.observability.credentials_ref + push_url = "https://logs.stackit.argus.eu01.stackit.cloud/instances//loki/api/v1/push" + } + metrics = { + credentials_ref = stackit_loadbalancer_observability_credential.observability.credentials_ref + push_url = "https://push.metrics.stackit.argus.eu01.stackit.cloud/instances//api/v1/receive" + } + } + } + target_pools = [ + { + name = "my-target-pool" + active_health_check = { + interval = "0.500s" + interval_jitter = "0.010s" + timeout = "1s" + healthy_threshold = "5" + unhealthy_threshold = "3" + http_health_checks = { + ok_status = ["200", "201"] + path = "/healthy" + } + } + target_port = 80 + targets = [ + { + display_name = "my-target" + ip = stackit_network_interface.nic.ipv4 + } + ] + tls_config = { + enabled = true + skip_certificate_validation = false + custom_ca = chomp(file("path/to/PEM_formated_CA")) + } + } + ] + disable_target_security_group_assignment = false # only needed if targets are not in the same network +} diff --git a/go.mod b/go.mod index 84c0de7ca..348f007a3 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,8 @@ require ( github.com/hashicorp/terraform-plugin-go v0.29.0 github.com/hashicorp/terraform-plugin-log v0.10.0 github.com/hashicorp/terraform-plugin-testing v1.13.3 - github.com/stackitcloud/stackit-sdk-go/core v0.19.0 + github.com/stackitcloud/stackit-sdk-go/core v0.20.0 + github.com/stackitcloud/stackit-sdk-go/services/alb v0.7.2 github.com/stackitcloud/stackit-sdk-go/services/cdn v1.6.0 github.com/stackitcloud/stackit-sdk-go/services/dns v0.17.1 github.com/stackitcloud/stackit-sdk-go/services/git v0.8.0 @@ -40,6 +41,7 @@ require ( github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex v1.3.1 github.com/teambition/rrule-go v1.8.2 golang.org/x/mod v0.30.0 + k8s.io/utils v0.0.0-20260108192941-914a6e750570 ) require ( diff --git a/go.sum b/go.sum index 7a43d814e..2379e16ce 100644 --- a/go.sum +++ b/go.sum @@ -152,8 +152,10 @@ github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= -github.com/stackitcloud/stackit-sdk-go/core v0.19.0 h1:dtJcs6/TTCzzb2RKI7HJugDrbCkaFEDmn1pOeFe8qnI= -github.com/stackitcloud/stackit-sdk-go/core v0.19.0/go.mod h1:fqto7M82ynGhEnpZU6VkQKYWYoFG5goC076JWXTUPRQ= +github.com/stackitcloud/stackit-sdk-go/core v0.20.0 h1:4rrUk6uT1g4nOn5/g1uXukP07Tux/o5xbMz/f/qE1rY= +github.com/stackitcloud/stackit-sdk-go/core v0.20.0/go.mod h1:fqto7M82ynGhEnpZU6VkQKYWYoFG5goC076JWXTUPRQ= +github.com/stackitcloud/stackit-sdk-go/services/alb v0.7.2 h1:x7ndqw6yaOw+TmThNeAkI+eN9vK5hWgjIJlFZrYPREo= +github.com/stackitcloud/stackit-sdk-go/services/alb v0.7.2/go.mod h1:wbPNu6e5r/5xhzznCKbC7fEJahrAOb89gmaIm+0w2/s= github.com/stackitcloud/stackit-sdk-go/services/authorization v0.9.0 h1:7ZKd3b+E/R4TEVShLTXxx5FrsuDuJBOyuVOuKTMa4mo= github.com/stackitcloud/stackit-sdk-go/services/authorization v0.9.0/go.mod h1:/FoXa6hF77Gv8brrvLBCKa5ie1Xy9xn39yfHwaln9Tw= github.com/stackitcloud/stackit-sdk-go/services/cdn v1.6.0 h1:Q+qIdejeMsYMkbtVoI9BpGlKGdSVFRBhH/zj44SP8TM= @@ -311,3 +313,5 @@ gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/utils v0.0.0-20260108192941-914a6e750570 h1:JT4W8lsdrGENg9W+YwwdLJxklIuKWdRm+BC+xt33FOY= +k8s.io/utils v0.0.0-20260108192941-914a6e750570/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= diff --git a/stackit/internal/conversion/conversion.go b/stackit/internal/conversion/conversion.go index 312535f01..88fdbee5c 100644 --- a/stackit/internal/conversion/conversion.go +++ b/stackit/internal/conversion/conversion.go @@ -3,6 +3,7 @@ package conversion import ( "context" "fmt" + "sort" "github.com/hashicorp/terraform-plugin-framework/diag" @@ -136,6 +137,32 @@ func StringListToPointer(list basetypes.ListValue) (*[]string, error) { return &listStr, nil } +// StringSetToPointer converts basetypes.SetValue to a pointer to a list of strings. +// It returns nil if the value is null or unknown. +// Note: It sorts the resulting slice to ensure deterministic behavior. +func StringSetToPointer(set basetypes.SetValue) (*[]string, error) { + if set.IsNull() || set.IsUnknown() { + return nil, nil + } + + elements := set.Elements() + result := make([]string, 0, len(elements)) + + for i, el := range elements { + elStr, ok := el.(types.String) + if !ok { + return nil, fmt.Errorf("element %d in set is not a string (type: %T)", i, el) + } + result = append(result, elStr.ValueString()) + } + + // Because Sets are unordered in Terraform, we sort here to + // prevent non-deterministic behavior in the provider logic or API calls. + sort.Strings(result) + + return &result, nil +} + // ToJSONMApPartialUpdatePayload returns a map[string]interface{} to be used in a PATCH request payload. // It takes a current map as it is in the terraform state and a desired map as it is in the user configuratiom // and builds a map which sets to null keys that should be removed, updates the values of existing keys and adds new keys diff --git a/stackit/internal/core/core.go b/stackit/internal/core/core.go index 405bf0c90..4b7ceac4c 100644 --- a/stackit/internal/core/core.go +++ b/stackit/internal/core/core.go @@ -37,6 +37,7 @@ type ProviderData struct { GitCustomEndpoint string IaaSCustomEndpoint string KMSCustomEndpoint string + ALBCustomEndpoint string LoadBalancerCustomEndpoint string LogMeCustomEndpoint string MariaDBCustomEndpoint string diff --git a/stackit/internal/services/alb/alb_acc_test.go b/stackit/internal/services/alb/alb_acc_test.go new file mode 100644 index 000000000..7ea4c4c19 --- /dev/null +++ b/stackit/internal/services/alb/alb_acc_test.go @@ -0,0 +1,599 @@ +package alb_test + +import ( + "context" + _ "embed" + "fmt" + "maps" + "strings" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/config" + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" + "github.com/stackitcloud/stackit-sdk-go/services/alb/wait" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + + stackitSdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/stackitcloud/stackit-sdk-go/core/utils" + "github.com/stackitcloud/stackit-sdk-go/services/alb" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/testutil" +) + +//go:embed testfiles/resource-min.tf +var resourceMinConfig string + +//go:embed testfiles/resource-max.tf +var resourceMaxConfig string + +var testConfigVarsMin = config.Variables{ + "project_id": config.StringVariable(testutil.ProjectId), + "region": config.StringVariable(testutil.Region), + "loadbalancer_name": config.StringVariable(fmt.Sprintf("tf-acc-l%s", acctest.RandStringFromCharSet(7, acctest.CharSetAlphaNum))), + "network_role": config.StringVariable("ROLE_LISTENERS_AND_TARGETS"), + "network_name": config.StringVariable(fmt.Sprintf("tf-acc-n%s", acctest.RandStringFromCharSet(7, acctest.CharSetAlphaNum))), + "plan_id": config.StringVariable("p10"), + "listener_port": config.StringVariable("5432"), + "host": config.StringVariable("*"), + "path_prefix": config.StringVariable("/"), + "protocol_http": config.StringVariable("PROTOCOL_HTTP"), + "target_pool_name": config.StringVariable("my-target-pool"), + "target_pool_port": config.StringVariable("5432"), + "target_display_name": config.StringVariable("my-target"), +} + +var testConfigVarsMax = config.Variables{ + "project_id": config.StringVariable(testutil.ProjectId), + "region": config.StringVariable(testutil.Region), + "loadbalancer_name": config.StringVariable(fmt.Sprintf("tf-acc-l%s", acctest.RandStringFromCharSet(7, acctest.CharSetAlphaNum))), + "labels_key_1": config.StringVariable("key1"), + "labels_value_1": config.StringVariable("value1"), + "labels_key_2": config.StringVariable("key2"), + "labels_value_2": config.StringVariable("value2"), + "plan_id": config.StringVariable("p10"), + "certificate_ids": config.StringVariable("name-v1-8c81bd317af8a03b8ef0851ccb074eb17d1ad589b540446244a5e593f78ef820"), + "protocol_https": config.StringVariable("PROTOCOL_HTTPS"), + "protocol_http": config.StringVariable("PROTOCOL_HTTP"), + "disable_security_group_assignment": config.BoolVariable(true), + "listener_port_1": config.StringVariable("443"), + "listener_port_4": config.StringVariable("80"), + "tls_config_enabled": config.BoolVariable(true), + "tls_config_skip": config.BoolVariable(false), + "tls_config_custom_ca": config.StringVariable("-----BEGIN CERTIFICATE-----\nMIIDCzCCAfOgAwIBAgIUTyPsTWC9ly7o+wNFYm0uu1+P8IEwDQYJKoZIhvcNAQEL\nBQAwFTETMBEGA1UEAwwKTXlDdXN0b21DQTAeFw0yNTAyMTkxOTI0MjBaFw0yNjAy\nMTkxOTI0MjBaMBUxEzARBgNVBAMMCk15Q3VzdG9tQ0EwggEiMA0GCSqGSIb3DQEB\nAQUAA4IBDwAwggEKAoIBAQCQMEYKbiNxU37fEwBOxkvCshBR+0MwxwLW8Mi3/pvo\nn3huxjcm7EaKW9r7kIaoHXbTS1tnO6rHAHKBDxzuoYD7C2SMSiLxddquNRvpkLaP\n8qAXneQY2VP7LzsAgsC04PKG0YC1NgF5sJGsiWIRGIm+csYLnPMnwaAGx4IvY6mH\nAmM64b6QRCg36LK+P6N9KTvSQLvvmFdkA2sDToCmN/Amp6xNDFq+aQGLwdQQqHDP\nTaUqPmEyiFHKvFUaFMNQVk8B1Om8ASo69m8U3Eat4ZOVW1titE393QkOdA6ZypMC\nrJJpeNNLLJq3mIOWOd7GEyAvjUfmJwGhqEFS7lMG67hnAgMBAAGjUzBRMB0GA1Ud\nDgQWBBSk/IM5jaOAJL3/Knyq3cVva04YZDAfBgNVHSMEGDAWgBSk/IM5jaOAJL3/\nKnyq3cVva04YZDAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQBe\nZ/mE8rNIbNbHQep/VppshaZUzgdy4nsmh0wvxMuHIQP0KHrxLCkhOn7A9fu4mY/P\nQ+8QqlnjTsM4cqiuFcd5V1Nk9VF/e5X3HXCDHh/jBFw+O5TGVAR/7DBw31lYv/Lt\nHakkjQCdawuvH3osO/UkElM/i2KC+iYBavTenm97AR7WGgW15/MIqxNaYE+nJth/\ndcVD0b5qSuYQaEmZ3CzMUi188R+go5ozCf2cOaa+3/LEYAaI3vKiSE8KTsshyoKm\nO6YZqrVxQCWCDTOsd28k7lHt8wJ+jzYcjCu60DUpg1ZpY+ZnmrE8vPPDb/zXhBn6\n/llXTWOUjmuTKnGsIDP5\n-----END CERTIFICATE-----"), + "web_socket": config.BoolVariable(true), + "query_parameters_name_1": config.StringVariable("a"), + "query_parameters_exact_match_1": config.StringVariable("b"), + "query_parameters_name_2": config.StringVariable("c"), + "query_parameters_exact_match_2": config.StringVariable("d"), + "headers_name_1": config.StringVariable("1"), + "headers_exact_match_1": config.StringVariable("2"), + "headers_name_2": config.StringVariable("3"), + "headers_exact_match_2": config.StringVariable("4"), + "headers_name_3": config.StringVariable("5"), + "host_1": config.StringVariable("*"), + "host_3": config.StringVariable("www.example.org"), + "host_4": config.StringVariable("www.*"), + "path_prefix_1": config.StringVariable("/specific-path-1"), + "path_prefix_2": config.StringVariable("/specific-path-2"), + "path_prefix_3": config.StringVariable("/specific-path-3"), + "path_prefix_4": config.StringVariable("/specific-path-4"), + "network_name_listener": config.StringVariable(fmt.Sprintf("tf-acc-l%s", acctest.RandStringFromCharSet(7, acctest.CharSetAlphaNum))), + "network_name_targets": config.StringVariable(fmt.Sprintf("tf-acc-t%s", acctest.RandStringFromCharSet(7, acctest.CharSetAlphaNum))), + "network_role_listeners": config.StringVariable("ROLE_LISTENERS"), + "network_role_targets": config.StringVariable("ROLE_TARGETS"), + "target_pool_name_1": config.StringVariable("my-target-pool-1"), + "target_pool_name_2": config.StringVariable("my-target-pool-2"), + "target_pool_name_3": config.StringVariable("my-target-pool-3"), + "target_pool_name_4": config.StringVariable("my-target-pool-4"), + "target_pool_port_1": config.StringVariable("443"), + "target_pool_port_2": config.StringVariable("1337"), + "target_pool_port_3": config.StringVariable("9001"), + "target_pool_port_4": config.StringVariable("1234"), + "target_display_name": config.StringVariable("example-target"), + "ahc_interval": config.StringVariable("1s"), + "ahc_interval_jitter": config.StringVariable("0.010s"), + "ahc_timeout": config.StringVariable("1s"), + "ahc_healthy_threshold": config.StringVariable("3"), + "ahc_unhealthy_threshold": config.StringVariable("5"), + "ahc_http_ok_status_200": config.StringVariable("200"), + "ahc_http_ok_status_201": config.StringVariable("201"), + "ahc_http_path": config.StringVariable("/healthy"), + "ephemeral_address": config.BoolVariable(true), + "private_network_only": config.BoolVariable(false), + "acl": config.StringVariable("192.168.0.0/24"), + "observability_logs_push_url": config.StringVariable("https://logs.observability.dummy.stackit.cloud"), + "observability_metrics_push_url": config.StringVariable("https://metrics.observability.dummy.stackit.cloud"), + "observability_credential_name": config.StringVariable(fmt.Sprintf("tf-acc-l%s", acctest.RandStringFromCharSet(7, acctest.CharSetAlphaNum))), + "observability_credential_username": config.StringVariable("obs-cred-username"), + "observability_credential_password": config.StringVariable("obs-cred-password"), +} + +func configVarsMinUpdated() config.Variables { + tempConfig := make(config.Variables, len(testConfigVarsMin)) + maps.Copy(tempConfig, testConfigVarsMin) + tempConfig["target_pool_port"] = config.StringVariable("5431") + return tempConfig +} + +func configVarsMaxUpdated() config.Variables { + tempConfig := make(config.Variables, len(testConfigVarsMax)) + maps.Copy(tempConfig, testConfigVarsMax) + tempConfig["ephemeral_address"] = config.BoolVariable(false) + tempConfig["web_socket"] = config.BoolVariable(false) + tempConfig["query_parameters_name_1"] = config.StringVariable("e") + tempConfig["query_parameters_exact_match_1"] = config.StringVariable("f") + tempConfig["query_parameters_name_2"] = config.StringVariable("g") + tempConfig["query_parameters_exact_match_2"] = config.StringVariable("h") + tempConfig["headers_name_1"] = config.StringVariable("6") + tempConfig["headers_exact_match_1"] = config.StringVariable("7") + tempConfig["headers_name_2"] = config.StringVariable("8") + tempConfig["headers_exact_match_2"] = config.StringVariable("9") + tempConfig["headers_name_3"] = config.StringVariable("0") + tempConfig["host_1"] = config.StringVariable("www.example.*") + tempConfig["target_pool_port_1"] = config.StringVariable("444") + tempConfig["ahc_http_ok_status_200"] = config.StringVariable("202") + tempConfig["ahc_http_ok_status_201"] = config.StringVariable("203") + tempConfig["ahc_timeout"] = config.StringVariable("5s") + tempConfig["ahc_healthy_threshold"] = config.StringVariable("5") + tempConfig["acl"] = config.StringVariable("10.11.10.8/24") + return tempConfig +} + +func TestAccALBResourceMin(t *testing.T) { + resource.ParallelTest(t, resource.TestCase{ + ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, + CheckDestroy: testAccCheckALBDestroy, + Steps: []resource.TestStep{ + // Creation + { + ConfigVariables: testConfigVarsMin, + Config: testutil.ALBProviderConfig() + resourceMinConfig, + Check: resource.ComposeAggregateTestCheckFunc( + // Load balancer instance resource + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "project_id", testutil.ConvertConfigVariable(testConfigVarsMin["project_id"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "region", testutil.ConvertConfigVariable(testConfigVarsMin["region"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "name", testutil.ConvertConfigVariable(testConfigVarsMin["loadbalancer_name"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "networks.0.role", testutil.ConvertConfigVariable(testConfigVarsMin["network_role"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "plan_id", testutil.ConvertConfigVariable(testConfigVarsMin["plan_id"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "listeners.0.port", testutil.ConvertConfigVariable(testConfigVarsMin["listener_port"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "listeners.0.http.hosts.0.host", testutil.ConvertConfigVariable(testConfigVarsMin["host"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "listeners.0.http.hosts.0.rules.0.path.prefix", testutil.ConvertConfigVariable(testConfigVarsMin["path_prefix"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "listeners.0.http.hosts.0.rules.0.target_pool", testutil.ConvertConfigVariable(testConfigVarsMin["target_pool_name"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "listeners.0.protocol", testutil.ConvertConfigVariable(testConfigVarsMin["protocol_http"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "target_pools.0.name", testutil.ConvertConfigVariable(testConfigVarsMin["target_pool_name"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "target_pools.0.target_port", testutil.ConvertConfigVariable(testConfigVarsMin["target_pool_port"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "target_pools.0.targets.0.display_name", testutil.ConvertConfigVariable(testConfigVarsMin["target_display_name"])), + resource.TestCheckResourceAttrSet("stackit_alb.loadbalancer", "networks.0.network_id"), + resource.TestCheckResourceAttrSet("stackit_alb.loadbalancer", "target_pools.0.targets.0.ip"), + resource.TestCheckResourceAttrSet("stackit_alb.loadbalancer", "external_address"), + resource.TestCheckResourceAttrSet("stackit_alb.loadbalancer", "target_security_group.id"), + resource.TestCheckResourceAttrSet("stackit_alb.loadbalancer", "target_security_group.name"), + resource.TestCheckResourceAttrSet("stackit_alb.loadbalancer", "load_balancer_security_group.id"), + resource.TestCheckResourceAttrSet("stackit_alb.loadbalancer", "load_balancer_security_group.name"), + resource.TestCheckResourceAttrSet("stackit_alb.loadbalancer", "version"), + resource.TestCheckResourceAttrSet("stackit_alb.loadbalancer", "status"), + resource.TestCheckNoResourceAttr("stackit_alb.loadbalancer", "disable_security_group_assignment"), + resource.TestCheckNoResourceAttr("stackit_alb.loadbalancer", "options"), + resource.TestCheckNoResourceAttr("stackit_alb.loadbalancer", "labels"), + ), + }, + // Data source + { + ConfigVariables: testConfigVarsMin, + Config: fmt.Sprintf(` + %s + + data "stackit_alb" "loadbalancer" { + project_id = stackit_alb.loadbalancer.project_id + name = stackit_alb.loadbalancer.name + } + `, + testutil.ALBProviderConfig()+resourceMinConfig, + ), + Check: resource.ComposeAggregateTestCheckFunc( + // Load balancer instance + resource.TestCheckResourceAttr("data.stackit_alb.loadbalancer", "project_id", testutil.ConvertConfigVariable(testConfigVarsMin["project_id"])), + resource.TestCheckResourceAttr("data.stackit_alb.loadbalancer", "region", testutil.ConvertConfigVariable(testConfigVarsMin["region"])), + resource.TestCheckResourceAttr("data.stackit_alb.loadbalancer", "name", testutil.ConvertConfigVariable(testConfigVarsMin["loadbalancer_name"])), + resource.TestCheckResourceAttr("data.stackit_alb.loadbalancer", "networks.0.role", testutil.ConvertConfigVariable(testConfigVarsMin["network_role"])), + resource.TestCheckResourceAttr("data.stackit_alb.loadbalancer", "plan_id", testutil.ConvertConfigVariable(testConfigVarsMin["plan_id"])), + resource.TestCheckResourceAttr("data.stackit_alb.loadbalancer", "listeners.0.port", testutil.ConvertConfigVariable(testConfigVarsMin["listener_port"])), + resource.TestCheckResourceAttr("data.stackit_alb.loadbalancer", "listeners.0.http.hosts.0.host", testutil.ConvertConfigVariable(testConfigVarsMin["host"])), + resource.TestCheckResourceAttr("data.stackit_alb.loadbalancer", "listeners.0.http.hosts.0.rules.0.path.prefix", testutil.ConvertConfigVariable(testConfigVarsMin["path_prefix"])), + resource.TestCheckResourceAttr("data.stackit_alb.loadbalancer", "listeners.0.http.hosts.0.rules.0.target_pool", testutil.ConvertConfigVariable(testConfigVarsMin["target_pool_name"])), + resource.TestCheckResourceAttr("data.stackit_alb.loadbalancer", "listeners.0.protocol", testutil.ConvertConfigVariable(testConfigVarsMin["protocol_http"])), + resource.TestCheckResourceAttr("data.stackit_alb.loadbalancer", "target_pools.0.name", testutil.ConvertConfigVariable(testConfigVarsMin["target_pool_name"])), + resource.TestCheckResourceAttr("data.stackit_alb.loadbalancer", "target_pools.0.target_port", testutil.ConvertConfigVariable(testConfigVarsMin["target_pool_port"])), + resource.TestCheckResourceAttr("data.stackit_alb.loadbalancer", "target_pools.0.targets.0.display_name", testutil.ConvertConfigVariable(testConfigVarsMin["target_display_name"])), + resource.TestCheckResourceAttrSet("data.stackit_alb.loadbalancer", "networks.0.network_id"), + resource.TestCheckResourceAttrSet("data.stackit_alb.loadbalancer", "target_pools.0.targets.0.ip"), + resource.TestCheckResourceAttrSet("data.stackit_alb.loadbalancer", "external_address"), + resource.TestCheckResourceAttrSet("stackit_alb.loadbalancer", "target_security_group.id"), + resource.TestCheckResourceAttrSet("stackit_alb.loadbalancer", "target_security_group.name"), + resource.TestCheckResourceAttrSet("stackit_alb.loadbalancer", "load_balancer_security_group.id"), + resource.TestCheckResourceAttrSet("stackit_alb.loadbalancer", "load_balancer_security_group.name"), + resource.TestCheckResourceAttrSet("data.stackit_alb.loadbalancer", "version"), + resource.TestCheckResourceAttrSet("data.stackit_alb.loadbalancer", "status"), + resource.TestCheckNoResourceAttr("data.stackit_alb.loadbalancer", "disable_security_group_assignment"), + resource.TestCheckNoResourceAttr("data.stackit_alb.loadbalancer", "options"), + resource.TestCheckNoResourceAttr("data.stackit_alb.loadbalancer", "labels"), + resource.TestCheckNoResourceAttr("data.stackit_alb.loadbalancer", "errors"), + resource.TestCheckResourceAttrPair( + "data.stackit_alb.loadbalancer", "project_id", + "stackit_alb.loadbalancer", "project_id", + ), + resource.TestCheckResourceAttrPair( + "data.stackit_alb.loadbalancer", "region", + "stackit_alb.loadbalancer", "region", + ), + resource.TestCheckResourceAttrPair( + "data.stackit_alb.loadbalancer", "name", + "stackit_alb.loadbalancer", "name", + ), + resource.TestCheckResourceAttrPair( + "data.stackit_alb.loadbalancer", "plan_id", + "stackit_alb.loadbalancer", "plan_id", + ), + resource.TestCheckResourceAttrPair( + "stackit_alb.loadbalancer", "external_address", + "data.stackit_alb.loadbalancer", "external_address", + ), + resource.TestCheckResourceAttrPair( + "stackit_alb.loadbalancer", "target_security_group.id", + "data.stackit_alb.loadbalancer", "target_security_group.id", + ), + resource.TestCheckResourceAttrPair( + "stackit_alb.loadbalancer", "target_security_group.name", + "data.stackit_alb.loadbalancer", "target_security_group.name", + ), + resource.TestCheckResourceAttrPair( + "stackit_alb.loadbalancer", "load_balancer_security_group.id", + "data.stackit_alb.loadbalancer", "load_balancer_security_group.id", + ), + resource.TestCheckResourceAttrPair( + "stackit_alb.loadbalancer", "load_balancer_security_group.name", + "data.stackit_alb.loadbalancer", "load_balancer_security_group.name", + ), + resource.TestCheckResourceAttrPair( + "stackit_alb.loadbalancer", "version", + "data.stackit_alb.loadbalancer", "version", + ), + resource.TestCheckResourceAttrPair( + "stackit_alb.loadbalancer", "status", + "data.stackit_alb.loadbalancer", "status", + ), + )}, + // Import + { + ConfigVariables: testConfigVarsMin, + ResourceName: "stackit_alb.loadbalancer", + ImportStateIdFunc: func(s *terraform.State) (string, error) { + r, ok := s.RootModule().Resources["stackit_alb.loadbalancer"] + if !ok { + return "", fmt.Errorf("couldn't find resource stackit_alb.loadbalancer") + } + name, ok := r.Primary.Attributes["name"] + if !ok { + return "", fmt.Errorf("couldn't find attribute name") + } + region, ok := r.Primary.Attributes["region"] + if !ok { + return "", fmt.Errorf("couldn't find attribute region") + } + return fmt.Sprintf("%s,%s,%s", testutil.ProjectId, region, name), nil + }, + ImportState: true, + ImportStateVerify: true, + }, + // Update + { + ConfigVariables: configVarsMinUpdated(), + Config: testutil.ALBProviderConfig() + resourceMinConfig, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "project_id", testutil.ConvertConfigVariable(testConfigVarsMin["project_id"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "name", testutil.ConvertConfigVariable(testConfigVarsMin["loadbalancer_name"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "target_pools.0.target_port", testutil.ConvertConfigVariable(configVarsMinUpdated()["target_pool_port"])), + ), + }, + // Deletion is done by the framework implicitly + }, + }) +} + +func TestAccALBResourceMax(t *testing.T) { + resource.ParallelTest(t, resource.TestCase{ + ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, + CheckDestroy: testAccCheckALBDestroy, + Steps: []resource.TestStep{ + // Creation + { + ConfigVariables: testConfigVarsMax, + Config: testutil.ALBProviderConfig() + resourceMaxConfig, + Check: resource.ComposeAggregateTestCheckFunc( + // Load balancer instance resource + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "project_id", testutil.ConvertConfigVariable(testConfigVarsMax["project_id"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "name", testutil.ConvertConfigVariable(testConfigVarsMax["loadbalancer_name"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "plan_id", testutil.ConvertConfigVariable(testConfigVarsMax["plan_id"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "labels.key1", testutil.ConvertConfigVariable(testConfigVarsMax["labels_value_1"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "labels.key2", testutil.ConvertConfigVariable(testConfigVarsMax["labels_value_2"])), + resource.TestCheckTypeSetElemNestedAttrs("stackit_alb.loadbalancer", "networks.*", map[string]string{"role": testutil.ConvertConfigVariable(testConfigVarsMax["network_role_listeners"])}), + resource.TestCheckTypeSetElemNestedAttrs("stackit_alb.loadbalancer", "networks.*", map[string]string{"role": testutil.ConvertConfigVariable(testConfigVarsMax["network_role_targets"])}), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "disable_target_security_group_assignment", testutil.ConvertConfigVariable(testConfigVarsMax["disable_security_group_assignment"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "listeners.0.port", testutil.ConvertConfigVariable(testConfigVarsMax["listener_port_1"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "listeners.0.http.hosts.0.host", testutil.ConvertConfigVariable(testConfigVarsMax["host_1"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "listeners.0.http.hosts.0.rules.0.target_pool", testutil.ConvertConfigVariable(testConfigVarsMax["target_pool_name_1"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "listeners.0.http.hosts.0.rules.0.web_socket", testutil.ConvertConfigVariable(testConfigVarsMax["web_socket"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "listeners.0.http.hosts.0.rules.0.query_parameters.0.name", testutil.ConvertConfigVariable(testConfigVarsMax["query_parameters_name_1"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "listeners.0.http.hosts.0.rules.0.query_parameters.0.exact_match", testutil.ConvertConfigVariable(testConfigVarsMax["query_parameters_exact_match_1"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "listeners.0.http.hosts.0.rules.0.query_parameters.1.name", testutil.ConvertConfigVariable(testConfigVarsMax["query_parameters_name_2"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "listeners.0.http.hosts.0.rules.0.query_parameters.1.exact_match", testutil.ConvertConfigVariable(testConfigVarsMax["query_parameters_exact_match_2"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "listeners.0.http.hosts.0.rules.0.headers.0.name", testutil.ConvertConfigVariable(testConfigVarsMax["headers_name_1"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "listeners.0.http.hosts.0.rules.0.headers.0.exact_match", testutil.ConvertConfigVariable(testConfigVarsMax["headers_exact_match_1"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "listeners.0.http.hosts.0.rules.0.headers.1.name", testutil.ConvertConfigVariable(testConfigVarsMax["headers_name_2"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "listeners.0.http.hosts.0.rules.0.headers.1.exact_match", testutil.ConvertConfigVariable(testConfigVarsMax["headers_exact_match_2"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "listeners.0.http.hosts.0.rules.0.headers.2.name", testutil.ConvertConfigVariable(testConfigVarsMax["headers_name_3"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "listeners.0.http.hosts.0.rules.0.path.prefix", testutil.ConvertConfigVariable(testConfigVarsMax["path_prefix_1"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "listeners.0.http.hosts.0.rules.1.path.prefix", testutil.ConvertConfigVariable(testConfigVarsMax["path_prefix_2"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "listeners.0.http.hosts.0.rules.1.target_pool", testutil.ConvertConfigVariable(testConfigVarsMax["target_pool_name_2"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "listeners.0.http.hosts.1.host", testutil.ConvertConfigVariable(testConfigVarsMax["host_3"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "listeners.0.http.hosts.1.rules.0.path.prefix", testutil.ConvertConfigVariable(testConfigVarsMax["path_prefix_3"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "listeners.0.http.hosts.1.rules.0.target_pool", testutil.ConvertConfigVariable(testConfigVarsMax["target_pool_name_3"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "listeners.0.https.certificate_config.certificate_ids.0", testutil.ConvertConfigVariable(testConfigVarsMax["certificate_ids"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "listeners.0.protocol", testutil.ConvertConfigVariable(testConfigVarsMax["protocol_https"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "listeners.1.port", testutil.ConvertConfigVariable(testConfigVarsMax["listener_port_4"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "listeners.1.http.hosts.0.host", testutil.ConvertConfigVariable(testConfigVarsMax["host_4"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "listeners.1.http.hosts.0.rules.0.path.prefix", testutil.ConvertConfigVariable(testConfigVarsMax["path_prefix_4"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "listeners.1.http.hosts.0.rules.0.target_pool", testutil.ConvertConfigVariable(testConfigVarsMax["target_pool_name_4"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "listeners.1.protocol", testutil.ConvertConfigVariable(testConfigVarsMax["protocol_http"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "target_pools.0.name", testutil.ConvertConfigVariable(testConfigVarsMax["target_pool_name_1"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "target_pools.0.target_port", testutil.ConvertConfigVariable(testConfigVarsMax["target_pool_port_1"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "target_pools.0.targets.0.display_name", testutil.ConvertConfigVariable(testConfigVarsMax["target_display_name"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "target_pools.0.active_health_check.interval", testutil.ConvertConfigVariable(testConfigVarsMax["ahc_interval"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "target_pools.0.active_health_check.interval_jitter", testutil.ConvertConfigVariable(testConfigVarsMax["ahc_interval_jitter"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "target_pools.0.active_health_check.timeout", testutil.ConvertConfigVariable(testConfigVarsMax["ahc_timeout"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "target_pools.0.active_health_check.healthy_threshold", testutil.ConvertConfigVariable(testConfigVarsMax["ahc_healthy_threshold"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "target_pools.0.active_health_check.unhealthy_threshold", testutil.ConvertConfigVariable(testConfigVarsMax["ahc_unhealthy_threshold"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "target_pools.0.active_health_check.http_health_checks.ok_status.0", testutil.ConvertConfigVariable(testConfigVarsMax["ahc_http_ok_status_200"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "target_pools.0.active_health_check.http_health_checks.ok_status.1", testutil.ConvertConfigVariable(testConfigVarsMax["ahc_http_ok_status_201"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "target_pools.0.active_health_check.http_health_checks.path", testutil.ConvertConfigVariable(testConfigVarsMax["ahc_http_path"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "target_pools.0.tls_config.enabled", testutil.ConvertConfigVariable(testConfigVarsMax["tls_config_enabled"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "target_pools.0.tls_config.skip_certificate_validation", testutil.ConvertConfigVariable(testConfigVarsMax["tls_config_skip"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "target_pools.0.tls_config.custom_ca", testutil.ConvertConfigVariable(testConfigVarsMax["tls_config_custom_ca"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "target_pools.1.name", testutil.ConvertConfigVariable(testConfigVarsMax["target_pool_name_2"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "target_pools.1.target_port", testutil.ConvertConfigVariable(testConfigVarsMax["target_pool_port_2"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "target_pools.1.targets.0.display_name", testutil.ConvertConfigVariable(testConfigVarsMax["target_display_name"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "target_pools.2.name", testutil.ConvertConfigVariable(testConfigVarsMax["target_pool_name_3"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "target_pools.2.target_port", testutil.ConvertConfigVariable(testConfigVarsMax["target_pool_port_3"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "target_pools.2.targets.0.display_name", testutil.ConvertConfigVariable(testConfigVarsMax["target_display_name"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "target_pools.3.name", testutil.ConvertConfigVariable(testConfigVarsMax["target_pool_name_4"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "target_pools.3.target_port", testutil.ConvertConfigVariable(testConfigVarsMax["target_pool_port_4"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "target_pools.3.targets.0.display_name", testutil.ConvertConfigVariable(testConfigVarsMax["target_display_name"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "options.private_network_only", testutil.ConvertConfigVariable(testConfigVarsMax["private_network_only"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "options.ephemeral_address", testutil.ConvertConfigVariable(testConfigVarsMax["ephemeral_address"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "options.acl.0", testutil.ConvertConfigVariable(testConfigVarsMax["acl"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "options.observability.logs.push_url", testutil.ConvertConfigVariable(testConfigVarsMax["observability_logs_push_url"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "options.observability.metrics.push_url", testutil.ConvertConfigVariable(testConfigVarsMax["observability_metrics_push_url"])), + resource.TestCheckResourceAttrSet("stackit_alb.loadbalancer", "networks.0.network_id"), + resource.TestCheckResourceAttrSet("stackit_alb.loadbalancer", "networks.1.network_id"), + resource.TestCheckResourceAttrSet("stackit_alb.loadbalancer", "options.observability.logs.credentials_ref"), + resource.TestCheckResourceAttrSet("stackit_alb.loadbalancer", "options.observability.metrics.credentials_ref"), + resource.TestCheckResourceAttrSet("stackit_alb.loadbalancer", "target_pools.0.targets.0.ip"), + resource.TestCheckResourceAttrSet("stackit_alb.loadbalancer", "target_pools.1.targets.0.ip"), + resource.TestCheckResourceAttrSet("stackit_alb.loadbalancer", "target_pools.2.targets.0.ip"), + resource.TestCheckResourceAttrSet("stackit_alb.loadbalancer", "target_pools.3.targets.0.ip"), + resource.TestCheckResourceAttrSet("stackit_alb.loadbalancer", "target_security_group.id"), + resource.TestCheckResourceAttrSet("stackit_alb.loadbalancer", "target_security_group.name"), + resource.TestCheckResourceAttrSet("stackit_alb.loadbalancer", "load_balancer_security_group.id"), + resource.TestCheckResourceAttrSet("stackit_alb.loadbalancer", "load_balancer_security_group.name"), + resource.TestCheckResourceAttrSet("stackit_alb.loadbalancer", "external_address"), + resource.TestCheckResourceAttrSet("stackit_alb.loadbalancer", "version"), + resource.TestCheckResourceAttrSet("stackit_alb.loadbalancer", "status"), + resource.TestCheckNoResourceAttr("stackit_alb.loadbalancer", "errors"), + ), + }, + // Data source + { + ConfigVariables: testConfigVarsMax, + Config: fmt.Sprintf(` + %s + + data "stackit_alb" "loadbalancer" { + project_id = stackit_alb.loadbalancer.project_id + name = stackit_alb.loadbalancer.name + } + `, + testutil.ALBProviderConfig()+resourceMaxConfig, + ), + Check: resource.ComposeAggregateTestCheckFunc( + // Load balancer instance + resource.TestCheckResourceAttr("data.stackit_alb.loadbalancer", "project_id", testutil.ConvertConfigVariable(testConfigVarsMax["project_id"])), + resource.TestCheckResourceAttr("data.stackit_alb.loadbalancer", "name", testutil.ConvertConfigVariable(testConfigVarsMax["loadbalancer_name"])), + resource.TestCheckResourceAttr("data.stackit_alb.loadbalancer", "plan_id", testutil.ConvertConfigVariable(testConfigVarsMax["plan_id"])), + resource.TestCheckResourceAttr("data.stackit_alb.loadbalancer", "labels.key1", testutil.ConvertConfigVariable(testConfigVarsMax["labels_value_1"])), + resource.TestCheckResourceAttr("data.stackit_alb.loadbalancer", "labels.key2", testutil.ConvertConfigVariable(testConfigVarsMax["labels_value_2"])), + resource.TestCheckTypeSetElemNestedAttrs("data.stackit_alb.loadbalancer", "networks.*", map[string]string{"role": testutil.ConvertConfigVariable(testConfigVarsMax["network_role_listeners"])}), + resource.TestCheckTypeSetElemNestedAttrs("data.stackit_alb.loadbalancer", "networks.*", map[string]string{"role": testutil.ConvertConfigVariable(testConfigVarsMax["network_role_targets"])}), + resource.TestCheckResourceAttr("data.stackit_alb.loadbalancer", "disable_target_security_group_assignment", testutil.ConvertConfigVariable(testConfigVarsMax["disable_security_group_assignment"])), + resource.TestCheckResourceAttr("data.stackit_alb.loadbalancer", "listeners.0.port", testutil.ConvertConfigVariable(testConfigVarsMax["listener_port_1"])), + resource.TestCheckResourceAttr("data.stackit_alb.loadbalancer", "listeners.0.http.hosts.0.host", testutil.ConvertConfigVariable(testConfigVarsMax["host_1"])), + resource.TestCheckResourceAttr("data.stackit_alb.loadbalancer", "listeners.0.http.hosts.0.rules.0.target_pool", testutil.ConvertConfigVariable(testConfigVarsMax["target_pool_name_1"])), + resource.TestCheckResourceAttr("data.stackit_alb.loadbalancer", "listeners.0.http.hosts.0.rules.0.web_socket", testutil.ConvertConfigVariable(testConfigVarsMax["web_socket"])), + resource.TestCheckResourceAttr("data.stackit_alb.loadbalancer", "listeners.0.http.hosts.0.rules.0.query_parameters.0.name", testutil.ConvertConfigVariable(testConfigVarsMax["query_parameters_name_1"])), + resource.TestCheckResourceAttr("data.stackit_alb.loadbalancer", "listeners.0.http.hosts.0.rules.0.query_parameters.0.exact_match", testutil.ConvertConfigVariable(testConfigVarsMax["query_parameters_exact_match_1"])), + resource.TestCheckResourceAttr("data.stackit_alb.loadbalancer", "listeners.0.http.hosts.0.rules.0.query_parameters.1.name", testutil.ConvertConfigVariable(testConfigVarsMax["query_parameters_name_2"])), + resource.TestCheckResourceAttr("data.stackit_alb.loadbalancer", "listeners.0.http.hosts.0.rules.0.query_parameters.1.exact_match", testutil.ConvertConfigVariable(testConfigVarsMax["query_parameters_exact_match_2"])), + resource.TestCheckResourceAttr("data.stackit_alb.loadbalancer", "listeners.0.http.hosts.0.rules.0.headers.0.name", testutil.ConvertConfigVariable(testConfigVarsMax["headers_name_1"])), + resource.TestCheckResourceAttr("data.stackit_alb.loadbalancer", "listeners.0.http.hosts.0.rules.0.headers.0.exact_match", testutil.ConvertConfigVariable(testConfigVarsMax["headers_exact_match_1"])), + resource.TestCheckResourceAttr("data.stackit_alb.loadbalancer", "listeners.0.http.hosts.0.rules.0.headers.1.name", testutil.ConvertConfigVariable(testConfigVarsMax["headers_name_2"])), + resource.TestCheckResourceAttr("data.stackit_alb.loadbalancer", "listeners.0.http.hosts.0.rules.0.headers.1.exact_match", testutil.ConvertConfigVariable(testConfigVarsMax["headers_exact_match_2"])), + resource.TestCheckResourceAttr("data.stackit_alb.loadbalancer", "listeners.0.http.hosts.0.rules.0.headers.2.name", testutil.ConvertConfigVariable(testConfigVarsMax["headers_name_3"])), + resource.TestCheckResourceAttr("data.stackit_alb.loadbalancer", "listeners.0.http.hosts.0.rules.0.path.prefix", testutil.ConvertConfigVariable(testConfigVarsMax["path_prefix_1"])), + resource.TestCheckResourceAttr("data.stackit_alb.loadbalancer", "listeners.0.http.hosts.0.rules.1.path.prefix", testutil.ConvertConfigVariable(testConfigVarsMax["path_prefix_2"])), + resource.TestCheckResourceAttr("data.stackit_alb.loadbalancer", "listeners.0.http.hosts.0.rules.1.target_pool", testutil.ConvertConfigVariable(testConfigVarsMax["target_pool_name_2"])), + resource.TestCheckResourceAttr("data.stackit_alb.loadbalancer", "listeners.0.http.hosts.1.host", testutil.ConvertConfigVariable(testConfigVarsMax["host_3"])), + resource.TestCheckResourceAttr("data.stackit_alb.loadbalancer", "listeners.0.http.hosts.1.rules.0.path.prefix", testutil.ConvertConfigVariable(testConfigVarsMax["path_prefix_3"])), + resource.TestCheckResourceAttr("data.stackit_alb.loadbalancer", "listeners.0.http.hosts.1.rules.0.target_pool", testutil.ConvertConfigVariable(testConfigVarsMax["target_pool_name_3"])), + resource.TestCheckResourceAttr("data.stackit_alb.loadbalancer", "listeners.0.https.certificate_config.certificate_ids.0", testutil.ConvertConfigVariable(testConfigVarsMax["certificate_ids"])), + resource.TestCheckResourceAttr("data.stackit_alb.loadbalancer", "listeners.0.protocol", testutil.ConvertConfigVariable(testConfigVarsMax["protocol_https"])), + resource.TestCheckResourceAttr("data.stackit_alb.loadbalancer", "listeners.1.port", testutil.ConvertConfigVariable(testConfigVarsMax["listener_port_4"])), + resource.TestCheckResourceAttr("data.stackit_alb.loadbalancer", "listeners.1.http.hosts.0.host", testutil.ConvertConfigVariable(testConfigVarsMax["host_4"])), + resource.TestCheckResourceAttr("data.stackit_alb.loadbalancer", "listeners.1.http.hosts.0.rules.0.path.prefix", testutil.ConvertConfigVariable(testConfigVarsMax["path_prefix_4"])), + resource.TestCheckResourceAttr("data.stackit_alb.loadbalancer", "listeners.1.http.hosts.0.rules.0.target_pool", testutil.ConvertConfigVariable(testConfigVarsMax["target_pool_name_4"])), + resource.TestCheckResourceAttr("data.stackit_alb.loadbalancer", "listeners.1.protocol", testutil.ConvertConfigVariable(testConfigVarsMax["protocol_http"])), + resource.TestCheckResourceAttr("data.stackit_alb.loadbalancer", "target_pools.0.name", testutil.ConvertConfigVariable(testConfigVarsMax["target_pool_name_1"])), + resource.TestCheckResourceAttr("data.stackit_alb.loadbalancer", "target_pools.0.target_port", testutil.ConvertConfigVariable(testConfigVarsMax["target_pool_port_1"])), + resource.TestCheckResourceAttr("data.stackit_alb.loadbalancer", "target_pools.0.targets.0.display_name", testutil.ConvertConfigVariable(testConfigVarsMax["target_display_name"])), + resource.TestCheckResourceAttr("data.stackit_alb.loadbalancer", "target_pools.0.active_health_check.interval", testutil.ConvertConfigVariable(testConfigVarsMax["ahc_interval"])), + resource.TestCheckResourceAttr("data.stackit_alb.loadbalancer", "target_pools.0.active_health_check.interval_jitter", testutil.ConvertConfigVariable(testConfigVarsMax["ahc_interval_jitter"])), + resource.TestCheckResourceAttr("data.stackit_alb.loadbalancer", "target_pools.0.active_health_check.timeout", testutil.ConvertConfigVariable(testConfigVarsMax["ahc_timeout"])), + resource.TestCheckResourceAttr("data.stackit_alb.loadbalancer", "target_pools.0.active_health_check.healthy_threshold", testutil.ConvertConfigVariable(testConfigVarsMax["ahc_healthy_threshold"])), + resource.TestCheckResourceAttr("data.stackit_alb.loadbalancer", "target_pools.0.active_health_check.unhealthy_threshold", testutil.ConvertConfigVariable(testConfigVarsMax["ahc_unhealthy_threshold"])), + resource.TestCheckResourceAttr("data.stackit_alb.loadbalancer", "target_pools.0.active_health_check.http_health_checks.ok_status.0", testutil.ConvertConfigVariable(testConfigVarsMax["ahc_http_ok_status_200"])), + resource.TestCheckResourceAttr("data.stackit_alb.loadbalancer", "target_pools.0.active_health_check.http_health_checks.ok_status.1", testutil.ConvertConfigVariable(testConfigVarsMax["ahc_http_ok_status_201"])), + resource.TestCheckResourceAttr("data.stackit_alb.loadbalancer", "target_pools.0.active_health_check.http_health_checks.path", testutil.ConvertConfigVariable(testConfigVarsMax["ahc_http_path"])), + resource.TestCheckResourceAttr("data.stackit_alb.loadbalancer", "target_pools.0.tls_config.enabled", testutil.ConvertConfigVariable(testConfigVarsMax["tls_config_enabled"])), + resource.TestCheckResourceAttr("data.stackit_alb.loadbalancer", "target_pools.0.tls_config.skip_certificate_validation", testutil.ConvertConfigVariable(testConfigVarsMax["tls_config_skip"])), + resource.TestCheckResourceAttr("data.stackit_alb.loadbalancer", "target_pools.0.tls_config.custom_ca", testutil.ConvertConfigVariable(testConfigVarsMax["tls_config_custom_ca"])), + resource.TestCheckResourceAttr("data.stackit_alb.loadbalancer", "target_pools.1.name", testutil.ConvertConfigVariable(testConfigVarsMax["target_pool_name_2"])), + resource.TestCheckResourceAttr("data.stackit_alb.loadbalancer", "target_pools.1.target_port", testutil.ConvertConfigVariable(testConfigVarsMax["target_pool_port_2"])), + resource.TestCheckResourceAttr("data.stackit_alb.loadbalancer", "target_pools.1.targets.0.display_name", testutil.ConvertConfigVariable(testConfigVarsMax["target_display_name"])), + resource.TestCheckResourceAttr("data.stackit_alb.loadbalancer", "target_pools.2.name", testutil.ConvertConfigVariable(testConfigVarsMax["target_pool_name_3"])), + resource.TestCheckResourceAttr("data.stackit_alb.loadbalancer", "target_pools.2.target_port", testutil.ConvertConfigVariable(testConfigVarsMax["target_pool_port_3"])), + resource.TestCheckResourceAttr("data.stackit_alb.loadbalancer", "target_pools.2.targets.0.display_name", testutil.ConvertConfigVariable(testConfigVarsMax["target_display_name"])), + resource.TestCheckResourceAttr("data.stackit_alb.loadbalancer", "target_pools.3.name", testutil.ConvertConfigVariable(testConfigVarsMax["target_pool_name_4"])), + resource.TestCheckResourceAttr("data.stackit_alb.loadbalancer", "target_pools.3.target_port", testutil.ConvertConfigVariable(testConfigVarsMax["target_pool_port_4"])), + resource.TestCheckResourceAttr("data.stackit_alb.loadbalancer", "target_pools.3.targets.0.display_name", testutil.ConvertConfigVariable(testConfigVarsMax["target_display_name"])), + resource.TestCheckResourceAttr("data.stackit_alb.loadbalancer", "options.private_network_only", testutil.ConvertConfigVariable(testConfigVarsMax["private_network_only"])), + resource.TestCheckResourceAttr("data.stackit_alb.loadbalancer", "options.ephemeral_address", testutil.ConvertConfigVariable(testConfigVarsMax["ephemeral_address"])), + resource.TestCheckResourceAttr("data.stackit_alb.loadbalancer", "options.acl.0", testutil.ConvertConfigVariable(testConfigVarsMax["acl"])), + resource.TestCheckResourceAttr("data.stackit_alb.loadbalancer", "options.observability.logs.push_url", testutil.ConvertConfigVariable(testConfigVarsMax["observability_logs_push_url"])), + resource.TestCheckResourceAttr("data.stackit_alb.loadbalancer", "options.observability.metrics.push_url", testutil.ConvertConfigVariable(testConfigVarsMax["observability_metrics_push_url"])), + resource.TestCheckResourceAttrSet("data.stackit_alb.loadbalancer", "networks.0.network_id"), + resource.TestCheckResourceAttrSet("data.stackit_alb.loadbalancer", "networks.1.network_id"), + resource.TestCheckResourceAttrSet("data.stackit_alb.loadbalancer", "options.observability.logs.credentials_ref"), + resource.TestCheckResourceAttrSet("data.stackit_alb.loadbalancer", "options.observability.metrics.credentials_ref"), + resource.TestCheckResourceAttrSet("data.stackit_alb.loadbalancer", "target_pools.0.targets.0.ip"), + resource.TestCheckResourceAttrSet("data.stackit_alb.loadbalancer", "target_pools.1.targets.0.ip"), + resource.TestCheckResourceAttrSet("data.stackit_alb.loadbalancer", "target_pools.2.targets.0.ip"), + resource.TestCheckResourceAttrSet("data.stackit_alb.loadbalancer", "target_pools.3.targets.0.ip"), + resource.TestCheckResourceAttrSet("data.stackit_alb.loadbalancer", "target_security_group.id"), + resource.TestCheckResourceAttrSet("data.stackit_alb.loadbalancer", "target_security_group.name"), + resource.TestCheckResourceAttrSet("data.stackit_alb.loadbalancer", "load_balancer_security_group.id"), + resource.TestCheckResourceAttrSet("data.stackit_alb.loadbalancer", "load_balancer_security_group.name"), + resource.TestCheckResourceAttrSet("data.stackit_alb.loadbalancer", "external_address"), + resource.TestCheckResourceAttrSet("data.stackit_alb.loadbalancer", "version"), + resource.TestCheckResourceAttrSet("data.stackit_alb.loadbalancer", "status"), + resource.TestCheckNoResourceAttr("data.stackit_alb.loadbalancer", "errors"), + )}, + // Import + { + ConfigVariables: testConfigVarsMax, + ResourceName: "stackit_alb.loadbalancer", + ImportStateIdFunc: func(s *terraform.State) (string, error) { + r, ok := s.RootModule().Resources["stackit_alb.loadbalancer"] + if !ok { + return "", fmt.Errorf("couldn't find resource stackit_alb.loadbalancer") + } + name, ok := r.Primary.Attributes["name"] + if !ok { + return "", fmt.Errorf("couldn't find attribute name") + } + region, ok := r.Primary.Attributes["region"] + if !ok { + return "", fmt.Errorf("couldn't find attribute region") + } + return fmt.Sprintf("%s,%s,%s", testutil.ProjectId, region, name), nil + }, + ImportState: true, + ImportStateVerify: true, + }, + // Update + { + ConfigVariables: configVarsMaxUpdated(), + Config: testutil.ALBProviderConfig() + resourceMaxConfig, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "project_id", testutil.ConvertConfigVariable(testConfigVarsMax["project_id"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "name", testutil.ConvertConfigVariable(testConfigVarsMax["loadbalancer_name"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "options.ephemeral_address", testutil.ConvertConfigVariable(configVarsMaxUpdated()["ephemeral_address"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "listeners.0.http.hosts.0.host", testutil.ConvertConfigVariable(configVarsMaxUpdated()["host_1"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "listeners.0.http.hosts.0.rules.0.web_socket", testutil.ConvertConfigVariable(configVarsMaxUpdated()["web_socket"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "listeners.0.http.hosts.0.rules.0.query_parameters.0.name", testutil.ConvertConfigVariable(configVarsMaxUpdated()["query_parameters_name_1"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "listeners.0.http.hosts.0.rules.0.query_parameters.0.exact_match", testutil.ConvertConfigVariable(configVarsMaxUpdated()["query_parameters_exact_match_1"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "listeners.0.http.hosts.0.rules.0.query_parameters.1.name", testutil.ConvertConfigVariable(configVarsMaxUpdated()["query_parameters_name_2"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "listeners.0.http.hosts.0.rules.0.query_parameters.1.exact_match", testutil.ConvertConfigVariable(configVarsMaxUpdated()["query_parameters_exact_match_2"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "listeners.0.http.hosts.0.rules.0.headers.0.name", testutil.ConvertConfigVariable(configVarsMaxUpdated()["headers_name_1"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "listeners.0.http.hosts.0.rules.0.headers.0.exact_match", testutil.ConvertConfigVariable(configVarsMaxUpdated()["headers_exact_match_1"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "listeners.0.http.hosts.0.rules.0.headers.1.name", testutil.ConvertConfigVariable(configVarsMaxUpdated()["headers_name_2"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "listeners.0.http.hosts.0.rules.0.headers.1.exact_match", testutil.ConvertConfigVariable(configVarsMaxUpdated()["headers_exact_match_2"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "listeners.0.http.hosts.0.rules.0.headers.2.name", testutil.ConvertConfigVariable(configVarsMaxUpdated()["headers_name_3"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "target_pools.0.target_port", testutil.ConvertConfigVariable(configVarsMaxUpdated()["target_pool_port_1"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "target_pools.0.active_health_check.timeout", testutil.ConvertConfigVariable(configVarsMaxUpdated()["ahc_timeout"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "target_pools.0.active_health_check.healthy_threshold", testutil.ConvertConfigVariable(configVarsMaxUpdated()["ahc_healthy_threshold"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "target_pools.0.active_health_check.http_health_checks.ok_status.0", testutil.ConvertConfigVariable(configVarsMaxUpdated()["ahc_http_ok_status_200"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "target_pools.0.active_health_check.http_health_checks.ok_status.1", testutil.ConvertConfigVariable(configVarsMaxUpdated()["ahc_http_ok_status_201"])), + resource.TestCheckResourceAttr("stackit_alb.loadbalancer", "options.acl.0", testutil.ConvertConfigVariable(configVarsMaxUpdated()["acl"])), + ), + }, + // Deletion is done by the framework implicitly + }, + }) +} + +func testAccCheckALBDestroy(s *terraform.State) error { + ctx := context.Background() + var client *alb.APIClient + var err error + if testutil.ALBCustomEndpoint == "" { + client, err = alb.NewAPIClient() + } else { + client, err = alb.NewAPIClient( + stackitSdkConfig.WithEndpoint(testutil.ALBCustomEndpoint), + ) + } + if err != nil { + return fmt.Errorf("creating client: %w", err) + } + + region := "eu01" + if testutil.Region != "" { + region = testutil.Region + } + loadbalancersToDestroy := []string{} + for _, rs := range s.RootModule().Resources { + if rs.Type != "stackit_loadbalancer" { + continue + } + // loadbalancer terraform ID: = "[project_id],[name]" + loadbalancerName := strings.Split(rs.Primary.ID, core.Separator)[1] + loadbalancersToDestroy = append(loadbalancersToDestroy, loadbalancerName) + } + + loadbalancersResp, err := client.ListLoadBalancers(ctx, testutil.ProjectId, region).Execute() + if err != nil { + return fmt.Errorf("getting loadbalancersResp: %w", err) + } + + if loadbalancersResp.LoadBalancers == nil || (loadbalancersResp.LoadBalancers != nil && len(*loadbalancersResp.LoadBalancers) == 0) { + fmt.Print("No load balancers found for project \n") + return nil + } + + items := *loadbalancersResp.LoadBalancers + for i := range items { + if items[i].Name == nil { + continue + } + if utils.Contains(loadbalancersToDestroy, *items[i].Name) { + _, err := client.DeleteLoadBalancerExecute(ctx, testutil.ProjectId, region, *items[i].Name) + if err != nil { + return fmt.Errorf("destroying load balancer %s during CheckDestroy: %w", *items[i].Name, err) + } + _, err = wait.DeleteLoadbalancerWaitHandler(ctx, client, testutil.ProjectId, region, *items[i].Name).WaitWithContext(ctx) + if err != nil { + return fmt.Errorf("destroying load balancer %s during CheckDestroy: waiting for deletion %w", *items[i].Name, err) + } + } + } + return nil +} diff --git a/stackit/internal/services/alb/applicationloadbalancer/datasource.go b/stackit/internal/services/alb/applicationloadbalancer/datasource.go new file mode 100644 index 000000000..e7ce39f86 --- /dev/null +++ b/stackit/internal/services/alb/applicationloadbalancer/datasource.go @@ -0,0 +1,587 @@ +package alb + +import ( + "context" + "fmt" + "net/http" + + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion" + albUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/alb/utils" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + albSdk "github.com/stackitcloud/stackit-sdk-go/services/alb" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" +) + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ datasource.DataSource = &albDataSource{} +) + +// NewApplicationLoadBalancerDataSource is a helper function to simplify the provider implementation. +func NewApplicationLoadBalancerDataSource() datasource.DataSource { + return &albDataSource{} +} + +// albDataSource is the data source implementation. +type albDataSource struct { + client *albSdk.APIClient + providerData core.ProviderData +} + +// Metadata returns the data source type name. +func (r *albDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_alb" +} + +// Configure adds the provider configured client to the data source. +func (r *albDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + var ok bool + r.providerData, ok = conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics) + if !ok { + return + } + + apiClient := albUtils.ConfigureClient(ctx, &r.providerData, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + r.client = apiClient + tflog.Info(ctx, "Load balancer client configured") +} + +// Schema defines the schema for the resource. +func (r *albDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + protocolOptions := []string{"PROTOCOL_UNSPECIFIED", "PROTOCOL_HTTP", "PROTOCOL_HTTPS"} + roleOptions := []string{"ROLE_UNSPECIFIED", "ROLE_LISTENERS_AND_TARGETS", "ROLE_LISTENERS", "ROLE_TARGETS"} + servicePlanOptions := []string{"p10"} + regionOptions := []string{"eu01", "eu02"} + + descriptions := map[string]string{ + "main": "Application Load Balancer resource schema.", + "id": "Terraform's internal resource ID. It is structured as \"`project_id`\",\"region\",\"`name`\".", + "project_id": "STACKIT project ID to which the Application Load Balancer is associated.", + "region": "The resource region. If not defined, the provider region is used. " + utils.FormatPossibleValues(regionOptions...), + "disable_target_security_group_assignment": "Disable target security group assignemt to allow targets outside of the given network. Connectivity to targets need to be ensured by the customer, including routing and Security Groups (targetSecurityGroup can be assigned). Not changeable after creation.", + "errors": "Reports all errors a Application Load Balancer has.", + "errors.type": "Enum: \"TYPE_UNSPECIFIED\" \"TYPE_INTERNAL\" \"TYPE_QUOTA_SECGROUP_EXCEEDED\" \"TYPE_QUOTA_SECGROUPRULE_EXCEEDED\" \"TYPE_PORT_NOT_CONFIGURED\" \"TYPE_FIP_NOT_CONFIGURED\" \"TYPE_TARGET_NOT_ACTIVE\" \"TYPE_METRICS_MISCONFIGURED\" \"TYPE_LOGS_MISCONFIGURED\"\nThe error type specifies which part of the Application Load Balancer encountered the error. I.e. the API will not check if a provided public IP is actually available in the project. Instead the Application Load Balancer with try to use the provided IP and if not available reports TYPE_FIP_NOT_CONFIGURED error.", + "errors.description": "The error description contains additional helpful user information to fix the error state of the Application Load Balancer. For example the IP 45.135.247.139 does not exist in the project, then the description will report: Floating IP \"45.135.247.139\" could not be found.", + "external_address": "The external IP address where this Application Load Balancer is exposed. Not changeable after creation.", + "labels": "Labels represent user-defined metadata as key-value pairs. Label count cannot exceed 64 per ALB.", + "listeners": "List of all listeners which will accept traffic. Limited to 20.", + "listeners.name": "Unique name for the listener", + "http": "Configuration for handling HTTP traffic on this listener.", + "hosts": "Defines routing rules grouped by hostname.", + "host": "Hostname to match. Supports wildcards (e.g. *.example.com).", + "rules": "Routing rules under the specified host, matched by path prefix.", + "cookie_persistence": "Routing persistence via cookies.", + "cookie_persistence.name": "The name of the cookie to use.", + "ttl": "TTL specifies the time-to-live for the cookie. The default value is 0s, and it acts as a session cookie, expiring when the client session ends.", + "headers": "Headers for the rule.", + "headers.exact_match": "Exact match for the header value.", + "headers.name": "Header name.", + "path": "Routing via path.", + "path.exact_match": "Exact path match. Only a request path exactly equal to the value will match, e.g. '/foo' matches only '/foo', not '/foo/bar' or '/foobar'.", + "path.prefix": "Prefix path match. Only matches on full segment boundaries, e.g. '/foo' matches '/foo' and '/foo/bar' but NOT '/foobar'.", + "query_parameters": "Query parameters for the rule.", + "query_parameters.exact_match": "Exact match for the query parameters value.", + "query_parameters.name": "Query parameter name.", + "target_pool": "Reference target pool by target pool name.", + "web_socket": "If enabled, when client sends an HTTP request with and Upgrade header, indicating the desire to establish a Websocket connection, if backend server supports WebSocket, it responds with HTTP 101 status code, switching protocols from HTTP to WebSocket. Hence the client and the server can exchange data in real-time using one long-lived TCP connection.", + "https": "Configuration for handling HTTPS traffic on this listener.", + "certificate_config": "TLS termination certificate configuration.", + "certificate_ids": "Certificate IDs for TLS termination.", + "port": "Port number on which the listener receives incoming traffic.", + "protocol": "Protocol is the highest network protocol we understand to load balance. " + utils.FormatPossibleValues(protocolOptions...), + "waf_config_name": "Enable Web Application Firewall (WAF), referenced by name. See \"Application Load Balancer - Web Application Firewall API\" for more information.", + "load_balancer_security_group": "Security Group permitting network traffic from the LoadBalancer to the targets. Useful when disableTargetSecurityGroupAssignment=true to manually assign target security groups to targets.", + "load_balancer_security_group.id": "ID of the security Group", + "load_balancer_security_group.name": "Name of the security Group", + "name": "Application Load balancer name.", + "networks": "List of networks that listeners and targets reside in.", + "network_id": "STACKIT network ID the Application Load Balancer and/or targets are in.", + "role": "The role defines how the Application Load Balancer is using the network. " + utils.FormatPossibleValues(roleOptions...), + "options": "Defines any optional functionality you want to have enabled on your Application Load Balancer.", + "acl": "Use this option to limit the IP ranges that can use the Application Load Balancer.", + "ephemeral_address": "This option automates the handling of the external IP address for an Application Load Balancer. If set to true a new IP address will be automatically created. It will also be automatically deleted when the Load Balancer is deleted.", + "observability": "We offer Load Balancer observability via STACKIT Observability or external solutions.", + "observability_logs": "Observability logs configuration.", + "observability_logs_credentials_ref": "Credentials reference for logging. This reference is created via the observability create endpoint and the credential needs to contain the basic auth username and password for the logging solution the push URL points to. Then this enables monitoring via remote write for the Application Load Balancer.", + "observability_logs_push_url": "The Observability(Logs)/Loki remote write Push URL you want the logs to be shipped to.", + "observability_metrics": "Observability metrics configuration.", + "observability_metrics_credentials_ref": "Credentials reference for metrics. This reference is created via the observability create endpoint and the credential needs to contain the basic auth username and password for the metrics solution the push URL points to. Then this enables monitoring via remote write for the Application Load Balancer.", + "observability_metrics_push_url": "The Observability(Metrics)/Prometheus remote write push URL you want the metrics to be shipped to.", + "plan_id": "Service Plan configures the size of the Application Load Balancer. " + utils.FormatPossibleValues(servicePlanOptions...) + ". This list can change in the future. Therefore, this is not an enum.", + "private_network_only": "Application Load Balancer is accessible only via a private network ip address. Not changeable after creation.", + "status": "Enum: \"STATUS_UNSPECIFIED\" \"STATUS_PENDING\" \"STATUS_READY\" \"STATUS_ERROR\" \"STATUS_TERMINATING\"", + "target_pools": "List of all target pools which will be used in the Application Load Balancer. Limited to 20.", + "active_health_checks": "Set this to customize active health checks for targets in this pool.", + "healthy_threshold": "Healthy threshold of the health checking.", + "http_health_checks": "Options for the HTTP health checking.", + "http_health_checks.ok_status": "List of HTTP status codes that indicate a healthy response.", + "http_health_checks.path": "Path to send the health check request to.", + "interval": "Interval duration of health checking in seconds.", + "interval_jitter": "Interval duration threshold of the health checking in seconds.", + "timeout": "Active health checking timeout duration in seconds.", + "unhealthy_threshold": "Unhealthy threshold of the health checking.", + "target_pools.name": "Target pool name.", + "target_port": "The number identifying the port where each target listens for traffic.", + "targets": "List of all targets which will be used in the pool. Limited to 250.", + "targets.display_name": "Target display name", + "ip": "Private target IP, which must by unique within a target pool.", + "tls_config": "Configuration for TLS bridging.", + "tls_config.custom_ca": "Specifies a custom Certificate Authority (CA). When provided, the target pool will trust certificates signed by this CA, in addition to any system-trusted CAs. This is useful for scenarios where the target pool needs to communicate with servers using self-signed or internally-issued certificates. Enabled needs to be set to true and skip validation to false for this option.", + "tls_config.enabled": "Enable TLS (Transport Layer Security) bridging for the connection between Application Load Balancer and targets in this pool. When enabled, public CAs are trusted. Can be used in tandem with the options either custom CA or skip validation or alone.", + "tls_config.skip_certificate_validation": "Bypass certificate validation for TLS bridging in this target pool. This option is insecure and can only be used with public CAs by setting enabled true. Meant to be used for testing purposes only!", + "target_security_group": "Security Group that allows the targets to receive traffic from the LoadBalancer. Useful when disableTargetSecurityGroupAssignment=true to manually assign target security groups to targets.", + "target_security_group.id": "ID of the security Group", + "target_security_group.name": "Name of the security Group", + "version": "Application Load Balancer resource version. Used for concurrency safe updates.", + } + + resp.Schema = schema.Schema{ + Description: descriptions["main"], + MarkdownDescription: ` +## Setting up supporting infrastructure` + "\n" + ` + +The example below creates the supporting infrastructure using the STACKIT Terraform provider, including the network, network interface, a public IP address and server resources. +`, + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: descriptions["id"], + Computed: true, + }, + "project_id": schema.StringAttribute{ + Description: descriptions["project_id"], + Required: true, + }, + "region": schema.StringAttribute{ + Description: descriptions["region"], + Computed: true, + }, + "disable_target_security_group_assignment": schema.BoolAttribute{ + Description: descriptions["disable_target_security_group_assignment"], + Computed: true, + }, + "errors": schema.SetNestedAttribute{ + Description: descriptions["errors"], + Computed: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "type": schema.StringAttribute{ + Description: descriptions["errors.type"], + Computed: true, + }, + "description": schema.StringAttribute{ + Description: descriptions["errors.description"], + Computed: true, + }, + }, + }, + }, + "external_address": schema.StringAttribute{ + Description: descriptions["external_address"], + Computed: true, + }, + "labels": schema.MapAttribute{ + Description: descriptions["labels"], + Computed: true, + ElementType: types.StringType, + }, + "plan_id": schema.StringAttribute{ + Description: descriptions["plan_id"], + Computed: true, + }, + "listeners": schema.ListNestedAttribute{ + Description: descriptions["listeners"], + Computed: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "name": schema.StringAttribute{ + Description: descriptions["listeners.name"], + Computed: true, + }, + "port": schema.Int64Attribute{ + Description: descriptions["port"], + Computed: true, + }, + "protocol": schema.StringAttribute{ + Description: descriptions["protocol"], + Computed: true, + }, + "waf_config_name": schema.StringAttribute{ + Description: descriptions["waf_config_name"], + Computed: true, + }, + "http": schema.SingleNestedAttribute{ + Description: "Configuration for HTTP traffic.", + Computed: true, + Attributes: map[string]schema.Attribute{ + "hosts": schema.ListNestedAttribute{ + Description: descriptions["hosts"], + Computed: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "host": schema.StringAttribute{ + Description: descriptions["host"], + Computed: true, + }, + "rules": schema.ListNestedAttribute{ + Description: descriptions["rules"], + Computed: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "target_pool": schema.StringAttribute{ + Description: descriptions["target_pool"], + Computed: true, + }, + "web_socket": schema.BoolAttribute{ + Description: descriptions["web_socket"], + Computed: true, + }, + "path": schema.SingleNestedAttribute{ + Description: descriptions["path"], + Computed: true, + Attributes: map[string]schema.Attribute{ + "exact_match": schema.StringAttribute{ + Description: descriptions["path.exact_match"], + Computed: true, + }, + "prefix": schema.StringAttribute{ + Description: descriptions["path.prefix"], + Computed: true, + }, + }, + }, + "headers": schema.SetNestedAttribute{ + Description: descriptions["headers"], + Computed: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "name": schema.StringAttribute{ + Description: descriptions["headers.name"], + Computed: true, + }, + "exact_match": schema.StringAttribute{ + Description: descriptions["headers.exact_match"], + Computed: true, + }, + }, + }, + }, + "query_parameters": schema.SetNestedAttribute{ + Description: descriptions["query_parameters"], + Computed: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "name": schema.StringAttribute{ + Description: descriptions["query_parameters.name"], + Computed: true, + }, + "exact_match": schema.StringAttribute{ + Description: descriptions["query_parameters.exact_match"], + Computed: true, + }, + }, + }, + }, + "cookie_persistence": schema.SingleNestedAttribute{ + Description: descriptions["cookie_persistence"], + Computed: true, + Attributes: map[string]schema.Attribute{ + "name": schema.StringAttribute{ + Description: descriptions["cookie_persistence.name"], + Computed: true, + }, + "ttl": schema.StringAttribute{ + Description: descriptions["ttl"], + Computed: true, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + "https": schema.SingleNestedAttribute{ + Description: descriptions["https"], + Computed: true, + Attributes: map[string]schema.Attribute{ + "certificate_config": schema.SingleNestedAttribute{ + Description: descriptions["certificate_config"], + Computed: true, + Attributes: map[string]schema.Attribute{ + "certificate_ids": schema.SetAttribute{ + Description: descriptions["certificate_ids"], + Computed: true, + ElementType: types.StringType, + }, + }, + }, + }, + }, + }, + }, + }, + "load_balancer_security_group": schema.SingleNestedAttribute{ + Description: descriptions["load_balancer_security_group"], + Computed: true, + Attributes: map[string]schema.Attribute{ + "name": schema.StringAttribute{ + Description: descriptions["load_balancer_security_group.name"], + Computed: true, + }, + "id": schema.StringAttribute{ + Description: descriptions["load_balancer_security_group.id"], + Computed: true, + }, + }, + }, + "name": schema.StringAttribute{ + Description: descriptions["name"], + Required: true, + }, + "networks": schema.SetNestedAttribute{ + Description: descriptions["networks"], + Computed: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "network_id": schema.StringAttribute{ + Description: descriptions["network_id"], + Computed: true, + }, + "role": schema.StringAttribute{ + Description: descriptions["role"], + Computed: true, + }, + }, + }, + }, + "options": schema.SingleNestedAttribute{ + Description: descriptions["options"], + Computed: true, + Attributes: map[string]schema.Attribute{ + "acl": schema.SetAttribute{ + Description: descriptions["acl"], + ElementType: types.StringType, + Computed: true, + }, + "ephemeral_address": schema.BoolAttribute{ + Description: descriptions["ephemeral_address"], + Computed: true, + }, + "private_network_only": schema.BoolAttribute{ + Description: descriptions["private_network_only"], + Computed: true, + }, + "observability": schema.SingleNestedAttribute{ + Description: descriptions["observability"], + Computed: true, + Attributes: map[string]schema.Attribute{ + "logs": schema.SingleNestedAttribute{ + Description: descriptions["observability_logs"], + Computed: true, + Attributes: map[string]schema.Attribute{ + "credentials_ref": schema.StringAttribute{ + Description: descriptions["observability_logs_credentials_ref"], + Computed: true, + }, + "push_url": schema.StringAttribute{ + Description: descriptions["observability_logs_credentials_ref"], + Computed: true, + }, + }, + }, + "metrics": schema.SingleNestedAttribute{ + Description: descriptions["observability_metrics"], + Computed: true, + Attributes: map[string]schema.Attribute{ + "credentials_ref": schema.StringAttribute{ + Description: descriptions["observability_metrics_credentials_ref"], + Computed: true, + }, + "push_url": schema.StringAttribute{ + Description: descriptions["observability_metrics_credentials_ref"], + Computed: true, + }, + }, + }, + }, + }, + }, + }, + "private_address": schema.StringAttribute{ + Description: descriptions["private_address"], + Computed: true, + }, + "status": schema.StringAttribute{ + Description: descriptions["status"], + Computed: true, + }, + "target_pools": schema.ListNestedAttribute{ + Description: descriptions["target_pools"], + Computed: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "active_health_check": schema.SingleNestedAttribute{ + Description: descriptions["active_health_check"], + Computed: true, + Attributes: map[string]schema.Attribute{ + "healthy_threshold": schema.Int64Attribute{ + Description: descriptions["healthy_threshold"], + Computed: true, + }, + "interval": schema.StringAttribute{ + Description: descriptions["interval"], + Computed: true, + }, + "interval_jitter": schema.StringAttribute{ + Description: descriptions["interval_jitter"], + Computed: true, + }, + "timeout": schema.StringAttribute{ + Description: descriptions["timeout"], + Computed: true, + }, + "unhealthy_threshold": schema.Int64Attribute{ + Description: descriptions["unhealthy_threshold"], + Computed: true, + }, + "http_health_checks": schema.SingleNestedAttribute{ + Description: descriptions["http_health_checks"], + Computed: true, + Attributes: map[string]schema.Attribute{ + "path": schema.StringAttribute{ + Description: descriptions["http_health_checks.path"], + Computed: true, + }, + "ok_status": schema.SetAttribute{ + Description: descriptions["http_health_checks.ok_status"], + Computed: true, + ElementType: types.StringType, + }, + }, + }, + }, + }, + "name": schema.StringAttribute{ + Description: descriptions["target_pools.name"], + Computed: true, + }, + "target_port": schema.Int64Attribute{ + Description: descriptions["target_port"], + Computed: true, + }, + "targets": schema.SetNestedAttribute{ + Description: descriptions["targets"], + Computed: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "display_name": schema.StringAttribute{ + Description: descriptions["targets.display_name"], + Computed: true, + }, + "ip": schema.StringAttribute{ + Description: descriptions["ip"], + Computed: true, + }, + }, + }, + }, + "tls_config": schema.SingleNestedAttribute{ + Description: descriptions["tls_config"], + Computed: true, + Attributes: map[string]schema.Attribute{ + "enabled": schema.BoolAttribute{ + Description: descriptions["tls_config.enabled"], + Computed: true, + }, + "skip_certificate_validation": schema.BoolAttribute{ + Description: descriptions["tls_config.skip_certificate_validation"], + Computed: true, + }, + "custom_ca": schema.StringAttribute{ + Description: descriptions["tls_config.custom_ca"], + Computed: true, + }, + }, + }, + }, + }, + }, + "target_security_group": schema.SingleNestedAttribute{ + Description: descriptions["target_security_group"], + Computed: true, + Attributes: map[string]schema.Attribute{ + "name": schema.StringAttribute{ + Description: descriptions["target_security_group.name"], + Computed: true, + }, + "id": schema.StringAttribute{ + Description: descriptions["target_security_group.id"], + Computed: true, + }, + }, + }, + "version": schema.StringAttribute{ + Description: descriptions["version"], + Computed: true, + }, + }, + } +} + +// Read refreshes the Terraform state with the latest data. +func (r *albDataSource) 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 + } + projectId := model.ProjectId.ValueString() + name := model.Name.ValueString() + region := r.providerData.GetRegionWithOverride(model.Region) + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "name", name) + ctx = tflog.SetField(ctx, "region", region) + + albResp, err := r.client.GetLoadBalancer(ctx, projectId, region, name).Execute() + if err != nil { + utils.LogError( + ctx, + &resp.Diagnostics, + err, + "Reading application load balancer", + fmt.Sprintf("Load balancer with name %q does not exist in project %q.", name, projectId), + map[int]string{ + http.StatusForbidden: fmt.Sprintf("Project with ID %q not found or forbidden access", projectId), + }, + ) + resp.State.RemoveResource(ctx) + return + } + + // Map response body to schema + err = mapFields(ctx, albResp, &model, region) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading application load balancer", fmt.Sprintf("Processing API payload: %v", err)) + return + } + + // Set refreshed state + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "Load balancer read") +} diff --git a/stackit/internal/services/alb/applicationloadbalancer/resource.go b/stackit/internal/services/alb/applicationloadbalancer/resource.go new file mode 100644 index 000000000..a5a04118b --- /dev/null +++ b/stackit/internal/services/alb/applicationloadbalancer/resource.go @@ -0,0 +1,2883 @@ +package alb + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "regexp" + "strings" + "time" + + "github.com/google/uuid" + "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/mapvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/objectplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/setplanmodifier" + "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-framework/types/basetypes" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/stackitcloud/stackit-sdk-go/core/oapierror" + albSdk "github.com/stackitcloud/stackit-sdk-go/services/alb" + "github.com/stackitcloud/stackit-sdk-go/services/alb/wait" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + albUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/alb/utils" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" +) + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ resource.Resource = &applicationLoadBalancerResource{} + _ resource.ResourceWithConfigure = &applicationLoadBalancerResource{} + _ resource.ResourceWithImportState = &applicationLoadBalancerResource{} + _ resource.ResourceWithModifyPlan = &applicationLoadBalancerResource{} +) + +type Model struct { + Id types.String `tfsdk:"id"` // needed by TF + ProjectId types.String `tfsdk:"project_id"` + DisableSecurityGroupAssignment types.Bool `tfsdk:"disable_target_security_group_assignment"` + Errors types.Set `tfsdk:"errors"` + ExternalAddress types.String `tfsdk:"external_address"` + Labels types.Map `tfsdk:"labels"` + Listeners types.List `tfsdk:"listeners"` + LoadBalancerSecurityGroup types.Object `tfsdk:"load_balancer_security_group"` + Name types.String `tfsdk:"name"` + Networks types.Set `tfsdk:"networks"` + Options types.Object `tfsdk:"options"` + PlanId types.String `tfsdk:"plan_id"` + PrivateAddress types.String `tfsdk:"private_address"` + Region types.String `tfsdk:"region"` + Status types.String `tfsdk:"status"` + TargetPools types.List `tfsdk:"target_pools"` + TargetSecurityGroup types.Object `tfsdk:"target_security_group"` + Version types.String `tfsdk:"version"` +} + +type errors struct { + Description types.String `tfsdk:"description"` + Type types.String `tfsdk:"type"` +} + +var errorsType = map[string]attr.Type{ + "description": types.StringType, + "type": types.StringType, +} + +type loadBalancerSecurityGroup struct { + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` +} + +var loadBalancerSecurityGroupType = map[string]attr.Type{ + "id": types.StringType, + "name": types.StringType, +} + +type targetSecurityGroup struct { + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` +} + +var targetSecurityGroupType = map[string]attr.Type{ + "id": types.StringType, + "name": types.StringType, +} + +// Struct corresponding to Model.Listeners[i] +type listener struct { + Name types.String `tfsdk:"name"` + Port types.Int64 `tfsdk:"port"` + Protocol types.String `tfsdk:"protocol"` + Http types.Object `tfsdk:"http"` + Https types.Object `tfsdk:"https"` + WafConfigName types.String `tfsdk:"waf_config_name"` +} + +// Types corresponding to listener +var listenerTypes = map[string]attr.Type{ + "name": types.StringType, + "port": types.Int64Type, + "protocol": types.StringType, + "http": types.ObjectType{AttrTypes: httpTypes}, + "https": types.ObjectType{AttrTypes: httpsTypes}, + "waf_config_name": types.StringType, +} + +type httpALB struct { + Hosts types.List `tfsdk:"hosts"` +} + +var httpTypes = map[string]attr.Type{ + "hosts": types.ListType{ElemType: types.ObjectType{AttrTypes: hostConfigTypes}}, +} + +type hostConfig struct { + Host types.String `tfsdk:"host"` + Rules types.List `tfsdk:"rules"` +} + +var hostConfigTypes = map[string]attr.Type{ + "host": types.StringType, + "rules": types.ListType{ElemType: types.ObjectType{AttrTypes: ruleTypes}}, +} + +type rule struct { + Path types.Object `tfsdk:"path"` + Headers types.Set `tfsdk:"headers"` + TargetPool types.String `tfsdk:"target_pool"` + WebSocket types.Bool `tfsdk:"web_socket"` + QueryParameters types.Set `tfsdk:"query_parameters"` + CookiePersistence types.Object `tfsdk:"cookie_persistence"` +} + +var ruleTypes = map[string]attr.Type{ + "path": types.ObjectType{AttrTypes: pathTypes}, + "headers": types.SetType{ElemType: types.ObjectType{AttrTypes: headersTypes}}, + "target_pool": types.StringType, + "web_socket": types.BoolType, + "query_parameters": types.SetType{ElemType: types.ObjectType{AttrTypes: queryParameterTypes}}, + "cookie_persistence": types.ObjectType{AttrTypes: cookiePersistenceTypes}, +} + +type pathALB struct { + Exact types.String `tfsdk:"exact_match"` + Prefix types.String `tfsdk:"prefix"` +} + +var pathTypes = map[string]attr.Type{ + "exact_match": types.StringType, + "prefix": types.StringType, +} + +type headers struct { + Name types.String `tfsdk:"name"` + ExactMatch types.String `tfsdk:"exact_match"` +} + +var headersTypes = map[string]attr.Type{ + "name": types.StringType, + "exact_match": types.StringType, +} + +type queryParameter struct { + Name types.String `tfsdk:"name"` + ExactMatch types.String `tfsdk:"exact_match"` +} + +var queryParameterTypes = map[string]attr.Type{ + "name": types.StringType, + "exact_match": types.StringType, +} + +type cookiePersistence struct { + Name types.String `tfsdk:"name"` + Ttl types.String `tfsdk:"ttl"` +} + +var cookiePersistenceTypes = map[string]attr.Type{ + "name": types.StringType, + "ttl": types.StringType, +} + +type https struct { + CertificateConfig types.Object `tfsdk:"certificate_config"` +} + +var httpsTypes = map[string]attr.Type{ + "certificate_config": types.ObjectType{AttrTypes: certificateConfigTypes}, +} + +type certificateConfig struct { + CertificateConfigIDs types.Set `tfsdk:"certificate_ids"` +} + +var certificateConfigTypes = map[string]attr.Type{ + "certificate_ids": types.SetType{ElemType: types.StringType}, +} + +// Struct corresponding to Model.Networks[i] +type network struct { + NetworkId types.String `tfsdk:"network_id"` + Role types.String `tfsdk:"role"` +} + +// Types corresponding to network +var networkTypes = map[string]attr.Type{ + "network_id": types.StringType, + "role": types.StringType, +} + +// Struct corresponding to Model.Options +type options struct { + ACL types.Set `tfsdk:"acl"` + PrivateNetworkOnly types.Bool `tfsdk:"private_network_only"` + Observability types.Object `tfsdk:"observability"` + EphemeralAddress types.Bool `tfsdk:"ephemeral_address"` +} + +// Types corresponding to options +var optionsTypes = map[string]attr.Type{ + "acl": types.SetType{ElemType: types.StringType}, + "private_network_only": types.BoolType, + "observability": types.ObjectType{AttrTypes: observabilityTypes}, + "ephemeral_address": types.BoolType, +} + +type observability struct { + Logs types.Object `tfsdk:"logs"` + Metrics types.Object `tfsdk:"metrics"` +} + +var observabilityTypes = map[string]attr.Type{ + "logs": types.ObjectType{AttrTypes: observabilityOptionTypes}, + "metrics": types.ObjectType{AttrTypes: observabilityOptionTypes}, +} + +type observabilityOption struct { + CredentialsRef types.String `tfsdk:"credentials_ref"` + PushUrl types.String `tfsdk:"push_url"` +} + +var observabilityOptionTypes = map[string]attr.Type{ + "credentials_ref": types.StringType, + "push_url": types.StringType, +} + +// Struct corresponding to Model.TargetPools[i] +type targetPool struct { + ActiveHealthCheck types.Object `tfsdk:"active_health_check"` + Name types.String `tfsdk:"name"` + TargetPort types.Int64 `tfsdk:"target_port"` + Targets types.Set `tfsdk:"targets"` + TLSConfig types.Object `tfsdk:"tls_config"` +} + +// Types corresponding to targetPool +var targetPoolTypes = map[string]attr.Type{ + "active_health_check": types.ObjectType{AttrTypes: activeHealthCheckTypes}, + "name": types.StringType, + "target_port": types.Int64Type, + "targets": types.SetType{ElemType: types.ObjectType{AttrTypes: targetTypes}}, + "tls_config": types.ObjectType{AttrTypes: tlsConfigTypes}, +} + +// Struct corresponding to targetPool.ActiveHealthCheck +type activeHealthCheck struct { + HealthyThreshold types.Int64 `tfsdk:"healthy_threshold"` + HttpHealthChecks types.Object `tfsdk:"http_health_checks"` + Interval types.String `tfsdk:"interval"` + IntervalJitter types.String `tfsdk:"interval_jitter"` + Timeout types.String `tfsdk:"timeout"` + UnhealthyThreshold types.Int64 `tfsdk:"unhealthy_threshold"` +} + +// Types corresponding to activeHealthCheck +var activeHealthCheckTypes = map[string]attr.Type{ + "healthy_threshold": types.Int64Type, + "http_health_checks": types.ObjectType{AttrTypes: httpHealthChecksTypes}, + "interval": types.StringType, + "interval_jitter": types.StringType, + "timeout": types.StringType, + "unhealthy_threshold": types.Int64Type, +} + +type httpHealthChecks struct { + OkStatus types.Set `tfsdk:"ok_status"` + Path types.String `tfsdk:"path"` +} + +var httpHealthChecksTypes = map[string]attr.Type{ + "path": types.StringType, + "ok_status": types.SetType{ElemType: types.StringType}, +} + +// Struct corresponding to targetPool.Targets[i] +type target struct { + DisplayName types.String `tfsdk:"display_name"` + Ip types.String `tfsdk:"ip"` +} + +// Types corresponding to target +var targetTypes = map[string]attr.Type{ + "display_name": types.StringType, + "ip": types.StringType, +} + +type tlsConfig struct { + CustomCA types.String `tfsdk:"custom_ca"` + Enabled types.Bool `tfsdk:"enabled"` + SkipCertValidation types.Bool `tfsdk:"skip_certificate_validation"` +} + +var tlsConfigTypes = map[string]attr.Type{ + "custom_ca": types.StringType, + "enabled": types.BoolType, + "skip_certificate_validation": types.BoolType, +} + +// NewApplicationLoadBalancerResource is a helper function to simplify the provider implementation. +func NewApplicationLoadBalancerResource() resource.Resource { + return &applicationLoadBalancerResource{} +} + +// applicationLoadBalancerResource is the resource implementation. +type applicationLoadBalancerResource struct { + client *albSdk.APIClient + providerData core.ProviderData +} + +// Metadata returns the resource type name. +func (r *applicationLoadBalancerResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_alb" +} + +// ModifyPlan implements resource.ResourceWithModifyPlan. +// Use the modifier to set the effective region in the current plan. +func (r *applicationLoadBalancerResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { // nolint:gocritic // function signature required by Terraform + var configModel Model + // skip initial empty configuration to avoid follow-up errors + 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 + } + + utils.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 *applicationLoadBalancerResource) ValidateConfig(ctx context.Context, req resource.ValidateConfigRequest, resp *resource.ValidateConfigResponse) { + // Do nothing! + // We do not validate the TF input, because the API does that and + // maintaining and syncing between them is not worth it, because + // 400 Bad Request error gives all the details the user needs via the API in TF, which + // also is the single source of truth. +} + +// Configure adds the provider configured client to the resource. +func (r *applicationLoadBalancerResource) 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 := albUtils.ConfigureClient(ctx, &r.providerData, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + r.client = apiClient + tflog.Info(ctx, "Application Load Balancer client configured") +} + +// Schema defines the schema for the resource. +func (r *applicationLoadBalancerResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + protocolOptions := []string{"PROTOCOL_UNSPECIFIED", "PROTOCOL_HTTP", "PROTOCOL_HTTPS"} + roleOptions := []string{"ROLE_UNSPECIFIED", "ROLE_LISTENERS_AND_TARGETS", "ROLE_LISTENERS", "ROLE_TARGETS"} + servicePlanOptions := []string{"p10"} + regionOptions := []string{"eu01", "eu02"} + + descriptions := map[string]string{ + "main": "Application Load Balancer resource schema.", + "id": "Terraform's internal resource ID. It is structured as \"`project_id`\",\"region\",\"`name`\".", + "project_id": "STACKIT project ID to which the Application Load Balancer is associated.", + "region": "The resource region. If not defined, the provider region is used. " + utils.FormatPossibleValues(regionOptions...), + "disable_target_security_group_assignment": "Disable target security group assignemt to allow targets outside of the given network. Connectivity to targets need to be ensured by the customer, including routing and Security Groups (targetSecurityGroup can be assigned). Not changeable after creation.", + "errors": "Reports all errors a Application Load Balancer has.", + "errors.type": "Enum: \"TYPE_UNSPECIFIED\" \"TYPE_INTERNAL\" \"TYPE_QUOTA_SECGROUP_EXCEEDED\" \"TYPE_QUOTA_SECGROUPRULE_EXCEEDED\" \"TYPE_PORT_NOT_CONFIGURED\" \"TYPE_FIP_NOT_CONFIGURED\" \"TYPE_TARGET_NOT_ACTIVE\" \"TYPE_METRICS_MISCONFIGURED\" \"TYPE_LOGS_MISCONFIGURED\"\nThe error type specifies which part of the Application Load Balancer encountered the error. I.e. the API will not check if a provided public IP is actually available in the project. Instead the Application Load Balancer with try to use the provided IP and if not available reports TYPE_FIP_NOT_CONFIGURED error.", + "errors.description": "The error description contains additional helpful user information to fix the error state of the Application Load Balancer. For example the IP 45.135.247.139 does not exist in the project, then the description will report: Floating IP \"45.135.247.139\" could not be found.", + "external_address": "The external IP address where this Application Load Balancer is exposed. Not changeable after creation.", + "labels": "Labels represent user-defined metadata as key-value pairs. Label count cannot exceed 64 per ALB.", + "listeners": "List of all listeners which will accept traffic. Limited to 20.", + "listeners.name": "Unique name for the listener", + "http": "Configuration for handling HTTP traffic on this listener.", + "hosts": "Defines routing rules grouped by hostname.", + "host": "Hostname to match. Supports wildcards (e.g. *.example.com).", + "rules": "Routing rules under the specified host, matched by path prefix.", + "cookie_persistence": "Routing persistence via cookies.", + "cookie_persistence.name": "The name of the cookie to use.", + "ttl": "TTL specifies the time-to-live for the cookie. The default value is 0s, and it acts as a session cookie, expiring when the client session ends.", + "headers": "Headers for the rule.", + "headers.exact_match": "Exact match for the header value.", + "headers.name": "Header name.", + "path": "Routing via path.", + "path.exact_match": "Exact path match. Only a request path exactly equal to the value will match, e.g. '/foo' matches only '/foo', not '/foo/bar' or '/foobar'.", + "path.prefix": "Prefix path match. Only matches on full segment boundaries, e.g. '/foo' matches '/foo' and '/foo/bar' but NOT '/foobar'.", + "query_parameters": "Query parameters for the rule.", + "query_parameters.exact_match": "Exact match for the query parameters value.", + "query_parameters.name": "Query parameter name.", + "target_pool": "Reference target pool by target pool name.", + "web_socket": "If enabled, when client sends an HTTP request with and Upgrade header, indicating the desire to establish a Websocket connection, if backend server supports WebSocket, it responds with HTTP 101 status code, switching protocols from HTTP to WebSocket. Hence the client and the server can exchange data in real-time using one long-lived TCP connection.", + "https": "Configuration for handling HTTPS traffic on this listener.", + "certificate_config": "TLS termination certificate configuration.", + "certificate_ids": "Certificate IDs for TLS termination.", + "port": "Port number on which the listener receives incoming traffic.", + "protocol": "Protocol is the highest network protocol we understand to load balance. " + utils.FormatPossibleValues(protocolOptions...), + "waf_config_name": "Enable Web Application Firewall (WAF), referenced by name. See \"Application Load Balancer - Web Application Firewall API\" for more information.", + "load_balancer_security_group": "Security Group permitting network traffic from the LoadBalancer to the targets. Useful when disableTargetSecurityGroupAssignment=true to manually assign target security groups to targets.", + "load_balancer_security_group.id": "ID of the security Group", + "load_balancer_security_group.name": "Name of the security Group", + "name": "Application Load balancer name.", + "networks": "List of networks that listeners and targets reside in.", + "network_id": "STACKIT network ID the Application Load Balancer and/or targets are in.", + "role": "The role defines how the Application Load Balancer is using the network. " + utils.FormatPossibleValues(roleOptions...), + "options": "Defines any optional functionality you want to have enabled on your Application Load Balancer.", + "acl": "Use this option to limit the IP ranges that can use the Application Load Balancer.", + "ephemeral_address": "This option automates the handling of the external IP address for an Application Load Balancer. If set to true a new IP address will be automatically created. It will also be automatically deleted when the Load Balancer is deleted.", + "observability": "We offer Load Balancer observability via STACKIT Observability or external solutions.", + "observability_logs": "Observability logs configuration.", + "observability_logs_credentials_ref": "Credentials reference for logging. This reference is created via the observability create endpoint and the credential needs to contain the basic auth username and password for the logging solution the push URL points to. Then this enables monitoring via remote write for the Application Load Balancer.", + "observability_logs_push_url": "The Observability(Logs)/Loki remote write Push URL you want the logs to be shipped to.", + "observability_metrics": "Observability metrics configuration.", + "observability_metrics_credentials_ref": "Credentials reference for metrics. This reference is created via the observability create endpoint and the credential needs to contain the basic auth username and password for the metrics solution the push URL points to. Then this enables monitoring via remote write for the Application Load Balancer.", + "observability_metrics_push_url": "The Observability(Metrics)/Prometheus remote write push URL you want the metrics to be shipped to.", + "plan_id": "Service Plan configures the size of the Application Load Balancer. " + utils.FormatPossibleValues(servicePlanOptions...) + ". This list can change in the future. Therefore, this is not an enum.", + "private_network_only": "Application Load Balancer is accessible only via a private network ip address. Not changeable after creation.", + "status": "Enum: \"STATUS_UNSPECIFIED\" \"STATUS_PENDING\" \"STATUS_READY\" \"STATUS_ERROR\" \"STATUS_TERMINATING\"", + "target_pools": "List of all target pools which will be used in the Application Load Balancer. Limited to 20.", + "active_health_checks": "Set this to customize active health checks for targets in this pool.", + "healthy_threshold": "Healthy threshold of the health checking.", + "http_health_checks": "Options for the HTTP health checking.", + "http_health_checks.ok_status": "List of HTTP status codes that indicate a healthy response.", + "http_health_checks.path": "Path to send the health check request to.", + "interval": "Interval duration of health checking in seconds.", + "interval_jitter": "Interval duration threshold of the health checking in seconds.", + "timeout": "Active health checking timeout duration in seconds.", + "unhealthy_threshold": "Unhealthy threshold of the health checking.", + "target_pools.name": "Target pool name.", + "target_port": "The number identifying the port where each target listens for traffic.", + "targets": "List of all targets which will be used in the pool. Limited to 250.", + "targets.display_name": "Target display name", + "ip": "Private target IP, which must by unique within a target pool.", + "tls_config": "Configuration for TLS bridging.", + "tls_config.custom_ca": "Specifies a custom Certificate Authority (CA). When provided, the target pool will trust certificates signed by this CA, in addition to any system-trusted CAs. This is useful for scenarios where the target pool needs to communicate with servers using self-signed or internally-issued certificates. Enabled needs to be set to true and skip validation to false for this option.", + "tls_config.enabled": "Enable TLS (Transport Layer Security) bridging for the connection between Application Load Balancer and targets in this pool. When enabled, public CAs are trusted. Can be used in tandem with the options either custom CA or skip validation or alone.", + "tls_config.skip_certificate_validation": "Bypass certificate validation for TLS bridging in this target pool. This option is insecure and can only be used with public CAs by setting enabled true. Meant to be used for testing purposes only!", + "target_security_group": "Security Group that allows the targets to receive traffic from the LoadBalancer. Useful when disableTargetSecurityGroupAssignment=true to manually assign target security groups to targets.", + "target_security_group.id": "ID of the security Group", + "target_security_group.name": "Name of the security Group", + "version": "Application Load Balancer resource version. Used for concurrency safe updates.", + } + + resp.Schema = schema.Schema{ + Description: descriptions["main"], + MarkdownDescription: ` +## Setting up supporting infrastructure` + "\n" + ` + +The example below creates the supporting infrastructure using the STACKIT Terraform provider, including the network, network interface, a public IP address and server resources. +`, + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: descriptions["id"], + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "project_id": schema.StringAttribute{ + Description: descriptions["project_id"], + Required: true, + Validators: []validator.String{ + validate.UUID(), + }, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "region": schema.StringAttribute{ + Description: descriptions["region"], + Optional: true, + // must be computed to allow for storing the override value from the provider + Computed: true, + Validators: []validator.String{ + stringvalidator.OneOf(regionOptions...), + }, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + stringplanmodifier.UseStateForUnknown(), + }, + }, + "disable_target_security_group_assignment": schema.BoolAttribute{ + Description: descriptions["disable_target_security_group_assignment"], + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.RequiresReplace(), + }, + }, + "errors": schema.SetNestedAttribute{ + Description: descriptions["errors"], + Computed: true, + PlanModifiers: []planmodifier.Set{ + setplanmodifier.UseStateForUnknown(), + }, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "type": schema.StringAttribute{ + Description: descriptions["errors.type"], + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "description": schema.StringAttribute{ + Description: descriptions["errors.description"], + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + }, + }, + }, + "external_address": schema.StringAttribute{ + Description: descriptions["external_address"], + Optional: true, + Computed: true, + Validators: []validator.String{ + validate.IP(false), + }, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + stringplanmodifier.UseStateForUnknown(), + }, + }, + "labels": schema.MapAttribute{ + Description: descriptions["labels"], + Optional: true, + ElementType: types.StringType, + Validators: []validator.Map{ + mapvalidator.SizeBetween(1, 64), + mapvalidator.KeysAre(stringvalidator.LengthBetween(1, 63)), + mapvalidator.ValueStringsAre(stringvalidator.LengthBetween(1, 63)), + }, + }, + "plan_id": schema.StringAttribute{ + Description: descriptions["plan_id"], + Required: true, + Validators: []validator.String{ + stringvalidator.OneOf(servicePlanOptions...), + }, + }, + "listeners": schema.ListNestedAttribute{ + Description: descriptions["listeners"], + Required: true, + Validators: []validator.List{ + listvalidator.SizeBetween(1, 20), + }, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "name": schema.StringAttribute{ + Description: descriptions["listeners.name"], + Computed: true, // will be required in v2 + Validators: []validator.String{ + stringvalidator.RegexMatches( + regexp.MustCompile(`^[0-9a-z](?:(?:[0-9a-z]|-){0,61}[0-9a-z])?$`), + "1-63 characters [0-9] & [a-z] also [-] but not at the beginning or end", + ), + }, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "port": schema.Int64Attribute{ + Description: descriptions["port"], + Required: true, + Validators: []validator.Int64{ + int64validator.Between(1, 65535), + }, + }, + "protocol": schema.StringAttribute{ + Description: descriptions["protocol"], + Required: true, + Validators: []validator.String{ + stringvalidator.OneOf(protocolOptions...), + }, + }, + "waf_config_name": schema.StringAttribute{ + Description: descriptions["waf_config_name"], + Optional: true, + Validators: []validator.String{ + stringvalidator.RegexMatches( + regexp.MustCompile(`^[0-9a-z](?:(?:[0-9a-z]|-){0,61}[0-9a-z])?$`), + "1-63 characters [0-9] & [a-z] also [-] but not at the beginning or end", + ), + }, + }, + "http": schema.SingleNestedAttribute{ + Description: "Configuration for HTTP traffic.", + Required: true, + Attributes: map[string]schema.Attribute{ + "hosts": schema.ListNestedAttribute{ + Description: descriptions["hosts"], + Required: true, + Validators: []validator.List{ + listvalidator.SizeBetween(1, 100), + }, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "host": schema.StringAttribute{ + Description: descriptions["host"], + Required: true, + Validators: []validator.String{ + stringvalidator.LengthBetween(1, 253), + }, + }, + "rules": schema.ListNestedAttribute{ // This order matters and needs to be a list + Description: descriptions["rules"], + Required: true, + Validators: []validator.List{ + listvalidator.SizeBetween(1, 100), + }, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "target_pool": schema.StringAttribute{ + Description: descriptions["target_pool"], + Required: true, + Validators: []validator.String{ + stringvalidator.RegexMatches( + regexp.MustCompile(`^[0-9a-z](?:(?:[0-9a-z]|-){0,61}[0-9a-z])?$`), + "1-63 characters [0-9] & [a-z] also [-] but not at the beginning or end", + ), + }, + }, + "web_socket": schema.BoolAttribute{ + Description: descriptions["web_socket"], + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + }, + "path": schema.SingleNestedAttribute{ + Description: descriptions["path"], + Optional: true, + Attributes: map[string]schema.Attribute{ + "exact_match": schema.StringAttribute{ + Description: descriptions["path.exact_match"], + Optional: true, + Validators: []validator.String{ + stringvalidator.LengthBetween(1, 253), + }, + }, + "prefix": schema.StringAttribute{ + Description: descriptions["path.prefix"], + Optional: true, + Validators: []validator.String{ + stringvalidator.LengthBetween(1, 253), + }, + }, + }, + }, + "headers": schema.SetNestedAttribute{ + Description: descriptions["headers"], + Optional: true, + Validators: []validator.Set{ + setvalidator.SizeBetween(1, 100), + }, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "name": schema.StringAttribute{ + Description: descriptions["headers.name"], + Required: true, + Validators: []validator.String{ + stringvalidator.LengthBetween(1, 253), + }, + }, + "exact_match": schema.StringAttribute{ + Description: descriptions["headers.exact_match"], + Optional: true, + Validators: []validator.String{ + stringvalidator.LengthBetween(1, 253), + }, + }, + }, + }, + }, + "query_parameters": schema.SetNestedAttribute{ + Description: descriptions["query_parameters"], + Optional: true, + Validators: []validator.Set{ + setvalidator.SizeBetween(1, 100), + }, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "name": schema.StringAttribute{ + Description: descriptions["query_parameters.name"], + Required: true, + Validators: []validator.String{ + stringvalidator.LengthBetween(1, 253), + }, + }, + "exact_match": schema.StringAttribute{ + Description: descriptions["query_parameters.exact_match"], + Optional: true, + Validators: []validator.String{ + stringvalidator.LengthBetween(1, 253), + }, + }, + }, + }, + }, + "cookie_persistence": schema.SingleNestedAttribute{ + Description: descriptions["cookie_persistence"], + Optional: true, + Attributes: map[string]schema.Attribute{ + "name": schema.StringAttribute{ + Description: descriptions["cookie_persistence.name"], + Required: true, + Validators: []validator.String{ + stringvalidator.RegexMatches( + regexp.MustCompile(`^[0-9a-z](?:(?:[0-9a-z]|-){0,61}[0-9a-z])?$`), + "1-63 characters [0-9] & [a-z] also [-] but not at the beginning or end", + ), + }, + }, + "ttl": schema.StringAttribute{ + Description: descriptions["ttl"], + Required: true, + Validators: []validator.String{ + stringvalidator.RegexMatches( + regexp.MustCompile(`^\d\d{0,7}s$`), + "The duration must be a whole number followed by 's' (for seconds), between 0s and 99999999s", + ), + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + "https": schema.SingleNestedAttribute{ + Description: descriptions["https"], + Optional: true, + Attributes: map[string]schema.Attribute{ + "certificate_config": schema.SingleNestedAttribute{ + Description: descriptions["certificate_config"], + Required: true, + Attributes: map[string]schema.Attribute{ + "certificate_ids": schema.SetAttribute{ + Description: descriptions["certificate_ids"], + Required: true, + ElementType: types.StringType, + Validators: []validator.Set{ + setvalidator.SizeBetween(1, 100), + setvalidator.ValueStringsAre(stringvalidator.LengthBetween(1, 253)), + }, + }, + }, + }, + }, + }, + }, + }, + }, + "load_balancer_security_group": schema.SingleNestedAttribute{ + Description: descriptions["load_balancer_security_group"], + Computed: true, + PlanModifiers: []planmodifier.Object{ + objectplanmodifier.UseStateForUnknown(), + }, + Attributes: map[string]schema.Attribute{ + "name": schema.StringAttribute{ + Description: descriptions["load_balancer_security_group.name"], + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "id": schema.StringAttribute{ + Description: descriptions["load_balancer_security_group.id"], + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + }, + }, + "name": schema.StringAttribute{ + Description: descriptions["name"], + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + stringvalidator.RegexMatches( + regexp.MustCompile(`^[0-9a-z](?:(?:[0-9a-z]|-){0,61}[0-9a-z])?$`), + "1-63 characters [0-9] & [a-z] also [-] but not at the beginning or end", + ), + }, + }, + "networks": schema.SetNestedAttribute{ + Description: descriptions["networks"], + Required: true, + PlanModifiers: []planmodifier.Set{ + setplanmodifier.RequiresReplace(), + }, + Validators: []validator.Set{ + setvalidator.SizeBetween(1, 2), + }, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "network_id": schema.StringAttribute{ + Description: descriptions["network_id"], + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + validate.UUID(), + }, + }, + "role": schema.StringAttribute{ + Description: descriptions["role"], + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + stringvalidator.OneOf(roleOptions...), + }, + }, + }, + }, + }, + "options": schema.SingleNestedAttribute{ + Description: descriptions["options"], + Optional: true, + Attributes: map[string]schema.Attribute{ + "acl": schema.SetAttribute{ + Description: descriptions["acl"], + ElementType: types.StringType, + Optional: true, + Validators: []validator.Set{ + setvalidator.SizeBetween(1, 100), + setvalidator.ValueStringsAre( + validate.CIDR(), + ), + }, + }, + "ephemeral_address": schema.BoolAttribute{ + Description: descriptions["ephemeral_address"], + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + }, + "private_network_only": schema.BoolAttribute{ + Description: descriptions["private_network_only"], + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + }, + "observability": schema.SingleNestedAttribute{ + Description: descriptions["observability"], + Optional: true, + Attributes: map[string]schema.Attribute{ + "logs": schema.SingleNestedAttribute{ + Description: descriptions["observability_logs"], + Optional: true, + Attributes: map[string]schema.Attribute{ + "credentials_ref": schema.StringAttribute{ + Description: descriptions["observability_logs_credentials_ref"], + Required: true, + Validators: []validator.String{ + stringvalidator.LengthBetween(17, 17), + }, + }, + "push_url": schema.StringAttribute{ + Description: descriptions["observability_logs_credentials_ref"], + Required: true, + Validators: []validator.String{ + stringvalidator.LengthBetween(1, 1000), + }, + }, + }, + }, + "metrics": schema.SingleNestedAttribute{ + Description: descriptions["observability_metrics"], + Optional: true, + Attributes: map[string]schema.Attribute{ + "credentials_ref": schema.StringAttribute{ + Description: descriptions["observability_metrics_credentials_ref"], + Required: true, + Validators: []validator.String{ + stringvalidator.LengthBetween(17, 17), + }, + }, + "push_url": schema.StringAttribute{ + Description: descriptions["observability_metrics_credentials_ref"], + Required: true, + Validators: []validator.String{ + stringvalidator.LengthBetween(1, 1000), + }, + }, + }, + }, + }, + }, + }, + }, + "private_address": schema.StringAttribute{ + Description: descriptions["private_address"], + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "status": schema.StringAttribute{ + Description: descriptions["status"], + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "target_pools": schema.ListNestedAttribute{ + Description: descriptions["target_pools"], + Required: true, + Validators: []validator.List{ + listvalidator.SizeBetween(1, 20), + }, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "active_health_check": schema.SingleNestedAttribute{ + Description: descriptions["active_health_check"], + Optional: true, + Attributes: map[string]schema.Attribute{ + "healthy_threshold": schema.Int64Attribute{ + Description: descriptions["healthy_threshold"], + Required: true, + Validators: []validator.Int64{ + int64validator.Between(1, 999), + }, + }, + "interval": schema.StringAttribute{ + Description: descriptions["interval"], + Required: true, + Validators: []validator.String{ + stringvalidator.RegexMatches( + regexp.MustCompile(`^[0-9]{1,3}s|[0-9]{1,3}\.(?:[0-9]{2}[1-9]|[0-9][1-9][0-9]|[1-9][0-9]{2})s$`), + "The duration must be between 0s and 999.999s (e.g.: 1s or 0.100s or 12.345s)", + ), + }, + }, + "interval_jitter": schema.StringAttribute{ + Description: descriptions["interval_jitter"], + Required: true, + Validators: []validator.String{ + stringvalidator.RegexMatches( + regexp.MustCompile(`^[0-9]{1,3}s|[0-9]{1,3}\.(?:[0-9]{2}[1-9]|[0-9][1-9][0-9]|[1-9][0-9]{2})s$`), + "The duration must be between 0s and 999.999s (e.g.: 1s or 0.100s or 12.345s)", + ), + }, + }, + "timeout": schema.StringAttribute{ + Description: descriptions["timeout"], + Required: true, + Validators: []validator.String{ + stringvalidator.RegexMatches( + regexp.MustCompile(`^[0-9]{1,3}s|[0-9]{1,3}\.(?:[0-9]{2}[1-9]|[0-9][1-9][0-9]|[1-9][0-9]{2})s$`), + "The duration must be between 0s and 999.999s (e.g.: 1s or 0.100s or 12.345s)", + ), + }, + }, + "unhealthy_threshold": schema.Int64Attribute{ + Description: descriptions["unhealthy_threshold"], + Required: true, + Validators: []validator.Int64{ + int64validator.Between(1, 999), + }, + }, + "http_health_checks": schema.SingleNestedAttribute{ + Description: descriptions["http_health_checks"], + Optional: true, + Attributes: map[string]schema.Attribute{ + "path": schema.StringAttribute{ + Description: descriptions["http_health_checks.path"], + Required: true, + Validators: []validator.String{ + stringvalidator.LengthBetween(1, 253), + }, + }, + "ok_status": schema.SetAttribute{ + Description: descriptions["http_health_checks.ok_status"], + Required: true, + ElementType: types.StringType, + Validators: []validator.Set{ + setvalidator.SizeBetween(1, 100), + setvalidator.ValueStringsAre( + stringvalidator.RegexMatches( + regexp.MustCompile(`\d{3}`), + "must match expression", + ), + ), + }, + }, + }, + }, + }, + }, + "name": schema.StringAttribute{ + Description: descriptions["target_pools.name"], + Required: true, + Validators: []validator.String{ + stringvalidator.RegexMatches( + regexp.MustCompile(`^[0-9a-z](?:(?:[0-9a-z]|-){0,61}[0-9a-z])?$`), + "1-63 characters [0-9] & [a-z] also [-] but not at the beginning or end", + ), + }, + }, + "target_port": schema.Int64Attribute{ + Description: descriptions["target_port"], + Required: true, + Validators: []validator.Int64{int64validator.Between(1, 65535)}, + }, + "targets": schema.SetNestedAttribute{ + Description: descriptions["targets"], + Required: true, + Validators: []validator.Set{ + setvalidator.SizeBetween(1, 250), + }, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "display_name": schema.StringAttribute{ + Description: descriptions["targets.display_name"], + Optional: true, + Validators: []validator.String{ + stringvalidator.RegexMatches( + regexp.MustCompile(`^[0-9a-z](?:(?:[0-9a-z]|-){0,61}[0-9a-z])?$`), + "1-63 characters [0-9] & [a-z] also [-] but not at the beginning or end", + ), + }, + }, + "ip": schema.StringAttribute{ + Description: descriptions["ip"], + Required: true, + Validators: []validator.String{ + validate.IP(false), + }, + }, + }, + }, + }, + "tls_config": schema.SingleNestedAttribute{ + Description: descriptions["tls_config"], + Optional: true, + Attributes: map[string]schema.Attribute{ + "enabled": schema.BoolAttribute{ + Description: descriptions["tls_config.enabled"], + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + }, + "skip_certificate_validation": schema.BoolAttribute{ + Description: descriptions["tls_config.skip_certificate_validation"], + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + }, + "custom_ca": schema.StringAttribute{ + Description: descriptions["tls_config.custom_ca"], + Optional: true, + Validators: []validator.String{ + stringvalidator.LengthBetween(1, 8192), + }, + }, + }, + }, + }, + }, + }, + "target_security_group": schema.SingleNestedAttribute{ + Description: descriptions["target_security_group"], + Computed: true, + PlanModifiers: []planmodifier.Object{ + objectplanmodifier.UseStateForUnknown(), + }, + Attributes: map[string]schema.Attribute{ + "name": schema.StringAttribute{ + Description: descriptions["target_security_group.name"], + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "id": schema.StringAttribute{ + Description: descriptions["target_security_group.id"], + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + }, + }, + "version": schema.StringAttribute{ + Description: descriptions["version"], + Computed: true, + }, + }, + } +} + +// Create creates the resource and sets the initial Terraform state. +func (r *applicationLoadBalancerResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { // nolint:gocritic // function signature required by Terraform + // Retrieve values from plan + var model Model + diags := req.Plan.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + projectId := model.ProjectId.ValueString() + region := model.Region.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "region", region) + + // Generate API request body from model + payload, err := toCreatePayload(ctx, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating Application Load Balancer", fmt.Sprintf("Payload for create: %v", err)) + return + } + + // Create a new Application Load Balancer + createResp, err := r.client.CreateLoadBalancer(ctx, projectId, region).CreateLoadBalancerPayload(*payload).XRequestID(uuid.NewString()).Execute() + if err != nil { + errStr := prettyApiErr(err) + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating Application Load Balancer", fmt.Sprintf("Calling API for create: %v", errStr)) + return + } + + waitResp, err := wait.CreateOrUpdateLoadbalancerWaitHandler(ctx, r.client, projectId, region, *createResp.Name).SetTimeout(90 * time.Minute).WaitWithContext(ctx) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating Application Load Balancer", fmt.Sprintf("Application Load Balancer creation waiting: %v", err)) + return + } + + // Map response body to schema + err = mapFields(ctx, waitResp, &model, region) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating Application Load Balancer", fmt.Sprintf("Processing API payload: %v", err)) + return + } + + // Set state to fully populated data + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + tflog.Info(ctx, "Application Load Balancer created") +} + +// Read refreshes the Terraform state with the latest data. +func (r *applicationLoadBalancerResource) 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 + } + projectId := model.ProjectId.ValueString() + name := model.Name.ValueString() + region := r.providerData.GetRegionWithOverride(model.Region) + + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "name", name) + ctx = tflog.SetField(ctx, "region", region) + + lbResp, err := r.client.GetLoadBalancer(ctx, projectId, region, name).Execute() + if err != nil { + oapiErr, ok := err.(*oapierror.GenericOpenAPIError) //nolint:errorlint //complaining that error.As should be used to catch wrapped errors, but this error should not be wrapped + if ok && oapiErr.StatusCode == http.StatusNotFound { + resp.State.RemoveResource(ctx) + return + } + errStr := prettyApiErr(err) + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading Application Load Balancer", fmt.Sprintf("Calling API: %v", errStr)) + return + } + + // Map response body to schema + err = mapFields(ctx, lbResp, &model, region) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading Application Load Balancer", fmt.Sprintf("Processing API payload: %v", err)) + return + } + + // Set refreshed state + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "Application Load Balancer read") +} + +// Update updates the resource and sets the updated Terraform state on success. +func (r *applicationLoadBalancerResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform + // Retrieve values from plan + var model Model + diags := req.Plan.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + projectId := model.ProjectId.ValueString() + name := model.Name.ValueString() + region := model.Region.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "name", name) + ctx = tflog.SetField(ctx, "region", region) + + // get version (computed field) for update call via state + var state Model + diags = req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + model.Version = state.Version + + // Generate API request body from model + payload, err := toUpdatePayload(ctx, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating Application Load Balancer", fmt.Sprintf("Payload for update: %s", err)) + return + } + + // Update target pool + updateResp, err := r.client.UpdateLoadBalancer(ctx, projectId, region, name).UpdateLoadBalancerPayload(*payload).Execute() + if err != nil { + errStr := prettyApiErr(err) + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating Application Load Balancer", fmt.Sprintf("Calling API for update: %v", errStr)) + return + } + + waitResp, err := wait.CreateOrUpdateLoadbalancerWaitHandler(ctx, r.client, projectId, region, *updateResp.Name).SetTimeout(90 * time.Minute).WaitWithContext(ctx) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating Application Load Balancer", fmt.Sprintf("Application Load Balancer update waiting: %v", err)) + return + } + + // Map response body to schema + err = mapFields(ctx, waitResp, &model, region) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating Application Load Balancer", fmt.Sprintf("Processing API payload: %v", err)) + return + } + + // Set state to fully populated data + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + tflog.Info(ctx, "Application Load Balancer updated") +} + +// Delete deletes the resource and removes the Terraform state on success. +func (r *applicationLoadBalancerResource) 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 + } + projectId := model.ProjectId.ValueString() + name := model.Name.ValueString() + region := model.Region.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "name", name) + ctx = tflog.SetField(ctx, "region", region) + + // Delete Application Load Balancer + _, err := r.client.DeleteLoadBalancer(ctx, projectId, region, name).Execute() + if err != nil { + errStr := prettyApiErr(err) + core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting Application Load Balancer", fmt.Sprintf("Calling API for delete: %v", errStr)) + return + } + + _, err = wait.DeleteLoadbalancerWaitHandler(ctx, r.client, projectId, region, name).WaitWithContext(ctx) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting Application Load Balancer", fmt.Sprintf("Application Load Balancer deleting waiting: %v", err)) + return + } + + tflog.Info(ctx, "Application Load Balancer deleted") +} + +// ImportState imports a resource into the Terraform state on success. +// The expected format of the resource import identifier is: project_id,name +func (r *applicationLoadBalancerResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + idParts := strings.Split(req.ID, core.Separator) + + if len(idParts) != 3 || idParts[0] == "" || idParts[1] == "" || idParts[2] == "" { + core.LogAndAddError(ctx, &resp.Diagnostics, + "Error importing Application Load Balancer", + fmt.Sprintf("Expected import identifier with format: [project_id],[region],[name] Got: %q", req.ID), + ) + return + } + + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("project_id"), idParts[0])...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("region"), idParts[1])...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("name"), idParts[2])...) + tflog.Info(ctx, "Application Load Balancer state imported") +} + +func prettyApiErr(err error) string { + oapiErr, ok := err.(*oapierror.GenericOpenAPIError) //nolint:errorlint //complaining that error.As should be used to catch wrapped errors, but this error should not be wrapped + if !ok { + return err.Error() + } + var prettyJSON bytes.Buffer + if err := json.Indent(&prettyJSON, oapiErr.Body, "", " "); err != nil { + return err.Error() + } + return fmt.Sprintf("%s, status code %d, Body:\n%s", oapiErr.ErrorMessage, oapiErr.StatusCode, prettyJSON.String()) +} + +// toCreatePayload and all other toX functions in this file turn a Terraform Application Load Balancer model into a createLoadBalancerPayload to be used with the Application Load Balancer API. +func toCreatePayload(ctx context.Context, model *Model) (*albSdk.CreateLoadBalancerPayload, error) { + if model == nil { + return nil, fmt.Errorf("nil model") + } + + labelsPayload, err := toLabelPayload(ctx, model) + if err != nil { + return nil, fmt.Errorf("converting labels: %w", err) + } + listenersPayload, err := toListenersPayload(ctx, model) + if err != nil { + return nil, fmt.Errorf("converting listeners: %w", err) + } + networksPayload, err := toNetworksPayload(ctx, model) + if err != nil { + return nil, fmt.Errorf("converting networks: %w", err) + } + optionsPayload, err := toOptionsPayload(ctx, model) + if err != nil { + return nil, fmt.Errorf("converting options: %w", err) + } + targetPoolsPayload, err := toTargetPoolsPayload(ctx, model) + if err != nil { + return nil, fmt.Errorf("converting target_pools: %w", err) + } + + return &albSdk.CreateLoadBalancerPayload{ + DisableTargetSecurityGroupAssignment: conversion.BoolValueToPointer(model.DisableSecurityGroupAssignment), + ExternalAddress: conversion.StringValueToPointer(model.ExternalAddress), + Labels: labelsPayload, + Listeners: listenersPayload, + Name: conversion.StringValueToPointer(model.Name), + Networks: networksPayload, + Options: optionsPayload, + PlanId: conversion.StringValueToPointer(model.PlanId), + TargetPools: targetPoolsPayload, + }, nil +} + +func toLabelPayload(ctx context.Context, model *Model) (albSdk.CreateLoadBalancerPayloadGetLabelsAttributeType, error) { + if model.Labels.IsNull() || model.Labels.IsUnknown() { + return nil, nil + } + var labels map[string]string + // Unpack types.Map -> map[string]string + diags := model.Labels.ElementsAs(ctx, &labels, false) + if diags.HasError() { + return nil, fmt.Errorf("converting labels: %w", core.DiagsToError(diags)) + } + + payload := albSdk.CreateLoadBalancerPayloadGetLabelsArgType{} + for key, value := range labels { + payload[key] = value + } + + return &payload, nil +} + +func toListenersPayload(ctx context.Context, model *Model) (*[]albSdk.Listener, error) { + if model.Listeners.IsNull() || model.Listeners.IsUnknown() { + return nil, nil + } + + listenersModel := []listener{} + diags := model.Listeners.ElementsAs(ctx, &listenersModel, false) + if diags.HasError() { + return nil, fmt.Errorf("converting listeners: %w", core.DiagsToError(diags)) + } + + payload := []albSdk.Listener{} + for i := range listenersModel { + listenerModel := listenersModel[i] + httpPayload, err := toHttpPayload(ctx, &listenerModel) + if err != nil { + return nil, fmt.Errorf("converting http payload: %w", err) + } + httpsPayload, err := toHttpsPayload(ctx, &listenerModel) + if err != nil { + return nil, fmt.Errorf("converting https payload: %w", err) + } + payload = append(payload, albSdk.Listener{ + Http: httpPayload, + Https: httpsPayload, + //Name: conversion.StringValueToPointer(listenerModel.Name), will be added in v2 + Port: conversion.Int64ValueToPointer(listenerModel.Port), + Protocol: albSdk.ListenerGetProtocolAttributeType(conversion.StringValueToPointer(listenerModel.Protocol)), + WafConfigName: conversion.StringValueToPointer(listenerModel.WafConfigName), + }) + } + + return &payload, nil +} + +func toHttpPayload(ctx context.Context, listenerModel *listener) (albSdk.ListenerGetHttpAttributeType, error) { + if listenerModel.Http.IsNull() || listenerModel.Http.IsUnknown() { + return nil, nil + } + + httpModel := httpALB{} + diags := listenerModel.Http.As(ctx, &httpModel, basetypes.ObjectAsOptions{}) + if diags.HasError() { + return nil, fmt.Errorf("converting http: %w", core.DiagsToError(diags)) + } + + hostsPayload, err := toHostsPayload(ctx, &httpModel) + if err != nil { + return nil, fmt.Errorf("converting host payload: %w", err) + } + + payload := albSdk.ListenerGetHttpArgType{ + Hosts: hostsPayload, + } + return &payload, nil +} + +func toHostsPayload(ctx context.Context, httpModel *httpALB) (albSdk.ProtocolOptionsHTTPGetHostsAttributeType, error) { + if httpModel.Hosts.IsNull() || httpModel.Hosts.IsUnknown() { + return nil, nil + } + + hostsModel := []hostConfig{} + diags := httpModel.Hosts.ElementsAs(ctx, &hostsModel, false) + if diags.HasError() { + return nil, fmt.Errorf("converting hosts: %w", core.DiagsToError(diags)) + } + + payload := albSdk.ProtocolOptionsHTTPGetHostsArgType{} + for i := range hostsModel { + hostModel := hostsModel[i] + if hostModel.Host.IsNull() || hostModel.Host.IsUnknown() { + return nil, fmt.Errorf("no hosts specified") + } + rulesPayload, err := toRulesPayload(ctx, &hostModel) + if err != nil { + return nil, fmt.Errorf("converting host payload: %w", err) + } + payload = append(payload, albSdk.HostConfig{ + Host: conversion.StringValueToPointer(hostModel.Host), + Rules: rulesPayload, + }) + } + return &payload, nil +} + +func toRulesPayload(ctx context.Context, hostConfigModel *hostConfig) (albSdk.HostConfigGetRulesAttributeType, error) { + if hostConfigModel.Rules.IsNull() || hostConfigModel.Rules.IsUnknown() { + return nil, nil + } + + rulesModel := []rule{} + diags := hostConfigModel.Rules.ElementsAs(ctx, &rulesModel, false) + if diags.HasError() { + return nil, fmt.Errorf("converting rules: %w", core.DiagsToError(diags)) + } + + payload := []albSdk.Rule{} + for i := range rulesModel { + ruleModel := rulesModel[i] + cookiePersistencePayload, err := toCookiePersistencePayload(ctx, &ruleModel) + if err != nil { + return nil, fmt.Errorf("converting rule payload: %w", err) + } + headersPayload, err := toHeadersPayload(ctx, &ruleModel) + if err != nil { + return nil, fmt.Errorf("converting rule payload: %w", err) + } + pathPayload, err := toPathPayload(ctx, &ruleModel) + if err != nil { + return nil, fmt.Errorf("converting rule payload: %w", err) + } + queryParametersPayload, err := toQueryParametersPayload(ctx, &ruleModel) + if err != nil { + return nil, fmt.Errorf("converting rule payload: %w", err) + } + payload = append(payload, albSdk.Rule{ + CookiePersistence: cookiePersistencePayload, + Headers: headersPayload, + Path: pathPayload, + PathPrefix: nil, // will be removed in v2 + QueryParameters: queryParametersPayload, + TargetPool: conversion.StringValueToPointer(ruleModel.TargetPool), + WebSocket: conversion.BoolValueToPointer(ruleModel.WebSocket), + }) + } + return &payload, nil +} + +func toQueryParametersPayload(ctx context.Context, ruleModel *rule) (albSdk.RuleGetQueryParametersAttributeType, error) { + if ruleModel.QueryParameters.IsNull() || ruleModel.QueryParameters.IsUnknown() { + return nil, nil + } + + queryParametersModel := []queryParameter{} + diags := ruleModel.QueryParameters.ElementsAs(ctx, &queryParametersModel, false) + if diags.HasError() { + return nil, fmt.Errorf("converting query parameter payload: %w", core.DiagsToError(diags)) + } + + payload := albSdk.RuleGetQueryParametersArgType{} + for i := range queryParametersModel { + queryParameterModel := queryParametersModel[i] + payload = append(payload, albSdk.QueryParameter{ + ExactMatch: conversion.StringValueToPointer(queryParameterModel.ExactMatch), + Name: conversion.StringValueToPointer(queryParameterModel.Name), + }) + } + + return &payload, nil +} + +func toPathPayload(ctx context.Context, ruleModel *rule) (albSdk.RuleGetPathAttributeType, error) { + if ruleModel.Path.IsNull() || ruleModel.Path.IsUnknown() { + return nil, nil + } + + pathModel := pathALB{} + diags := ruleModel.Path.As(ctx, &pathModel, basetypes.ObjectAsOptions{}) + if diags.HasError() { + return nil, fmt.Errorf("converting path: %w", core.DiagsToError(diags)) + } + + if (pathModel.Exact.IsNull() || pathModel.Exact.IsUnknown()) && (pathModel.Prefix.IsNull() || pathModel.Prefix.IsUnknown()) { + return nil, fmt.Errorf("no path prefix or exact match specified") + } + if !(pathModel.Exact.IsNull() || pathModel.Exact.IsUnknown()) && !(pathModel.Prefix.IsNull() || pathModel.Prefix.IsUnknown()) { + return nil, fmt.Errorf("path prefix and exact match are specified at the same time") + } + + payload := albSdk.RuleGetPathArgType{ + Exact: conversion.StringValueToPointer(pathModel.Exact), + Prefix: conversion.StringValueToPointer(pathModel.Prefix), + } + return &payload, nil +} + +func toCookiePersistencePayload(ctx context.Context, ruleModel *rule) (albSdk.RuleGetCookiePersistenceAttributeType, error) { + if ruleModel.CookiePersistence.IsNull() || ruleModel.CookiePersistence.IsUnknown() { + return nil, nil + } + + cookieModel := cookiePersistence{} + diags := ruleModel.CookiePersistence.As(ctx, &cookieModel, basetypes.ObjectAsOptions{}) + if diags.HasError() { + return nil, fmt.Errorf("converting cookie persistence config: %w", core.DiagsToError(diags)) + } + + payload := albSdk.RuleGetCookiePersistenceArgType{ + Name: conversion.StringValueToPointer(cookieModel.Name), + Ttl: conversion.StringValueToPointer(cookieModel.Ttl), + } + + return &payload, nil +} + +func toHeadersPayload(ctx context.Context, ruleModel *rule) (albSdk.RuleGetHeadersAttributeType, error) { + if ruleModel.Headers.IsNull() || ruleModel.Headers.IsUnknown() { + return nil, nil + } + + headersModel := []headers{} + diags := ruleModel.Headers.ElementsAs(ctx, &headersModel, false) + if diags.HasError() { + return nil, fmt.Errorf("converting headers: %w", core.DiagsToError(diags)) + } + + payload := albSdk.RuleGetHeadersArgType{} + for i := range headersModel { + header := headersModel[i] + payload = append(payload, albSdk.HttpHeader{ + ExactMatch: conversion.StringValueToPointer(header.ExactMatch), + Name: conversion.StringValueToPointer(header.Name), + }) + } + return &payload, nil +} + +func toHttpsPayload(ctx context.Context, listenerModel *listener) (albSdk.ListenerGetHttpsAttributeType, error) { + if listenerModel.Https.IsNull() || listenerModel.Https.IsUnknown() { + return nil, nil + } + + httpsModel := https{} + diags := listenerModel.Https.As(ctx, &httpsModel, basetypes.ObjectAsOptions{}) + if diags.HasError() { + return nil, fmt.Errorf("converting https: %w", core.DiagsToError(diags)) + } + + certificateConfigPayload, err := toCertificateConfigPayload(ctx, &httpsModel) + if err != nil { + return nil, fmt.Errorf("converting certificate config: %w", err) + } + + payload := albSdk.ListenerGetHttpsArgType{ + CertificateConfig: certificateConfigPayload, + } + + return &payload, nil +} + +func toCertificateConfigPayload(ctx context.Context, https *https) (*albSdk.CertificateConfig, error) { + if https.CertificateConfig.IsNull() || https.CertificateConfig.IsUnknown() { + return nil, nil + } + + certificateConfigModel := certificateConfig{} + diags := https.CertificateConfig.As(ctx, &certificateConfigModel, basetypes.ObjectAsOptions{}) + if diags.HasError() { + return nil, fmt.Errorf("converting certificate config: %w", core.DiagsToError(diags)) + } + if certificateConfigModel.CertificateConfigIDs.IsNull() || certificateConfigModel.CertificateConfigIDs.IsUnknown() { + return nil, fmt.Errorf("converting certificate config: no certificate config found") + } + + certificateConfigSet, err := conversion.StringSetToPointer(certificateConfigModel.CertificateConfigIDs) + if err != nil { + return nil, fmt.Errorf("converting certificate config list: %w", err) + } + + payload := albSdk.CertificateConfig{ + CertificateIds: certificateConfigSet, + } + return &payload, nil +} + +func toNetworksPayload(ctx context.Context, model *Model) (*[]albSdk.Network, error) { + if model.Networks.IsNull() || model.Networks.IsUnknown() { + return nil, nil + } + + networksModel := []network{} + diags := model.Networks.ElementsAs(ctx, &networksModel, false) + if diags.HasError() { + return nil, fmt.Errorf("converting networks: %w", core.DiagsToError(diags)) + } + + payload := []albSdk.Network{} + for i := range networksModel { + networkModel := networksModel[i] + payload = append(payload, albSdk.Network{ + NetworkId: conversion.StringValueToPointer(networkModel.NetworkId), + Role: albSdk.NetworkGetRoleAttributeType(conversion.StringValueToPointer(networkModel.Role)), + }) + } + + return &payload, nil +} + +func toOptionsPayload(ctx context.Context, model *Model) (*albSdk.LoadBalancerOptions, error) { + if model.Options.IsNull() || model.Options.IsUnknown() { + return nil, nil + } + + optionsModel := options{} + diags := model.Options.As(ctx, &optionsModel, basetypes.ObjectAsOptions{}) + if diags.HasError() { + return nil, fmt.Errorf("converting options: %w", core.DiagsToError(diags)) + } + + accessControlPayload := &albSdk.LoadbalancerOptionAccessControl{} + if !(optionsModel.ACL.IsNull() || optionsModel.ACL.IsUnknown()) { + var aclModel []string + diags := optionsModel.ACL.ElementsAs(ctx, &aclModel, false) + if diags.HasError() { + return nil, fmt.Errorf("converting acl: %w", core.DiagsToError(diags)) + } + accessControlPayload.AllowedSourceRanges = &aclModel + } + + observabilityPayload := &albSdk.LoadbalancerOptionObservability{} + if !(optionsModel.Observability.IsNull() || optionsModel.Observability.IsUnknown()) { + observabilityModel := observability{} + diags := optionsModel.Observability.As(ctx, &observabilityModel, basetypes.ObjectAsOptions{}) + if diags.HasError() { + return nil, fmt.Errorf("converting observability: %w", core.DiagsToError(diags)) + } + + // observability logs + observabilityLogsModel := observabilityOption{} + diags = observabilityModel.Logs.As(ctx, &observabilityLogsModel, basetypes.ObjectAsOptions{}) + if diags.HasError() { + return nil, fmt.Errorf("converting observability logs: %w", core.DiagsToError(diags)) + } + observabilityPayload.Logs = &albSdk.LoadbalancerOptionLogs{ + CredentialsRef: observabilityLogsModel.CredentialsRef.ValueStringPointer(), + PushUrl: observabilityLogsModel.PushUrl.ValueStringPointer(), + } + + // observability metrics + observabilityMetricsModel := observabilityOption{} + diags = observabilityModel.Metrics.As(ctx, &observabilityMetricsModel, basetypes.ObjectAsOptions{}) + if diags.HasError() { + return nil, fmt.Errorf("converting observability metrics: %w", core.DiagsToError(diags)) + } + observabilityPayload.Metrics = &albSdk.LoadbalancerOptionMetrics{ + CredentialsRef: observabilityMetricsModel.CredentialsRef.ValueStringPointer(), + PushUrl: observabilityMetricsModel.PushUrl.ValueStringPointer(), + } + } + + payload := albSdk.LoadBalancerOptions{ + AccessControl: accessControlPayload, + Observability: observabilityPayload, + PrivateNetworkOnly: conversion.BoolValueToPointer(optionsModel.PrivateNetworkOnly), + EphemeralAddress: conversion.BoolValueToPointer(optionsModel.EphemeralAddress), + } + + return &payload, nil +} + +func toTargetPoolsPayload(ctx context.Context, model *Model) (*[]albSdk.TargetPool, error) { + if model.TargetPools.IsNull() || model.TargetPools.IsUnknown() { + return nil, nil + } + + targetPoolsModel := []targetPool{} + diags := model.TargetPools.ElementsAs(ctx, &targetPoolsModel, false) + if diags.HasError() { + return nil, fmt.Errorf("converting targetPools: %w", core.DiagsToError(diags)) + } + + payload := []albSdk.TargetPool{} + for i := range targetPoolsModel { + targetPoolModel := targetPoolsModel[i] + + activeHealthCheckPayload, err := toActiveHealthCheckPayload(ctx, &targetPoolModel) + if err != nil { + return nil, fmt.Errorf("converting index %d: converting active_health_check: %w", i, err) + } + targetsPayload, err := toTargetsPayload(ctx, &targetPoolModel) + if err != nil { + return nil, fmt.Errorf("converting index %d: converting targets: %w", i, err) + } + tlsConfigPayload, err := toTlsConfigPayload(ctx, &targetPoolModel) + if err != nil { + return nil, fmt.Errorf("converting index %d: converting tls_config: %w", i, err) + } + + payload = append(payload, albSdk.TargetPool{ + ActiveHealthCheck: activeHealthCheckPayload, + Name: conversion.StringValueToPointer(targetPoolModel.Name), + TargetPort: conversion.Int64ValueToPointer(targetPoolModel.TargetPort), + Targets: targetsPayload, + TlsConfig: tlsConfigPayload, + }) + } + + return &payload, nil +} + +func toTlsConfigPayload(ctx context.Context, tp *targetPool) (albSdk.TargetPoolGetTlsConfigAttributeType, error) { + if tp.TLSConfig.IsNull() || tp.TLSConfig.IsUnknown() { + return nil, nil + } + + tlsConfigModel := tlsConfig{} + diags := tp.TLSConfig.As(ctx, &tlsConfigModel, basetypes.ObjectAsOptions{}) + if diags.HasError() { + return nil, fmt.Errorf("converting target pool TLS config: %w", core.DiagsToError(diags)) + } + + payload := albSdk.TargetPoolGetTlsConfigArgType{ + Enabled: conversion.BoolValueToPointer(tlsConfigModel.Enabled), + SkipCertificateValidation: conversion.BoolValueToPointer(tlsConfigModel.SkipCertValidation), + } + + if !tlsConfigModel.CustomCA.IsNull() && !tlsConfigModel.CustomCA.IsUnknown() { + customCa := base64.StdEncoding.EncodeToString([]byte(tlsConfigModel.CustomCA.ValueString())) + payload.CustomCa = &customCa + } + + return &payload, nil +} + +func toUpdatePayload(ctx context.Context, model *Model) (*albSdk.UpdateLoadBalancerPayload, error) { + if model == nil { + return nil, fmt.Errorf("nil model") + } + + labelsPayload, err := toLabelPayload(ctx, model) + if err != nil { + return nil, fmt.Errorf("converting labels: %w", err) + } + listenersPayload, err := toListenersPayload(ctx, model) + if err != nil { + return nil, fmt.Errorf("converting listeners: %w", err) + } + networksPayload, err := toNetworksPayload(ctx, model) + if err != nil { + return nil, fmt.Errorf("converting networks: %w", err) + } + optionsPayload, err := toOptionsPayload(ctx, model) + if err != nil { + return nil, fmt.Errorf("converting options: %w", err) + } + targetPoolsPayload, err := toTargetPoolsPayload(ctx, model) + if err != nil { + return nil, fmt.Errorf("converting target_pools: %w", err) + } + externalAddressPayload, err := toExternalAddress(ctx, model) + if err != nil { + return nil, fmt.Errorf("converting external_address: %w", err) + } + + return &albSdk.UpdateLoadBalancerPayload{ + DisableTargetSecurityGroupAssignment: conversion.BoolValueToPointer(model.DisableSecurityGroupAssignment), + ExternalAddress: externalAddressPayload, + Labels: labelsPayload, + Listeners: listenersPayload, + Name: conversion.StringValueToPointer(model.Name), + Networks: networksPayload, + Options: optionsPayload, + PlanId: conversion.StringValueToPointer(model.PlanId), + TargetPools: targetPoolsPayload, + Version: conversion.StringValueToPointer(model.Version), + }, nil +} + +// toExternalAddress needs to exist because during UPDATE the model will always have it, but +// we do not send it if ephemeral_address or private_network_only options are set. +func toExternalAddress(ctx context.Context, m *Model) (albSdk.UpdateLoadBalancerPayloadGetExternalAddressAttributeType, error) { + if m.Options.IsNull() || m.Options.IsUnknown() { + // no ephemeral or private option are set + return conversion.StringValueToPointer(m.ExternalAddress), nil + } + o := &options{} + diags := m.Options.As(ctx, o, basetypes.ObjectAsOptions{}) + if diags.HasError() { + return nil, fmt.Errorf("converting options: %w", core.DiagsToError(diags)) + } + if (o.EphemeralAddress.IsNull() || o.EphemeralAddress.IsUnknown()) && (o.PrivateNetworkOnly.IsNull() || o.PrivateNetworkOnly.IsUnknown()) { + // options exist but ephemeral or private option are set (default false) so use external address + return conversion.StringValueToPointer(m.ExternalAddress), nil + } + if !o.EphemeralAddress.IsNull() && !o.PrivateNetworkOnly.IsNull() && o.EphemeralAddress.ValueBool() && o.PrivateNetworkOnly.ValueBool() { + // options exist but both ephemeral or private option are set true so error for impossible combination + return nil, fmt.Errorf("ephemeral_address and private_network_only cannot both be true") + } + if !o.EphemeralAddress.IsNull() && o.EphemeralAddress.ValueBool() { + // ephemeral exist and true so send no external address + return nil, nil + } + if !o.PrivateNetworkOnly.IsNull() && o.PrivateNetworkOnly.ValueBool() { + // private exist and true so send no external address + return nil, nil + } + // ephemeral and private exist, but is false so use external address + return conversion.StringValueToPointer(m.ExternalAddress), nil +} + +func toActiveHealthCheckPayload(ctx context.Context, tp *targetPool) (*albSdk.ActiveHealthCheck, error) { + if tp.ActiveHealthCheck.IsNull() || tp.ActiveHealthCheck.IsUnknown() { + return nil, nil + } + + activeHealthCheckModel := activeHealthCheck{} + diags := tp.ActiveHealthCheck.As(ctx, &activeHealthCheckModel, basetypes.ObjectAsOptions{}) + if diags.HasError() { + return nil, fmt.Errorf("converting active health check: %w", core.DiagsToError(diags)) + } + + httpHealthChecksPayload, err := toHttpHealthChecksPayload(ctx, &activeHealthCheckModel) + if err != nil { + return nil, fmt.Errorf("converting http health check: %w", err) + } + + return &albSdk.ActiveHealthCheck{ + HealthyThreshold: conversion.Int64ValueToPointer(activeHealthCheckModel.HealthyThreshold), + Interval: conversion.StringValueToPointer(activeHealthCheckModel.Interval), + IntervalJitter: conversion.StringValueToPointer(activeHealthCheckModel.IntervalJitter), + Timeout: conversion.StringValueToPointer(activeHealthCheckModel.Timeout), + UnhealthyThreshold: conversion.Int64ValueToPointer(activeHealthCheckModel.UnhealthyThreshold), + HttpHealthChecks: httpHealthChecksPayload, + }, nil +} + +func toHttpHealthChecksPayload(ctx context.Context, check *activeHealthCheck) (albSdk.ActiveHealthCheckGetHttpHealthChecksAttributeType, error) { + if check.HttpHealthChecks.IsNull() || check.HttpHealthChecks.IsUnknown() { + return nil, nil + } + + httpHealthChecksModel := httpHealthChecks{} + diags := check.HttpHealthChecks.As(ctx, &httpHealthChecksModel, basetypes.ObjectAsOptions{}) + if diags.HasError() { + return nil, fmt.Errorf("converting active health check: %w", core.DiagsToError(diags)) + } + + okStatus, err := conversion.StringSetToPointer(httpHealthChecksModel.OkStatus) + if err != nil { + return nil, fmt.Errorf("converting active health check ok status: %w", err) + } + + payload := albSdk.HttpHealthChecks{ + OkStatuses: okStatus, + Path: conversion.StringValueToPointer(httpHealthChecksModel.Path), + } + return &payload, nil +} + +func toTargetsPayload(ctx context.Context, tp *targetPool) (*[]albSdk.Target, error) { + if tp.Targets.IsNull() || tp.Targets.IsUnknown() { + return nil, nil + } + + targetsModel := []target{} + diags := tp.Targets.ElementsAs(ctx, &targetsModel, false) + if diags.HasError() { + return nil, fmt.Errorf("converting Targets list: %w", core.DiagsToError(diags)) + } + + payload := []albSdk.Target{} + for i := range targetsModel { + targetModel := targetsModel[i] + payload = append(payload, albSdk.Target{ + DisplayName: conversion.StringValueToPointer(targetModel.DisplayName), + Ip: conversion.StringValueToPointer(targetModel.Ip), + }) + } + + return &payload, nil +} + +// mapFields and all other map functions in this file translate an API resource into a Terraform model. +func mapFields(ctx context.Context, alb *albSdk.LoadBalancer, m *Model, region string) error { + if alb == nil { + return fmt.Errorf("response input is nil") + } + if m == nil { + return fmt.Errorf("model input is nil") + } + + var name string + if m.Name.ValueString() != "" { + name = m.Name.ValueString() + } else if alb.Name != nil { + name = *alb.Name + } else { + return fmt.Errorf("name not present") + } + m.Region = types.StringValue(region) + m.Name = types.StringValue(name) + m.Id = utils.BuildInternalTerraformId(m.ProjectId.ValueString(), m.Region.ValueString(), name) + + m.PlanId = types.StringPointerValue(alb.PlanId) + m.PrivateAddress = types.StringPointerValue(alb.PrivateAddress) + m.ExternalAddress = types.StringPointerValue(alb.ExternalAddress) + m.Version = types.StringPointerValue(alb.Version) + m.Status = types.StringPointerValue((*string)(alb.Status)) + mapDisableSecurityGroupAssignment(alb, m) + err := mapErrors(alb, m) + if err != nil { + return fmt.Errorf("mapping errors: %w", err) + } + err = mapTargetSecurityGroup(alb, m) + if err != nil { + return fmt.Errorf("mapping target security group: %w", err) + } + err = mapLoadBalancerSecurityGroup(alb, m) + if err != nil { + return fmt.Errorf("mapping load balancer security group: %w", err) + } + err = mapLabels(alb, m) + if err != nil { + return fmt.Errorf("mapping labels: %w", err) + } + err = mapListeners(ctx, alb, m) + if err != nil { + return fmt.Errorf("mapping listeners: %w", err) + } + err = mapNetworks(alb, m) + if err != nil { + return fmt.Errorf("mapping network: %w", err) + } + err = mapOptions(ctx, alb, m) + if err != nil { + return fmt.Errorf("mapping options: %w", err) + } + err = mapTargetPools(ctx, alb, m) + if err != nil { + return fmt.Errorf("mapping target pools: %w", err) + } + + return nil +} + +func mapDisableSecurityGroupAssignment(applicationLoadBalancerResp *albSdk.LoadBalancer, m *Model) { + m.DisableSecurityGroupAssignment = types.BoolValue(false) + // If the disable target security group assignment field is nil in the response we set it to false in the TF state + // to prevent an inconsistent result after apply error + if applicationLoadBalancerResp.DisableTargetSecurityGroupAssignment != nil && *applicationLoadBalancerResp.DisableTargetSecurityGroupAssignment { + m.DisableSecurityGroupAssignment = types.BoolValue(true) + } + return +} + +func mapErrors(applicationLoadBalancerResp *albSdk.LoadBalancer, m *Model) error { + if applicationLoadBalancerResp.Errors == nil { + m.Errors = types.SetNull(types.ObjectType{AttrTypes: errorsType}) + return nil + } + + errorsSet := []attr.Value{} + for i, errorsResp := range *applicationLoadBalancerResp.Errors { + errorMap := map[string]attr.Value{ + "description": types.StringPointerValue(errorsResp.Description), + "type": types.StringPointerValue((*string)(errorsResp.Type)), + } + + errorTF, diags := types.ObjectValue(errorsType, errorMap) + if diags.HasError() { + return fmt.Errorf("mapping error %d: %w", i, core.DiagsToError(diags)) + } + + errorsSet = append(errorsSet, errorTF) + } + + errorsTF, diags := types.SetValue( + types.ObjectType{AttrTypes: errorsType}, + errorsSet, + ) + if diags.HasError() { + return fmt.Errorf("mapping errors: %w", core.DiagsToError(diags)) + } + + m.Errors = errorsTF + return nil +} + +func mapLoadBalancerSecurityGroup(applicationLoadBalancerResp *albSdk.LoadBalancer, m *Model) error { + if applicationLoadBalancerResp.LoadBalancerSecurityGroup == nil { + m.LoadBalancerSecurityGroup = types.ObjectNull(loadBalancerSecurityGroupType) + return nil + } + + lbSecurityGroupMap := map[string]attr.Value{ + "id": types.StringPointerValue(applicationLoadBalancerResp.LoadBalancerSecurityGroup.Id), + "name": types.StringPointerValue(applicationLoadBalancerResp.LoadBalancerSecurityGroup.Name), + } + + lbSecurityGroupTF, diags := types.ObjectValue(loadBalancerSecurityGroupType, lbSecurityGroupMap) + if diags.HasError() { + return fmt.Errorf("mapping loadBalancerSecurityGroup: %w", core.DiagsToError(diags)) + } + + m.LoadBalancerSecurityGroup = lbSecurityGroupTF + return nil +} + +func mapTargetSecurityGroup(applicationLoadBalancerResp *albSdk.LoadBalancer, m *Model) error { + if applicationLoadBalancerResp.TargetSecurityGroup == nil { + m.TargetSecurityGroup = types.ObjectNull(targetSecurityGroupType) + return nil + } + + tSecurityGroupMap := map[string]attr.Value{ + "id": types.StringPointerValue(applicationLoadBalancerResp.TargetSecurityGroup.Id), + "name": types.StringPointerValue(applicationLoadBalancerResp.TargetSecurityGroup.Name), + } + + tSecurityGroupTF, diags := types.ObjectValue(targetSecurityGroupType, tSecurityGroupMap) + if diags.HasError() { + return fmt.Errorf("mapping targetSecurityGroup: %w", core.DiagsToError(diags)) + } + + m.TargetSecurityGroup = tSecurityGroupTF + return nil +} + +func mapLabels(applicationLoadBalancerResp *albSdk.LoadBalancer, m *Model) error { + if applicationLoadBalancerResp.Labels == nil { + m.Labels = types.MapNull(types.StringType) + return nil + } + + labelsMap := map[string]attr.Value{} + for key, value := range *applicationLoadBalancerResp.Labels { + labelsMap[key] = types.StringValue(value) + } + + labelsTF, diags := types.MapValue( + types.StringType, + labelsMap, + ) + if diags.HasError() { + return fmt.Errorf("mapping labels: %w", core.DiagsToError(diags)) + } + + m.Labels = labelsTF + return nil +} + +func mapListeners(ctx context.Context, applicationLoadBalancerResp *albSdk.LoadBalancer, m *Model) error { + if applicationLoadBalancerResp.Listeners == nil { + m.Listeners = types.ListNull(types.ObjectType{AttrTypes: listenerTypes}) + return nil + } + + var configListeners []listener + if !m.Listeners.IsNull() && !m.Listeners.IsUnknown() { + diags := m.Listeners.ElementsAs(ctx, &configListeners, false) + if diags.HasError() { + return fmt.Errorf("unpacking listeners from model: %w", core.DiagsToError(diags)) + } + } + + listenersSet := []attr.Value{} + for i, listenerResp := range *applicationLoadBalancerResp.Listeners { + var configMatch *listener + for _, cl := range configListeners { + if !cl.Name.IsNull() && cl.Name.ValueString() == *listenerResp.Name { + configMatch = &cl + break + } + } + var httpModel = types.ObjectNull(httpTypes) + if configMatch != nil { + httpModel = configMatch.Http + } + + listenerMap := map[string]attr.Value{ + "name": types.StringPointerValue(listenerResp.Name), + "port": types.Int64PointerValue(listenerResp.Port), + "protocol": types.StringValue(string(listenerResp.GetProtocol())), + "waf_config_name": types.StringPointerValue(listenerResp.WafConfigName), + } + + err := mapHttp(ctx, listenerResp.Http, listenerMap, httpModel) + if err != nil { + return fmt.Errorf("mapping http %d: %w", i, err) + } + + err = mapHttps(ctx, listenerResp.Https, listenerMap) + if err != nil { + return fmt.Errorf("mapping https %d: %w", i, err) + } + + listenerTF, diags := types.ObjectValue(listenerTypes, listenerMap) + if diags.HasError() { + return fmt.Errorf("mapping listener %d: %w", i, core.DiagsToError(diags)) + } + + listenersSet = append(listenersSet, listenerTF) + } + + listenersTF, diags := types.ListValue( + types.ObjectType{AttrTypes: listenerTypes}, + listenersSet, + ) + if diags.HasError() { + return fmt.Errorf("mapping listeners: %w", core.DiagsToError(diags)) + } + + m.Listeners = listenersTF + return nil +} + +func mapHttp(ctx context.Context, httpResp albSdk.ListenerGetHttpAttributeType, l map[string]attr.Value, httpModel basetypes.ObjectValue) error { + if httpResp == nil { + l["http"] = types.ObjectNull(httpTypes) + return nil + } + + var configHttp *httpALB + diags := httpModel.As(ctx, &configHttp, basetypes.ObjectAsOptions{}) + if diags.HasError() { + return fmt.Errorf("unpacking http from model: %w", core.DiagsToError(diags)) + } + var hostsModel = types.ListNull(types.ObjectType{AttrTypes: hostConfigTypes}) + if configHttp != nil { + hostsModel = configHttp.Hosts + } + + httpMap := map[string]attr.Value{} + err := mapHosts(ctx, httpResp.Hosts, httpMap, hostsModel) + if err != nil { + return fmt.Errorf("mapping hosts: %w", err) + } + + httpTF, diags := types.ObjectValue(httpTypes, httpMap) + if diags.HasError() { + return fmt.Errorf("mapping http: %w", core.DiagsToError(diags)) + } + + l["http"] = httpTF + return nil +} + +func mapHosts(ctx context.Context, hostsResp albSdk.ProtocolOptionsHTTPGetHostsAttributeType, h map[string]attr.Value, hostsModel types.List) error { + if hostsResp == nil { + h["hosts"] = types.ListNull(types.ObjectType{AttrTypes: hostConfigTypes}) + return nil + } + + var configHosts []hostConfig + diags := hostsModel.ElementsAs(ctx, &configHosts, false) + if diags.HasError() { + return fmt.Errorf("unpacking hosts from model: %w", core.DiagsToError(diags)) + } + + hostsSet := []attr.Value{} + for i, hostResp := range *hostsResp { + var configMatch *hostConfig + for _, ch := range configHosts { + if !ch.Host.IsNull() && ch.Host.ValueString() == *hostResp.Host { + configMatch = &ch + break + } + } + var rulesModel = types.ListNull(types.ObjectType{AttrTypes: ruleTypes}) + if configMatch != nil { + rulesModel = configMatch.Rules + } + + hostMap := map[string]attr.Value{ + "host": types.StringPointerValue(hostResp.Host), + } + + err := mapRules(ctx, hostResp.Rules, hostMap, rulesModel) + if err != nil { + return fmt.Errorf("mapping rules %d: %w", i, err) + } + + hostTF, diags := types.ObjectValue(hostConfigTypes, hostMap) + if diags.HasError() { + return fmt.Errorf("mapping host %d: %w", i, core.DiagsToError(diags)) + } + + hostsSet = append(hostsSet, hostTF) + } + + hostsTF, diags := types.ListValue( + types.ObjectType{AttrTypes: hostConfigTypes}, + hostsSet, + ) + if diags.HasError() { + return fmt.Errorf("mapping hosts: %w", core.DiagsToError(diags)) + } + + h["hosts"] = hostsTF + return nil +} + +func mapRules(ctx context.Context, rulesResp albSdk.HostConfigGetRulesAttributeType, h map[string]attr.Value, rulesModel types.List) error { + if rulesResp == nil { + h["rules"] = types.ListNull(types.ObjectType{AttrTypes: ruleTypes}) + return nil + } + + var configRules []rule + diags := rulesModel.ElementsAs(ctx, &configRules, false) + if diags.HasError() { + return fmt.Errorf("unpacking rules from model: %w", core.DiagsToError(diags)) + } + + rulesList := []attr.Value{} + for i, ruleResp := range *rulesResp { + webSocket := types.BoolValue(false) + // If the webSocket is nil in the response we set it to false in the TF state to + // prevent an inconsistent result after apply error + if ruleResp.WebSocket != nil && *ruleResp.WebSocket { + webSocket = types.BoolValue(true) + } + + ruleMap := map[string]attr.Value{ + "target_pool": types.StringPointerValue(ruleResp.TargetPool), + "web_socket": webSocket, + } + + err := mapPath(ruleResp.Path, ruleMap) + if err != nil { + return fmt.Errorf("mapping Path %d: %w", i, err) + } + + err = mapHeaders(ruleResp.Headers, ruleMap) + if err != nil { + return fmt.Errorf("mapping Headers %d: %w", i, err) + } + + err = mapQueryParameters(ruleResp.QueryParameters, ruleMap) + if err != nil { + return fmt.Errorf("mapping Query Parameters %d: %w", i, err) + } + + err = mapCookiePersistence(ruleResp.CookiePersistence, ruleMap) + if err != nil { + return fmt.Errorf("mapping Cookie Persistence %d: %w", i, err) + } + + ruleTF, diags := types.ObjectValue(ruleTypes, ruleMap) + if diags.HasError() { + return fmt.Errorf("mapping Rule %d: %w", i, core.DiagsToError(diags)) + } + + rulesList = append(rulesList, ruleTF) + } + + rulesTF, diags := types.ListValue( + types.ObjectType{AttrTypes: ruleTypes}, + rulesList, + ) + if diags.HasError() { + return fmt.Errorf("mapping rules: %w", core.DiagsToError(diags)) + } + + h["rules"] = rulesTF + return nil +} + +func mapPath(pathResp albSdk.RuleGetPathAttributeType, r map[string]attr.Value) error { + if pathResp == nil { + r["path"] = types.ObjectNull(pathTypes) + return nil + } + + pathMap := map[string]attr.Value{ + "exact_match": types.StringPointerValue(pathResp.Exact), + "prefix": types.StringPointerValue(pathResp.Prefix), + } + + pathTF, diags := types.ObjectValue(pathTypes, pathMap) + if diags.HasError() { + return fmt.Errorf("mapping path: %w", core.DiagsToError(diags)) + } + + r["path"] = pathTF + return nil +} + +func mapQueryParameters(queryParamsResp albSdk.RuleGetQueryParametersAttributeType, r map[string]attr.Value) error { + if queryParamsResp == nil { + r["query_parameters"] = types.SetNull(types.ObjectType{AttrTypes: queryParameterTypes}) + return nil + } + + queryParamsSet := []attr.Value{} + for i, queryParamResp := range *queryParamsResp { + queryParamMap := map[string]attr.Value{ + "name": types.StringPointerValue(queryParamResp.Name), + "exact_match": types.StringPointerValue(queryParamResp.ExactMatch), + } + + queryParamTF, diags := types.ObjectValue(queryParameterTypes, queryParamMap) + if diags.HasError() { + return fmt.Errorf("mapping queryParameter %d: %w", i, core.DiagsToError(diags)) + } + + queryParamsSet = append(queryParamsSet, queryParamTF) + } + + queryParamTF, diags := types.SetValue( + types.ObjectType{AttrTypes: queryParameterTypes}, + queryParamsSet, + ) + if diags.HasError() { + return fmt.Errorf("mapping queryParameters: %w", core.DiagsToError(diags)) + } + + r["query_parameters"] = queryParamTF + return nil +} + +func mapHeaders(headersResp albSdk.RuleGetHeadersAttributeType, r map[string]attr.Value) error { + if headersResp == nil { + r["headers"] = types.SetNull(types.ObjectType{AttrTypes: headersTypes}) + return nil + } + + headersSet := []attr.Value{} + for i, headerResp := range *headersResp { + headerMap := map[string]attr.Value{ + "name": types.StringPointerValue(headerResp.Name), + "exact_match": types.StringPointerValue(headerResp.ExactMatch), + } + + headerTF, diags := types.ObjectValue(headersTypes, headerMap) + if diags.HasError() { + return fmt.Errorf("mapping header %d: %w", i, core.DiagsToError(diags)) + } + + headersSet = append(headersSet, headerTF) + } + + headersTF, diags := types.SetValue( + types.ObjectType{AttrTypes: headersTypes}, + headersSet, + ) + if diags.HasError() { + return fmt.Errorf("mapping headers: %w", core.DiagsToError(diags)) + } + + r["headers"] = headersTF + return nil +} + +func mapCookiePersistence(cookiePersistResp albSdk.RuleGetCookiePersistenceAttributeType, r map[string]attr.Value) error { + if cookiePersistResp == nil { + r["cookie_persistence"] = types.ObjectNull(cookiePersistenceTypes) + return nil + } + + cookiePersistMap := map[string]attr.Value{ + "name": types.StringPointerValue(cookiePersistResp.Name), + "ttl": types.StringPointerValue(cookiePersistResp.Ttl), + } + + cookiePersistTF, diags := types.ObjectValue(cookiePersistenceTypes, cookiePersistMap) + if diags.HasError() { + return fmt.Errorf("mapping cookiePersistence: %w", core.DiagsToError(diags)) + } + + r["cookie_persistence"] = cookiePersistTF + return nil +} + +func mapHttps(ctx context.Context, httpsResp albSdk.ListenerGetHttpsAttributeType, l map[string]attr.Value) error { + if httpsResp == nil { + l["https"] = types.ObjectNull(httpsTypes) + return nil + } + + httpsMap := map[string]attr.Value{} + + err := mapCertificates(ctx, httpsResp.CertificateConfig, httpsMap) + if err != nil { + return fmt.Errorf("mapping certificates: %w", err) + } + + httpsTF, diags := types.ObjectValue(httpsTypes, httpsMap) + if diags.HasError() { + return fmt.Errorf("mapping https: %w", core.DiagsToError(diags)) + } + + l["https"] = httpsTF + return nil +} + +func mapCertificates(ctx context.Context, certResp albSdk.ProtocolOptionsHTTPSGetCertificateConfigAttributeType, h map[string]attr.Value) error { + if certResp == nil { + h["certificate_config"] = types.ObjectNull(certificateConfigTypes) + return nil + } + + certificateIDsTF, diags := types.SetValueFrom(ctx, types.StringType, certResp.CertificateIds) + if diags.HasError() { + return fmt.Errorf("mapping certificateIDs: %w", core.DiagsToError(diags)) + } + certMap := map[string]attr.Value{ + "certificate_ids": certificateIDsTF, + } + + certTF, diags := types.ObjectValue(certificateConfigTypes, certMap) + if diags.HasError() { + return fmt.Errorf("mapping certificates: %w", core.DiagsToError(diags)) + } + + h["certificate_config"] = certTF + return nil +} + +func mapNetworks(applicationLoadBalancerResp *albSdk.LoadBalancer, m *Model) error { + if applicationLoadBalancerResp.Networks == nil { + m.Networks = types.SetNull(types.ObjectType{AttrTypes: networkTypes}) + return nil + } + + networksSet := []attr.Value{} + for i, networkResp := range *applicationLoadBalancerResp.Networks { + networkMap := map[string]attr.Value{ + "network_id": types.StringPointerValue(networkResp.NetworkId), + "role": types.StringValue(string(networkResp.GetRole())), + } + + networkTF, diags := types.ObjectValue(networkTypes, networkMap) + if diags.HasError() { + return fmt.Errorf("mapping index %d: %w", i, core.DiagsToError(diags)) + } + + networksSet = append(networksSet, networkTF) + } + + networksTF, diags := types.SetValue( + types.ObjectType{AttrTypes: networkTypes}, + networksSet, + ) + if diags.HasError() { + return fmt.Errorf("mapping networks: %w", core.DiagsToError(diags)) + } + + m.Networks = networksTF + return nil +} + +func mapOptions(ctx context.Context, applicationLoadBalancerResp *albSdk.LoadBalancer, m *Model) error { + if applicationLoadBalancerResp.Options == nil { + m.Options = types.ObjectNull(optionsTypes) + return nil + } + + opt := applicationLoadBalancerResp.Options + // If no options are set in the model and the response has no fields filed, + // leave the option out of the model to prevent an inconsistent result after apply error + if (m.Options.IsNull() || m.Options.IsUnknown()) && !opt.HasEphemeralAddress() && !opt.HasPrivateNetworkOnly() && !opt.HasAccessControl() && !opt.HasObservability() { + return nil + } + + privateNetworkOnlyTF := types.BoolValue(false) + ephemeralAddressTF := types.BoolValue(false) + // If the private_network_only and/or ephemeral_address field is nil in the response we set it to + // false in the TF state to prevent an inconsistent result after apply error + if opt.PrivateNetworkOnly != nil && *opt.PrivateNetworkOnly { + privateNetworkOnlyTF = types.BoolValue(true) + } + if opt.EphemeralAddress != nil && *opt.EphemeralAddress { + ephemeralAddressTF = types.BoolValue(true) + } + + optionsMap := map[string]attr.Value{ + "private_network_only": privateNetworkOnlyTF, + "ephemeral_address": ephemeralAddressTF, + } + + err := mapACL(opt.AccessControl, optionsMap) + if err != nil { + return fmt.Errorf("mapping field ACL: %w", err) + } + + err = mapObservability(opt.Observability, optionsMap) + if err != nil { + return fmt.Errorf("mapping field Observability: %w", err) + } + + optionsTF, diags := types.ObjectValue(optionsTypes, optionsMap) + if diags.HasError() { + return fmt.Errorf("mapping options: %w", core.DiagsToError(diags)) + } + + m.Options = optionsTF + return nil +} + +func mapObservability(observabilityResp *albSdk.LoadbalancerOptionObservability, o map[string]attr.Value) error { + if observabilityResp == nil { + o["observability"] = types.ObjectNull(observabilityTypes) + return nil + } + + observabilityLogsMap := map[string]attr.Value{ + "credentials_ref": types.StringNull(), + "push_url": types.StringNull(), + } + if observabilityResp.HasLogs() { + observabilityLogsMap["credentials_ref"] = types.StringPointerValue(observabilityResp.Logs.CredentialsRef) + observabilityLogsMap["push_url"] = types.StringPointerValue(observabilityResp.Logs.PushUrl) + } + observabilityLogsTF, diags := types.ObjectValue(observabilityOptionTypes, observabilityLogsMap) + if diags.HasError() { + return fmt.Errorf("mapping logs: %w", core.DiagsToError(diags)) + } + + observabilityMetricsMap := map[string]attr.Value{ + "credentials_ref": types.StringNull(), + "push_url": types.StringNull(), + } + if observabilityResp.HasMetrics() { + observabilityMetricsMap["credentials_ref"] = types.StringPointerValue(observabilityResp.Metrics.CredentialsRef) + observabilityMetricsMap["push_url"] = types.StringPointerValue(observabilityResp.Metrics.PushUrl) + } + observabilityMetricsTF, diags := types.ObjectValue(observabilityOptionTypes, observabilityMetricsMap) + if diags.HasError() { + return fmt.Errorf("mapping metrics: %w", core.DiagsToError(diags)) + } + + observabilityMap := map[string]attr.Value{ + "logs": observabilityLogsTF, + "metrics": observabilityMetricsTF, + } + observabilityTF, diags := types.ObjectValue(observabilityTypes, observabilityMap) + if diags.HasError() { + return fmt.Errorf("mapping observability: %w", core.DiagsToError(diags)) + } + + o["observability"] = observabilityTF + return nil +} + +func mapACL(accessControlResp *albSdk.LoadbalancerOptionAccessControl, o map[string]attr.Value) error { + if accessControlResp == nil || accessControlResp.AllowedSourceRanges == nil { + o["acl"] = types.SetNull(types.StringType) + return nil + } + + aclSet := []attr.Value{} + for _, rangeResp := range *accessControlResp.AllowedSourceRanges { + rangeTF := types.StringValue(rangeResp) + aclSet = append(aclSet, rangeTF) + } + + aclTF, diags := types.SetValue(types.StringType, aclSet) + if diags.HasError() { + return fmt.Errorf("mapping ALC: %w", core.DiagsToError(diags)) + } + + o["acl"] = aclTF + return nil +} + +func mapTargetPools(ctx context.Context, applicationLoadBalancerResp *albSdk.LoadBalancer, m *Model) error { + if applicationLoadBalancerResp.TargetPools == nil { + m.TargetPools = types.ListNull(types.ObjectType{AttrTypes: targetPoolTypes}) + return nil + } + + var configTargetPools []targetPool + if !m.TargetPools.IsNull() && !m.TargetPools.IsUnknown() { + diags := m.TargetPools.ElementsAs(ctx, &configTargetPools, false) + if diags.HasError() { + return fmt.Errorf("unpacking target pools from model: %w", core.DiagsToError(diags)) + } + } + + targetPoolsSet := []attr.Value{} + for i, targetPoolResp := range *applicationLoadBalancerResp.TargetPools { + var configMatch *targetPool + for _, ctp := range configTargetPools { + if !ctp.Name.IsNull() && ctp.Name.ValueString() == *targetPoolResp.Name { + configMatch = &ctp + break + } + } + var tlsModel = types.ObjectNull(tlsConfigTypes) + if configMatch != nil { + tlsModel = configMatch.TLSConfig + } + + targetPoolMap := map[string]attr.Value{ + "name": types.StringPointerValue(targetPoolResp.Name), + "target_port": types.Int64PointerValue(targetPoolResp.TargetPort), + } + + err := mapActiveHealthCheck(ctx, targetPoolResp.ActiveHealthCheck, targetPoolMap) + if err != nil { + return fmt.Errorf("mapping index %d, field ActiveHealthCheck: %w", i, err) + } + + err = mapTLSConfig(ctx, targetPoolResp.TlsConfig, targetPoolMap, tlsModel) + if err != nil { + return fmt.Errorf("mapping index %d, field TLSConfig: %w", i, err) + } + + err = mapTargets(targetPoolResp.Targets, targetPoolMap) + if err != nil { + return fmt.Errorf("mapping index %d, field Targets: %w", i, err) + } + + targetPoolTF, diags := types.ObjectValue(targetPoolTypes, targetPoolMap) + if diags.HasError() { + return fmt.Errorf("mapping index %d: %w", i, core.DiagsToError(diags)) + } + targetPoolsSet = append(targetPoolsSet, targetPoolTF) + } + + targetPoolsTF, diags := types.ListValue( + types.ObjectType{AttrTypes: targetPoolTypes}, + targetPoolsSet, + ) + if diags.HasError() { + return fmt.Errorf("mapping targetPools: %w", core.DiagsToError(diags)) + } + + m.TargetPools = targetPoolsTF + return nil +} + +func mapActiveHealthCheck(ctx context.Context, activeHealthCheckResp *albSdk.ActiveHealthCheck, tp map[string]attr.Value) error { + if activeHealthCheckResp == nil { + tp["active_health_check"] = types.ObjectNull(activeHealthCheckTypes) + return nil + } + + activeHealthCheckMap := map[string]attr.Value{ + "healthy_threshold": types.Int64PointerValue(activeHealthCheckResp.HealthyThreshold), + "interval": types.StringPointerValue(activeHealthCheckResp.Interval), + "interval_jitter": types.StringPointerValue(activeHealthCheckResp.IntervalJitter), + "timeout": types.StringPointerValue(activeHealthCheckResp.Timeout), + "unhealthy_threshold": types.Int64PointerValue(activeHealthCheckResp.UnhealthyThreshold), + } + + err := mapHttpHealthChecks(ctx, activeHealthCheckResp.HttpHealthChecks, activeHealthCheckMap) + if err != nil { + return fmt.Errorf("map HttpHealthChecks: %w", err) + } + + activeHealthCheckTF, diags := types.ObjectValue(activeHealthCheckTypes, activeHealthCheckMap) + if diags.HasError() { + return fmt.Errorf("mapping activeHealthChecks: %w", core.DiagsToError(diags)) + } + + tp["active_health_check"] = activeHealthCheckTF + return nil +} + +func mapHttpHealthChecks(ctx context.Context, httpHealthChecksResp *albSdk.HttpHealthChecks, ahc map[string]attr.Value) error { + if httpHealthChecksResp == nil { + ahc["http_health_checks"] = types.ObjectNull(httpHealthChecksTypes) + return nil + } + + okStatusesTF, diags := types.SetValueFrom(ctx, types.StringType, httpHealthChecksResp.OkStatuses) + if diags.HasError() { + return fmt.Errorf("map OkStatuses list: %w", core.DiagsToError(diags)) + } + httpHealthChecksMap := map[string]attr.Value{ + "ok_status": okStatusesTF, + "path": types.StringPointerValue(httpHealthChecksResp.Path), + } + + httpHealthChecksTF, diags := types.ObjectValue(httpHealthChecksTypes, httpHealthChecksMap) + if diags.HasError() { + return fmt.Errorf("mapping httpHealthChecks: %w", core.DiagsToError(diags)) + } + + ahc["http_health_checks"] = httpHealthChecksTF + return nil +} + +func mapTLSConfig(ctx context.Context, targetPoolTLSConfigResp *albSdk.TargetPoolTlsConfig, tp map[string]attr.Value, tlsModel basetypes.ObjectValue) error { + if targetPoolTLSConfigResp == nil { + tp["tls_config"] = types.ObjectNull(tlsConfigTypes) + return nil + } + + var configTLS = &tlsConfig{} + if !tlsModel.IsNull() && !tlsModel.IsUnknown() { + diags := tlsModel.As(ctx, configTLS, basetypes.ObjectAsOptions{}) + if diags.HasError() { + return fmt.Errorf("unpacking tls config from model: %w", core.DiagsToError(diags)) + } + } + + enabled := types.BoolValue(false) + skipCertificateValidation := types.BoolValue(false) + // If the enabled or skip field is nil in the response we set it to false in the TF state to + // prevent an inconsistent result after apply error + if targetPoolTLSConfigResp.Enabled != nil && *targetPoolTLSConfigResp.Enabled { + enabled = types.BoolValue(true) + } + if targetPoolTLSConfigResp.SkipCertificateValidation != nil && *targetPoolTLSConfigResp.SkipCertificateValidation { + skipCertificateValidation = types.BoolValue(true) + } + + tlsConfigMap := map[string]attr.Value{ + "custom_ca": types.StringNull(), + "enabled": enabled, + "skip_certificate_validation": skipCertificateValidation, + } + + if targetPoolTLSConfigResp.CustomCa != nil { + pemBytes, err := base64.StdEncoding.DecodeString(*targetPoolTLSConfigResp.CustomCa) + if err != nil { + return fmt.Errorf("base64 decoding custom ca: %w", err) + } + tlsConfigMap["custom_ca"] = types.StringValue(string(pemBytes)) + } + + targetPoolTLSConfigTF, diags := types.ObjectValue(tlsConfigTypes, tlsConfigMap) + if diags.HasError() { + return fmt.Errorf("mapping TLSConfig: %w", core.DiagsToError(diags)) + } + + tp["tls_config"] = targetPoolTLSConfigTF + return nil +} + +func mapTargets(targetsResp *[]albSdk.Target, tp map[string]attr.Value) error { + if targetsResp == nil || *targetsResp == nil { + tp["targets"] = types.SetNull(types.ObjectType{AttrTypes: targetTypes}) + return nil + } + + targetsSet := []attr.Value{} + for i, targetResp := range *targetsResp { + targetMap := map[string]attr.Value{ + "display_name": types.StringPointerValue(targetResp.DisplayName), + "ip": types.StringPointerValue(targetResp.Ip), + } + + targetTF, diags := types.ObjectValue(targetTypes, targetMap) + if diags.HasError() { + return fmt.Errorf("mapping index %d: %w", i, core.DiagsToError(diags)) + } + + targetsSet = append(targetsSet, targetTF) + } + + targetsTF, diags := types.SetValue( + types.ObjectType{AttrTypes: targetTypes}, + targetsSet, + ) + if diags.HasError() { + return fmt.Errorf("mapping targets: %w", core.DiagsToError(diags)) + } + + tp["targets"] = targetsTF + return nil +} diff --git a/stackit/internal/services/alb/applicationloadbalancer/resource_test.go b/stackit/internal/services/alb/applicationloadbalancer/resource_test.go new file mode 100644 index 000000000..f8d0ff2ab --- /dev/null +++ b/stackit/internal/services/alb/applicationloadbalancer/resource_test.go @@ -0,0 +1,1598 @@ +package alb + +import ( + "context" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/types" + albSdk "github.com/stackitcloud/stackit-sdk-go/services/alb" + "k8s.io/utils/ptr" +) + +const ( + projectID = "b8c3fbaa-3ab4-4a8e-9584-de22453d046f" + region = "eu01" + lbName = "example-lb2" + externalAddress = "188.34.80.229" + lbVersion = "lb-1" + lbID = projectID + "," + region + "," + lbName + sgLBID = "8c06e3b6-531b-43a0-b965-3ae73da83d1b" + sgTargetID = "19cc8a91-d590-4166-b27d-211da3cb44d3" + targetPoolName = "my-pool" + credentialsRef = "credentials-nzkp4" +) + +func fixtureModel(explicitBool *bool, mods ...func(m *Model)) *Model { + resp := &Model{ + Id: types.StringValue(lbID), + ProjectId: types.StringValue(projectID), + DisableSecurityGroupAssignment: types.BoolPointerValue(explicitBool), + Errors: types.SetValueMust( + types.ObjectType{AttrTypes: errorsType}, + []attr.Value{ + types.ObjectValueMust( + errorsType, + map[string]attr.Value{ + "description": types.StringValue("quota test error"), + "type": types.StringValue(string(albSdk.LOADBALANCERERRORTYPE_QUOTA_SECGROUP_EXCEEDED)), + }, + ), + types.ObjectValueMust( + errorsType, + map[string]attr.Value{ + "description": types.StringValue("fip test error"), + "type": types.StringValue(string(albSdk.LOADBALANCERERRORTYPE_FIP_NOT_CONFIGURED)), + }, + ), + }, + ), + ExternalAddress: types.StringValue(externalAddress), + Labels: types.MapValueMust(types.StringType, map[string]attr.Value{ + "key": types.StringValue("value"), + "key2": types.StringValue("value2"), + }), + Listeners: types.ListValueMust( + types.ObjectType{AttrTypes: listenerTypes}, + []attr.Value{ + types.ObjectValueMust( + listenerTypes, + map[string]attr.Value{ + "name": types.StringValue("http-80"), + "port": types.Int64Value(80), + "protocol": types.StringValue("PROTOCOL_HTTP"), + "waf_config_name": types.StringValue("my-waf-config"), + "http": types.ObjectValueMust( + httpTypes, + map[string]attr.Value{ + "hosts": types.ListValueMust( + types.ObjectType{AttrTypes: hostConfigTypes}, + []attr.Value{types.ObjectValueMust( + hostConfigTypes, + map[string]attr.Value{ + "host": types.StringValue("*"), + "rules": types.ListValueMust( + types.ObjectType{AttrTypes: ruleTypes}, + []attr.Value{types.ObjectValueMust( + ruleTypes, + map[string]attr.Value{ + "target_pool": types.StringValue(targetPoolName), + "web_socket": types.BoolPointerValue(explicitBool), + "path": types.ObjectValueMust(pathTypes, + map[string]attr.Value{ + "exact_match": types.StringNull(), + "prefix": types.StringValue("/"), + }, + ), + "headers": types.SetValueMust( + types.ObjectType{AttrTypes: headersTypes}, + []attr.Value{types.ObjectValueMust( + headersTypes, + map[string]attr.Value{ + "name": types.StringValue("a-header"), + "exact_match": types.StringValue("value"), + }), + }, + ), + "query_parameters": types.SetValueMust( + types.ObjectType{AttrTypes: queryParameterTypes}, + []attr.Value{types.ObjectValueMust( + queryParameterTypes, + map[string]attr.Value{ + "name": types.StringValue("a_query_parameter"), + "exact_match": types.StringValue("value"), + }), + }, + ), + "cookie_persistence": types.ObjectValueMust( + cookiePersistenceTypes, + map[string]attr.Value{ + "name": types.StringValue("cookie_name"), + "ttl": types.StringValue("3s"), + }, + ), + }, + ), + }), + }, + ), + }), + }, + ), + "https": types.ObjectValueMust( + httpsTypes, + map[string]attr.Value{ + "certificate_config": types.ObjectValueMust( + certificateConfigTypes, + map[string]attr.Value{ + "certificate_ids": types.SetValueMust( + types.StringType, + []attr.Value{ + types.StringValue(credentialsRef), + }, + ), + }, + ), + }, + ), + }, + ), + }, + ), + LoadBalancerSecurityGroup: types.ObjectValueMust( + loadBalancerSecurityGroupType, + map[string]attr.Value{ + "id": types.StringValue(sgLBID), + "name": types.StringValue("loadbalancer/" + lbName + "/backend-port"), + }, + ), + Name: types.StringValue(lbName), + Networks: types.SetValueMust( + types.ObjectType{AttrTypes: networkTypes}, + []attr.Value{ + types.ObjectValueMust( + networkTypes, + map[string]attr.Value{ + "network_id": types.StringValue("c7c92cc1-a6bd-4e15-a129-b6e2b9899bbc"), + "role": types.StringValue("ROLE_LISTENERS"), + }, + ), + types.ObjectValueMust( + networkTypes, + map[string]attr.Value{ + "network_id": types.StringValue("ed3f1822-ca1c-4969-bea6-74c6b3e9aa40"), + "role": types.StringValue("ROLE_TARGETS"), + }, + ), + }, + ), + Options: types.ObjectValueMust( + optionsTypes, + map[string]attr.Value{ + "acl": types.SetValueMust( + types.StringType, + []attr.Value{ + types.StringValue("192.168.0.0"), + types.StringValue("192.168.0.1"), + }, + ), + "ephemeral_address": types.BoolPointerValue(explicitBool), + "private_network_only": types.BoolPointerValue(explicitBool), + "observability": types.ObjectValueMust( + observabilityTypes, + map[string]attr.Value{ + "logs": types.ObjectValueMust( + observabilityOptionTypes, + map[string]attr.Value{ + "credentials_ref": types.StringValue(credentialsRef), + "push_url": types.StringValue("http://www.example.org/push"), + }, + ), + "metrics": types.ObjectValueMust( + observabilityOptionTypes, + map[string]attr.Value{ + "credentials_ref": types.StringValue(credentialsRef), + "push_url": types.StringValue("http://www.example.org/pull"), + }, + ), + }, + ), + }, + ), + PlanId: types.StringValue("p10"), + PrivateAddress: types.StringValue("10.1.11.0"), + Region: types.StringValue(region), + Status: types.StringValue("STATUS_READY"), + TargetPools: types.ListValueMust( + types.ObjectType{AttrTypes: targetPoolTypes}, + []attr.Value{ + types.ObjectValueMust( + targetPoolTypes, + map[string]attr.Value{ + "name": types.StringValue(targetPoolName), + "target_port": types.Int64Value(80), + "targets": types.SetValueMust( + types.ObjectType{AttrTypes: targetTypes}, + []attr.Value{ + types.ObjectValueMust( + targetTypes, + map[string]attr.Value{ + "display_name": types.StringValue("test-backend-server"), + "ip": types.StringValue("192.168.0.218"), + }, + ), + }, + ), + "tls_config": types.ObjectValueMust( + tlsConfigTypes, + map[string]attr.Value{ + "enabled": types.BoolPointerValue(explicitBool), + "custom_ca": types.StringValue("-----BEGIN CERTIFICATE-----\nMIIDCzCCAfOgAwIBAgIUTyPsTWC9ly7o+wNFYm0uu1+P8IEwDQYJKoZIhvcNAQEL\nBQAwFTETMBEGA1UEAwwKTXlDdXN0b21DQTAeFw0yNTAyMTkxOTI0MjBaFw0yNjAy\nMTkxOTI0MjBaMBUxEzARBgNVBAMMCk15Q3VzdG9tQ0EwggEiMA0GCSqGSIb3DQEB\nAQUAA4IBDwAwggEKAoIBAQCQMEYKbiNxU37fEwBOxkvCshBR+0MwxwLW8Mi3/pvo\nn3huxjcm7EaKW9r7kIaoHXbTS1tnO6rHAHKBDxzuoYD7C2SMSiLxddquNRvpkLaP\n8qAXneQY2VP7LzsAgsC04PKG0YC1NgF5sJGsiWIRGIm+csYLnPMnwaAGx4IvY6mH\nAmM64b6QRCg36LK+P6N9KTvSQLvvmFdkA2sDToCmN/Amp6xNDFq+aQGLwdQQqHDP\nTaUqPmEyiFHKvFUaFMNQVk8B1Om8ASo69m8U3Eat4ZOVW1titE393QkOdA6ZypMC\nrJJpeNNLLJq3mIOWOd7GEyAvjUfmJwGhqEFS7lMG67hnAgMBAAGjUzBRMB0GA1Ud\nDgQWBBSk/IM5jaOAJL3/Knyq3cVva04YZDAfBgNVHSMEGDAWgBSk/IM5jaOAJL3/\nKnyq3cVva04YZDAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQBe\nZ/mE8rNIbNbHQep/VppshaZUzgdy4nsmh0wvxMuHIQP0KHrxLCkhOn7A9fu4mY/P\nQ+8QqlnjTsM4cqiuFcd5V1Nk9VF/e5X3HXCDHh/jBFw+O5TGVAR/7DBw31lYv/Lt\nHakkjQCdawuvH3osO/UkElM/i2KC+iYBavTenm97AR7WGgW15/MIqxNaYE+nJth/\ndcVD0b5qSuYQaEmZ3CzMUi188R+go5ozCf2cOaa+3/LEYAaI3vKiSE8KTsshyoKm\nO6YZqrVxQCWCDTOsd28k7lHt8wJ+jzYcjCu60DUpg1ZpY+ZnmrE8vPPDb/zXhBn6\n/llXTWOUjmuTKnGsIDP5\n-----END CERTIFICATE-----"), + "skip_certificate_validation": types.BoolPointerValue(explicitBool), + }, + ), + "active_health_check": types.ObjectValueMust( + activeHealthCheckTypes, + map[string]attr.Value{ + "healthy_threshold": types.Int64Value(1), + "interval": types.StringValue("2s"), + "interval_jitter": types.StringValue("3s"), + "timeout": types.StringValue("4s"), + "unhealthy_threshold": types.Int64Value(5), + "http_health_checks": types.ObjectValueMust( + httpHealthChecksTypes, + map[string]attr.Value{ + "ok_status": types.SetValueMust( + types.StringType, + []attr.Value{ + types.StringValue("200"), + types.StringValue("201"), + }, + ), + "path": types.StringValue("/health"), + }, + ), + }, + ), + }, + ), + }, + ), + TargetSecurityGroup: types.ObjectValueMust( + targetSecurityGroupType, + map[string]attr.Value{ + "id": types.StringValue(sgTargetID), + "name": types.StringValue("loadbalancer/" + lbName + "/backend"), + }, + ), + Version: types.StringValue(lbVersion), + } + for _, mod := range mods { + mod(resp) + } + return resp +} + +func fixtureModelNull(mods ...func(m *Model)) *Model { + resp := &Model{ + Id: types.StringNull(), + ProjectId: types.StringNull(), + DisableSecurityGroupAssignment: types.BoolNull(), + Errors: types.SetNull(types.ObjectType{AttrTypes: errorsType}), + ExternalAddress: types.StringNull(), + Labels: types.MapNull(types.StringType), + Listeners: types.ListNull(types.ObjectType{AttrTypes: listenerTypes}), + LoadBalancerSecurityGroup: types.ObjectNull(loadBalancerSecurityGroupType), + Name: types.StringNull(), + Networks: types.SetNull(types.ObjectType{AttrTypes: networkTypes}), + Options: types.ObjectNull(optionsTypes), + PlanId: types.StringNull(), + PrivateAddress: types.StringNull(), + Region: types.StringNull(), + Status: types.StringNull(), + TargetPools: types.ListNull(types.ObjectType{AttrTypes: targetPoolTypes}), + TargetSecurityGroup: types.ObjectNull(targetSecurityGroupType), + Version: types.StringNull(), + } + for _, mod := range mods { + mod(resp) + } + return resp +} + +func fixtureCreatePayload(lb *albSdk.LoadBalancer) *albSdk.CreateLoadBalancerPayload { + (*lb.Listeners)[0].Name = nil // will be required in ALB API V2 + + return &albSdk.CreateLoadBalancerPayload{ + DisableTargetSecurityGroupAssignment: lb.DisableTargetSecurityGroupAssignment, + ExternalAddress: lb.ExternalAddress, + Labels: lb.Labels, + Listeners: lb.Listeners, + Name: lb.Name, + Networks: lb.Networks, + Options: lb.Options, + PlanId: lb.PlanId, + TargetPools: lb.TargetPools, + } +} + +func fixtureUpdatePayload(lb *albSdk.LoadBalancer) *albSdk.UpdateLoadBalancerPayload { + (*lb.Listeners)[0].Name = nil // will be required in ALB API V2 + + return &albSdk.UpdateLoadBalancerPayload{ + DisableTargetSecurityGroupAssignment: lb.DisableTargetSecurityGroupAssignment, + ExternalAddress: lb.ExternalAddress, + Labels: lb.Labels, + Listeners: lb.Listeners, + Name: lb.Name, + Networks: lb.Networks, + Options: lb.Options, + PlanId: lb.PlanId, + TargetPools: lb.TargetPools, + Version: lb.Version, + } +} + +func fixtureApplicationLoadBalancer(explicitBool *bool, mods ...func(m *albSdk.LoadBalancer)) *albSdk.LoadBalancer { + resp := &albSdk.LoadBalancer{ + DisableTargetSecurityGroupAssignment: explicitBool, + ExternalAddress: ptr.To(externalAddress), + Errors: ptr.To([]albSdk.LoadBalancerError{ + { + Description: ptr.To("quota test error"), + Type: ptr.To(albSdk.LOADBALANCERERRORTYPE_QUOTA_SECGROUP_EXCEEDED), + }, + { + Description: ptr.To("fip test error"), + Type: ptr.To(albSdk.LOADBALANCERERRORTYPE_FIP_NOT_CONFIGURED), + }, + }), + Name: ptr.To(lbName), + PlanId: ptr.To("p10"), + PrivateAddress: ptr.To("10.1.11.0"), + Region: ptr.To(region), + Status: ptr.To(albSdk.LoadBalancerStatus("STATUS_READY")), + Version: ptr.To(lbVersion), + Labels: &map[string]string{ + "key": "value", + "key2": "value2", + }, + Networks: &[]albSdk.Network{ + { + NetworkId: ptr.To("c7c92cc1-a6bd-4e15-a129-b6e2b9899bbc"), + Role: ptr.To(albSdk.NetworkRole("ROLE_LISTENERS")), + }, + { + NetworkId: ptr.To("ed3f1822-ca1c-4969-bea6-74c6b3e9aa40"), + Role: ptr.To(albSdk.NetworkRole("ROLE_TARGETS")), + }, + }, + Listeners: &[]albSdk.Listener{ + { + Name: ptr.To("http-80"), + Port: ptr.To(int64(80)), + Protocol: ptr.To(albSdk.ListenerProtocol("PROTOCOL_HTTP")), + Http: &albSdk.ProtocolOptionsHTTP{ + Hosts: &[]albSdk.HostConfig{ + { + Host: ptr.To("*"), + Rules: &[]albSdk.Rule{ + { + TargetPool: ptr.To(targetPoolName), + WebSocket: explicitBool, + Path: &albSdk.Path{ + Prefix: ptr.To("/"), + }, + Headers: &[]albSdk.HttpHeader{ + {Name: ptr.To("a-header"), ExactMatch: ptr.To("value")}, + }, + QueryParameters: &[]albSdk.QueryParameter{ + {Name: ptr.To("a_query_parameter"), ExactMatch: ptr.To("value")}, + }, + CookiePersistence: &albSdk.CookiePersistence{ + Name: ptr.To("cookie_name"), + Ttl: ptr.To("3s"), + }, + }, + }, + }, + }, + }, + Https: &albSdk.ProtocolOptionsHTTPS{ + CertificateConfig: ptr.To(albSdk.CertificateConfig{ + CertificateIds: &[]string{ + credentialsRef, + }, + }), + }, + WafConfigName: ptr.To("my-waf-config"), + }, + }, + TargetPools: &[]albSdk.TargetPool{ + { + Name: ptr.To(targetPoolName), + TargetPort: ptr.To(int64(80)), + Targets: &[]albSdk.Target{ + { + DisplayName: ptr.To("test-backend-server"), + Ip: ptr.To("192.168.0.218"), + }, + }, + TlsConfig: &albSdk.TargetPoolTlsConfig{ + Enabled: explicitBool, + SkipCertificateValidation: explicitBool, + CustomCa: ptr.To("LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSURDekNDQWZPZ0F3SUJBZ0lVVHlQc1RXQzlseTdvK3dORlltMHV1MStQOElFd0RRWUpLb1pJaHZjTkFRRUwKQlFBd0ZURVRNQkVHQTFVRUF3d0tUWGxEZFhOMGIyMURRVEFlRncweU5UQXlNVGt4T1RJME1qQmFGdzB5TmpBeQpNVGt4T1RJME1qQmFNQlV4RXpBUkJnTlZCQU1NQ2sxNVEzVnpkRzl0UTBFd2dnRWlNQTBHQ1NxR1NJYjNEUUVCCkFRVUFBNElCRHdBd2dnRUtBb0lCQVFDUU1FWUtiaU54VTM3ZkV3Qk94a3ZDc2hCUiswTXd4d0xXOE1pMy9wdm8KbjNodXhqY203RWFLVzlyN2tJYW9IWGJUUzF0bk82ckhBSEtCRHh6dW9ZRDdDMlNNU2lMeGRkcXVOUnZwa0xhUAo4cUFYbmVRWTJWUDdMenNBZ3NDMDRQS0cwWUMxTmdGNXNKR3NpV0lSR0ltK2NzWUxuUE1ud2FBR3g0SXZZNm1ICkFtTTY0YjZRUkNnMzZMSytQNk45S1R2U1FMdnZtRmRrQTJzRFRvQ21OL0FtcDZ4TkRGcSthUUdMd2RRUXFIRFAKVGFVcVBtRXlpRkhLdkZVYUZNTlFWazhCMU9tOEFTbzY5bThVM0VhdDRaT1ZXMXRpdEUzOTNRa09kQTZaeXBNQwpySkpwZU5OTExKcTNtSU9XT2Q3R0V5QXZqVWZtSndHaHFFRlM3bE1HNjdobkFnTUJBQUdqVXpCUk1CMEdBMVVkCkRnUVdCQlNrL0lNNWphT0FKTDMvS255cTNjVnZhMDRZWkRBZkJnTlZIU01FR0RBV2dCU2svSU01amFPQUpMMy8KS255cTNjVnZhMDRZWkRBUEJnTlZIUk1CQWY4RUJUQURBUUgvTUEwR0NTcUdTSWIzRFFFQkN3VUFBNElCQVFCZQpaL21FOHJOSWJOYkhRZXAvVnBwc2hhWlV6Z2R5NG5zbWgwd3Z4TXVISVFQMEtIcnhMQ2toT243QTlmdTRtWS9QClErOFFxbG5qVHNNNGNxaXVGY2Q1VjFOazlWRi9lNVgzSFhDREhoL2pCRncrTzVUR1ZBUi83REJ3MzFsWXYvTHQKSGFra2pRQ2Rhd3V2SDNvc08vVWtFbE0vaTJLQytpWUJhdlRlbm05N0FSN1dHZ1cxNS9NSXF4TmFZRStuSnRoLwpkY1ZEMGI1cVN1WVFhRW1aM0N6TVVpMTg4UitnbzVvekNmMmNPYWErMy9MRVlBYUkzdktpU0U4S1Rzc2h5b0ttCk82WVpxclZ4UUNXQ0RUT3NkMjhrN2xIdDh3SitqelljakN1NjBEVXBnMVpwWStabm1yRTh2UFBEYi96WGhCbjYKL2xsWFRXT1VqbXVUS25Hc0lEUDUKLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQ=="), + }, + ActiveHealthCheck: &albSdk.ActiveHealthCheck{ + HealthyThreshold: ptr.To(int64(1)), + UnhealthyThreshold: ptr.To(int64(5)), + Interval: ptr.To("2s"), + IntervalJitter: ptr.To("3s"), + Timeout: ptr.To("4s"), + HttpHealthChecks: &albSdk.HttpHealthChecks{ + Path: ptr.To("/health"), + OkStatuses: &[]string{"200", "201"}, + }, + }, + }, + }, + Options: ptr.To(albSdk.LoadBalancerOptions{ + EphemeralAddress: explicitBool, + PrivateNetworkOnly: explicitBool, + Observability: &albSdk.LoadbalancerOptionObservability{ + Logs: &albSdk.LoadbalancerOptionLogs{ + CredentialsRef: ptr.To(credentialsRef), + PushUrl: ptr.To("http://www.example.org/push"), + }, + Metrics: &albSdk.LoadbalancerOptionMetrics{ + CredentialsRef: ptr.To(credentialsRef), + PushUrl: ptr.To("http://www.example.org/pull"), + }, + }, + AccessControl: &albSdk.LoadbalancerOptionAccessControl{ + AllowedSourceRanges: &[]string{"192.168.0.0", "192.168.0.1"}, + }, + }), + LoadBalancerSecurityGroup: &albSdk.CreateLoadBalancerPayloadLoadBalancerSecurityGroup{ + Id: ptr.To(sgLBID), + Name: ptr.To("loadbalancer/" + lbName + "/backend-port"), + }, + TargetSecurityGroup: &albSdk.CreateLoadBalancerPayloadTargetSecurityGroup{ + Id: ptr.To(sgTargetID), + Name: ptr.To("loadbalancer/" + lbName + "/backend"), + }, + } + for _, mod := range mods { + mod(resp) + } + return resp +} + +func TestToCreatePayload(t *testing.T) { + tests := []struct { + description string + input *Model + expected *albSdk.CreateLoadBalancerPayload + isValid bool + }{ + { + description: "valid", + input: fixtureModel(nil), + expected: fixtureCreatePayload(fixtureApplicationLoadBalancer(nil)), + isValid: true, + }, + { + description: "valid empty", + input: fixtureModelNull(), + expected: &albSdk.CreateLoadBalancerPayload{}, + isValid: true, + }, + { + description: "model nil", + input: nil, + expected: nil, + isValid: false, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + output, err := toCreatePayload(context.Background(), tt.input) + if !tt.isValid && err == nil { + t.Fatalf("Should have failed") + } + if tt.isValid && err != nil { + t.Fatalf("Should not have failed: %v", err) + } + if tt.isValid { + diff := cmp.Diff(output, tt.expected) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + } + }) + } +} + +func TestToTargetPoolUpdatePayload(t *testing.T) { + tests := []struct { + description string + input *Model + expected *albSdk.UpdateLoadBalancerPayload + isValid bool + }{ + { + description: "valid", + input: fixtureModel(nil), + expected: fixtureUpdatePayload(fixtureApplicationLoadBalancer(nil)), + isValid: true, + }, + { + description: "valid empty", + input: fixtureModelNull(), + expected: &albSdk.UpdateLoadBalancerPayload{}, + isValid: true, + }, + { + description: "model nil", + input: nil, + expected: nil, + isValid: false, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + output, err := toUpdatePayload(context.Background(), tt.input) + if !tt.isValid && err == nil { + t.Fatalf("Should have failed") + } + if tt.isValid && err != nil { + t.Fatalf("Should not have failed: %v", err) + } + if tt.isValid { + diff := cmp.Diff(output, tt.expected) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + } + }) + } +} + +func TestMapFields(t *testing.T) { + const testRegion = "eu01" + tests := []struct { + description string + input *albSdk.LoadBalancer + output *Model + modelPrivateNetworkOnly *bool + region string + expected *Model + isValid bool + }{ + { + description: "valid full model", + input: fixtureApplicationLoadBalancer(nil), + output: &Model{ + ProjectId: types.StringValue(projectID), + }, + region: testRegion, + expected: fixtureModel(ptr.To(false)), + isValid: true, + }, + { + description: "error alb nil", + input: nil, + output: &Model{ + ProjectId: types.StringValue(projectID), + }, + region: testRegion, + expected: fixtureModel(nil), + isValid: false, + }, + { + description: "error model nil", + input: fixtureApplicationLoadBalancer(nil), + output: nil, + region: testRegion, + expected: fixtureModel(nil), + isValid: false, + }, + { + description: "error no name", + input: fixtureApplicationLoadBalancer(nil, func(m *albSdk.LoadBalancer) { + m.Name = nil + }), + output: &Model{ + ProjectId: types.StringValue(projectID), + Name: types.StringValue(""), + }, + region: testRegion, + expected: fixtureModel(nil), + isValid: false, + }, + { + description: "valid name in model", + input: fixtureApplicationLoadBalancer(nil), + output: &Model{ + ProjectId: types.StringValue(projectID), + Name: types.StringValue(lbName), + }, + region: testRegion, + expected: fixtureModel(ptr.To(false)), + isValid: true, + }, + { + description: "false - explicitly set", + input: fixtureApplicationLoadBalancer(ptr.To(false)), + output: &Model{ + ProjectId: types.StringValue(projectID), + }, + region: testRegion, + expected: fixtureModel(ptr.To(false)), + isValid: true, + }, + { + description: "true - explicitly set", + input: fixtureApplicationLoadBalancer(ptr.To(true)), + output: &Model{ + ProjectId: types.StringValue(projectID), + }, + region: testRegion, + expected: fixtureModel(ptr.To(true)), + isValid: true, + }, + { + description: "false - only in model set", + input: fixtureApplicationLoadBalancer(nil), + output: fixtureModel(ptr.To(false)), + region: testRegion, + expected: fixtureModel(ptr.To(false)), + isValid: true, + }, + { + description: "true - only in model set", + input: fixtureApplicationLoadBalancer(nil), + output: fixtureModel(ptr.To(true)), + region: testRegion, + expected: fixtureModel(ptr.To(false)), + isValid: true, + }, + { + description: "valid empty", + input: &albSdk.LoadBalancer{}, + output: &Model{ + ProjectId: types.StringValue(projectID), + Name: types.StringValue(lbName), + }, + region: testRegion, + expected: fixtureModelNull(func(m *Model) { + m.Id = types.StringValue(strings.Join([]string{projectID, region, lbName}, ",")) + m.ProjectId = types.StringValue(projectID) + m.Name = types.StringValue(lbName) + m.Region = types.StringValue(region) + m.DisableSecurityGroupAssignment = types.BoolValue(false) + }), + isValid: true, + }, + { + description: "mapTargets no response", + input: fixtureApplicationLoadBalancer(nil, func(m *albSdk.LoadBalancer) { + m.TargetPools = &[]albSdk.TargetPool{ + { // empty target pool + ActiveHealthCheck: nil, + Name: nil, + TargetPort: nil, + Targets: nil, + TlsConfig: nil, + }, + } + }), + output: &Model{ + ProjectId: types.StringValue(projectID), + }, + region: testRegion, + expected: fixtureModel(ptr.To(false), func(m *Model) { + m.TargetPools = types.ListValueMust( + types.ObjectType{AttrTypes: targetPoolTypes}, + []attr.Value{ + types.ObjectValueMust( + targetPoolTypes, + map[string]attr.Value{ + "name": types.StringNull(), + "target_port": types.Int64Null(), + "targets": types.SetNull(types.ObjectType{AttrTypes: targetTypes}), + "tls_config": types.ObjectNull(tlsConfigTypes), + "active_health_check": types.ObjectNull(activeHealthCheckTypes), + }, + ), + }, + ) + }), + isValid: true, + }, + { + description: "mapHttpHealthChecks no response", + input: fixtureApplicationLoadBalancer(nil, func(m *albSdk.LoadBalancer) { + m.TargetPools = &[]albSdk.TargetPool{ + { + Name: ptr.To(targetPoolName), + TargetPort: ptr.To(int64(80)), + Targets: &[]albSdk.Target{ + { + DisplayName: ptr.To("test-backend-server"), + Ip: ptr.To("192.168.0.218"), + }, + }, + ActiveHealthCheck: &albSdk.ActiveHealthCheck{ + HealthyThreshold: ptr.To(int64(1)), + UnhealthyThreshold: ptr.To(int64(5)), + Interval: ptr.To("2s"), + IntervalJitter: ptr.To("3s"), + Timeout: ptr.To("4s"), + }, + }, + } + }), + output: &Model{ + ProjectId: types.StringValue(projectID), + }, + region: testRegion, + expected: fixtureModel(ptr.To(false), func(m *Model) { + m.TargetPools = types.ListValueMust( + types.ObjectType{AttrTypes: targetPoolTypes}, + []attr.Value{ + types.ObjectValueMust( + targetPoolTypes, + map[string]attr.Value{ + "name": types.StringValue(targetPoolName), + "target_port": types.Int64Value(80), + "targets": types.SetValueMust( + types.ObjectType{AttrTypes: targetTypes}, + []attr.Value{ + types.ObjectValueMust( + targetTypes, + map[string]attr.Value{ + "display_name": types.StringValue("test-backend-server"), + "ip": types.StringValue("192.168.0.218"), + }, + ), + }, + ), + "tls_config": types.ObjectNull(tlsConfigTypes), + "active_health_check": types.ObjectValueMust( + activeHealthCheckTypes, + map[string]attr.Value{ + "healthy_threshold": types.Int64Value(1), + "interval": types.StringValue("2s"), + "interval_jitter": types.StringValue("3s"), + "timeout": types.StringValue("4s"), + "unhealthy_threshold": types.Int64Value(5), + "http_health_checks": types.ObjectNull(httpHealthChecksTypes), + }, + ), + }, + ), + }, + ) + }), + isValid: true, + }, + { + description: "mapOptions no response", + input: fixtureApplicationLoadBalancer(nil, func(m *albSdk.LoadBalancer) { + m.Options = &albSdk.LoadBalancerOptions{ + AccessControl: nil, + Observability: nil, + EphemeralAddress: nil, + PrivateNetworkOnly: nil, + } + }), + output: &Model{ + ProjectId: types.StringValue(projectID), + }, + region: testRegion, + expected: fixtureModel(ptr.To(false), func(m *Model) { + m.Options = types.ObjectNull(optionsTypes) + }), + isValid: true, + }, + { + description: "mapCertificates no response", + input: fixtureApplicationLoadBalancer(nil, func(m *albSdk.LoadBalancer) { + m.Listeners = &[]albSdk.Listener{ + { + Name: ptr.To("http-80"), + Port: ptr.To(int64(80)), + Protocol: ptr.To(albSdk.ListenerProtocol("PROTOCOL_HTTP")), + Http: &albSdk.ProtocolOptionsHTTP{ + Hosts: &[]albSdk.HostConfig{ + { + Host: ptr.To("*"), + Rules: &[]albSdk.Rule{ + { + TargetPool: ptr.To(targetPoolName), + WebSocket: nil, + Path: &albSdk.Path{ + Prefix: ptr.To("/"), + }, + Headers: &[]albSdk.HttpHeader{ + {Name: ptr.To("a-header"), ExactMatch: ptr.To("value")}, + }, + QueryParameters: &[]albSdk.QueryParameter{ + {Name: ptr.To("a_query_parameter"), ExactMatch: ptr.To("value")}, + }, + CookiePersistence: &albSdk.CookiePersistence{ + Name: ptr.To("cookie_name"), + Ttl: ptr.To("3s"), + }, + }, + }, + }, + }, + }, + Https: &albSdk.ProtocolOptionsHTTPS{ + CertificateConfig: nil, + }, + WafConfigName: ptr.To("my-waf-config"), + }, + } + }), + output: &Model{ + ProjectId: types.StringValue(projectID), + }, + region: testRegion, + expected: fixtureModel(ptr.To(false), func(m *Model) { + m.Listeners = types.ListValueMust( + types.ObjectType{AttrTypes: listenerTypes}, + []attr.Value{ + types.ObjectValueMust( + listenerTypes, + map[string]attr.Value{ + "name": types.StringValue("http-80"), + "port": types.Int64Value(80), + "protocol": types.StringValue("PROTOCOL_HTTP"), + "waf_config_name": types.StringValue("my-waf-config"), + "http": types.ObjectValueMust( + httpTypes, + map[string]attr.Value{ + "hosts": types.ListValueMust( + types.ObjectType{AttrTypes: hostConfigTypes}, + []attr.Value{types.ObjectValueMust( + hostConfigTypes, + map[string]attr.Value{ + "host": types.StringValue("*"), + "rules": types.ListValueMust( + types.ObjectType{AttrTypes: ruleTypes}, + []attr.Value{types.ObjectValueMust( + ruleTypes, + map[string]attr.Value{ + "target_pool": types.StringValue(targetPoolName), + "web_socket": types.BoolValue(false), + "path": types.ObjectValueMust(pathTypes, + map[string]attr.Value{ + "exact_match": types.StringNull(), + "prefix": types.StringValue("/"), + }, + ), + "headers": types.SetValueMust( + types.ObjectType{AttrTypes: headersTypes}, + []attr.Value{types.ObjectValueMust( + headersTypes, + map[string]attr.Value{ + "name": types.StringValue("a-header"), + "exact_match": types.StringValue("value"), + }), + }, + ), + "query_parameters": types.SetValueMust( + types.ObjectType{AttrTypes: queryParameterTypes}, + []attr.Value{types.ObjectValueMust( + queryParameterTypes, + map[string]attr.Value{ + "name": types.StringValue("a_query_parameter"), + "exact_match": types.StringValue("value"), + }), + }, + ), + "cookie_persistence": types.ObjectValueMust( + cookiePersistenceTypes, + map[string]attr.Value{ + "name": types.StringValue("cookie_name"), + "ttl": types.StringValue("3s"), + }, + ), + }, + ), + }), + }, + ), + }), + }, + ), + "https": types.ObjectValueMust( + httpsTypes, + map[string]attr.Value{ + "certificate_config": types.ObjectNull(certificateConfigTypes), + }, + ), + }, + ), + }, + ) + }), + isValid: true, + }, + { + description: "mapHttps no response", + input: fixtureApplicationLoadBalancer(nil, func(m *albSdk.LoadBalancer) { + m.Listeners = &[]albSdk.Listener{ + { + Name: ptr.To("http-80"), + Port: ptr.To(int64(80)), + Protocol: ptr.To(albSdk.ListenerProtocol("PROTOCOL_HTTP")), + Http: &albSdk.ProtocolOptionsHTTP{ + Hosts: &[]albSdk.HostConfig{ + { + Host: ptr.To("*"), + Rules: &[]albSdk.Rule{ + { + TargetPool: ptr.To(targetPoolName), + WebSocket: nil, + Path: &albSdk.Path{ + Prefix: ptr.To("/"), + }, + Headers: &[]albSdk.HttpHeader{ + {Name: ptr.To("a-header"), ExactMatch: ptr.To("value")}, + }, + QueryParameters: &[]albSdk.QueryParameter{ + {Name: ptr.To("a_query_parameter"), ExactMatch: ptr.To("value")}, + }, + CookiePersistence: &albSdk.CookiePersistence{ + Name: ptr.To("cookie_name"), + Ttl: ptr.To("3s"), + }, + }, + }, + }, + }, + }, + Https: nil, + WafConfigName: ptr.To("my-waf-config"), + }, + } + }), + output: &Model{ + ProjectId: types.StringValue(projectID), + }, + region: testRegion, + expected: fixtureModel(ptr.To(false), func(m *Model) { + m.Listeners = types.ListValueMust( + types.ObjectType{AttrTypes: listenerTypes}, + []attr.Value{ + types.ObjectValueMust( + listenerTypes, + map[string]attr.Value{ + "name": types.StringValue("http-80"), + "port": types.Int64Value(80), + "protocol": types.StringValue("PROTOCOL_HTTP"), + "waf_config_name": types.StringValue("my-waf-config"), + "http": types.ObjectValueMust( + httpTypes, + map[string]attr.Value{ + "hosts": types.ListValueMust( + types.ObjectType{AttrTypes: hostConfigTypes}, + []attr.Value{types.ObjectValueMust( + hostConfigTypes, + map[string]attr.Value{ + "host": types.StringValue("*"), + "rules": types.ListValueMust( + types.ObjectType{AttrTypes: ruleTypes}, + []attr.Value{types.ObjectValueMust( + ruleTypes, + map[string]attr.Value{ + "target_pool": types.StringValue(targetPoolName), + "web_socket": types.BoolValue(false), + "path": types.ObjectValueMust(pathTypes, + map[string]attr.Value{ + "exact_match": types.StringNull(), + "prefix": types.StringValue("/"), + }, + ), + "headers": types.SetValueMust( + types.ObjectType{AttrTypes: headersTypes}, + []attr.Value{types.ObjectValueMust( + headersTypes, + map[string]attr.Value{ + "name": types.StringValue("a-header"), + "exact_match": types.StringValue("value"), + }), + }, + ), + "query_parameters": types.SetValueMust( + types.ObjectType{AttrTypes: queryParameterTypes}, + []attr.Value{types.ObjectValueMust( + queryParameterTypes, + map[string]attr.Value{ + "name": types.StringValue("a_query_parameter"), + "exact_match": types.StringValue("value"), + }), + }, + ), + "cookie_persistence": types.ObjectValueMust( + cookiePersistenceTypes, + map[string]attr.Value{ + "name": types.StringValue("cookie_name"), + "ttl": types.StringValue("3s"), + }, + ), + }, + ), + }), + }, + ), + }), + }, + ), + "https": types.ObjectNull(httpsTypes), + }, + ), + }, + ) + }), + isValid: true, + }, + { + description: "mapRules contents no response", + input: fixtureApplicationLoadBalancer(nil, func(m *albSdk.LoadBalancer) { + m.Listeners = &[]albSdk.Listener{ + { + Name: ptr.To("http-80"), + Port: ptr.To(int64(80)), + Protocol: ptr.To(albSdk.ListenerProtocol("PROTOCOL_HTTP")), + Http: &albSdk.ProtocolOptionsHTTP{ + Hosts: &[]albSdk.HostConfig{ + { + Host: ptr.To("*"), + Rules: &[]albSdk.Rule{ + { + TargetPool: ptr.To(targetPoolName), + WebSocket: nil, + Path: nil, + Headers: nil, + QueryParameters: nil, + CookiePersistence: nil, + }, + }, + }, + }, + }, + Https: &albSdk.ProtocolOptionsHTTPS{ + CertificateConfig: ptr.To(albSdk.CertificateConfig{ + CertificateIds: &[]string{ + credentialsRef, + }, + }), + }, + WafConfigName: ptr.To("my-waf-config"), + }, + } + }), + output: &Model{ + ProjectId: types.StringValue(projectID), + }, + region: testRegion, + expected: fixtureModel(ptr.To(false), func(m *Model) { + m.Listeners = types.ListValueMust( + types.ObjectType{AttrTypes: listenerTypes}, + []attr.Value{ + types.ObjectValueMust( + listenerTypes, + map[string]attr.Value{ + "name": types.StringValue("http-80"), + "port": types.Int64Value(80), + "protocol": types.StringValue("PROTOCOL_HTTP"), + "waf_config_name": types.StringValue("my-waf-config"), + "http": types.ObjectValueMust( + httpTypes, + map[string]attr.Value{ + "hosts": types.ListValueMust( + types.ObjectType{AttrTypes: hostConfigTypes}, + []attr.Value{types.ObjectValueMust( + hostConfigTypes, + map[string]attr.Value{ + "host": types.StringValue("*"), + "rules": types.ListValueMust( + types.ObjectType{AttrTypes: ruleTypes}, + []attr.Value{types.ObjectValueMust( + ruleTypes, + map[string]attr.Value{ + "target_pool": types.StringValue(targetPoolName), + "web_socket": types.BoolValue(false), + "path": types.ObjectNull(pathTypes), + "headers": types.SetNull(types.ObjectType{AttrTypes: headersTypes}), + "query_parameters": types.SetNull(types.ObjectType{AttrTypes: queryParameterTypes}), + "cookie_persistence": types.ObjectNull(cookiePersistenceTypes), + }, + ), + }), + }, + ), + }), + }, + ), + "https": types.ObjectValueMust( + httpsTypes, + map[string]attr.Value{ + "certificate_config": types.ObjectValueMust( + certificateConfigTypes, + map[string]attr.Value{ + "certificate_ids": types.SetValueMust( + types.StringType, + []attr.Value{ + types.StringValue(credentialsRef), + }, + ), + }, + ), + }, + ), + }, + ), + }, + ) + }), + isValid: true, + }, + { + description: "mapRules no response", + input: fixtureApplicationLoadBalancer(nil, func(m *albSdk.LoadBalancer) { + m.Listeners = &[]albSdk.Listener{ + { + Name: ptr.To("http-80"), + Port: ptr.To(int64(80)), + Protocol: ptr.To(albSdk.ListenerProtocol("PROTOCOL_HTTP")), + Http: &albSdk.ProtocolOptionsHTTP{ + Hosts: &[]albSdk.HostConfig{ + { + Host: ptr.To("*"), + Rules: nil, + }, + }, + }, + Https: &albSdk.ProtocolOptionsHTTPS{ + CertificateConfig: ptr.To(albSdk.CertificateConfig{ + CertificateIds: &[]string{ + credentialsRef, + }, + }), + }, + WafConfigName: ptr.To("my-waf-config"), + }, + } + }), + output: &Model{ + ProjectId: types.StringValue(projectID), + }, + region: testRegion, + expected: fixtureModel(ptr.To(false), func(m *Model) { + m.Listeners = types.ListValueMust( + types.ObjectType{AttrTypes: listenerTypes}, + []attr.Value{ + types.ObjectValueMust( + listenerTypes, + map[string]attr.Value{ + "name": types.StringValue("http-80"), + "port": types.Int64Value(80), + "protocol": types.StringValue("PROTOCOL_HTTP"), + "waf_config_name": types.StringValue("my-waf-config"), + "http": types.ObjectValueMust( + httpTypes, + map[string]attr.Value{ + "hosts": types.ListValueMust( + types.ObjectType{AttrTypes: hostConfigTypes}, + []attr.Value{types.ObjectValueMust( + hostConfigTypes, + map[string]attr.Value{ + "host": types.StringValue("*"), + "rules": types.ListNull(types.ObjectType{AttrTypes: ruleTypes}), + }), + }, + ), + }, + ), + "https": types.ObjectValueMust( + httpsTypes, + map[string]attr.Value{ + "certificate_config": types.ObjectValueMust( + certificateConfigTypes, + map[string]attr.Value{ + "certificate_ids": types.SetValueMust( + types.StringType, + []attr.Value{ + types.StringValue(credentialsRef), + }, + ), + }, + ), + }, + ), + }, + ), + }, + ) + }), + isValid: true, + }, + { + description: "mapHosts no response", + input: fixtureApplicationLoadBalancer(nil, func(m *albSdk.LoadBalancer) { + m.Listeners = &[]albSdk.Listener{ + { + Name: ptr.To("http-80"), + Port: ptr.To(int64(80)), + Protocol: ptr.To(albSdk.ListenerProtocol("PROTOCOL_HTTP")), + Http: &albSdk.ProtocolOptionsHTTP{ + Hosts: nil, + }, + Https: &albSdk.ProtocolOptionsHTTPS{ + CertificateConfig: ptr.To(albSdk.CertificateConfig{ + CertificateIds: &[]string{ + credentialsRef, + }, + }), + }, + WafConfigName: ptr.To("my-waf-config"), + }, + } + }), + output: &Model{ + ProjectId: types.StringValue(projectID), + }, + region: testRegion, + expected: fixtureModel(ptr.To(false), func(m *Model) { + m.Listeners = types.ListValueMust( + types.ObjectType{AttrTypes: listenerTypes}, + []attr.Value{ + types.ObjectValueMust( + listenerTypes, + map[string]attr.Value{ + "name": types.StringValue("http-80"), + "port": types.Int64Value(80), + "protocol": types.StringValue("PROTOCOL_HTTP"), + "waf_config_name": types.StringValue("my-waf-config"), + "http": types.ObjectValueMust( + httpTypes, + map[string]attr.Value{ + "hosts": types.ListNull(types.ObjectType{AttrTypes: hostConfigTypes}), + }, + ), + "https": types.ObjectValueMust( + httpsTypes, + map[string]attr.Value{ + "certificate_config": types.ObjectValueMust( + certificateConfigTypes, + map[string]attr.Value{ + "certificate_ids": types.SetValueMust( + types.StringType, + []attr.Value{ + types.StringValue(credentialsRef), + }, + ), + }, + ), + }, + ), + }, + ), + }, + ) + }), + isValid: true, + }, + { + description: "mapHttp no response", + input: fixtureApplicationLoadBalancer(nil, func(m *albSdk.LoadBalancer) { + m.Listeners = &[]albSdk.Listener{ + { + Name: ptr.To("http-80"), + Port: ptr.To(int64(80)), + Protocol: ptr.To(albSdk.ListenerProtocol("PROTOCOL_HTTP")), + Http: nil, + Https: &albSdk.ProtocolOptionsHTTPS{ + CertificateConfig: ptr.To(albSdk.CertificateConfig{ + CertificateIds: &[]string{ + credentialsRef, + }, + }), + }, + WafConfigName: ptr.To("my-waf-config"), + }, + } + }), + output: &Model{ + ProjectId: types.StringValue(projectID), + }, + region: testRegion, + expected: fixtureModel(ptr.To(false), func(m *Model) { + m.Listeners = types.ListValueMust( + types.ObjectType{AttrTypes: listenerTypes}, + []attr.Value{ + types.ObjectValueMust( + listenerTypes, + map[string]attr.Value{ + "name": types.StringValue("http-80"), + "port": types.Int64Value(80), + "protocol": types.StringValue("PROTOCOL_HTTP"), + "waf_config_name": types.StringValue("my-waf-config"), + "http": types.ObjectNull(httpTypes), + "https": types.ObjectValueMust( + httpsTypes, + map[string]attr.Value{ + "certificate_config": types.ObjectValueMust( + certificateConfigTypes, + map[string]attr.Value{ + "certificate_ids": types.SetValueMust( + types.StringType, + []attr.Value{ + types.StringValue(credentialsRef), + }, + ), + }, + ), + }, + ), + }, + ), + }, + ) + }), + isValid: true, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + err := mapFields(context.Background(), tt.input, tt.output, tt.region) + if !tt.isValid && err == nil { + t.Fatalf("Should have failed") + } + if tt.isValid && err != nil { + t.Fatalf("Should not have failed: %v", err) + } + if tt.isValid { + diff := cmp.Diff(tt.output, tt.expected) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + } + }) + } +} + +func Test_toExternalAddress(t *testing.T) { + tests := []struct { + name string + input *Model + expected albSdk.UpdateLoadBalancerPayloadGetExternalAddressAttributeType + isValid bool + }{ + { + name: "valid", + input: &Model{ + ExternalAddress: types.StringValue(externalAddress), + }, + expected: ptr.To(externalAddress), + isValid: true, + }, + { + name: "valid with option", + input: &Model{ + ExternalAddress: types.StringValue(externalAddress), + Options: types.ObjectValueMust(optionsTypes, + map[string]attr.Value{ + "acl": types.SetNull(types.StringType), + "observability": types.ObjectNull(observabilityTypes), + "private_network_only": types.BoolNull(), + "ephemeral_address": types.BoolNull(), + }), + }, + expected: ptr.To(externalAddress), + isValid: true, + }, + { + name: "invalid with option and both true", + input: &Model{ + ExternalAddress: types.StringValue(externalAddress), + Options: types.ObjectValueMust(optionsTypes, + map[string]attr.Value{ + "acl": types.SetNull(types.StringType), + "observability": types.ObjectNull(observabilityTypes), + "private_network_only": types.BoolValue(true), + "ephemeral_address": types.BoolValue(true), + }), + }, + expected: nil, + isValid: false, + }, + { + name: "valid with option and both false", + input: &Model{ + ExternalAddress: types.StringValue(externalAddress), + Options: types.ObjectValueMust(optionsTypes, + map[string]attr.Value{ + "acl": types.SetNull(types.StringType), + "observability": types.ObjectNull(observabilityTypes), + "private_network_only": types.BoolValue(false), + "ephemeral_address": types.BoolValue(false), + }), + }, + expected: ptr.To(externalAddress), + isValid: true, + }, + { + name: "valid with option and ephemeral address", + input: &Model{ + ExternalAddress: types.StringValue(externalAddress), + Options: types.ObjectValueMust(optionsTypes, + map[string]attr.Value{ + "acl": types.SetNull(types.StringType), + "observability": types.ObjectNull(observabilityTypes), + "private_network_only": types.BoolNull(), + "ephemeral_address": types.BoolValue(true), + }), + }, + expected: nil, + isValid: true, + }, + { + name: "valid with option and private network only", + input: &Model{ + ExternalAddress: types.StringValue(externalAddress), + Options: types.ObjectValueMust(optionsTypes, + map[string]attr.Value{ + "acl": types.SetNull(types.StringType), + "observability": types.ObjectNull(observabilityTypes), + "private_network_only": types.BoolValue(true), + "ephemeral_address": types.BoolNull(), + }), + }, + expected: nil, + isValid: true, + }, + { + name: "valid with option and null false", + input: &Model{ + ExternalAddress: types.StringValue(externalAddress), + Options: types.ObjectValueMust(optionsTypes, + map[string]attr.Value{ + "acl": types.SetNull(types.StringType), + "observability": types.ObjectNull(observabilityTypes), + "private_network_only": types.BoolNull(), + "ephemeral_address": types.BoolValue(false), + }), + }, + expected: ptr.To(externalAddress), + isValid: true, + }, + { + name: "valid with option and false null", + input: &Model{ + ExternalAddress: types.StringValue(externalAddress), + Options: types.ObjectValueMust(optionsTypes, + map[string]attr.Value{ + "acl": types.SetNull(types.StringType), + "observability": types.ObjectNull(observabilityTypes), + "private_network_only": types.BoolValue(false), + "ephemeral_address": types.BoolNull(), + }), + }, + expected: ptr.To(externalAddress), + isValid: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := toExternalAddress(context.Background(), tt.input) + if !tt.isValid && err == nil { + t.Fatalf("Should have failed") + } + if tt.isValid && err != nil { + t.Fatalf("Should not have failed: %v", err) + } + if tt.isValid { + diff := cmp.Diff(got, tt.expected) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + } + }) + } +} + +func Test_toPathPayload(t *testing.T) { + tests := []struct { + name string + input *rule + expected albSdk.RuleGetPathAttributeType + isValid bool + }{ + { + name: "valid prefix", + input: &rule{ + Path: types.ObjectValueMust(pathTypes, + map[string]attr.Value{ + "exact_match": types.StringNull(), + "prefix": types.StringValue("/"), + }), + }, + expected: &albSdk.Path{ + Exact: nil, + Prefix: ptr.To("/"), + }, + isValid: true, + }, + { + name: "valid exact", + input: &rule{ + Path: types.ObjectValueMust(pathTypes, + map[string]attr.Value{ + "exact_match": types.StringValue("exact-match"), + "prefix": types.StringNull(), + }), + }, + expected: &albSdk.Path{ + Exact: ptr.To("exact-match"), + Prefix: nil, + }, + isValid: true, + }, + { + name: "valid none set", + input: &rule{ + Path: types.ObjectValueMust(pathTypes, + map[string]attr.Value{ + "exact_match": types.StringNull(), + "prefix": types.StringNull(), + }), + }, + expected: nil, + isValid: false, + }, + { + name: "valid both set", + input: &rule{ + Path: types.ObjectValueMust(pathTypes, + map[string]attr.Value{ + "exact_match": types.StringValue("exact-match"), + "prefix": types.StringValue("/"), + }), + }, + expected: nil, + isValid: false, + }, + { + name: "input path nil", + input: &rule{}, + expected: nil, + isValid: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := toPathPayload(context.Background(), tt.input) + if !tt.isValid && err == nil { + t.Fatalf("Should have failed") + } + if tt.isValid && err != nil { + t.Fatalf("Should not have failed: %v", err) + } + if tt.isValid { + diff := cmp.Diff(got, tt.expected) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + } + }) + } +} diff --git a/stackit/internal/services/alb/testfiles/resource-max.tf b/stackit/internal/services/alb/testfiles/resource-max.tf new file mode 100644 index 000000000..bb4713982 --- /dev/null +++ b/stackit/internal/services/alb/testfiles/resource-max.tf @@ -0,0 +1,312 @@ +// backend server data +variable "image_id" { + description = "A valid Image ID available in the project for the target server" + type = string + default = "939249d1-6f48-4ab7-929b-95170728311a" +} +variable "availability_zone" { + description = "The availability zone" + type = string + default = "eu01-1" +} +variable "machine_type" { + description = "The machine flavor" + type = string + default = "c2i.1" +} +variable "server_name_max" { + description = "The name of the backend server" + type = string + default = "backend-server-max" +} + +// observability +variable "observability_credential_name" {} +variable "observability_credential_username" {} +variable "observability_credential_password" {} + +// general data +variable "project_id" {} +variable "region" {} + +// load balancer data +variable "loadbalancer_name" {} +variable "plan_id" {} +variable "target_display_name" {} +variable "labels_key_1" {} +variable "labels_value_1" {} +variable "labels_key_2" {} +variable "labels_value_2" {} +variable "ahc_interval" {} +variable "ahc_interval_jitter" {} +variable "ahc_timeout" {} +variable "ahc_healthy_threshold" {} +variable "ahc_unhealthy_threshold" {} +variable "ahc_http_ok_status_200" {} +variable "ahc_http_ok_status_201" {} +variable "ahc_http_path" {} +variable "tls_config_enabled" {} +variable "tls_config_skip" {} +variable "tls_config_custom_ca" {} +variable "listener_port_1" {} +variable "host_1" {} +variable "target_pool_name_1" {} +variable "target_pool_port_1" {} +variable "web_socket" {} +variable "query_parameters_name_1" {} +variable "query_parameters_exact_match_1" {} +variable "query_parameters_name_2" {} +variable "query_parameters_exact_match_2" {} +variable "headers_name_1" {} +variable "headers_exact_match_1" {} +variable "headers_name_2" {} +variable "headers_exact_match_2" {} +variable "headers_name_3" {} +variable "path_prefix_1" {} +variable "path_prefix_2" {} +variable "target_pool_name_2" {} +variable "target_pool_port_2" {} +variable "host_3" {} +variable "path_prefix_3" {} +variable "target_pool_name_3" {} +variable "target_pool_port_3" {} +variable "certificate_ids" {} +variable "listener_port_4" {} +variable "host_4" {} +variable "path_prefix_4" {} +variable "target_pool_name_4" {} +variable "target_pool_port_4" {} +variable "network_name_listener" {} +variable "network_name_targets" {} +variable "network_role_listeners" {} +variable "network_role_targets" {} +variable "disable_security_group_assignment" {} +variable "protocol_https" {} +variable "protocol_http" {} +variable "private_network_only" {} +variable "acl" {} +variable "ephemeral_address" {} +variable "observability_logs_push_url" {} +variable "observability_metrics_push_url" {} + +resource "stackit_network" "listener_network" { + project_id = var.project_id + name = var.network_name_listener + ipv4_nameservers = ["1.1.1.1"] + ipv4_prefix = "10.11.10.0/24" + routed = "true" +} + +resource "stackit_network" "target_network" { + project_id = var.project_id + name = var.network_name_targets + ipv4_nameservers = ["1.1.1.1"] + ipv4_prefix = "10.11.11.0/24" + routed = "true" +} + +resource "stackit_network_interface" "network_interface_listener" { + project_id = var.project_id + network_id = stackit_network.listener_network.network_id + lifecycle { + ignore_changes = [ + security_group_ids, + ] + } +} + +resource "stackit_network_interface" "network_interface_target" { + project_id = var.project_id + network_id = stackit_network.target_network.network_id + lifecycle { + ignore_changes = [ + security_group_ids, + ] + } +} + +resource "stackit_server" "server_max" { + project_id = var.project_id + availability_zone = var.availability_zone + name = var.server_name_max + machine_type = var.machine_type + boot_volume = { + size = 20 + source_type = "image" + source_id = var.image_id + delete_on_termination = "true" + } + network_interfaces = [ + stackit_network_interface.network_interface_target.network_interface_id + ] + # Explicit dependencies to ensure ordering + depends_on = [ + stackit_network.target_network, + stackit_network_interface.network_interface_target + ] +} + +resource "stackit_loadbalancer_observability_credential" "observer" { + project_id = var.project_id + display_name = var.observability_credential_name + password = var.observability_credential_password + username = var.observability_credential_username +} + +resource "stackit_alb" "loadbalancer" { + region = var.region + project_id = var.project_id + name = var.loadbalancer_name + plan_id = var.plan_id + disable_target_security_group_assignment = var.disable_security_group_assignment + labels = { + (var.labels_key_1) = var.labels_value_1 + (var.labels_key_2) = var.labels_value_2 + } + target_pools = [ + { + name = var.target_pool_name_1 + active_health_check = { + interval = var.ahc_interval + interval_jitter = var.ahc_interval_jitter + timeout = var.ahc_timeout + healthy_threshold = var.ahc_healthy_threshold + unhealthy_threshold = var.ahc_unhealthy_threshold + http_health_checks = { + ok_status = [var.ahc_http_ok_status_200, var.ahc_http_ok_status_201] + path = var.ahc_http_path + } + } + target_port = var.target_pool_port_1 + targets = [ + { + display_name = var.target_display_name + ip = stackit_network_interface.network_interface_target.ipv4 + } + ] + tls_config = { + enabled = var.tls_config_enabled + skip_certificate_validation = var.tls_config_skip + custom_ca = var.tls_config_custom_ca + } + }, { + name = var.target_pool_name_2 + target_port = var.target_pool_port_2 + targets = [ + { + display_name = var.target_display_name + ip = stackit_network_interface.network_interface_target.ipv4 + } + ] + }, { + name = var.target_pool_name_3 + target_port = var.target_pool_port_3 + targets = [ + { + display_name = var.target_display_name + ip = stackit_network_interface.network_interface_target.ipv4 + } + ] + }, { + name = var.target_pool_name_4 + target_port = var.target_pool_port_4 + targets = [ + { + display_name = var.target_display_name + ip = stackit_network_interface.network_interface_target.ipv4 + } + ] + } + ] + listeners = [{ + port = var.listener_port_1 + http = { + hosts = [{ + host = var.host_1 + rules = [{ + target_pool = var.target_pool_name_1 + web_socket = var.web_socket + query_parameters = [{ + name = var.query_parameters_name_1 + exact_match = var.query_parameters_exact_match_1 + }, { + name = var.query_parameters_name_2 + exact_match = var.query_parameters_exact_match_2 + }] + headers = [{ + name = var.headers_name_1 + exact_match = var.headers_exact_match_1 + }, { + name = var.headers_name_2 + exact_match = var.headers_exact_match_2 + }, { + name = var.headers_name_3 + }] + path = { + prefix = var.path_prefix_1 + } + }, { + path = { + prefix = var.path_prefix_2 + } + target_pool = var.target_pool_name_2 + }] + }, { + host = var.host_3 + rules = [{ + path = { + prefix = var.path_prefix_3 + } + target_pool = var.target_pool_name_3 + }] + }] + } + https = { + certificate_config = { + certificate_ids = [ + var.certificate_ids + ] + } + } + protocol = var.protocol_https + }, { + port = var.listener_port_4 + http = { + hosts = [{ + host = var.host_4 + rules = [{ + path = { + prefix = var.path_prefix_4 + } + target_pool = var.target_pool_name_4 + }] + }] + } + protocol = var.protocol_http + }] + networks = [ + { + network_id = stackit_network.listener_network.network_id + role = var.network_role_listeners + }, + { + network_id = stackit_network.target_network.network_id + role = var.network_role_targets + } + ] + options = { + ephemeral_address = var.ephemeral_address + private_network_only = var.private_network_only + acl = [var.acl] + observability = { + logs = { + credentials_ref = stackit_loadbalancer_observability_credential.observer.credentials_ref + push_url = var.observability_logs_push_url + } + metrics = { + credentials_ref = stackit_loadbalancer_observability_credential.observer.credentials_ref + push_url = var.observability_metrics_push_url + } + } + } +} diff --git a/stackit/internal/services/alb/testfiles/resource-min.tf b/stackit/internal/services/alb/testfiles/resource-min.tf new file mode 100644 index 000000000..306d067c4 --- /dev/null +++ b/stackit/internal/services/alb/testfiles/resource-min.tf @@ -0,0 +1,128 @@ +// backend server data +variable "image_id" { + description = "A valid Image ID available in the project for the target server" + type = string + default = "939249d1-6f48-4ab7-929b-95170728311a" +} +variable "availability_zone" { + description = "The availability zone" + type = string + default = "eu01-1" +} +variable "machine_type" { + description = "The machine flavor" + type = string + default = "c2i.1" +} +variable "server_name_min" { + description = "The name of the backend server" + type = string + default = "backend-server-min" +} + +// general data +variable "project_id" {} +variable "region" {} + +// load balancer data +variable "loadbalancer_name" {} +variable "network_role" {} +variable "network_name" {} +variable "plan_id" {} +variable "listener_port" {} +variable "host" {} +variable "path_prefix" {} +variable "protocol_http" {} +variable "target_pool_name" {} +variable "target_pool_port" {} +variable "target_display_name" {} + +resource "stackit_network" "network" { + project_id = var.project_id + name = var.network_name + ipv4_nameservers = ["1.1.1.1"] + ipv4_prefix = "192.168.3.0/25" + routed = "true" +} + +resource "stackit_network_interface" "network_interface" { + project_id = var.project_id + network_id = stackit_network.network.network_id + lifecycle { + ignore_changes = [ + security_group_ids, + ] + } +} + +resource "stackit_public_ip" "public_ip" { + project_id = var.project_id + network_interface_id = stackit_network_interface.network_interface.network_interface_id + lifecycle { + ignore_changes = [ + network_interface_id + ] + } +} + +resource "stackit_server" "server" { + project_id = var.project_id + availability_zone = var.availability_zone + name = var.server_name_min + machine_type = var.machine_type + boot_volume = { + size = 20 + source_type = "image" + source_id = var.image_id + delete_on_termination = "true" + } + network_interfaces = [ + stackit_network_interface.network_interface.network_interface_id + ] + # Explicit dependencies to ensure ordering + depends_on = [ + stackit_network.network, + stackit_network_interface.network_interface + ] +} + +resource "stackit_alb" "loadbalancer" { + region = var.region + project_id = var.project_id + name = var.loadbalancer_name + plan_id = var.plan_id + target_pools = [ + { + name = var.target_pool_name + target_port = var.target_pool_port + targets = [ + { + display_name = var.target_display_name + ip = stackit_network_interface.network_interface.ipv4 + } + ] + } + ] + listeners = [{ + port = var.listener_port + http = { + hosts = [{ + host = var.host + rules = [{ + target_pool = var.target_pool_name + path = { + prefix = var.path_prefix + } + }] + }] + } + protocol = var.protocol_http + }] + networks = [ + { + network_id = stackit_network.network.network_id + role = var.network_role + } + ] + external_address = stackit_public_ip.public_ip.ip +} diff --git a/stackit/internal/services/alb/utils/util.go b/stackit/internal/services/alb/utils/util.go new file mode 100644 index 000000000..b7b184702 --- /dev/null +++ b/stackit/internal/services/alb/utils/util.go @@ -0,0 +1,30 @@ +package utils + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-sdk-go/services/alb" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" +) + +func ConfigureClient(ctx context.Context, providerData *core.ProviderData, diags *diag.Diagnostics) *alb.APIClient { + apiClientConfigOptions := []config.ConfigurationOption{ + config.WithCustomAuth(providerData.RoundTripper), + utils.UserAgentConfigOption(providerData.Version), + } + if providerData.LoadBalancerCustomEndpoint != "" { + apiClientConfigOptions = append(apiClientConfigOptions, config.WithEndpoint(providerData.LoadBalancerCustomEndpoint)) + } + apiClient, err := alb.NewAPIClient(apiClientConfigOptions...) + if err != nil { + core.LogAndAddError(ctx, diags, "Error configuring API client", fmt.Sprintf("Configuring client: %v. This is an error related to the provider configuration, not to the resource configuration", err)) + return nil + } + + return apiClient +} diff --git a/stackit/internal/services/alb/utils/util_test.go b/stackit/internal/services/alb/utils/util_test.go new file mode 100644 index 000000000..e13d89924 --- /dev/null +++ b/stackit/internal/services/alb/utils/util_test.go @@ -0,0 +1,93 @@ +package utils + +import ( + "context" + "os" + "reflect" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/diag" + sdkClients "github.com/stackitcloud/stackit-sdk-go/core/clients" + "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/stackitcloud/stackit-sdk-go/services/alb" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" +) + +const ( + testVersion = "1.2.3" + testCustomEndpoint = "https://loadbalancer-custom-endpoint.api.stackit.cloud" +) + +func TestConfigureClient(t *testing.T) { + /* mock authentication by setting service account token env variable */ + os.Clearenv() + err := os.Setenv(sdkClients.ServiceAccountToken, "mock-val") + if err != nil { + t.Errorf("error setting env variable: %v", err) + } + + type args struct { + providerData *core.ProviderData + } + tests := []struct { + name string + args args + wantErr bool + expected *alb.APIClient + }{ + { + name: "default endpoint", + args: args{ + providerData: &core.ProviderData{ + Version: testVersion, + }, + }, + expected: func() *alb.APIClient { + apiClient, err := alb.NewAPIClient( + utils.UserAgentConfigOption(testVersion), + ) + if err != nil { + t.Errorf("error configuring client: %v", err) + } + return apiClient + }(), + wantErr: false, + }, + { + name: "custom endpoint", + args: args{ + providerData: &core.ProviderData{ + Version: testVersion, + LoadBalancerCustomEndpoint: testCustomEndpoint, + }, + }, + expected: func() *alb.APIClient { + apiClient, err := alb.NewAPIClient( + utils.UserAgentConfigOption(testVersion), + config.WithEndpoint(testCustomEndpoint), + ) + if err != nil { + t.Errorf("error configuring client: %v", err) + } + return apiClient + }(), + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + diags := diag.Diagnostics{} + + actual := ConfigureClient(ctx, tt.args.providerData, &diags) + if diags.HasError() != tt.wantErr { + t.Errorf("ConfigureClient() error = %v, want %v", diags.HasError(), tt.wantErr) + } + + if !reflect.DeepEqual(actual, tt.expected) { + t.Errorf("ConfigureClient() = %v, want %v", actual, tt.expected) + } + }) + } +} diff --git a/stackit/internal/testutil/testutil.go b/stackit/internal/testutil/testutil.go index ea15ce31b..d86005971 100644 --- a/stackit/internal/testutil/testutil.go +++ b/stackit/internal/testutil/testutil.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "strconv" "strings" "time" @@ -51,6 +52,7 @@ var ( // TestImageLocalFilePath is the local path to an image file used for image acceptance tests TestImageLocalFilePath = getenv("TF_ACC_TEST_IMAGE_LOCAL_FILE_PATH", "default") + ALBCustomEndpoint = os.Getenv("TF_ACC_ALB_CUSTOM_ENDPOINT") CdnCustomEndpoint = os.Getenv("TF_ACC_CDN_CUSTOM_ENDPOINT") DnsCustomEndpoint = os.Getenv("TF_ACC_DNS_CUSTOM_ENDPOINT") GitCustomEndpoint = os.Getenv("TF_ACC_GIT_CUSTOM_ENDPOINT") @@ -80,6 +82,20 @@ var ( // Provider config helper functions +func ALBProviderConfig() string { + if ALBCustomEndpoint == "" { + return ` + provider "stackit" { + default_region = "eu01" + }` + } + return fmt.Sprintf(` + provider "stackit" { + alb_custom_endpoint = "%s" + }`, + ALBCustomEndpoint, + ) +} func ObservabilityProviderConfig() string { if ObservabilityCustomEndpoint == "" { return `provider "stackit" { @@ -584,12 +600,16 @@ func CreateDefaultLocalFile() os.File { func ConvertConfigVariable(variable config.Variable) string { tmpByteArray, _ := variable.MarshalJSON() - // In case the variable is a string, the quotes should be removed - if tmpByteArray[0] == '"' && tmpByteArray[len(tmpByteArray)-1] == '"' { - result := string(tmpByteArray[1 : len(tmpByteArray)-1]) - // Replace escaped quotes which where added MarshalJSON - rawString := strings.ReplaceAll(result, `\"`, `"`) - return rawString - } - return string(tmpByteArray) + input := string(tmpByteArray) + + // If it's a JSON string (starts and ends with quotes) + if strings.HasPrefix(input, `"`) && strings.HasSuffix(input, `"`) { + // Unquote converts the "escaped" string back to a raw Go string + // interpreting \n as a real newline, \" as a quote, etc. + if unquoted, err := strconv.Unquote(input); err == nil { + return unquoted + } + } + + return input } diff --git a/stackit/provider.go b/stackit/provider.go index fbc149f25..6b7124529 100644 --- a/stackit/provider.go +++ b/stackit/provider.go @@ -18,6 +18,7 @@ import ( "github.com/stackitcloud/stackit-sdk-go/core/config" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/features" + alb "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/alb/applicationloadbalancer" roleAssignements "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/authorization/roleassignments" cdnCustomDomain "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/cdn/customdomain" cdn "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/cdn/distribution" @@ -138,6 +139,7 @@ type providerModel struct { PostgresFlexCustomEndpoint types.String `tfsdk:"postgresflex_custom_endpoint"` MongoDBFlexCustomEndpoint types.String `tfsdk:"mongodbflex_custom_endpoint"` ModelServingCustomEndpoint types.String `tfsdk:"modelserving_custom_endpoint"` + ALBCustomEndpoint types.String `tfsdk:"alb_custom_endpoint"` LoadBalancerCustomEndpoint types.String `tfsdk:"loadbalancer_custom_endpoint"` LogMeCustomEndpoint types.String `tfsdk:"logme_custom_endpoint"` RabbitMQCustomEndpoint types.String `tfsdk:"rabbitmq_custom_endpoint"` @@ -180,6 +182,7 @@ func (p *Provider) Schema(_ context.Context, _ provider.SchemaRequest, resp *pro "kms_custom_endpoint": "Custom endpoint for the KMS service", "mongodbflex_custom_endpoint": "Custom endpoint for the MongoDB Flex service", "modelserving_custom_endpoint": "Custom endpoint for the AI Model Serving service", + "alb_custom_endpoint": "Custom endpoint for the Application Load Balancer service", "loadbalancer_custom_endpoint": "Custom endpoint for the Load Balancer service", "logme_custom_endpoint": "Custom endpoint for the LogMe service", "rabbitmq_custom_endpoint": "Custom endpoint for the RabbitMQ service", @@ -293,6 +296,10 @@ func (p *Provider) Schema(_ context.Context, _ provider.SchemaRequest, resp *pro Optional: true, Description: descriptions["mongodbflex_custom_endpoint"], }, + "alb_custom_endpoint": schema.StringAttribute{ + Optional: true, + Description: descriptions["alb_custom_endpoint"], + }, "loadbalancer_custom_endpoint": schema.StringAttribute{ Optional: true, Description: descriptions["loadbalancer_custom_endpoint"], @@ -426,6 +433,7 @@ func (p *Provider) Configure(ctx context.Context, req provider.ConfigureRequest, setStringField(providerConfig.KMSCustomEndpoint, func(v string) { providerData.KMSCustomEndpoint = v }) setStringField(providerConfig.ModelServingCustomEndpoint, func(v string) { providerData.ModelServingCustomEndpoint = v }) setStringField(providerConfig.MongoDBFlexCustomEndpoint, func(v string) { providerData.MongoDBFlexCustomEndpoint = v }) + setStringField(providerConfig.ALBCustomEndpoint, func(v string) { providerData.ALBCustomEndpoint = v }) setStringField(providerConfig.LoadBalancerCustomEndpoint, func(v string) { providerData.LoadBalancerCustomEndpoint = v }) setStringField(providerConfig.LogMeCustomEndpoint, func(v string) { providerData.LogMeCustomEndpoint = v }) setStringField(providerConfig.RabbitMQCustomEndpoint, func(v string) { providerData.RabbitMQCustomEndpoint = v }) @@ -471,6 +479,7 @@ func (p *Provider) Configure(ctx context.Context, req provider.ConfigureRequest, // DataSources defines the data sources implemented in the provider. func (p *Provider) DataSources(_ context.Context) []func() datasource.DataSource { return []func() datasource.DataSource{ + alb.NewApplicationLoadBalancerDataSource, alertGroup.NewAlertGroupDataSource, cdn.NewDistributionDataSource, cdnCustomDomain.NewCustomDomainDataSource, @@ -570,6 +579,7 @@ func (p *Provider) Resources(_ context.Context) []func() resource.Resource { kmsKey.NewKeyResource, kmsKeyRing.NewKeyRingResource, kmsWrappingKey.NewWrappingKeyResource, + alb.NewApplicationLoadBalancerResource, loadBalancer.NewLoadBalancerResource, loadBalancerObservabilityCredential.NewObservabilityCredentialResource, logMeInstance.NewInstanceResource,