diff --git a/docs/getting-started.md b/docs/getting-started.md index 2c8cce6..e46057f 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -334,41 +334,21 @@ What exactly fails over, and what was measured, is in [High availability](archit ### Kubernetes: DNS automation for Gateway API resources -For Gateway API resources (for example Envoy Gateway with `Gateway` + `HTTPRoute`), use DNS records directly via `stackit_dns_record_set` until native provider support for `extensions.dns.gatewayApi` is available. - -For the existing sample content in this repository (`landing_zone_sample_gateway` + `landing_zone_sample_http_route` in `src/_landing-zone-kubernetes.tf`), the DNS record is created automatically based on the Envoy Gateway LoadBalancer endpoint discovered via `kubernetes_resources`. - -Implementation pattern: +Native `extensions.dns.gateway_api` support is available in the STACKIT Terraform provider since v0.109.0 and is enabled by default whenever the SKE DNS extension has at least one zone. Disable it explicitly if the cluster does not use Gateway API resources: ```hcl -# Discover Envoy-managed LoadBalancer service endpoint for each sample gateway -data "kubernetes_resources" "landing_zone_sample_gateway_service" { - provider = kubernetes.platform - - api_version = "v1" - kind = "Service" - namespace = "envoy-gateway-system" - label_selector = "gateway.envoyproxy.io/owning-gateway-name=,gateway.envoyproxy.io/owning-gateway-namespace=" -} - -# Create A or CNAME record depending on endpoint type -resource "stackit_dns_record_set" "landing_zone_sample_gateway" { - project_id = module.landing_zone["corp-exmpl"].project_id - zone_id = module.landing_zone["corp-exmpl"].dns_zone_id - - name = "app.${module.landing_zone["corp-exmpl"].dns_zone_dns_name}" - type = local.endpoint.ip != null ? "A" : "CNAME" - ttl = 60 - - records = [coalesce(local.endpoint.ip, local.endpoint.hostname)] - - lifecycle { - precondition { - condition = local.endpoint.ip != null || local.endpoint.hostname != null - error_message = "Gateway load balancer endpoint is not available yet for DNS record creation." +platform_kubernetes = { + my-cluster = { + dns = { + enabled = true + gateway_api = false } + # ... } } ``` -This ensures a stable, Terraform-managed DNS path without external scripts until provider-native `gatewayApi` DNS extension support is available. +With the default `gateway_api = true`, the SKE DNS extension configures ExternalDNS to handle Gateway API resources (`Gateway`, `HTTPRoute`) automatically. You no longer need to manage DNS records or discover LoadBalancer endpoints manually. + +> [!NOTE] +> The SKE DNS extension does not install Gateway API CRDs. The optional Envoy Gateway demo installs them automatically. For other Gateway API implementations, install compatible CRDs separately. Once the CRDs are available, ExternalDNS is configured during the next cluster reconcile; no additional OpenTofu apply is required. diff --git a/src/README.md b/src/README.md index a27eda9..bff17b0 100644 --- a/src/README.md +++ b/src/README.md @@ -19,7 +19,6 @@ | ---- | ------- | | [helm.platform](#provider\_helm.platform) | 3.3.0 | | [kubernetes.platform](#provider\_kubernetes.platform) | 3.2.1 | -| [stackit](#provider\_stackit) | 0.114.0 | | [terraform](#provider\_terraform) | n/a | | [vault](#provider\_vault) | 5.11.0 | @@ -54,10 +53,8 @@ | [kubernetes_secret_v1.landing_zone_user_token](https://registry.terraform.io/providers/hashicorp/kubernetes/3.2.1/docs/resources/secret_v1) | resource | | [kubernetes_service_account_v1.landing_zone_user](https://registry.terraform.io/providers/hashicorp/kubernetes/3.2.1/docs/resources/service_account_v1) | resource | | [kubernetes_service_v1.landing_zone_sample_load](https://registry.terraform.io/providers/hashicorp/kubernetes/3.2.1/docs/resources/service_v1) | resource | -| [stackit_dns_record_set.landing_zone_sample_gateway](https://registry.terraform.io/providers/stackitcloud/stackit/0.114.0/docs/resources/dns_record_set) | resource | | [terraform_data.firewall_api_bootstrap](https://registry.terraform.io/providers/hashicorp/terraform/latest/docs/resources/data) | resource | | [vault_kv_secret_v2.firewall_api_credentials](https://registry.terraform.io/providers/hashicorp/vault/5.11.0/docs/resources/kv_secret_v2) | resource | -| [kubernetes_resources.landing_zone_sample_gateway_service](https://registry.terraform.io/providers/hashicorp/kubernetes/3.2.1/docs/data-sources/resources) | data source | ## Inputs @@ -83,7 +80,7 @@ | [organization\_id](#input\_organization\_id) | Container ID of the root organization. | `string` | n/a | yes | | [organization\_owners](#input\_organization\_owners) | List of organization owners. | `list(string)` | `[]` | no | | [owner\_email](#input\_owner\_email) | Email address of the owner. Required for STACKIT resource manager. | `string` | n/a | yes | -| [platform\_kubernetes](#input\_platform\_kubernetes) | Map of central, region-scoped platform Kubernetes deployments. Empty map skips deployment. |
map(object({
region = string
network = optional(object({
sna_enabled = optional(bool, false)
sna_network_area_id = optional(string, null)
firewall_next_hop_ip = optional(string, null)
sna_network_prefix_length = optional(number, 24)
}), {})
dns = optional(object({
enabled = optional(bool, true)
create_zones = optional(bool, true)
zones = optional(list(string), [])
}), {})
observability = optional(object({
enabled = optional(bool, true)
plan_name = optional(string, "Observability-Starter-EU01")
acl = optional(list(string), [])
name = optional(string, null)
}), {})
encrypted_volumes = optional(object({
enabled = optional(bool, false)
storage_class_name = optional(string, "stackit-encrypted-premium")
kms_keyring_name = optional(string, "ske-volume-keyring")
kms_key_name = optional(string, "ske-volume-key")
kms_key_version = optional(string, "1")
}), {})
debug_bastion = optional(object({
enabled = optional(bool, false)
name = optional(string, null)
availability_zone = optional(string, null)
machine_type = optional(string, "g2i.1")
image_id = optional(string, "7b10e105-295b-4369-b6e0-567ec940a02b")
boot_volume_size = optional(number, 20)
ssh_public_key = optional(string, null)
ssh_public_key_path = optional(string, "~/.ssh/id_rsa.pub")
ssh_allowed_cidrs = optional(list(string), ["0.0.0.0/0"])
assign_public_ip = optional(bool, true)
install_kubectl = optional(bool, true)
}), {})
role_assignments = optional(list(object({
role = string
subject = string
})), [])
cluster = object({
name = string
kubernetes_version_min = optional(string, null)
node_pools = optional(list(object({
name = string
machine_type = string
minimum = number
maximum = number
availability_zones = list(string)
allow_system_components = optional(bool, false)
volume_size = optional(number, 20)
volume_type = optional(string, "storage_premium_perf1")
os_name = optional(string, "flatcar")
labels = optional(map(string), {})
})), [
{
name = "system"
machine_type = "g3i.4"
minimum = 2
maximum = 2
availability_zones = ["eu01-1"]
allow_system_components = true
volume_size = 20
volume_type = "storage_premium_perf1"
os_name = "flatcar"
labels = {
"workload-role" = "system"
}
},
{
name = "application"
machine_type = "g3i.4"
minimum = 2
maximum = 2
availability_zones = ["eu01-2"]
allow_system_components = false
volume_size = 20
volume_type = "storage_premium_perf1"
os_name = "flatcar"
labels = {
"workload-role" = "application"
}
}
])
maintenance = optional(object({
enable_kubernetes_version_updates = optional(bool, true)
enable_machine_image_version_updates = optional(bool, true)
start = optional(string, "01:00:00Z")
end = optional(string, "02:00:00Z")
}), {})
})
}))
| `{}` | no | +| [platform\_kubernetes](#input\_platform\_kubernetes) | Map of central, region-scoped platform Kubernetes deployments. Empty map skips deployment. |
map(object({
region = string
network = optional(object({
sna_enabled = optional(bool, false)
sna_network_area_id = optional(string, null)
firewall_next_hop_ip = optional(string, null)
sna_network_prefix_length = optional(number, 24)
}), {})
dns = optional(object({
enabled = optional(bool, true)
create_zones = optional(bool, true)
zones = optional(list(string), [])
gateway_api = optional(bool, true)
}), {})
observability = optional(object({
enabled = optional(bool, true)
plan_name = optional(string, "Observability-Starter-EU01")
acl = optional(list(string), [])
name = optional(string, null)
}), {})
encrypted_volumes = optional(object({
enabled = optional(bool, false)
storage_class_name = optional(string, "stackit-encrypted-premium")
kms_keyring_name = optional(string, "ske-volume-keyring")
kms_key_name = optional(string, "ske-volume-key")
kms_key_version = optional(string, "1")
}), {})
debug_bastion = optional(object({
enabled = optional(bool, false)
name = optional(string, null)
availability_zone = optional(string, null)
machine_type = optional(string, "g2i.1")
image_id = optional(string, "7b10e105-295b-4369-b6e0-567ec940a02b")
boot_volume_size = optional(number, 20)
ssh_public_key = optional(string, null)
ssh_public_key_path = optional(string, "~/.ssh/id_rsa.pub")
ssh_allowed_cidrs = optional(list(string), ["0.0.0.0/0"])
assign_public_ip = optional(bool, true)
install_kubectl = optional(bool, true)
}), {})
role_assignments = optional(list(object({
role = string
subject = string
})), [])
cluster = object({
name = string
kubernetes_version_min = optional(string, null)
node_pools = optional(list(object({
name = string
machine_type = string
minimum = number
maximum = number
availability_zones = list(string)
allow_system_components = optional(bool, false)
volume_size = optional(number, 20)
volume_type = optional(string, "storage_premium_perf1")
os_name = optional(string, "flatcar")
labels = optional(map(string), {})
})), [
{
name = "system"
machine_type = "g3i.4"
minimum = 2
maximum = 2
availability_zones = ["eu01-1"]
allow_system_components = true
volume_size = 20
volume_type = "storage_premium_perf1"
os_name = "flatcar"
labels = {
"workload-role" = "system"
}
},
{
name = "application"
machine_type = "g3i.4"
minimum = 2
maximum = 2
availability_zones = ["eu01-2"]
allow_system_components = false
volume_size = 20
volume_type = "storage_premium_perf1"
os_name = "flatcar"
labels = {
"workload-role" = "application"
}
}
])
maintenance = optional(object({
enable_kubernetes_version_updates = optional(bool, true)
enable_machine_image_version_updates = optional(bool, true)
start = optional(string, "01:00:00Z")
end = optional(string, "02:00:00Z")
}), {})
})
}))
| `{}` | no | | [platform\_kubernetes\_kube\_config\_override](#input\_platform\_kubernetes\_kube\_config\_override) | Optional raw kubeconfig used by kubernetes/helm platform providers when no platform\_kubernetes module output is available. | `string` | `null` | no | | [region](#input\_region) | STACKIT region for regional resources. | `string` | `"eu01"` | no | | [rm\_folder\_parent\_id](#input\_rm\_folder\_parent\_id) | ID of the parent folder under which the resource manager folders will be created. If not provided, the resource manager folders will be created under the organization. | `string` | `null` | no | diff --git a/src/_landing-zone-kubernetes.tf b/src/_landing-zone-kubernetes.tf index 2415576..ba39197 100644 --- a/src/_landing-zone-kubernetes.tf +++ b/src/_landing-zone-kubernetes.tf @@ -2,6 +2,15 @@ ## LANDING ZONE ON KUBERNETES ## ################################ +# The Kubernetes side of a landing zone: every entry in landing_zone_namespace_services +# gets a tenant slice of the shared platform cluster — its own namespace, a scoped service +# account with a Role limited to that namespace, and optionally a Kyverno policy that +# blocks direct Secret management so credentials have to come through the Secrets Manager. +# +# The sample workload behind sample_load is demo material, not part of the landing zone +# contract: a pod and a Gateway API route that together prove the path from the internet +# to a namespace works. Drop it once real workloads move in. + locals { secrets_enforcement_default_exempt_principals = [ "system:serviceaccount:external-secrets:external-secrets", @@ -78,13 +87,6 @@ locals { for key, value in local.landing_zone_namespace_services : key => value if value.secrets_enforcement.enabled } - - sample_gateway_lb_endpoint_by_key = { - for key, data in data.kubernetes_resources.landing_zone_sample_gateway_service : key => { - ip = try(one(data.objects).status.loadBalancer.ingress[0].ip, null) - hostname = try(one(data.objects).status.loadBalancer.ingress[0].hostname, null) - } - } } module "namespace_service_demo" { @@ -592,49 +594,3 @@ resource "kubernetes_manifest" "landing_zone_sample_http_route" { ] } -data "kubernetes_resources" "landing_zone_sample_gateway_service" { - provider = kubernetes.platform - - for_each = { - for key, value in local.landing_zone_namespace_services : key => value - if value.sample_load.enabled && value.dns_fqdn != null - } - - api_version = "v1" - kind = "Service" - namespace = "envoy-gateway-system" - label_selector = "gateway.envoyproxy.io/owning-gateway-name=${kubernetes_manifest.landing_zone_sample_gateway[each.key].manifest.metadata.name},gateway.envoyproxy.io/owning-gateway-namespace=${kubernetes_namespace_v1.landing_zone[each.key].metadata[0].name}" - - depends_on = [ - kubernetes_manifest.landing_zone_sample_gateway, - ] -} - -resource "stackit_dns_record_set" "landing_zone_sample_gateway" { - for_each = { - for key, value in local.landing_zone_namespace_services : key => value - if value.sample_load.enabled && value.dns_fqdn != null - } - - project_id = module.landing_zone[each.key].project_id - zone_id = module.landing_zone[each.key].dns_zone_id - name = each.value.dns_fqdn - type = try(local.sample_gateway_lb_endpoint_by_key[each.key].ip, null) != null ? "A" : "CNAME" - ttl = 60 - records = [ - coalesce( - try(local.sample_gateway_lb_endpoint_by_key[each.key].ip, null), - try(local.sample_gateway_lb_endpoint_by_key[each.key].hostname, null), - ), - ] - - lifecycle { - precondition { - condition = ( - try(local.sample_gateway_lb_endpoint_by_key[each.key].ip, null) != null || - try(local.sample_gateway_lb_endpoint_by_key[each.key].hostname, null) != null - ) - error_message = "Gateway load balancer endpoint is not available yet for DNS record creation." - } - } -} diff --git a/src/main.tf b/src/main.tf index a6f4627..5a7bc81 100644 --- a/src/main.tf +++ b/src/main.tf @@ -131,6 +131,7 @@ module "platform_kubernetes" { enabled = each.value.dns.enabled create_zones = each.value.dns.create_zones zones = length(each.value.dns.zones) > 0 ? each.value.dns.zones : compact(distinct([for lz in values(module.landing_zone) : try(lz.dns_zone_dns_name, null)])) + gateway_api = each.value.dns.gateway_api } } diff --git a/src/modules/platform-kubernetes/3-cluster.tf b/src/modules/platform-kubernetes/3-cluster.tf index 5d466ee..d2acc8e 100644 --- a/src/modules/platform-kubernetes/3-cluster.tf +++ b/src/modules/platform-kubernetes/3-cluster.tf @@ -36,8 +36,9 @@ resource "stackit_ske_cluster" "this" { instance_id = var.observability.enabled ? stackit_observability_instance.this[0].instance_id : null } dns = { - enabled = var.dns.enabled && length(local.effective_dns_zones) > 0 - zones = local.effective_dns_zones + enabled = var.dns.enabled && length(local.effective_dns_zones) > 0 + zones = local.effective_dns_zones + gateway_api = var.dns.enabled && length(local.effective_dns_zones) > 0 ? var.dns.gateway_api : null } } diff --git a/src/modules/platform-kubernetes/README.md b/src/modules/platform-kubernetes/README.md index 6536c51..c109346 100644 --- a/src/modules/platform-kubernetes/README.md +++ b/src/modules/platform-kubernetes/README.md @@ -11,8 +11,8 @@ | Name | Version | | ---- | ------- | -| [stackit](#provider\_stackit) | 0.114.0 | -| [time](#provider\_time) | 0.14.1 | +| [stackit](#provider\_stackit) | 0.116.0 | +| [time](#provider\_time) | 0.14.2 | ## Modules @@ -47,7 +47,7 @@ | ---- | ----------- | ---- | ------- | :------: | | [cluster](#input\_cluster) | SKE cluster configuration. |
object({
name = string
kubernetes_version_min = optional(string, null)
node_pools = optional(list(object({
name = string
machine_type = string
minimum = number
maximum = number
availability_zones = list(string)
allow_system_components = optional(bool, false)
volume_size = optional(number, 20)
volume_type = optional(string, "storage_premium_perf1")
os_name = optional(string, "flatcar")
labels = optional(map(string), {})
})), [
{
name = "system"
machine_type = "g3i.4"
minimum = 2
maximum = 2
availability_zones = ["eu01-1"]
allow_system_components = true
volume_size = 20
volume_type = "storage_premium_perf1"
os_name = "flatcar"
labels = {
"workload-role" = "system"
}
},
{
name = "application"
machine_type = "g3i.4"
minimum = 2
maximum = 2
availability_zones = ["eu01-2"]
allow_system_components = false
volume_size = 20
volume_type = "storage_premium_perf1"
os_name = "flatcar"
labels = {
"workload-role" = "application"
}
}
])
maintenance = optional(object({
enable_kubernetes_version_updates = optional(bool, true)
enable_machine_image_version_updates = optional(bool, true)
start = optional(string, "01:00:00Z")
end = optional(string, "02:00:00Z")
}), {})
})
| n/a | yes | | [debug\_bastion](#input\_debug\_bastion) | Optional debug bastion VM in the SNA network with SSH access to test SKE connectivity from inside the private network. |
object({
enabled = optional(bool, false)
name = optional(string, null)
availability_zone = optional(string, null)
machine_type = optional(string, "g2i.1")
image_id = optional(string, "7b10e105-295b-4369-b6e0-567ec940a02b")
boot_volume_size = optional(number, 20)
ssh_public_key = optional(string, null)
ssh_public_key_path = optional(string, "~/.ssh/id_rsa.pub")
ssh_allowed_cidrs = optional(list(string), ["0.0.0.0/0"])
assign_public_ip = optional(bool, true)
install_kubectl = optional(bool, true)
})
| `{}` | no | -| [dns](#input\_dns) | SKE DNS extension configuration. If create\_zones is true, zones are created in the platform project before cluster creation. |
object({
enabled = optional(bool, true)
create_zones = optional(bool, true)
zones = optional(list(string), [])
})
| `{}` | no | +| [dns](#input\_dns) | SKE DNS extension configuration. If create\_zones is true, zones are created in the platform project before cluster creation. gateway\_api enables Gateway API support for ExternalDNS. |
object({
enabled = optional(bool, true)
create_zones = optional(bool, true)
zones = optional(list(string), [])
gateway_api = optional(bool, true)
})
| `{}` | no | | [encrypted\_volumes](#input\_encrypted\_volumes) | Optional encrypted volume setup for SKE via KMS and Kubernetes storage class. |
object({
enabled = optional(bool, false)
storage_class_name = optional(string, "stackit-encrypted-premium")
kms_keyring_name = optional(string, "ske-volume-keyring")
kms_key_name = optional(string, "ske-volume-key")
kms_key_version = optional(string, "1")
})
| `{}` | no | | [labels](#input\_labels) | Additional labels to apply to resources in this module. | `map(string)` | `{}` | no | | [naming\_pattern](#input\_naming\_pattern) | Naming prefix for resources in this module, e.g. myco-pltfm-k8s-eu01. | `string` | n/a | yes | diff --git a/src/modules/platform-kubernetes/variables.tf b/src/modules/platform-kubernetes/variables.tf index 366a3d8..6fc6336 100644 --- a/src/modules/platform-kubernetes/variables.tf +++ b/src/modules/platform-kubernetes/variables.tf @@ -58,8 +58,9 @@ variable "dns" { enabled = optional(bool, true) create_zones = optional(bool, true) zones = optional(list(string), []) + gateway_api = optional(bool, true) }) - description = "SKE DNS extension configuration. If create_zones is true, zones are created in the platform project before cluster creation." + description = "SKE DNS extension configuration. If create_zones is true, zones are created in the platform project before cluster creation. gateway_api enables Gateway API support for ExternalDNS." default = {} } diff --git a/src/variables.tf b/src/variables.tf index 3ea2ec3..d9279cb 100644 --- a/src/variables.tf +++ b/src/variables.tf @@ -81,6 +81,7 @@ variable "platform_kubernetes" { enabled = optional(bool, true) create_zones = optional(bool, true) zones = optional(list(string), []) + gateway_api = optional(bool, true) }), {}) observability = optional(object({ enabled = optional(bool, true)