From 9763fe6825c07667622cb8014cd0e4415a2c4f41 Mon Sep 17 00:00:00 2001 From: Denis Mishin Date: Mon, 10 Mar 2025 16:48:18 -0400 Subject: [PATCH] route: add health check support --- docs/data-sources/route.md | 84 +++++ docs/data-sources/routes.md | 84 +++++ docs/resources/route.md | 84 +++++ example/main.tf | 103 ++++++ internal/provider/convert.go | 8 + internal/provider/route.go | 143 +++++++++ internal/provider/route_data_source.go | 140 +++++++++ internal/provider/route_model.go | 415 +++++++++++++++++++++++++ internal/provider/route_model_test.go | 168 ++++++++++ 9 files changed, 1229 insertions(+) diff --git a/docs/data-sources/route.md b/docs/data-sources/route.md index 4f56d31..5a574a2 100644 --- a/docs/data-sources/route.md +++ b/docs/data-sources/route.md @@ -33,6 +33,7 @@ Route data source - `description` (String) Description of the route. - `enable_google_cloud_serverless_authentication` (Boolean) Enable Google Cloud serverless authentication. - `from` (String) From URL. +- `health_checks` (Attributes Set) Health checks for the route. (see [below for nested schema](#nestedatt--health_checks)) - `host_path_regex_rewrite_pattern` (String) Host path regex rewrite pattern. - `host_path_regex_rewrite_substitution` (String) Host path regex rewrite substitution. - `host_rewrite` (String) Host rewrite. @@ -80,6 +81,89 @@ Optional: - `infer_from_ppl` (Boolean) + +### Nested Schema for `health_checks` + +Read-Only: + +- `grpc_health_check` (Attributes) gRPC health check settings. (see [below for nested schema](#nestedatt--health_checks--grpc_health_check)) +- `healthy_threshold` (Number) Number of successes before marking healthy. +- `http_health_check` (Attributes) HTTP health check settings. (see [below for nested schema](#nestedatt--health_checks--http_health_check)) +- `initial_jitter` (String) An optional jitter amount for the first health check. +- `interval` (String) The interval between health checks. +- `interval_jitter` (String) An optional jitter amount for every interval. +- `interval_jitter_percent` (Number) An optional jitter percentage. +- `tcp_health_check` (Attributes) TCP health check settings. (see [below for nested schema](#nestedatt--health_checks--tcp_health_check)) +- `timeout` (String) The time to wait for a health check response. +- `unhealthy_threshold` (Number) Number of failures before marking unhealthy. + + +### Nested Schema for `health_checks.grpc_health_check` + +Read-Only: + +- `authority` (String) Authority header value. +- `service_name` (String) Service name to check. + + + +### Nested Schema for `health_checks.http_health_check` + +Read-Only: + +- `codec_client_type` (String) Application protocol for health checks. +- `expected_statuses` (Attributes Set) Expected status code ranges. (see [below for nested schema](#nestedatt--health_checks--http_health_check--expected_statuses)) +- `host` (String) The host header value. +- `path` (String) The request path. +- `retriable_statuses` (Attributes Set) Retriable status code ranges. (see [below for nested schema](#nestedatt--health_checks--http_health_check--retriable_statuses)) + + +### Nested Schema for `health_checks.http_health_check.expected_statuses` + +Read-Only: + +- `end` (Number) End of status code range. +- `start` (Number) Start of status code range. + + + +### Nested Schema for `health_checks.http_health_check.retriable_statuses` + +Read-Only: + +- `end` (Number) End of status code range. +- `start` (Number) Start of status code range. + + + + +### Nested Schema for `health_checks.tcp_health_check` + +Read-Only: + +- `receive` (Attributes Set) Expected response payloads. (see [below for nested schema](#nestedatt--health_checks--tcp_health_check--receive)) +- `send` (Attributes) Payload to send. (see [below for nested schema](#nestedatt--health_checks--tcp_health_check--send)) + + +### Nested Schema for `health_checks.tcp_health_check.receive` + +Read-Only: + +- `binary_b64` (String) Base64 encoded binary payload. +- `text` (String) Hex encoded payload. + + + +### Nested Schema for `health_checks.tcp_health_check.send` + +Read-Only: + +- `binary_b64` (String) Base64 encoded binary payload. +- `text` (String) Hex encoded payload. + + + + ### Nested Schema for `rewrite_response_headers` diff --git a/docs/data-sources/routes.md b/docs/data-sources/routes.md index 54feb1f..5e38cf3 100644 --- a/docs/data-sources/routes.md +++ b/docs/data-sources/routes.md @@ -45,6 +45,7 @@ Read-Only: - `description` (String) Description of the route. - `enable_google_cloud_serverless_authentication` (Boolean) Enable Google Cloud serverless authentication. - `from` (String) From URL. +- `health_checks` (Attributes Set) Health checks for the route. (see [below for nested schema](#nestedatt--routes--health_checks)) - `host_path_regex_rewrite_pattern` (String) Host path regex rewrite pattern. - `host_path_regex_rewrite_substitution` (String) Host path regex rewrite substitution. - `host_rewrite` (String) Host rewrite. @@ -93,6 +94,89 @@ Optional: - `infer_from_ppl` (Boolean) + +### Nested Schema for `routes.health_checks` + +Read-Only: + +- `grpc_health_check` (Attributes) gRPC health check settings. (see [below for nested schema](#nestedatt--routes--health_checks--grpc_health_check)) +- `healthy_threshold` (Number) Number of successes before marking healthy. +- `http_health_check` (Attributes) HTTP health check settings. (see [below for nested schema](#nestedatt--routes--health_checks--http_health_check)) +- `initial_jitter` (String) An optional jitter amount for the first health check. +- `interval` (String) The interval between health checks. +- `interval_jitter` (String) An optional jitter amount for every interval. +- `interval_jitter_percent` (Number) An optional jitter percentage. +- `tcp_health_check` (Attributes) TCP health check settings. (see [below for nested schema](#nestedatt--routes--health_checks--tcp_health_check)) +- `timeout` (String) The time to wait for a health check response. +- `unhealthy_threshold` (Number) Number of failures before marking unhealthy. + + +### Nested Schema for `routes.health_checks.grpc_health_check` + +Read-Only: + +- `authority` (String) Authority header value. +- `service_name` (String) Service name to check. + + + +### Nested Schema for `routes.health_checks.http_health_check` + +Read-Only: + +- `codec_client_type` (String) Application protocol for health checks. +- `expected_statuses` (Attributes Set) Expected status code ranges. (see [below for nested schema](#nestedatt--routes--health_checks--http_health_check--expected_statuses)) +- `host` (String) The host header value. +- `path` (String) The request path. +- `retriable_statuses` (Attributes Set) Retriable status code ranges. (see [below for nested schema](#nestedatt--routes--health_checks--http_health_check--retriable_statuses)) + + +### Nested Schema for `routes.health_checks.http_health_check.expected_statuses` + +Read-Only: + +- `end` (Number) End of status code range. +- `start` (Number) Start of status code range. + + + +### Nested Schema for `routes.health_checks.http_health_check.retriable_statuses` + +Read-Only: + +- `end` (Number) End of status code range. +- `start` (Number) Start of status code range. + + + + +### Nested Schema for `routes.health_checks.tcp_health_check` + +Read-Only: + +- `receive` (Attributes Set) Expected response payloads. (see [below for nested schema](#nestedatt--routes--health_checks--tcp_health_check--receive)) +- `send` (Attributes) Payload to send. (see [below for nested schema](#nestedatt--routes--health_checks--tcp_health_check--send)) + + +### Nested Schema for `routes.health_checks.tcp_health_check.receive` + +Read-Only: + +- `binary_b64` (String) Base64 encoded binary payload. +- `text` (String) Hex encoded payload. + + + +### Nested Schema for `routes.health_checks.tcp_health_check.send` + +Read-Only: + +- `binary_b64` (String) Base64 encoded binary payload. +- `text` (String) Hex encoded payload. + + + + ### Nested Schema for `routes.rewrite_response_headers` diff --git a/docs/resources/route.md b/docs/resources/route.md index 3bb4365..92138e9 100644 --- a/docs/resources/route.md +++ b/docs/resources/route.md @@ -29,6 +29,7 @@ Route for Pomerium. - `bearer_token_format` (String) Bearer token format. - `description` (String) Description of the route. - `enable_google_cloud_serverless_authentication` (Boolean) Enable Google Cloud serverless authentication. +- `health_checks` (Attributes Set) Health checks for the route. (see [below for nested schema](#nestedatt--health_checks)) - `host_path_regex_rewrite_pattern` (String) Rewrites the Host header according to a regular expression matching the path. - `host_path_regex_rewrite_substitution` (String) Rewrites the Host header according to a regular expression matching the substitution. - `host_rewrite` (String) Rewrites the Host header to a new literal value. @@ -77,6 +78,89 @@ Route for Pomerium. - `id` (String) Unique identifier for the route. + +### Nested Schema for `health_checks` + +Optional: + +- `grpc_health_check` (Attributes) gRPC health check settings. (see [below for nested schema](#nestedatt--health_checks--grpc_health_check)) +- `healthy_threshold` (Number) The number of healthy health checks required before a host is marked healthy. +- `http_health_check` (Attributes) HTTP health check settings. (see [below for nested schema](#nestedatt--health_checks--http_health_check)) +- `initial_jitter` (String) An optional jitter amount in milliseconds. If specified, Envoy will start health checking after for a random time in ms between 0 and initial_jitter. +- `interval` (String) The interval between health checks. +- `interval_jitter` (String) An optional jitter amount in milliseconds. If specified, during every interval Envoy will add interval_jitter to the wait time. +- `interval_jitter_percent` (Number) An optional jitter amount as a percentage of interval_ms. If specified, during every interval Envoy will add interval_ms * interval_jitter_percent / 100 to the wait time. +- `tcp_health_check` (Attributes) TCP health check settings. (see [below for nested schema](#nestedatt--health_checks--tcp_health_check)) +- `timeout` (String) The time to wait for a health check response. If the timeout is reached the health check attempt will be considered a failure. +- `unhealthy_threshold` (Number) The number of unhealthy health checks required before a host is marked unhealthy. + + +### Nested Schema for `health_checks.grpc_health_check` + +Optional: + +- `authority` (String) The value of the :authority header in the gRPC health check request. +- `service_name` (String) An optional service name parameter which will be sent to gRPC service. + + + +### Nested Schema for `health_checks.http_health_check` + +Optional: + +- `codec_client_type` (String) Use specified application protocol for health checks. +- `expected_statuses` (Attributes Set) Specifies a list of HTTP response statuses considered healthy. (see [below for nested schema](#nestedatt--health_checks--http_health_check--expected_statuses)) +- `host` (String) The value of the host header in the HTTP health check request. +- `path` (String) Specifies the HTTP path that will be requested during health checking. +- `retriable_statuses` (Attributes Set) Specifies a list of HTTP response statuses considered retriable. (see [below for nested schema](#nestedatt--health_checks--http_health_check--retriable_statuses)) + + +### Nested Schema for `health_checks.http_health_check.expected_statuses` + +Required: + +- `end` (Number) End of status code range. +- `start` (Number) Start of status code range. + + + +### Nested Schema for `health_checks.http_health_check.retriable_statuses` + +Required: + +- `end` (Number) End of status code range. +- `start` (Number) Start of status code range. + + + + +### Nested Schema for `health_checks.tcp_health_check` + +Optional: + +- `receive` (Attributes Set) When checking the response, 'fuzzy' matching is performed such that each payload block must be found, and in the order specified, but not necessarily contiguous. (see [below for nested schema](#nestedatt--health_checks--tcp_health_check--receive)) +- `send` (Attributes) Empty payloads imply a connect-only health check. (see [below for nested schema](#nestedatt--health_checks--tcp_health_check--send)) + + +### Nested Schema for `health_checks.tcp_health_check.receive` + +Optional: + +- `binary_b64` (String) Base64 encoded binary payload. +- `text` (String) Hex encoded payload. E.g., '000000FF'. + + + +### Nested Schema for `health_checks.tcp_health_check.send` + +Optional: + +- `binary_b64` (String) Base64 encoded binary payload. +- `text` (String) Hex encoded payload. E.g., '000000FF'. + + + + ### Nested Schema for `jwt_groups_filter` diff --git a/example/main.tf b/example/main.tf index 1778bb6..6984596 100644 --- a/example/main.tf +++ b/example/main.tf @@ -244,6 +244,109 @@ resource "pomerium_route" "advanced_route" { show_error_details = true } +# Example route with HTTP health check +resource "pomerium_route" "http_health_check_route" { + name = "http-health-check-route" + namespace_id = pomerium_namespace.test_namespace.id + from = "https://http-health.localhost.pomerium.io" + to = ["https://backend-service.internal"] + policies = [pomerium_policy.test_policy.id] + + # Configure HTTP health check + health_checks = [ + { + timeout = "5s" + interval = "10s" + initial_jitter = "100ms" + interval_jitter = "200ms" + interval_jitter_percent = 5 + unhealthy_threshold = 2 + healthy_threshold = 2 + + http_health_check = { + host = "backend-service.internal" + path = "/health" + codec_client_type = "HTTP2" + expected_statuses = [ + { + start = 200 + end = 300 + } + ] + retriable_statuses = [ + { + start = 500 + end = 503 + } + ] + } + } + ] + + timeout = "30s" + idle_timeout = "5m" + allow_websockets = true + preserve_host_header = true +} + +# Example route with TCP health check +resource "pomerium_route" "tcp_health_check_route" { + name = "tcp-health-check-route" + namespace_id = pomerium_namespace.test_namespace.id + from = "https://tcp-health.localhost.pomerium.io" + to = ["https://tcp-service.internal"] + policies = [pomerium_policy.test_policy.id] + + # Configure TCP health check + health_checks = [ + { + timeout = "3s" + interval = "15s" + unhealthy_threshold = 3 + healthy_threshold = 1 + + tcp_health_check = { + send = { + text = "000000FF" # Hex encoded payload + } + receive = [ + { + text = "0000FFFF" # Expected response + } + ] + } + } + ] +} + +# Example route with gRPC health check +resource "pomerium_route" "grpc_health_check_route" { + name = "grpc-health-check-route" + namespace_id = pomerium_namespace.test_namespace.id + from = "https://grpc-health.localhost.pomerium.io" + to = ["https://grpc-service.internal"] + policies = [pomerium_policy.test_policy.id] + + # Configure gRPC health check + health_checks = [ + { + timeout = "2s" + interval = "5s" + unhealthy_threshold = 2 + healthy_threshold = 1 + + grpc_health_check = { + service_name = "my-grpc-service" + authority = "grpc.example.com" + } + } + ] + + set_request_headers = { + "X-Health-Check" = "enabled" + } +} + # Data source examples data "pomerium_namespaces" "all_namespaces" {} diff --git a/internal/provider/convert.go b/internal/provider/convert.go index 63e644e..4b1e095 100644 --- a/internal/provider/convert.go +++ b/internal/provider/convert.go @@ -329,6 +329,14 @@ func ToBearerTokenFormat(src types.String) *pb.BearerTokenFormat { } } +// UInt32ToInt64OrNull converts a uint32 to types.Int64, returning null if the value is 0 +func UInt32ToInt64OrNull(value uint32) types.Int64 { + if value > 0 { + return types.Int64Value(int64(value)) + } + return types.Int64Null() +} + func ToRouteStringList(ctx context.Context, dst **pb.Route_StringList, src types.Set, diagnostics *diag.Diagnostics) { if src.IsNull() || src.IsUnknown() { *dst = nil diff --git a/internal/provider/route.go b/internal/provider/route.go index ea1c67b..5248b89 100644 --- a/internal/provider/route.go +++ b/internal/provider/route.go @@ -274,6 +274,149 @@ func (r *RouteResource) Schema(_ context.Context, _ resource.SchemaRequest, resp Optional: true, ElementType: types.StringType, }, + "health_checks": schema.SetNestedAttribute{ + Description: "Health checks for the route.", + Optional: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "timeout": schema.StringAttribute{ + Description: "The time to wait for a health check response. If the timeout is reached the health check attempt will be considered a failure.", + Optional: true, + CustomType: timetypes.GoDurationType{}, + }, + "interval": schema.StringAttribute{ + Description: "The interval between health checks.", + Optional: true, + CustomType: timetypes.GoDurationType{}, + }, + "initial_jitter": schema.StringAttribute{ + Description: "An optional jitter amount in milliseconds. If specified, Envoy will start health checking after for a random time in ms between 0 and initial_jitter.", + Optional: true, + CustomType: timetypes.GoDurationType{}, + }, + "interval_jitter": schema.StringAttribute{ + Description: "An optional jitter amount in milliseconds. If specified, during every interval Envoy will add interval_jitter to the wait time.", + Optional: true, + CustomType: timetypes.GoDurationType{}, + }, + "interval_jitter_percent": schema.Int64Attribute{ + Description: "An optional jitter amount as a percentage of interval_ms. If specified, during every interval Envoy will add interval_ms * interval_jitter_percent / 100 to the wait time.", + Optional: true, + }, + "unhealthy_threshold": schema.Int64Attribute{ + Description: "The number of unhealthy health checks required before a host is marked unhealthy.", + Optional: true, + }, + "healthy_threshold": schema.Int64Attribute{ + Description: "The number of healthy health checks required before a host is marked healthy.", + Optional: true, + }, + "http_health_check": schema.SingleNestedAttribute{ + Description: "HTTP health check settings.", + Optional: true, + Attributes: map[string]schema.Attribute{ + "host": schema.StringAttribute{ + Description: "The value of the host header in the HTTP health check request.", + Optional: true, + }, + "path": schema.StringAttribute{ + Description: "Specifies the HTTP path that will be requested during health checking.", + Optional: true, + }, + "expected_statuses": schema.SetNestedAttribute{ + Description: "Specifies a list of HTTP response statuses considered healthy.", + Optional: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "start": schema.Int64Attribute{ + Description: "Start of status code range.", + Required: true, + }, + "end": schema.Int64Attribute{ + Description: "End of status code range.", + Required: true, + }, + }, + }, + }, + "retriable_statuses": schema.SetNestedAttribute{ + Description: "Specifies a list of HTTP response statuses considered retriable.", + Optional: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "start": schema.Int64Attribute{ + Description: "Start of status code range.", + Required: true, + }, + "end": schema.Int64Attribute{ + Description: "End of status code range.", + Required: true, + }, + }, + }, + }, + "codec_client_type": schema.StringAttribute{ + Description: "Use specified application protocol for health checks.", + Optional: true, + Validators: []validator.String{ + stringvalidator.OneOf("HTTP1", "HTTP2"), + }, + }, + }, + }, + "tcp_health_check": schema.SingleNestedAttribute{ + Description: "TCP health check settings.", + Optional: true, + Attributes: map[string]schema.Attribute{ + "send": schema.SingleNestedAttribute{ + Description: "Empty payloads imply a connect-only health check.", + Optional: true, + Attributes: map[string]schema.Attribute{ + "text": schema.StringAttribute{ + Description: "Hex encoded payload. E.g., '000000FF'.", + Optional: true, + }, + "binary_b64": schema.StringAttribute{ + Description: "Base64 encoded binary payload.", + Optional: true, + }, + }, + }, + "receive": schema.SetNestedAttribute{ + Description: "When checking the response, 'fuzzy' matching is performed such that each payload block must be found, and in the order specified, but not necessarily contiguous.", + Optional: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "text": schema.StringAttribute{ + Description: "Hex encoded payload. E.g., '000000FF'.", + Optional: true, + }, + "binary_b64": schema.StringAttribute{ + Description: "Base64 encoded binary payload.", + Optional: true, + }, + }, + }, + }, + }, + }, + "grpc_health_check": schema.SingleNestedAttribute{ + Description: "gRPC health check settings.", + Optional: true, + Attributes: map[string]schema.Attribute{ + "service_name": schema.StringAttribute{ + Description: "An optional service name parameter which will be sent to gRPC service.", + Optional: true, + }, + "authority": schema.StringAttribute{ + Description: "The value of the :authority header in the gRPC health check request.", + Optional: true, + }, + }, + }, + }, + }, + }, }, } } diff --git a/internal/provider/route_data_source.go b/internal/provider/route_data_source.go index 3c27d62..08090db 100644 --- a/internal/provider/route_data_source.go +++ b/internal/provider/route_data_source.go @@ -237,6 +237,146 @@ func getRouteDataSourceAttributes(idRequired bool) map[string]schema.Attribute { Computed: true, ElementType: types.StringType, }, + "health_checks": schema.SetNestedAttribute{ + Description: "Health checks for the route.", + Computed: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "timeout": schema.StringAttribute{ + Description: "The time to wait for a health check response.", + Computed: true, + CustomType: timetypes.GoDurationType{}, + }, + "interval": schema.StringAttribute{ + Description: "The interval between health checks.", + Computed: true, + CustomType: timetypes.GoDurationType{}, + }, + "initial_jitter": schema.StringAttribute{ + Description: "An optional jitter amount for the first health check.", + Computed: true, + CustomType: timetypes.GoDurationType{}, + }, + "interval_jitter": schema.StringAttribute{ + Description: "An optional jitter amount for every interval.", + Computed: true, + CustomType: timetypes.GoDurationType{}, + }, + "interval_jitter_percent": schema.Int64Attribute{ + Description: "An optional jitter percentage.", + Computed: true, + }, + "unhealthy_threshold": schema.Int64Attribute{ + Description: "Number of failures before marking unhealthy.", + Computed: true, + }, + "healthy_threshold": schema.Int64Attribute{ + Description: "Number of successes before marking healthy.", + Computed: true, + }, + "http_health_check": schema.SingleNestedAttribute{ + Description: "HTTP health check settings.", + Computed: true, + Attributes: map[string]schema.Attribute{ + "host": schema.StringAttribute{ + Description: "The host header value.", + Computed: true, + }, + "path": schema.StringAttribute{ + Description: "The request path.", + Computed: true, + }, + "expected_statuses": schema.SetNestedAttribute{ + Description: "Expected status code ranges.", + Computed: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "start": schema.Int64Attribute{ + Description: "Start of status code range.", + Computed: true, + }, + "end": schema.Int64Attribute{ + Description: "End of status code range.", + Computed: true, + }, + }, + }, + }, + "retriable_statuses": schema.SetNestedAttribute{ + Description: "Retriable status code ranges.", + Computed: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "start": schema.Int64Attribute{ + Description: "Start of status code range.", + Computed: true, + }, + "end": schema.Int64Attribute{ + Description: "End of status code range.", + Computed: true, + }, + }, + }, + }, + "codec_client_type": schema.StringAttribute{ + Description: "Application protocol for health checks.", + Computed: true, + }, + }, + }, + "tcp_health_check": schema.SingleNestedAttribute{ + Description: "TCP health check settings.", + Computed: true, + Attributes: map[string]schema.Attribute{ + "send": schema.SingleNestedAttribute{ + Description: "Payload to send.", + Computed: true, + Attributes: map[string]schema.Attribute{ + "text": schema.StringAttribute{ + Description: "Hex encoded payload.", + Computed: true, + }, + "binary_b64": schema.StringAttribute{ + Description: "Base64 encoded binary payload.", + Computed: true, + }, + }, + }, + "receive": schema.SetNestedAttribute{ + Description: "Expected response payloads.", + Computed: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "text": schema.StringAttribute{ + Description: "Hex encoded payload.", + Computed: true, + }, + "binary_b64": schema.StringAttribute{ + Description: "Base64 encoded binary payload.", + Computed: true, + }, + }, + }, + }, + }, + }, + "grpc_health_check": schema.SingleNestedAttribute{ + Description: "gRPC health check settings.", + Computed: true, + Attributes: map[string]schema.Attribute{ + "service_name": schema.StringAttribute{ + Description: "Service name to check.", + Computed: true, + }, + "authority": schema.StringAttribute{ + Description: "Authority header value.", + Computed: true, + }, + }, + }, + }, + }, + }, } } diff --git a/internal/provider/route_model.go b/internal/provider/route_model.go index 4eab368..d7e6381 100644 --- a/internal/provider/route_model.go +++ b/internal/provider/route_model.go @@ -2,10 +2,12 @@ package provider import ( "context" + "encoding/base64" "github.com/hashicorp/terraform-plugin-framework-timetypes/timetypes" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/pomerium/enterprise-client-go/pb" @@ -60,6 +62,7 @@ type RouteModel struct { TLSUpstreamAllowRenegotiation types.Bool `tfsdk:"tls_upstream_allow_renegotiation"` TLSUpstreamServerName types.String `tfsdk:"tls_upstream_server_name"` To types.Set `tfsdk:"to"` + HealthChecks types.Set `tfsdk:"health_checks"` } var rewriteHeaderAttrTypes = map[string]attr.Type{ @@ -127,6 +130,415 @@ func RewriteHeaderObjectType() attr.Type { return types.ObjectType{AttrTypes: rewriteHeaderAttrTypes} } +// Type definitions for health check objects +func HealthCheckPayloadObjectType() types.ObjectType { + return types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "text": types.StringType, + "binary_b64": types.StringType, + }, + } +} + +func Int64RangeObjectType() types.ObjectType { + return types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "start": types.Int64Type, + "end": types.Int64Type, + }, + } +} + +func HTTPHealthCheckObjectType() types.ObjectType { + return types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "host": types.StringType, + "path": types.StringType, + "expected_statuses": types.SetType{ElemType: Int64RangeObjectType()}, + "retriable_statuses": types.SetType{ElemType: Int64RangeObjectType()}, + "codec_client_type": types.StringType, + }, + } +} + +func TCPHealthCheckObjectType() types.ObjectType { + return types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "send": HealthCheckPayloadObjectType(), + "receive": types.SetType{ElemType: HealthCheckPayloadObjectType()}, + }, + } +} + +func GrpcHealthCheckObjectType() types.ObjectType { + return types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "service_name": types.StringType, + "authority": types.StringType, + }, + } +} + +func HealthCheckObjectType() types.ObjectType { + return types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "timeout": timetypes.GoDurationType{}, + "interval": timetypes.GoDurationType{}, + "initial_jitter": timetypes.GoDurationType{}, + "interval_jitter": timetypes.GoDurationType{}, + "interval_jitter_percent": types.Int64Type, + "unhealthy_threshold": types.Int64Type, + "healthy_threshold": types.Int64Type, + "http_health_check": HTTPHealthCheckObjectType(), + "tcp_health_check": TCPHealthCheckObjectType(), + "grpc_health_check": GrpcHealthCheckObjectType(), + }, + } +} + +// Convert health check payload between Terraform and protobuf +func payloadFromPB(payload *pb.HealthCheck_Payload) (types.Object, diag.Diagnostics) { + var diags diag.Diagnostics + + if payload == nil { + return types.ObjectNull(HealthCheckPayloadObjectType().AttrTypes), diags + } + + attrs := map[string]attr.Value{ + "text": types.StringNull(), + "binary_b64": types.StringNull(), + } + + switch p := payload.GetPayload().(type) { + case *pb.HealthCheck_Payload_Text: + attrs["text"] = types.StringValue(p.Text) + case *pb.HealthCheck_Payload_Binary: + attrs["binary_b64"] = types.StringValue(base64.StdEncoding.EncodeToString(p.Binary)) + } + + return types.ObjectValue(HealthCheckPayloadObjectType().AttrTypes, attrs) +} + +func payloadToPB(obj types.Object) (*pb.HealthCheck_Payload, diag.Diagnostics) { + var diags diag.Diagnostics + + if obj.IsNull() { + return nil, diags + } + + attrs := obj.Attributes() + payload := &pb.HealthCheck_Payload{} + + text := attrs["text"].(types.String) + binaryB64 := attrs["binary_b64"].(types.String) + + if !text.IsNull() { + payload.Payload = &pb.HealthCheck_Payload_Text{ + Text: text.ValueString(), + } + } else if !binaryB64.IsNull() { + binaryData, err := base64.StdEncoding.DecodeString(binaryB64.ValueString()) + if err != nil { + diags.AddError("Invalid base64 data", "Could not decode base64 binary payload: "+err.Error()) + return nil, diags + } + payload.Payload = &pb.HealthCheck_Payload_Binary{ + Binary: binaryData, + } + } + + return payload, diags +} + +// Convert Int64Range between Terraform and protobuf +func int64RangeFromPB(r *pb.Int64Range) (types.Object, diag.Diagnostics) { + var diags diag.Diagnostics + + if r == nil { + return types.ObjectNull(Int64RangeObjectType().AttrTypes), diags + } + + attrs := map[string]attr.Value{ + "start": types.Int64Value(r.Start), + "end": types.Int64Value(r.End), + } + + return types.ObjectValue(Int64RangeObjectType().AttrTypes, attrs) +} + +func int64RangeToPB(obj types.Object) (*pb.Int64Range, diag.Diagnostics) { + var diags diag.Diagnostics + + if obj.IsNull() { + return nil, diags + } + + attrs := obj.Attributes() + r := &pb.Int64Range{ + Start: attrs["start"].(types.Int64).ValueInt64(), + End: attrs["end"].(types.Int64).ValueInt64(), + } + + return r, diags +} + +// Convert HealthCheck between Terraform and protobuf +func HealthCheckFromPB(hc *pb.HealthCheck) (types.Object, diag.Diagnostics) { + var diags diag.Diagnostics + + if hc == nil { + return types.ObjectNull(HealthCheckObjectType().AttrTypes), diags + } + + attrs := map[string]attr.Value{ + "timeout": FromDuration(hc.Timeout), + "interval": FromDuration(hc.Interval), + "initial_jitter": FromDuration(hc.InitialJitter), + "interval_jitter": FromDuration(hc.IntervalJitter), + "interval_jitter_percent": UInt32ToInt64OrNull(hc.IntervalJitterPercent), + "unhealthy_threshold": UInt32ToInt64OrNull(hc.UnhealthyThreshold), + "healthy_threshold": UInt32ToInt64OrNull(hc.HealthyThreshold), + "http_health_check": types.ObjectNull(HTTPHealthCheckObjectType().AttrTypes), + "tcp_health_check": types.ObjectNull(TCPHealthCheckObjectType().AttrTypes), + "grpc_health_check": types.ObjectNull(GrpcHealthCheckObjectType().AttrTypes), + } + + if httpHc := hc.GetHttpHealthCheck(); httpHc != nil { + expectedStatusesElem := []attr.Value{} + for _, status := range httpHc.ExpectedStatuses { + statusObj, diagsStatus := int64RangeFromPB(status) + diags.Append(diagsStatus...) + expectedStatusesElem = append(expectedStatusesElem, statusObj) + } + expectedStatuses, _ := types.SetValue(Int64RangeObjectType(), expectedStatusesElem) + + retriableStatusesElem := []attr.Value{} + for _, status := range httpHc.RetriableStatuses { + statusObj, diagsStatus := int64RangeFromPB(status) + diags.Append(diagsStatus...) + retriableStatusesElem = append(retriableStatusesElem, statusObj) + } + retriableStatuses, _ := types.SetValue(Int64RangeObjectType(), retriableStatusesElem) + + httpAttrs := map[string]attr.Value{ + "host": types.StringValue(httpHc.Host), + "path": types.StringValue(httpHc.Path), + "expected_statuses": expectedStatuses, + "retriable_statuses": retriableStatuses, + "codec_client_type": types.StringValue(httpHc.CodecClientType.String()), + } + + httpHealthCheck, _ := types.ObjectValue(HTTPHealthCheckObjectType().AttrTypes, httpAttrs) + attrs["http_health_check"] = httpHealthCheck + } else if tcpHc := hc.GetTcpHealthCheck(); tcpHc != nil { + sendPayload, diagsSend := payloadFromPB(tcpHc.Send) + diags.Append(diagsSend...) + + receiveElements := []attr.Value{} + for _, payload := range tcpHc.Receive { + payloadObj, diagsPayload := payloadFromPB(payload) + diags.Append(diagsPayload...) + receiveElements = append(receiveElements, payloadObj) + } + receiveSet, _ := types.SetValue(HealthCheckPayloadObjectType(), receiveElements) + + tcpAttrs := map[string]attr.Value{ + "send": sendPayload, + "receive": receiveSet, + } + + tcpHealthCheck, _ := types.ObjectValue(TCPHealthCheckObjectType().AttrTypes, tcpAttrs) + attrs["tcp_health_check"] = tcpHealthCheck + } else if grpcHc := hc.GetGrpcHealthCheck(); grpcHc != nil { + grpcAttrs := map[string]attr.Value{ + "service_name": types.StringValue(grpcHc.ServiceName), + "authority": types.StringValue(grpcHc.Authority), + } + + grpcHealthCheck, _ := types.ObjectValue(GrpcHealthCheckObjectType().AttrTypes, grpcAttrs) + attrs["grpc_health_check"] = grpcHealthCheck + } else { + diags.AddAttributeError(path.Root("health_checks"), "health check not specified", "must specify one of http_health_check, tcp_health_check, or grpc_health_check") + } + + return types.ObjectValue(HealthCheckObjectType().AttrTypes, attrs) +} + +func HealthCheckToPB(obj types.Object) (*pb.HealthCheck, diag.Diagnostics) { + var diags diag.Diagnostics + + if obj.IsNull() { + return nil, diags + } + + attrs := obj.Attributes() + hc := &pb.HealthCheck{} + + // Convert basic fields + timeout := attrs["timeout"].(timetypes.GoDuration) + interval := attrs["interval"].(timetypes.GoDuration) + initialJitter := attrs["initial_jitter"].(timetypes.GoDuration) + intervalJitter := attrs["interval_jitter"].(timetypes.GoDuration) + intervalJitterPercent := attrs["interval_jitter_percent"].(types.Int64) + unhealthyThreshold := attrs["unhealthy_threshold"].(types.Int64) + healthyThreshold := attrs["healthy_threshold"].(types.Int64) + + ToDuration(&hc.Timeout, timeout, &diags) + ToDuration(&hc.Interval, interval, &diags) + ToDuration(&hc.InitialJitter, initialJitter, &diags) + ToDuration(&hc.IntervalJitter, intervalJitter, &diags) + if !intervalJitterPercent.IsNull() { + hc.IntervalJitterPercent = uint32(intervalJitterPercent.ValueInt64()) + } + if !unhealthyThreshold.IsNull() { + hc.UnhealthyThreshold = uint32(unhealthyThreshold.ValueInt64()) + } + if !healthyThreshold.IsNull() { + hc.HealthyThreshold = uint32(healthyThreshold.ValueInt64()) + } + + httpHc := attrs["http_health_check"].(types.Object) + tcpHc := attrs["tcp_health_check"].(types.Object) + grpcHc := attrs["grpc_health_check"].(types.Object) + + if !httpHc.IsNull() { + httpAttrs := httpHc.Attributes() + httpHealthCheck := &pb.HealthCheck_HttpHealthCheck{} + + host := httpAttrs["host"].(types.String) + path := httpAttrs["path"].(types.String) + codecType := httpAttrs["codec_client_type"].(types.String) + expectedStatuses := httpAttrs["expected_statuses"].(types.Set) + retriableStatuses := httpAttrs["retriable_statuses"].(types.Set) + + if !host.IsNull() { + httpHealthCheck.Host = host.ValueString() + } + if !path.IsNull() { + httpHealthCheck.Path = path.ValueString() + } + if !codecType.IsNull() { + // Handle codec client type enum properly + switch codecType.ValueString() { + case "HTTP1": + httpHealthCheck.CodecClientType = pb.CodecClientType_HTTP1 + case "HTTP2": + httpHealthCheck.CodecClientType = pb.CodecClientType_HTTP2 + default: + // Default to HTTP1 if not specified or invalid + httpHealthCheck.CodecClientType = pb.CodecClientType_HTTP1 + } + } + + if !expectedStatuses.IsNull() { + for _, elem := range expectedStatuses.Elements() { + obj := elem.(types.Object) + statusRange, diagsRange := int64RangeToPB(obj) + diags.Append(diagsRange...) + httpHealthCheck.ExpectedStatuses = append(httpHealthCheck.ExpectedStatuses, statusRange) + } + } + + if !retriableStatuses.IsNull() { + for _, elem := range retriableStatuses.Elements() { + obj := elem.(types.Object) + statusRange, diagsRange := int64RangeToPB(obj) + diags.Append(diagsRange...) + httpHealthCheck.RetriableStatuses = append(httpHealthCheck.RetriableStatuses, statusRange) + } + } + + hc.HealthChecker = &pb.HealthCheck_HttpHealthCheck_{ + HttpHealthCheck: httpHealthCheck, + } + } else if !tcpHc.IsNull() { + tcpAttrs := tcpHc.Attributes() + tcpHealthCheck := &pb.HealthCheck_TcpHealthCheck{} + + send := tcpAttrs["send"].(types.Object) + receive := tcpAttrs["receive"].(types.Set) + + if !send.IsNull() { + sendPayload, diagsSend := payloadToPB(send) + diags.Append(diagsSend...) + tcpHealthCheck.Send = sendPayload + } + + if !receive.IsNull() { + for _, elem := range receive.Elements() { + obj := elem.(types.Object) + payload, diagsPayload := payloadToPB(obj) + diags.Append(diagsPayload...) + tcpHealthCheck.Receive = append(tcpHealthCheck.Receive, payload) + } + } + + hc.HealthChecker = &pb.HealthCheck_TcpHealthCheck_{ + TcpHealthCheck: tcpHealthCheck, + } + } else if !grpcHc.IsNull() { + grpcAttrs := grpcHc.Attributes() + grpcHealthCheck := &pb.HealthCheck_GrpcHealthCheck{} + + serviceName := grpcAttrs["service_name"].(types.String) + authority := grpcAttrs["authority"].(types.String) + + if !serviceName.IsNull() { + grpcHealthCheck.ServiceName = serviceName.ValueString() + } + if !authority.IsNull() { + grpcHealthCheck.Authority = authority.ValueString() + } + + hc.HealthChecker = &pb.HealthCheck_GrpcHealthCheck_{ + GrpcHealthCheck: grpcHealthCheck, + } + } else { + diags.AddAttributeError(path.Root("health_checks"), "health check not specified", "must specify one of http_health_check, tcp_health_check, or grpc_health_check") + } + + return hc, diags +} + +// Convert health checks between Terraform and protobuf +func healthChecksFromPB(dst *types.Set, src []*pb.HealthCheck, diags *diag.Diagnostics) { + if len(src) == 0 { + *dst = types.SetNull(HealthCheckObjectType()) + return + } + + elements := make([]attr.Value, 0, len(src)) + for _, hc := range src { + healthCheck, diagsHc := HealthCheckFromPB(hc) + diags.Append(diagsHc...) + elements = append(elements, healthCheck) + } + + result, diagsSet := types.SetValue(HealthCheckObjectType(), elements) + diags.Append(diagsSet...) + *dst = result +} + +func healthChecksToPB(dst *[]*pb.HealthCheck, src types.Set, diags *diag.Diagnostics) { + if src.IsNull() { + return + } + + elements := src.Elements() + healthChecks := make([]*pb.HealthCheck, 0, len(elements)) + + for _, element := range elements { + obj := element.(types.Object) + hc, diagsHc := HealthCheckToPB(obj) + diags.Append(diagsHc...) + if hc != nil { + healthChecks = append(healthChecks, hc) + } + } + + *dst = healthChecks +} + func ConvertRouteToPB( ctx context.Context, src *RouteResourceModel, @@ -184,6 +596,7 @@ func ConvertRouteToPB( ToRouteStringList(ctx, &pbRoute.IdpAccessTokenAllowedAudiences, src.IDPAccessTokenAllowedAudiences, &diagnostics) pbRoute.OriginatorId = OriginatorID OptionalEnumValueToPB(&pbRoute.LoadBalancingPolicy, src.LoadBalancingPolicy, "LOAD_BALANCING_POLICY", &diagnostics) + healthChecksToPB(&pbRoute.HealthChecks, src.HealthChecks, &diagnostics) return pbRoute, diagnostics } @@ -244,5 +657,7 @@ func ConvertRouteFromPB( dst.BearerTokenFormat = FromBearerTokenFormat(src.BearerTokenFormat) dst.IDPAccessTokenAllowedAudiences = FromStringList(src.IdpAccessTokenAllowedAudiences) dst.LoadBalancingPolicy = OptionalEnumValueFromPB(src.LoadBalancingPolicy, "LOAD_BALANCING_POLICY") + healthChecksFromPB(&dst.HealthChecks, src.HealthChecks, &diagnostics) + return diagnostics } diff --git a/internal/provider/route_model_test.go b/internal/provider/route_model_test.go index 8e9f3a6..5353eae 100644 --- a/internal/provider/route_model_test.go +++ b/internal/provider/route_model_test.go @@ -78,6 +78,64 @@ func TestConvertRoute(t *testing.T) { InferFromPpl: ptr(true), }, OriginatorId: provider.OriginatorID, + HealthChecks: []*pb.HealthCheck{ + { + Timeout: durationpb.New(5 * time.Second), + Interval: durationpb.New(10 * time.Second), + InitialJitter: durationpb.New(100 * time.Millisecond), + IntervalJitter: durationpb.New(200 * time.Millisecond), + IntervalJitterPercent: 5, + UnhealthyThreshold: 2, + HealthyThreshold: 2, + HealthChecker: &pb.HealthCheck_HttpHealthCheck_{ + HttpHealthCheck: &pb.HealthCheck_HttpHealthCheck{ + Host: "health.example.com", + Path: "/health", + CodecClientType: pb.CodecClientType_HTTP2, + ExpectedStatuses: []*pb.Int64Range{ + {Start: 200, End: 300}, + }, + RetriableStatuses: []*pb.Int64Range{ + {Start: 500, End: 501}, + }, + }, + }, + }, + { + Timeout: durationpb.New(3 * time.Second), + Interval: durationpb.New(15 * time.Second), + UnhealthyThreshold: 3, + HealthyThreshold: 1, + HealthChecker: &pb.HealthCheck_TcpHealthCheck_{ + TcpHealthCheck: &pb.HealthCheck_TcpHealthCheck{ + Send: &pb.HealthCheck_Payload{ + Payload: &pb.HealthCheck_Payload_Text{ + Text: "000000FF", + }, + }, + Receive: []*pb.HealthCheck_Payload{ + { + Payload: &pb.HealthCheck_Payload_Text{ + Text: "0000FFFF", + }, + }, + }, + }, + }, + }, + { + Timeout: durationpb.New(2 * time.Second), + Interval: durationpb.New(5 * time.Second), + UnhealthyThreshold: 2, + HealthyThreshold: 1, + HealthChecker: &pb.HealthCheck_GrpcHealthCheck_{ + GrpcHealthCheck: &pb.HealthCheck_GrpcHealthCheck{ + ServiceName: "my-service", + Authority: "grpc.example.com", + }, + }, + }, + }, } tfRoute := provider.RouteModel{ @@ -153,6 +211,116 @@ func TestConvertRoute(t *testing.T) { }, )}, ), + HealthChecks: types.SetValueMust( + provider.HealthCheckObjectType(), + []attr.Value{ + types.ObjectValueMust( + provider.HealthCheckObjectType().AttrTypes, + map[string]attr.Value{ + "timeout": timetypes.NewGoDurationValue(5 * time.Second), + "interval": timetypes.NewGoDurationValue(10 * time.Second), + "initial_jitter": timetypes.NewGoDurationValue(100 * time.Millisecond), + "interval_jitter": timetypes.NewGoDurationValue(200 * time.Millisecond), + "interval_jitter_percent": types.Int64Value(5), + "unhealthy_threshold": types.Int64Value(2), + "healthy_threshold": types.Int64Value(2), + "tcp_health_check": types.ObjectNull(provider.TCPHealthCheckObjectType().AttrTypes), + "grpc_health_check": types.ObjectNull(provider.GrpcHealthCheckObjectType().AttrTypes), + "http_health_check": types.ObjectValueMust( + provider.HTTPHealthCheckObjectType().AttrTypes, + map[string]attr.Value{ + "host": types.StringValue("health.example.com"), + "path": types.StringValue("/health"), + "codec_client_type": types.StringValue("HTTP2"), + "expected_statuses": types.SetValueMust( + provider.Int64RangeObjectType(), + []attr.Value{ + types.ObjectValueMust( + provider.Int64RangeObjectType().AttrTypes, + map[string]attr.Value{ + "start": types.Int64Value(200), + "end": types.Int64Value(300), + }, + ), + }, + ), + "retriable_statuses": types.SetValueMust( + provider.Int64RangeObjectType(), + []attr.Value{ + types.ObjectValueMust( + provider.Int64RangeObjectType().AttrTypes, + map[string]attr.Value{ + "start": types.Int64Value(500), + "end": types.Int64Value(501), + }, + ), + }, + ), + }, + ), + }, + ), + types.ObjectValueMust( + provider.HealthCheckObjectType().AttrTypes, + map[string]attr.Value{ + "timeout": timetypes.NewGoDurationValue(3 * time.Second), + "interval": timetypes.NewGoDurationValue(15 * time.Second), + "initial_jitter": timetypes.NewGoDurationNull(), + "interval_jitter": timetypes.NewGoDurationNull(), + "interval_jitter_percent": types.Int64Null(), + "unhealthy_threshold": types.Int64Value(3), + "healthy_threshold": types.Int64Value(1), + "http_health_check": types.ObjectNull(provider.HTTPHealthCheckObjectType().AttrTypes), + "grpc_health_check": types.ObjectNull(provider.GrpcHealthCheckObjectType().AttrTypes), + "tcp_health_check": types.ObjectValueMust( + provider.TCPHealthCheckObjectType().AttrTypes, + map[string]attr.Value{ + "send": types.ObjectValueMust( + provider.HealthCheckPayloadObjectType().AttrTypes, + map[string]attr.Value{ + "text": types.StringValue("000000FF"), + "binary_b64": types.StringNull(), + }, + ), + "receive": types.SetValueMust( + provider.HealthCheckPayloadObjectType(), + []attr.Value{ + types.ObjectValueMust( + provider.HealthCheckPayloadObjectType().AttrTypes, + map[string]attr.Value{ + "text": types.StringValue("0000FFFF"), + "binary_b64": types.StringNull(), + }, + ), + }, + ), + }, + ), + }, + ), + types.ObjectValueMust( + provider.HealthCheckObjectType().AttrTypes, + map[string]attr.Value{ + "timeout": timetypes.NewGoDurationValue(2 * time.Second), + "interval": timetypes.NewGoDurationValue(5 * time.Second), + "initial_jitter": timetypes.NewGoDurationNull(), + "interval_jitter": timetypes.NewGoDurationNull(), + "interval_jitter_percent": types.Int64Null(), + "unhealthy_threshold": types.Int64Value(2), + "healthy_threshold": types.Int64Value(1), + "http_health_check": types.ObjectNull(provider.HTTPHealthCheckObjectType().AttrTypes), + "tcp_health_check": types.ObjectNull(provider.TCPHealthCheckObjectType().AttrTypes), + "grpc_health_check": types.ObjectValueMust( + provider.GrpcHealthCheckObjectType().AttrTypes, + map[string]attr.Value{ + "service_name": types.StringValue("my-service"), + "authority": types.StringValue("grpc.example.com"), + }, + ), + }, + ), + }, + ), } t.Run("pb to tf", func(t *testing.T) {