From a9abe539b7e12b2a0265628676b551f0cdb8e3cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Weberru=C3=9F?= Date: Fri, 12 Jun 2026 15:42:27 +0000 Subject: [PATCH 1/4] feat: add landing-zone namespace service demo and observability workflow --- src/main.tf | 32 + src/modules/landing-zone/8-observability.tf | 12 + src/modules/landing-zone/outputs.tf | 31 + src/modules/landing-zone/variables.tf | 11 + src/modules/platform-kubernetes/1-project.tf | 22 + .../platform-kubernetes/2-dns-zones.tf | 12 + .../2-network-area-membership.tf | 8 + .../platform-kubernetes/2-observability.tf | 8 + .../platform-kubernetes/2-sna-network.tf | 8 + src/modules/platform-kubernetes/3-cluster.tf | 82 + .../4-encrypted-volumes.tf | 50 + .../platform-kubernetes/5-debug-bastion.tf | 115 ++ src/modules/platform-kubernetes/README.md | 16 + src/modules/platform-kubernetes/outputs.tf | 71 + src/modules/platform-kubernetes/terraform.tf | 14 + src/modules/platform-kubernetes/variables.tf | 126 ++ src/namespace-service.tf | 1371 +++++++++++++++++ src/outputs.tf | 170 +- src/providers.tf | 53 + src/terraform.tf | 12 + src/variables.tf | 166 ++ 21 files changed, 2385 insertions(+), 5 deletions(-) create mode 100644 src/modules/landing-zone/8-observability.tf create mode 100644 src/modules/platform-kubernetes/1-project.tf create mode 100644 src/modules/platform-kubernetes/2-dns-zones.tf create mode 100644 src/modules/platform-kubernetes/2-network-area-membership.tf create mode 100644 src/modules/platform-kubernetes/2-observability.tf create mode 100644 src/modules/platform-kubernetes/2-sna-network.tf create mode 100644 src/modules/platform-kubernetes/3-cluster.tf create mode 100644 src/modules/platform-kubernetes/4-encrypted-volumes.tf create mode 100644 src/modules/platform-kubernetes/5-debug-bastion.tf create mode 100644 src/modules/platform-kubernetes/README.md create mode 100644 src/modules/platform-kubernetes/outputs.tf create mode 100644 src/modules/platform-kubernetes/terraform.tf create mode 100644 src/modules/platform-kubernetes/variables.tf create mode 100644 src/namespace-service.tf diff --git a/src/main.tf b/src/main.tf index a776587..ffd98d2 100644 --- a/src/main.tf +++ b/src/main.tf @@ -66,6 +66,37 @@ module "devops" { allowed_network_ranges = var.devops.allowed_network_ranges } +######################### +## PLATFORM KUBERNETES ## +######################### + +module "platform_kubernetes" { + source = "./modules/platform-kubernetes" + for_each = var.platform_kubernetes + + owner_email = var.owner_email + naming_pattern = "${var.company_code}-pltfm-k8s-${each.value.region}" + parent_container_id = module.governance.folder_container_ids["platform"] + labels = var.labels + region = each.value.region + role_assignments = each.value.role_assignments + cluster = each.value.cluster + observability = each.value.observability + encrypted_volumes = each.value.encrypted_volumes + debug_bastion = each.value.debug_bastion + + network = { + mode = each.value.network.mode + sna_network_area_id = each.value.network.sna_network_area_id != null ? each.value.network.sna_network_area_id : try(module.connectivity[0].network_area_id, null) + } + + dns = { + 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)])) + } +} + ############### ## SANDBOXES ## ############### @@ -98,5 +129,6 @@ module "landing_zone" { role_assignments = each.value.role_assignments network_prefix_length = each.value.network_prefix_length custom_roles = each.value.custom_roles + observability = each.value.observability firewall_next_hop_ip = var.connectivity != null && var.connectivity.firewall != null ? module.connectivity[0].firewall_next_hop_ip : null # if firewall is enabled, pass the next hop IP to the landing zones for route configuration } diff --git a/src/modules/landing-zone/8-observability.tf b/src/modules/landing-zone/8-observability.tf new file mode 100644 index 0000000..468fbd8 --- /dev/null +++ b/src/modules/landing-zone/8-observability.tf @@ -0,0 +1,12 @@ +################### +## OBSERVABILITY ## +################### + +resource "stackit_observability_instance" "this" { + count = var.observability.enabled ? 1 : 0 + + project_id = stackit_resourcemanager_project.this.project_id + name = var.observability.name != null ? var.observability.name : "${var.naming_pattern}-obs" + plan_name = var.observability.plan_name + acl = var.observability.acl +} diff --git a/src/modules/landing-zone/outputs.tf b/src/modules/landing-zone/outputs.tf index 64741fc..3e598c3 100644 --- a/src/modules/landing-zone/outputs.tf +++ b/src/modules/landing-zone/outputs.tf @@ -31,4 +31,35 @@ output "connected_network_area_id" { output "landing_zone_type" { description = "The type of the landing zone, either 'corporate' or 'public'." value = var.corporate ? "corporate" : "public" +} + +output "secretsmanager_instance_id" { + description = "The ID of the landing zone Secrets Manager instance." + value = stackit_secretsmanager_instance.this.instance_id +} + +output "observability_instance_id" { + description = "The optional observability instance ID in the landing zone project." + value = var.observability.enabled ? stackit_observability_instance.this[0].instance_id : null +} + +output "observability_grafana_url" { + description = "The Grafana URL of the optional landing zone observability instance." + value = var.observability.enabled ? stackit_observability_instance.this[0].grafana_url : null +} + +output "observability_grafana_admin_user" { + description = "The initial Grafana admin user of the optional landing zone observability instance." + value = var.observability.enabled ? stackit_observability_instance.this[0].grafana_initial_admin_user : null +} + +output "observability_grafana_admin_password" { + description = "The initial Grafana admin password of the optional landing zone observability instance." + value = var.observability.enabled ? stackit_observability_instance.this[0].grafana_initial_admin_password : null + sensitive = true +} + +output "observability_metrics_push_url" { + description = "The Prometheus remote-write URL of the optional landing zone observability instance." + value = var.observability.enabled ? stackit_observability_instance.this[0].metrics_push_url : null } \ No newline at end of file diff --git a/src/modules/landing-zone/variables.tf b/src/modules/landing-zone/variables.tf index 797f6e3..6792605 100644 --- a/src/modules/landing-zone/variables.tf +++ b/src/modules/landing-zone/variables.tf @@ -88,4 +88,15 @@ variable "secretsmanager_acls" { type = list(string) description = "List of ACL rules for the Secrets Manager instance. Set to empty list for no ACLs or null to skip Secrets Manager creation." default = [] +} + +variable "observability" { + type = object({ + enabled = optional(bool, false) + plan_name = optional(string, "Observability-Starter-EU01") + acl = optional(list(string), []) + name = optional(string, null) + }) + description = "Optional observability instance configuration in the landing zone project." + default = {} } \ No newline at end of file diff --git a/src/modules/platform-kubernetes/1-project.tf b/src/modules/platform-kubernetes/1-project.tf new file mode 100644 index 0000000..9252f40 --- /dev/null +++ b/src/modules/platform-kubernetes/1-project.tf @@ -0,0 +1,22 @@ +locals { + project_labels = merge( + { "region" = var.region }, + var.network.mode == "sna" && var.network.sna_network_area_id != null ? { "networkArea" = var.network.sna_network_area_id } : {}, + var.labels + ) +} + +resource "stackit_resourcemanager_project" "this" { + parent_container_id = var.parent_container_id + name = var.project_name != null ? var.project_name : var.naming_pattern + owner_email = var.owner_email + labels = length(local.project_labels) > 0 ? local.project_labels : null +} + +resource "stackit_authorization_project_role_assignment" "this" { + for_each = { for assignment in var.role_assignments : "${assignment.role}-${assignment.subject}" => assignment } + + resource_id = stackit_resourcemanager_project.this.project_id + role = each.value.role + subject = each.value.subject +} diff --git a/src/modules/platform-kubernetes/2-dns-zones.tf b/src/modules/platform-kubernetes/2-dns-zones.tf new file mode 100644 index 0000000..c269fa2 --- /dev/null +++ b/src/modules/platform-kubernetes/2-dns-zones.tf @@ -0,0 +1,12 @@ +locals { + dns_extension_zones = distinct(compact(var.dns.zones)) +} + +resource "stackit_dns_zone" "ske_extension" { + for_each = var.dns.create_zones ? { for zone in local.dns_extension_zones : zone => zone } : {} + + project_id = stackit_resourcemanager_project.this.project_id + name = each.value + dns_name = each.value + contact_email = var.owner_email +} diff --git a/src/modules/platform-kubernetes/2-network-area-membership.tf b/src/modules/platform-kubernetes/2-network-area-membership.tf new file mode 100644 index 0000000..c7db91e --- /dev/null +++ b/src/modules/platform-kubernetes/2-network-area-membership.tf @@ -0,0 +1,8 @@ +resource "time_sleep" "wait_for_network_area_membership" { + count = var.network.mode == "sna" ? 1 : 0 + + # Allow backend propagation after project label updates before SKE SNA validation. + create_duration = "30s" + + depends_on = [stackit_resourcemanager_project.this] +} diff --git a/src/modules/platform-kubernetes/2-observability.tf b/src/modules/platform-kubernetes/2-observability.tf new file mode 100644 index 0000000..cf5e774 --- /dev/null +++ b/src/modules/platform-kubernetes/2-observability.tf @@ -0,0 +1,8 @@ +resource "stackit_observability_instance" "this" { + count = var.observability.enabled ? 1 : 0 + + project_id = stackit_resourcemanager_project.this.project_id + name = var.observability.name != null ? var.observability.name : "${var.naming_pattern}-obs" + plan_name = var.observability.plan_name + acl = var.observability.acl +} diff --git a/src/modules/platform-kubernetes/2-sna-network.tf b/src/modules/platform-kubernetes/2-sna-network.tf new file mode 100644 index 0000000..525202d --- /dev/null +++ b/src/modules/platform-kubernetes/2-sna-network.tf @@ -0,0 +1,8 @@ +resource "stackit_network" "sna" { + count = local.use_sna ? 1 : 0 + + project_id = stackit_resourcemanager_project.this.project_id + name = "${var.cluster.name}-sna" + ipv4_prefix_length = var.network.sna_network_prefix_length + routed = true +} diff --git a/src/modules/platform-kubernetes/3-cluster.tf b/src/modules/platform-kubernetes/3-cluster.tf new file mode 100644 index 0000000..5fbcc6f --- /dev/null +++ b/src/modules/platform-kubernetes/3-cluster.tf @@ -0,0 +1,82 @@ +locals { + use_sna = lower(var.network.mode) == "sna" + + effective_observability_instance_id = var.observability.enabled ? stackit_observability_instance.this[0].instance_id : null + effective_dns_zones = var.dns.create_zones ? sort([ + for zone in values(stackit_dns_zone.ske_extension) : zone.dns_name + ]) : local.dns_extension_zones + default_node_pools = [ + { + name = "ha-a" + machine_type = "g3i.4" + minimum = 2 + maximum = 2 + availability_zones = ["${var.region}-1"] + volume_size = 20 + volume_type = "storage_premium_perf1" + os_name = "flatcar" + labels = {} + }, + { + name = "ha-b" + machine_type = "g3i.4" + minimum = 2 + maximum = 2 + availability_zones = ["${var.region}-2"] + volume_size = 20 + volume_type = "storage_premium_perf1" + os_name = "flatcar" + labels = {} + } + ] + effective_node_pools = length(var.cluster.node_pools) > 0 ? var.cluster.node_pools : local.default_node_pools +} + +resource "stackit_ske_cluster" "this" { + project_id = stackit_resourcemanager_project.this.project_id + region = var.region + name = var.cluster.name + depends_on = [time_sleep.wait_for_network_area_membership, stackit_dns_zone.ske_extension] + kubernetes_version_min = var.cluster.kubernetes_version_min + node_pools = local.effective_node_pools + + maintenance = { + enable_kubernetes_version_updates = var.cluster.maintenance.enable_kubernetes_version_updates + enable_machine_image_version_updates = var.cluster.maintenance.enable_machine_image_version_updates + start = var.cluster.maintenance.start + end = var.cluster.maintenance.end + } + + network = local.use_sna ? { + id = stackit_network.sna[0].network_id + control_plane = { + access_scope = "SNA" + } + } : { + id = null + control_plane = { + access_scope = "PUBLIC" + } + } + + extensions = { + observability = { + enabled = var.observability.enabled + instance_id = local.effective_observability_instance_id + } + dns = { + enabled = var.dns.enabled && length(local.effective_dns_zones) > 0 + zones = local.effective_dns_zones + } + } +} + +resource "stackit_ske_kubeconfig" "this" { + project_id = stackit_resourcemanager_project.this.project_id + region = var.region + cluster_name = stackit_ske_cluster.this.name + + refresh = true + expiration = 7200 + refresh_before = 1800 +} diff --git a/src/modules/platform-kubernetes/4-encrypted-volumes.tf b/src/modules/platform-kubernetes/4-encrypted-volumes.tf new file mode 100644 index 0000000..425e73b --- /dev/null +++ b/src/modules/platform-kubernetes/4-encrypted-volumes.tf @@ -0,0 +1,50 @@ +data "stackit_service_accounts" "ske_internal" { + count = var.encrypted_volumes.enabled ? 1 : 0 + + project_id = stackit_resourcemanager_project.this.project_id + email_suffix = "@ske.sa.stackit.cloud" + + depends_on = [stackit_ske_cluster.this] +} + +resource "stackit_kms_keyring" "this" { + count = var.encrypted_volumes.enabled ? 1 : 0 + + project_id = stackit_resourcemanager_project.this.project_id + display_name = "${var.naming_pattern}-${var.encrypted_volumes.kms_keyring_name}" +} + +resource "stackit_kms_key" "this" { + count = var.encrypted_volumes.enabled ? 1 : 0 + + project_id = stackit_resourcemanager_project.this.project_id + keyring_id = stackit_kms_keyring.this[0].keyring_id + display_name = "${var.naming_pattern}-${var.encrypted_volumes.kms_key_name}" + protection = "software" + algorithm = "aes_256_gcm" + purpose = "symmetric_encrypt_decrypt" +} + +resource "stackit_service_account" "kms_manager" { + count = var.encrypted_volumes.enabled ? 1 : 0 + + project_id = stackit_resourcemanager_project.this.project_id + # STACKIT service account names are limited to 20 characters. + name = "${substr(var.naming_pattern, 0, 8)}-kms-mgr" +} + +resource "stackit_authorization_project_role_assignment" "kms_admin" { + count = var.encrypted_volumes.enabled ? 1 : 0 + + resource_id = stackit_resourcemanager_project.this.project_id + role = "kms.admin" + subject = stackit_service_account.kms_manager[0].email +} + +resource "stackit_authorization_service_account_role_assignment" "ske_impersonation" { + count = var.encrypted_volumes.enabled ? 1 : 0 + + resource_id = stackit_service_account.kms_manager[0].service_account_id + role = "user" + subject = data.stackit_service_accounts.ske_internal[0].items[0].email +} diff --git a/src/modules/platform-kubernetes/5-debug-bastion.tf b/src/modules/platform-kubernetes/5-debug-bastion.tf new file mode 100644 index 0000000..cfe807e --- /dev/null +++ b/src/modules/platform-kubernetes/5-debug-bastion.tf @@ -0,0 +1,115 @@ +locals { + debug_bastion_enabled = var.debug_bastion.enabled && var.network.mode == "sna" + + debug_bastion_short_prefix = trim(replace(substr(var.naming_pattern, 0, 14), "/-{2,}/", "-"), "-") + + debug_bastion_name = var.debug_bastion.name != null ? var.debug_bastion.name : "${var.naming_pattern}-dbg" + + debug_bastion_ssh_public_key = local.debug_bastion_enabled ? ( + var.debug_bastion.ssh_public_key != null ? trimspace(var.debug_bastion.ssh_public_key) : trimspace(file(pathexpand(var.debug_bastion.ssh_public_key_path))) + ) : null + + debug_bastion_user_data = var.debug_bastion.install_kubectl ? ( + < /etc/apt/sources.list.d/kubernetes.list + - apt-get update + - apt-get install -y kubectl +EOT + ) : null +} + +check "debug_bastion_requires_sna" { + assert { + condition = var.debug_bastion.enabled ? var.network.mode == "sna" : true + error_message = "debug_bastion requires network.mode = \"sna\"." + } +} + +check "debug_bastion_ssh_key_required" { + assert { + condition = !var.debug_bastion.enabled || local.debug_bastion_ssh_public_key != "" + error_message = "debug_bastion requires a non-empty ssh_public_key or ssh_public_key_path." + } +} + +resource "stackit_key_pair" "debug_bastion" { + count = local.debug_bastion_enabled ? 1 : 0 + + name = "${local.debug_bastion_short_prefix}-dbg-key" + public_key = local.debug_bastion_ssh_public_key +} + +resource "stackit_security_group" "debug_bastion" { + count = local.debug_bastion_enabled ? 1 : 0 + + project_id = stackit_resourcemanager_project.this.project_id + name = "${local.debug_bastion_short_prefix}-dbg-sg" + description = "Debug bastion SSH access" + stateful = true +} + +resource "stackit_security_group_rule" "debug_bastion_ssh" { + for_each = local.debug_bastion_enabled ? { + for cidr in var.debug_bastion.ssh_allowed_cidrs : cidr => cidr + } : {} + + project_id = stackit_resourcemanager_project.this.project_id + security_group_id = stackit_security_group.debug_bastion[0].security_group_id + direction = "ingress" + ether_type = "IPv4" + ip_range = each.value + + protocol = { + name = "tcp" + } + + port_range = { + min = 22 + max = 22 + } +} + +resource "stackit_network_interface" "debug_bastion" { + count = local.debug_bastion_enabled ? 1 : 0 + + project_id = stackit_resourcemanager_project.this.project_id + network_id = stackit_network.sna[0].network_id + name = "${local.debug_bastion_name}-nic" + security = true + security_group_ids = [stackit_security_group.debug_bastion[0].security_group_id] +} + +resource "stackit_server" "debug_bastion" { + count = local.debug_bastion_enabled ? 1 : 0 + + project_id = stackit_resourcemanager_project.this.project_id + name = local.debug_bastion_name + + boot_volume = { + source_type = "image" + source_id = var.debug_bastion.image_id + size = var.debug_bastion.boot_volume_size + } + + availability_zone = var.debug_bastion.availability_zone + machine_type = var.debug_bastion.machine_type + keypair_name = stackit_key_pair.debug_bastion[0].name + network_interfaces = [ + stackit_network_interface.debug_bastion[0].network_interface_id + ] + user_data = local.debug_bastion_user_data +} + +resource "stackit_public_ip" "debug_bastion" { + count = local.debug_bastion_enabled && var.debug_bastion.assign_public_ip ? 1 : 0 + + project_id = stackit_resourcemanager_project.this.project_id + network_interface_id = stackit_network_interface.debug_bastion[0].network_interface_id +} diff --git a/src/modules/platform-kubernetes/README.md b/src/modules/platform-kubernetes/README.md new file mode 100644 index 0000000..0b66553 --- /dev/null +++ b/src/modules/platform-kubernetes/README.md @@ -0,0 +1,16 @@ + + +## Platform Kubernetes Module + +This module provisions a central, region-scoped platform Kubernetes foundation: + +- Dedicated platform project +- SKE cluster with SNA/public network mode support +- HA baseline defaults: two node pools across two AZs with minimum two nodes per pool +- Optional central observability extension wiring +- Optional DNS extension wiring +- Optional encrypted volume foundation via KMS and Act-As IAM wiring + +Run tfdocs/pre-commit hooks to regenerate full inputs/outputs documentation. + + diff --git a/src/modules/platform-kubernetes/outputs.tf b/src/modules/platform-kubernetes/outputs.tf new file mode 100644 index 0000000..ffadda4 --- /dev/null +++ b/src/modules/platform-kubernetes/outputs.tf @@ -0,0 +1,71 @@ +output "dns_extension_zones" { + description = "DNS zones configured for SKE DNS extension." + value = distinct(compact(var.dns.zones)) +} + +output "encrypted_volume_support" { + description = "Configuration values for encrypted SKE volumes when enabled." + value = var.encrypted_volumes.enabled ? { + storage_class_name = var.encrypted_volumes.storage_class_name + kms_keyring_id = stackit_kms_keyring.this[0].keyring_id + kms_key_id = stackit_kms_key.this[0].key_id + kms_project_id = stackit_resourcemanager_project.this.project_id + kms_key_version = var.encrypted_volumes.kms_key_version + kms_service_account_email = stackit_service_account.kms_manager[0].email + } : null +} + +output "debug_bastion" { + description = "Debug bastion metadata when enabled for private cluster troubleshooting." + value = local.debug_bastion_enabled ? { + enabled = true + server_id = stackit_server.debug_bastion[0].server_id + network_interface_id = stackit_network_interface.debug_bastion[0].network_interface_id + public_ip = var.debug_bastion.assign_public_ip ? stackit_public_ip.debug_bastion[0].ip : null + ssh_user = "ubuntu" + ssh_command = var.debug_bastion.assign_public_ip ? "ssh ubuntu@${stackit_public_ip.debug_bastion[0].ip}" : null + } : { + enabled = false + server_id = null + network_interface_id = null + public_ip = null + ssh_user = null + ssh_command = null + } +} + +output "observability_instance_id" { + description = "The observability instance ID used for cluster extension." + value = var.observability.enabled ? stackit_observability_instance.this[0].instance_id : null +} + +output "project_container_id" { + description = "The container ID of the created STACKIT project." + value = stackit_resourcemanager_project.this.container_id +} + +output "project_id" { + description = "The project ID of the created STACKIT project." + value = stackit_resourcemanager_project.this.project_id +} + +output "project_name" { + description = "The name of the created STACKIT project." + value = stackit_resourcemanager_project.this.name +} + +output "ske_cluster_name" { + description = "The name of the created SKE cluster." + value = stackit_ske_cluster.this.name +} + +output "ske_cluster_region" { + description = "The region of the created SKE cluster." + value = stackit_ske_cluster.this.region +} + +output "kube_config" { + description = "Kubeconfig for the created SKE cluster." + value = stackit_ske_kubeconfig.this.kube_config + sensitive = true +} diff --git a/src/modules/platform-kubernetes/terraform.tf b/src/modules/platform-kubernetes/terraform.tf new file mode 100644 index 0000000..a8778a8 --- /dev/null +++ b/src/modules/platform-kubernetes/terraform.tf @@ -0,0 +1,14 @@ +terraform { + required_version = ">= 1.10" + + required_providers { + stackit = { + source = "stackitcloud/stackit" + version = ">=0.98.0" + } + time = { + source = "hashicorp/time" + version = ">=0.12.0" + } + } +} diff --git a/src/modules/platform-kubernetes/variables.tf b/src/modules/platform-kubernetes/variables.tf new file mode 100644 index 0000000..0121ed1 --- /dev/null +++ b/src/modules/platform-kubernetes/variables.tf @@ -0,0 +1,126 @@ +variable "cluster" { + type = 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) + volume_size = optional(number, 20) + volume_type = optional(string, "storage_premium_perf1") + os_name = optional(string, "flatcar") + labels = optional(map(string), {}) + })), []) + 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") + }), {}) + }) + description = "SKE cluster configuration." +} + +variable "dns" { + type = object({ + enabled = optional(bool, true) + create_zones = optional(bool, true) + zones = optional(list(string), []) + }) + description = "SKE DNS extension configuration. If create_zones is true, zones are created in the platform project before cluster creation." + default = {} +} + +variable "encrypted_volumes" { + type = 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") + }) + description = "Optional encrypted volume setup for SKE via KMS and Kubernetes storage class." + default = {} +} + +variable "debug_bastion" { + type = 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) + }) + description = "Optional debug bastion VM in the SNA network with SSH access to test SKE connectivity from inside the private network." + default = {} +} + +variable "labels" { + type = map(string) + description = "Additional labels to apply to resources in this module." + default = {} +} + +variable "naming_pattern" { + type = string + description = "Naming prefix for resources in this module, e.g. myco-pltfm-k8s-eu01." +} + +variable "network" { + type = object({ + mode = optional(string, "public") + sna_network_area_id = optional(string, null) + sna_network_prefix_length = optional(number, 24) + }) + description = "Network mode for SKE. mode=public configures public control plane, mode=sna configures SNA and requires sna_network_area_id at apply time." + default = {} +} + +variable "observability" { + type = object({ + enabled = optional(bool, true) + plan_name = optional(string, "Observability-Starter-EU01") + acl = optional(list(string), []) + name = optional(string, null) + }) + description = "Observability configuration for central cluster monitoring in the same project as the cluster." + default = {} +} + +variable "owner_email" { + type = string + description = "Email address of the project owner. Required for project creation." +} + +variable "parent_container_id" { + type = string + description = "Parent container ID (folder or organization) where the project will be created." +} + +variable "project_name" { + type = string + description = "Name of the STACKIT project to create." + default = null +} + +variable "region" { + type = string + description = "STACKIT region for the SKE cluster." +} + +variable "role_assignments" { + type = list(object({ + role = string + subject = string + })) + description = "List of role assignments for the project. Subject can be a user email or service account email." + default = [] +} diff --git a/src/namespace-service.tf b/src/namespace-service.tf new file mode 100644 index 0000000..c037df6 --- /dev/null +++ b/src/namespace-service.tf @@ -0,0 +1,1371 @@ +############################# +## LANDING ZONE NAMESPACES ## +############################# + +locals { + secrets_enforcement_default_exempt_principals = [ + "system:serviceaccount:external-secrets:external-secrets", + "system:serviceaccount:external-secrets:external-secrets-operator", + ] + + landing_zone_namespace_services = { + for key, value in var.landing_zones : key => { + namespace = value.namespace_service.namespace != null ? value.namespace_service.namespace : trim(replace(lower(replace("${value.project_code}-${value.env}", "/[^a-zA-Z0-9-]/", "-")), "/-{2,}/", "-"), "-") + dns_subdomain = value.namespace_service.dns_subdomain + dns_fqdn = value.namespace_service.dns_subdomain != null && try(module.landing_zone[key].dns_zone_dns_name, null) != null ? "${value.namespace_service.dns_subdomain}.${module.landing_zone[key].dns_zone_dns_name}" : null + service_account_name = value.namespace_service.kubernetes_access.service_account_name != null ? value.namespace_service.kubernetes_access.service_account_name : trim(replace(lower(replace("${value.project_code}-${value.env}-ns-user", "/[^a-zA-Z0-9-]/", "-")), "/-{2,}/", "-"), "-") + enable_kubernetes_access = value.namespace_service.kubernetes_access.enabled + sample_load = { + enabled = value.namespace_service.sample_load.enabled + image = value.namespace_service.sample_load.image + } + demo = { + enabled = value.namespace_service.demo.enabled + image = value.namespace_service.demo.image + ingress_class_name = value.namespace_service.demo.ingress_class_name + install_ingress_controller = value.namespace_service.demo.install_ingress_controller + external_secret_enabled = value.namespace_service.demo.external_secret_enabled + dashboard_example_enabled = value.namespace_service.demo.dashboard_example_enabled + ingress_host = local.platform_kubernetes_cluster_key != null && length(module.platform_kubernetes[local.platform_kubernetes_cluster_key].dns_extension_zones) > 0 ? "${key}.${module.platform_kubernetes[local.platform_kubernetes_cluster_key].dns_extension_zones[0]}" : null + } + labels = value.namespace_service.labels + annotations = value.namespace_service.annotations + use_secretsmanager = value.namespace_service.secretsmanager + secrets_enforcement = { + enabled = value.namespace_service.secrets_enforcement.enabled + mode = lower(value.namespace_service.secrets_enforcement.mode) + allow_opaque_secret_types = value.namespace_service.secrets_enforcement.allow_opaque_secret_types + break_glass = { + enabled = value.namespace_service.secrets_enforcement.break_glass.enabled + ttl_hours = value.namespace_service.secrets_enforcement.break_glass.ttl_hours + principals = value.namespace_service.secrets_enforcement.break_glass.principals + } + } + } + if value.namespace_service.enabled + } + + landing_zone_namespace_services_kyverno = { + for key, value in local.landing_zone_namespace_services : key => value + if value.secrets_enforcement.enabled + } + + landing_zone_namespace_services_demo = { + for key, value in local.landing_zone_namespace_services : key => value + if value.demo.enabled + } + + landing_zone_namespace_services_demo_external_secret = { + for key, value in local.landing_zone_namespace_services_demo : key => value + if value.demo.external_secret_enabled + } + + landing_zone_namespace_services_demo_observability = { + for key, value in local.landing_zone_namespace_services_demo : key => value + if value.demo.dashboard_example_enabled && try(module.landing_zone[key].observability_metrics_push_url, null) != null + } +} + +check "landing_zone_namespace_services_unique_namespaces" { + assert { + condition = length(local.landing_zone_namespace_services) == length(distinct([for svc in values(local.landing_zone_namespace_services) : svc.namespace])) + error_message = "Each enabled namespace_service must resolve to a unique namespace name." + } +} + +check "landing_zone_namespace_services_non_empty_namespaces" { + assert { + condition = alltrue([for svc in values(local.landing_zone_namespace_services) : length(svc.namespace) > 0]) + error_message = "Each enabled namespace_service must resolve to a non-empty namespace name." + } +} + +check "landing_zone_namespace_services_demo_requires_dns" { + assert { + condition = alltrue([for svc in values(local.landing_zone_namespace_services) : !svc.demo.enabled || svc.demo.ingress_host != null]) + error_message = "namespace_service.demo.enabled requires one platform_kubernetes.dns.zones entry for external DNS management." + } +} + +check "landing_zone_namespace_services_demo_dashboard_requires_observability" { + assert { + condition = alltrue([for key, svc in local.landing_zone_namespace_services : !svc.demo.dashboard_example_enabled || try(module.landing_zone[key].observability_grafana_url, null) != null]) + error_message = "namespace_service.demo.dashboard_example_enabled requires landing_zones..observability.enabled=true." + } +} + +check "landing_zone_namespace_services_demo_external_secret_requires_sm" { + assert { + condition = alltrue([for svc in values(local.landing_zone_namespace_services) : !svc.demo.external_secret_enabled || svc.use_secretsmanager]) + error_message = "namespace_service.demo.external_secret_enabled requires namespace_service.secretsmanager=true." + } +} + +resource "helm_release" "kyverno" { + provider = helm.platform + count = length(local.landing_zone_namespace_services_kyverno) > 0 ? 1 : 0 + + name = "kyverno" + namespace = "kyverno" + repository = "https://kyverno.github.io/kyverno/" + chart = "kyverno" + create_namespace = true + wait = true + timeout = 600 + atomic = true + cleanup_on_fail = true +} + +resource "helm_release" "external_secrets" { + provider = helm.platform + count = length(local.landing_zone_namespace_services_demo_external_secret) > 0 ? 1 : 0 + + name = "external-secrets" + namespace = "external-secrets" + repository = "https://charts.external-secrets.io" + chart = "external-secrets" + create_namespace = true + wait = true + timeout = 600 + atomic = true + cleanup_on_fail = true +} + +resource "helm_release" "demo_ingress_nginx" { + provider = helm.platform + count = length([for svc in values(local.landing_zone_namespace_services_demo) : svc if svc.demo.install_ingress_controller]) > 0 ? 1 : 0 + + name = "lz-demo-ingress-nginx" + namespace = "ingress-nginx" + repository = "https://kubernetes.github.io/ingress-nginx" + chart = "ingress-nginx" + create_namespace = true + wait = true + timeout = 600 + atomic = true + cleanup_on_fail = true + + set = [ + { + name = "controller.ingressClass" + value = "lz-demo" + }, + { + name = "controller.ingressClassResource.name" + value = "lz-demo" + }, + { + name = "controller.ingressClassResource.controllerValue" + value = "k8s.io/ingress-nginx-lz-demo" + }, + { + name = "controller.ingressClassByName" + value = "true" + }, + { + name = "controller.watchIngressWithoutClass" + value = "false" + }, + { + name = "controller.service.type" + value = "LoadBalancer" + }, + ] +} + +resource "kubernetes_service_account_v1" "landing_zone_demo_kube_state_metrics" { + provider = kubernetes.platform + count = length(local.landing_zone_namespace_services_demo_observability) > 0 ? 1 : 0 + + metadata { + name = "lz-demo-kube-state-metrics" + namespace = "external-secrets" + labels = { + "stackit.cloud/demo" = "true" + } + } +} + +resource "kubernetes_cluster_role_v1" "landing_zone_demo_kube_state_metrics" { + provider = kubernetes.platform + count = length(local.landing_zone_namespace_services_demo_observability) > 0 ? 1 : 0 + + metadata { + name = "lz-demo-kube-state-metrics" + labels = { + "stackit.cloud/demo" = "true" + } + } + + rule { + api_groups = [""] + resources = ["pods", "services", "endpoints", "persistentvolumeclaims", "persistentvolumes", "nodes", "namespaces", "resourcequotas", "limitranges", "secrets", "configmaps", "serviceaccounts"] + verbs = ["list", "watch"] + } + + rule { + api_groups = ["apps"] + resources = ["deployments", "daemonsets", "statefulsets", "replicasets"] + verbs = ["list", "watch"] + } + + rule { + api_groups = ["batch"] + resources = ["jobs", "cronjobs"] + verbs = ["list", "watch"] + } + + rule { + api_groups = ["autoscaling"] + resources = ["horizontalpodautoscalers"] + verbs = ["list", "watch"] + } + + rule { + api_groups = ["networking.k8s.io"] + resources = ["ingresses", "networkpolicies"] + verbs = ["list", "watch"] + } + + rule { + api_groups = ["storage.k8s.io"] + resources = ["storageclasses", "volumeattachments"] + verbs = ["list", "watch"] + } +} + +resource "kubernetes_cluster_role_binding_v1" "landing_zone_demo_kube_state_metrics" { + provider = kubernetes.platform + count = length(local.landing_zone_namespace_services_demo_observability) > 0 ? 1 : 0 + + metadata { + name = "lz-demo-kube-state-metrics" + labels = { + "stackit.cloud/demo" = "true" + } + } + + role_ref { + api_group = "rbac.authorization.k8s.io" + kind = "ClusterRole" + name = kubernetes_cluster_role_v1.landing_zone_demo_kube_state_metrics[0].metadata[0].name + } + + subject { + kind = "ServiceAccount" + name = kubernetes_service_account_v1.landing_zone_demo_kube_state_metrics[0].metadata[0].name + namespace = kubernetes_service_account_v1.landing_zone_demo_kube_state_metrics[0].metadata[0].namespace + } +} + +resource "kubernetes_deployment_v1" "landing_zone_demo_kube_state_metrics" { + provider = kubernetes.platform + count = length(local.landing_zone_namespace_services_demo_observability) > 0 ? 1 : 0 + + metadata { + name = "lz-demo-kube-state-metrics" + namespace = "external-secrets" + labels = { + "app.kubernetes.io/name" = "lz-demo-kube-state-metrics" + "stackit.cloud/demo" = "true" + } + } + + spec { + replicas = 1 + + selector { + match_labels = { + "app.kubernetes.io/name" = "lz-demo-kube-state-metrics" + } + } + + template { + metadata { + labels = { + "app.kubernetes.io/name" = "lz-demo-kube-state-metrics" + } + } + + spec { + service_account_name = kubernetes_service_account_v1.landing_zone_demo_kube_state_metrics[0].metadata[0].name + + container { + name = "kube-state-metrics" + image = "registry.k8s.io/kube-state-metrics/kube-state-metrics:v2.13.0" + + args = [ + "--port=8080", + "--telemetry-port=8081", + ] + + port { + name = "http-metrics" + container_port = 8080 + } + + port { + name = "telemetry" + container_port = 8081 + } + + readiness_probe { + http_get { + path = "/readyz" + port = "telemetry" + } + initial_delay_seconds = 15 + timeout_seconds = 5 + } + + liveness_probe { + http_get { + path = "/livez" + port = "telemetry" + } + initial_delay_seconds = 20 + timeout_seconds = 5 + } + } + } + } + } + + depends_on = [ + kubernetes_cluster_role_binding_v1.landing_zone_demo_kube_state_metrics, + ] +} + +resource "kubernetes_service_v1" "landing_zone_demo_kube_state_metrics" { + provider = kubernetes.platform + count = length(local.landing_zone_namespace_services_demo_observability) > 0 ? 1 : 0 + + metadata { + name = "lz-demo-kube-state-metrics" + namespace = "external-secrets" + labels = { + "app.kubernetes.io/name" = "lz-demo-kube-state-metrics" + "stackit.cloud/demo" = "true" + } + } + + spec { + selector = { + "app.kubernetes.io/name" = "lz-demo-kube-state-metrics" + } + + port { + name = "http-metrics" + port = 8080 + target_port = "http-metrics" + protocol = "TCP" + } + } + + depends_on = [ + kubernetes_deployment_v1.landing_zone_demo_kube_state_metrics, + ] +} + +resource "stackit_secretsmanager_user" "landing_zone_demo_external_secret" { + for_each = local.landing_zone_namespace_services_demo_external_secret + + project_id = module.landing_zone[each.key].project_id + instance_id = module.landing_zone[each.key].secretsmanager_instance_id + description = "Demo ExternalSecret reader for ${each.key}" + write_enabled = true +} + +resource "stackit_observability_credential" "landing_zone_demo_metrics_remote_write" { + for_each = local.landing_zone_namespace_services_demo_observability + + project_id = module.landing_zone[each.key].project_id + instance_id = module.landing_zone[each.key].observability_instance_id + description = "Demo remote-write credential for ${each.key}" +} + +resource "kubernetes_secret_v1" "landing_zone_demo_vault_auth" { + provider = kubernetes.platform + + for_each = local.landing_zone_namespace_services_demo_external_secret + + metadata { + name = "${each.key}-demo-vault-auth" + namespace = "external-secrets" + labels = { + "stackit.cloud/landing-zone" = each.key + "stackit.cloud/demo" = "true" + } + } + + data = { + password = stackit_secretsmanager_user.landing_zone_demo_external_secret[each.key].password + } + + type = "Opaque" + + depends_on = [ + helm_release.external_secrets, + stackit_secretsmanager_user.landing_zone_demo_external_secret, + ] +} + +resource "kubernetes_manifest" "landing_zone_demo_secret_store" { + provider = kubernetes.platform + + for_each = local.landing_zone_namespace_services_demo_external_secret + + manifest = { + apiVersion = "external-secrets.io/v1" + kind = "ClusterSecretStore" + metadata = { + name = "${each.key}-stackit-sm-store" + labels = { + "stackit.cloud/landing-zone" = each.key + "stackit.cloud/demo" = "true" + } + } + spec = { + provider = { + vault = { + server = "https://prod.sm.${var.region}.stackit.cloud" + path = module.landing_zone[each.key].secretsmanager_instance_id + version = "v2" + auth = { + userPass = { + path = "userpass" + username = stackit_secretsmanager_user.landing_zone_demo_external_secret[each.key].username + secretRef = { + name = kubernetes_secret_v1.landing_zone_demo_vault_auth[each.key].metadata[0].name + key = "password" + namespace = "external-secrets" + } + } + } + } + } + } + } + + computed_fields = [ + "metadata", + "spec", + "status", + ] + + depends_on = [ + helm_release.external_secrets, + kubernetes_secret_v1.landing_zone_demo_vault_auth, + stackit_secretsmanager_user.landing_zone_demo_external_secret, + ] +} + +resource "kubernetes_manifest" "landing_zone_demo_external_secret" { + provider = kubernetes.platform + + for_each = local.landing_zone_namespace_services_demo_external_secret + + manifest = { + apiVersion = "external-secrets.io/v1" + kind = "ExternalSecret" + metadata = { + name = "${each.key}-demo-app-secret" + namespace = each.value.namespace + labels = { + "stackit.cloud/landing-zone" = each.key + "stackit.cloud/demo" = "true" + } + } + spec = { + refreshInterval = "1m" + secretStoreRef = { + name = kubernetes_manifest.landing_zone_demo_secret_store[each.key].manifest.metadata.name + kind = "ClusterSecretStore" + } + target = { + name = "${each.key}-demo-app-secret" + creationPolicy = "Owner" + } + data = [{ + secretKey = "APP_MESSAGE" + remoteRef = { + key = "namespace-demo/${each.key}/app" + property = "APP_MESSAGE" + } + }] + } + } + + computed_fields = [ + "metadata", + "spec", + "status", + ] + + depends_on = [ + kubernetes_manifest.landing_zone_demo_secret_store, + kubernetes_namespace_v1.landing_zone, + ] +} + +resource "kubernetes_deployment_v1" "landing_zone_demo_app" { + provider = kubernetes.platform + + for_each = local.landing_zone_namespace_services_demo + + metadata { + name = "${each.key}-demo-app" + namespace = each.value.namespace + labels = { + "app.kubernetes.io/name" = "${each.key}-demo-app" + "stackit.cloud/landing-zone" = each.key + "stackit.cloud/demo-scenario" = "namespace-service" + "stackit.cloud/demo-component" = "workload" + } + } + + spec { + replicas = 1 + + selector { + match_labels = { + "app.kubernetes.io/name" = "${each.key}-demo-app" + } + } + + template { + metadata { + labels = { + "app.kubernetes.io/name" = "${each.key}-demo-app" + "stackit.cloud/landing-zone" = each.key + "stackit.cloud/demo-scenario" = "namespace-service" + "stackit.cloud/demo-component" = "workload" + } + } + + spec { + container { + name = "app" + image = each.value.demo.image + args = ["-listen=:5678", "-text=STACKIT Landing Zone Demo"] + + port { + container_port = 5678 + } + + env { + name = "APP_MESSAGE" + + value_from { + secret_key_ref { + name = "${each.key}-demo-app-secret" + key = "APP_MESSAGE" + optional = true + } + } + } + } + } + } + } + + depends_on = [ + kubernetes_manifest.landing_zone_demo_external_secret, + ] +} + +resource "kubernetes_service_v1" "landing_zone_demo_app" { + provider = kubernetes.platform + + for_each = local.landing_zone_namespace_services_demo + + metadata { + name = "${each.key}-demo-app" + namespace = each.value.namespace + labels = { + "stackit.cloud/landing-zone" = each.key + "stackit.cloud/demo" = "true" + } + } + + spec { + selector = { + "app.kubernetes.io/name" = "${each.key}-demo-app" + } + + port { + port = 80 + target_port = 5678 + protocol = "TCP" + } + } +} + +resource "kubernetes_ingress_v1" "landing_zone_demo_app" { + provider = kubernetes.platform + + for_each = local.landing_zone_namespace_services_demo + + metadata { + name = "${each.key}-demo-app" + namespace = each.value.namespace + annotations = { + "external-dns.alpha.kubernetes.io/hostname" = each.value.demo.ingress_host + "stackit.cloud/demo" = "true" + "kubernetes.io/ingress.class" = each.value.demo.ingress_class_name + } + } + + spec { + ingress_class_name = each.value.demo.ingress_class_name + + rule { + host = each.value.demo.ingress_host + + http { + path { + path = "/" + path_type = "Prefix" + + backend { + service { + name = kubernetes_service_v1.landing_zone_demo_app[each.key].metadata[0].name + + port { + number = 80 + } + } + } + } + } + } + } + + depends_on = [ + helm_release.demo_ingress_nginx, + ] +} + +resource "kubernetes_config_map_v1" "landing_zone_demo_dashboard_example" { + provider = kubernetes.platform + + for_each = { + for key, value in local.landing_zone_namespace_services_demo : key => value + if value.demo.dashboard_example_enabled + } + + metadata { + name = "${each.key}-demo-dashboard-example" + namespace = each.value.namespace + labels = { + "stackit.cloud/landing-zone" = each.key + "stackit.cloud/demo" = "true" + } + } + + data = { + "grafana-dashboard.json" = local.landing_zone_demo_dashboard_json[each.key] + } +} + +resource "kubernetes_config_map_v1" "landing_zone_demo_metrics_agent_config" { + provider = kubernetes.platform + + for_each = local.landing_zone_namespace_services_demo_observability + + metadata { + name = "${each.key}-demo-metrics-agent-config" + namespace = each.value.namespace + labels = { + "stackit.cloud/landing-zone" = each.key + "stackit.cloud/demo" = "true" + } + } + + data = { + "prometheus.yml" = <<-EOT + global: + scrape_interval: 30s + scrape_configs: + - job_name: lz-demo-kube-state-metrics + static_configs: + - targets: + - lz-demo-kube-state-metrics.external-secrets.svc.cluster.local:8080 + metric_relabel_configs: + - source_labels: [namespace] + regex: ${each.value.namespace} + action: keep + remote_write: + - url: ${module.landing_zone[each.key].observability_metrics_push_url} + basic_auth: + username: ${stackit_observability_credential.landing_zone_demo_metrics_remote_write[each.key].username} + password: ${stackit_observability_credential.landing_zone_demo_metrics_remote_write[each.key].password} + EOT + } + + depends_on = [ + kubernetes_service_v1.landing_zone_demo_kube_state_metrics, + stackit_observability_credential.landing_zone_demo_metrics_remote_write, + ] +} + +resource "kubernetes_deployment_v1" "landing_zone_demo_metrics_agent" { + provider = kubernetes.platform + + for_each = local.landing_zone_namespace_services_demo_observability + + metadata { + name = "${each.key}-demo-metrics-agent" + namespace = each.value.namespace + labels = { + "app.kubernetes.io/name" = "${each.key}-demo-metrics-agent" + "stackit.cloud/landing-zone" = each.key + "stackit.cloud/demo" = "true" + } + } + + spec { + replicas = 1 + + selector { + match_labels = { + "app.kubernetes.io/name" = "${each.key}-demo-metrics-agent" + } + } + + template { + metadata { + labels = { + "app.kubernetes.io/name" = "${each.key}-demo-metrics-agent" + } + } + + spec { + container { + name = "prometheus-agent" + image = "prom/prometheus:v2.54.1" + args = [ + "--config.file=/etc/prometheus/prometheus.yml", + "--enable-feature=agent", + "--storage.agent.path=/prometheus", + ] + + port { + container_port = 9090 + } + + volume_mount { + name = "config" + mount_path = "/etc/prometheus" + read_only = true + } + } + + volume { + name = "config" + + config_map { + name = kubernetes_config_map_v1.landing_zone_demo_metrics_agent_config[each.key].metadata[0].name + } + } + } + } + } + + depends_on = [ + kubernetes_config_map_v1.landing_zone_demo_metrics_agent_config, + ] +} + +locals { + landing_zone_demo_dashboard_json = { + for key, value in local.landing_zone_namespace_services_demo : key => jsonencode({ + uid = "lz-demo-${key}" + title = "Landing Zone Demo - ${value.namespace}" + tags = ["stackit", "landing-zone", "demo", value.namespace] + schemaVersion = 39 + version = 2 + editable = true + timezone = "browser" + refresh = "30s" + graphTooltip = 1 + time = { + from = "now-6h" + to = "now" + } + panels = [ + { + id = 1 + title = "Running Demo Pods" + type = "stat" + gridPos = { + h = 5 + w = 6 + x = 0 + y = 0 + } + datasource = "Thanos" + options = { + colorMode = "value" + graphMode = "none" + justifyMode = "auto" + reduceOptions = { + calcs = ["lastNotNull"] + fields = "" + values = false + } + textMode = "auto" + } + fieldConfig = { + defaults = { + unit = "none" + thresholds = { + mode = "absolute" + steps = [{ + color = "green" + value = null + }] + } + } + overrides = [] + } + targets = [{ + refId = "A" + legendFormat = "running demo pods" + expr = "sum(kube_pod_status_phase{namespace=\"${value.namespace}\",phase=\"Running\",pod=~\"${key}-demo-app-.*|${local.landing_zone_namespace_services[key].service_account_name}-sample-load.*\"} == 1)" + }] + }, + { + id = 2 + title = "Pods Running (All in Namespace)" + type = "stat" + gridPos = { + h = 5 + w = 6 + x = 6 + y = 0 + } + datasource = "Thanos" + options = { + colorMode = "value" + graphMode = "none" + justifyMode = "auto" + reduceOptions = { + calcs = ["lastNotNull"] + fields = "" + values = false + } + textMode = "auto" + } + fieldConfig = { + defaults = { + unit = "none" + thresholds = { + mode = "absolute" + steps = [{ + color = "blue" + value = null + }] + } + } + overrides = [] + } + targets = [{ + refId = "A" + legendFormat = "running pods" + expr = "sum(kube_pod_status_phase{namespace=\"${value.namespace}\",phase=\"Running\"} == 1)" + }] + }, + { + id = 3 + title = "Services in Namespace" + type = "stat" + gridPos = { + h = 5 + w = 6 + x = 12 + y = 0 + } + datasource = "Thanos" + options = { + colorMode = "value" + graphMode = "none" + justifyMode = "auto" + reduceOptions = { + calcs = ["lastNotNull"] + fields = "" + values = false + } + textMode = "auto" + } + fieldConfig = { + defaults = { + unit = "none" + thresholds = { + mode = "absolute" + steps = [{ + color = "green" + value = null + }] + } + } + overrides = [] + } + targets = [{ + refId = "A" + legendFormat = "services" + expr = "count(kube_service_info{namespace=\"${value.namespace}\"})" + }] + }, + { + id = 4 + title = "Ready Containers" + type = "stat" + gridPos = { + h = 5 + w = 6 + x = 0 + y = 5 + } + datasource = "Thanos" + fieldConfig = { + defaults = { + unit = "none" + } + overrides = [] + } + options = { + colorMode = "value" + graphMode = "none" + justifyMode = "auto" + reduceOptions = { + calcs = ["lastNotNull"] + fields = "" + values = false + } + textMode = "auto" + } + targets = [{ + refId = "A" + legendFormat = "ready containers" + expr = "sum(kube_pod_container_status_ready{namespace=\"${value.namespace}\"} == 1)" + }] + }, + { + id = 5 + title = "Available Deployment Replicas" + type = "stat" + gridPos = { + h = 5 + w = 6 + x = 6 + y = 5 + } + datasource = "Thanos" + fieldConfig = { + defaults = { + unit = "none" + } + overrides = [] + } + options = { + colorMode = "value" + graphMode = "none" + justifyMode = "auto" + reduceOptions = { + calcs = ["lastNotNull"] + fields = "" + values = false + } + textMode = "auto" + } + targets = [{ + refId = "A" + legendFormat = "available replicas" + expr = "sum(kube_deployment_status_replicas_available{namespace=\"${value.namespace}\"})" + }] + }, + { + id = 6 + title = "Sample Load Pod Running" + type = "stat" + gridPos = { + h = 5 + w = 6 + x = 12 + y = 5 + } + datasource = "Thanos" + fieldConfig = { + defaults = { + unit = "none" + } + overrides = [] + } + options = { + colorMode = "value" + graphMode = "none" + justifyMode = "auto" + reduceOptions = { + calcs = ["lastNotNull"] + fields = "" + values = false + } + textMode = "auto" + } + targets = [{ + refId = "A" + legendFormat = "sample load running" + expr = "sum(kube_pod_status_phase{namespace=\"${value.namespace}\",phase=\"Running\",pod=~\"${local.landing_zone_namespace_services[key].service_account_name}-sample-load.*\"} == 1)" + }] + }, + { + id = 7 + title = "Namespace Pods by Phase" + type = "timeseries" + gridPos = { + h = 8 + w = 24 + x = 0 + y = 10 + } + datasource = "Thanos" + fieldConfig = { + defaults = { + unit = "none" + } + overrides = [] + } + options = { + legend = { + displayMode = "list" + placement = "bottom" + } + tooltip = { + mode = "multi" + } + } + targets = [{ + refId = "A" + legendFormat = "{{phase}}" + expr = "sum by (phase) (kube_pod_status_phase{namespace=\"${value.namespace}\"} == 1)" + }] + } + ] + }) + } +} + +resource "null_resource" "landing_zone_demo_grafana_dashboard" { + for_each = { + for key, value in local.landing_zone_namespace_services_demo : key => value + if value.demo.dashboard_example_enabled && try(module.landing_zone[key].observability_grafana_url, null) != null + } + + triggers = { + dashboard_sha = sha256(local.landing_zone_demo_dashboard_json[each.key]) + grafana_url = module.landing_zone[each.key].observability_grafana_url + namespace = each.value.namespace + } + + provisioner "local-exec" { + command = <<-EOT + cat < value + if value.enable_kubernetes_access + } + + metadata { + name = each.value.service_account_name + namespace = kubernetes_namespace_v1.landing_zone[each.key].metadata[0].name + + labels = { + "stackit.cloud/landing-zone" = each.key + "stackit.cloud/access-scope" = "namespace" + } + } +} + +resource "kubernetes_role_v1" "landing_zone_user" { + provider = kubernetes.platform + + for_each = kubernetes_service_account_v1.landing_zone_user + + metadata { + name = "${each.value.metadata[0].name}-role" + namespace = each.value.metadata[0].namespace + } + + rule { + api_groups = [""] + resources = ["pods", "pods/log", "services", "configmaps", "events", "serviceaccounts"] + verbs = ["get", "list", "watch", "create", "update", "patch", "delete"] + } + + dynamic "rule" { + for_each = local.landing_zone_namespace_services[each.key].secrets_enforcement.enabled ? [] : [1] + + content { + api_groups = [""] + resources = ["secrets"] + verbs = ["get", "list", "watch", "create", "update", "patch", "delete"] + } + } + + rule { + api_groups = ["apps"] + resources = ["deployments", "replicasets", "statefulsets", "daemonsets"] + verbs = ["get", "list", "watch", "create", "update", "patch", "delete"] + } + + rule { + api_groups = ["batch"] + resources = ["jobs", "cronjobs"] + verbs = ["get", "list", "watch", "create", "update", "patch", "delete"] + } + + rule { + api_groups = ["networking.k8s.io"] + resources = ["ingresses", "networkpolicies"] + verbs = ["get", "list", "watch", "create", "update", "patch", "delete"] + } + + rule { + api_groups = ["autoscaling"] + resources = ["horizontalpodautoscalers"] + verbs = ["get", "list", "watch", "create", "update", "patch", "delete"] + } +} + +resource "kubernetes_role_binding_v1" "landing_zone_user" { + provider = kubernetes.platform + + for_each = kubernetes_service_account_v1.landing_zone_user + + metadata { + name = "${each.value.metadata[0].name}-binding" + namespace = each.value.metadata[0].namespace + } + + role_ref { + api_group = "rbac.authorization.k8s.io" + kind = "Role" + name = kubernetes_role_v1.landing_zone_user[each.key].metadata[0].name + } + + subject { + kind = "ServiceAccount" + name = each.value.metadata[0].name + namespace = each.value.metadata[0].namespace + } +} + +resource "kubernetes_secret_v1" "landing_zone_user_token" { + provider = kubernetes.platform + + for_each = kubernetes_service_account_v1.landing_zone_user + + metadata { + name = "${each.value.metadata[0].name}-token" + namespace = each.value.metadata[0].namespace + annotations = { + "kubernetes.io/service-account.name" = each.value.metadata[0].name + } + } + + type = "kubernetes.io/service-account-token" +} + +resource "kubernetes_pod_v1" "landing_zone_sample_load" { + provider = kubernetes.platform + + for_each = { + for key, value in kubernetes_service_account_v1.landing_zone_user : key => value + if local.landing_zone_namespace_services[key].sample_load.enabled + } + + metadata { + name = "${each.value.metadata[0].name}-sample-load" + namespace = each.value.metadata[0].namespace + + labels = { + "stackit.cloud/landing-zone" = each.key + "stackit.cloud/sample-load" = "true" + } + } + + spec { + restart_policy = "Never" + + container { + name = "sample" + image = local.landing_zone_namespace_services[each.key].sample_load.image + command = ["sh", "-c", "ls -la /mnt/secret && cat /mnt/secret/token | head -c 40 || true; sleep 3600"] + + volume_mount { + name = "namespace-token" + mount_path = "/mnt/secret" + read_only = true + } + } + + volume { + name = "namespace-token" + + secret { + secret_name = kubernetes_secret_v1.landing_zone_user_token[each.key].metadata[0].name + } + } + } +} diff --git a/src/outputs.tf b/src/outputs.tf index 647d98e..5334b42 100644 --- a/src/outputs.tf +++ b/src/outputs.tf @@ -37,6 +37,22 @@ output "connectivity_firewall_public_ip" { value = try(module.connectivity[0].firewall_public_ip, null) } +output "platform_kubernetes_projects" { + description = "Map of platform Kubernetes projects and cluster metadata per key." + value = { + for k, v in module.platform_kubernetes : k => { + project_id = v.project_id + project_name = v.project_name + ske_cluster_name = v.ske_cluster_name + ske_cluster_region = v.ske_cluster_region + observability_instance_id = v.observability_instance_id + encrypted_volume_support = v.encrypted_volume_support + debug_bastion = v.debug_bastion + dns_extension_zones = v.dns_extension_zones + } + } +} + output "sandbox_projects" { description = "The created sandbox projects." value = length(module.sandboxes) > 0 ? module.sandboxes[0].projects : {} @@ -46,11 +62,155 @@ output "landing_zone_projects" { description = "Map of landing zone project IDs." value = { for k, v in module.landing_zone : k => { - project_id = v.project_id - project_name = v.project_name - dns_zone_name = v.dns_zone_dns_name - landing_zone_type = v.landing_zone_type - connected_network_area_id = v.connected_network_area_id == null ? "" : v.connected_network_area_id + project_id = v.project_id + project_name = v.project_name + dns_zone_name = v.dns_zone_dns_name + secretsmanager_instance_id = v.secretsmanager_instance_id + observability_instance_id = v.observability_instance_id + observability_grafana_url = v.observability_grafana_url + observability_grafana_user = v.observability_grafana_admin_user + observability_metrics_push_url = v.observability_metrics_push_url + landing_zone_type = v.landing_zone_type + connected_network_area_id = v.connected_network_area_id == null ? "" : v.connected_network_area_id + } + } +} + +output "landing_zone_observability_access" { + description = "Sensitive Grafana access data for landing zone observability instances." + sensitive = true + value = { + for k, v in module.landing_zone : k => { + grafana_url = v.observability_grafana_url + grafana_admin_user = v.observability_grafana_admin_user + grafana_admin_password = v.observability_grafana_admin_password + } + } +} + +output "landing_zone_namespace_services" { + description = "Map of created landing zone namespace services in the central platform Kubernetes cluster." + value = { + for k, v in kubernetes_namespace_v1.landing_zone : k => { + namespace = v.metadata[0].name + labels = v.metadata[0].labels + annotations = v.metadata[0].annotations + } + } +} + +output "landing_zone_namespace_service_requests" { + description = "Map of resolved landing zone namespace-service requests before Kubernetes apply-time metadata resolution." + value = { + for k, v in local.landing_zone_namespace_services : k => { + namespace = v.namespace + dns_fqdn = v.dns_fqdn + use_secretsmanager = v.use_secretsmanager + secrets_enforcement = { + enabled = v.secrets_enforcement.enabled + mode = v.secrets_enforcement.mode + policy_engine = "kyverno" + } + } + } +} + +output "landing_zone_namespace_secret_enforcement" { + description = "Map of resolved secret-enforcement settings per enabled landing zone namespace service." + value = { + for k, v in local.landing_zone_namespace_services : k => { + enabled = v.secrets_enforcement.enabled + mode = v.secrets_enforcement.mode + policy_engine = "kyverno" + allow_opaque_secret_types = v.secrets_enforcement.allow_opaque_secret_types + break_glass = v.secrets_enforcement.break_glass + } + } +} + +output "landing_zone_namespace_secret_enforcement_policies" { + description = "Map of created namespace-level secret-enforcement policy objects." + value = { + for k, v in kubernetes_manifest.landing_zone_secret_enforcement_policy : k => { + name = v.manifest.metadata.name + namespace = v.manifest.metadata.namespace + engine = "kyverno" + mode = local.landing_zone_namespace_services[k].secrets_enforcement.mode + } + } +} + +output "landing_zone_namespace_users" { + description = "Map of namespace-scoped Kubernetes access identities for enabled landing zone namespace services." + value = { + for k, v in kubernetes_service_account_v1.landing_zone_user : k => { + namespace = v.metadata[0].namespace + service_account_name = v.metadata[0].name + role_name = kubernetes_role_v1.landing_zone_user[k].metadata[0].name + role_binding_name = kubernetes_role_binding_v1.landing_zone_user[k].metadata[0].name + token_secret_name = kubernetes_secret_v1.landing_zone_user_token[k].metadata[0].name + } + } +} + +output "landing_zone_namespace_user_kubeconfigs" { + description = "Map of namespace-scoped kubeconfigs for landing zone namespace users." + sensitive = true + value = { + for k, v in kubernetes_service_account_v1.landing_zone_user : k => yamlencode({ + apiVersion = "v1" + kind = "Config" + clusters = [{ + name = "platform" + cluster = { + server = yamldecode(local.platform_kubernetes_kube_config).clusters[0].cluster.server + certificate-authority-data = yamldecode(local.platform_kubernetes_kube_config).clusters[0].cluster["certificate-authority-data"] + } + }] + users = [{ + name = v.metadata[0].name + user = { + token = lookup(kubernetes_secret_v1.landing_zone_user_token[k].data, "token", null) + } + }] + contexts = [{ + name = "${v.metadata[0].name}@platform" + context = { + cluster = "platform" + user = v.metadata[0].name + namespace = v.metadata[0].namespace + } + }] + current-context = "${v.metadata[0].name}@platform" + }) + } +} + +output "landing_zone_namespace_sample_load" { + description = "Map of optional namespace sample-load pods that mount the namespace user token secret." + value = { + for k, v in kubernetes_pod_v1.landing_zone_sample_load : k => { + namespace = v.metadata[0].namespace + pod_name = v.metadata[0].name + mounted_secret_name = kubernetes_secret_v1.landing_zone_user_token[k].metadata[0].name + phase = try(v.status[0].phase, null) + } + } +} + +output "landing_zone_namespace_demo_samples" { + description = "Map of optional end-to-end demo resources for namespace services (external secret, service, ingress, dashboard example)." + value = { + for k, v in kubernetes_deployment_v1.landing_zone_demo_app : k => { + namespace = v.metadata[0].namespace + deployment_name = v.metadata[0].name + service_name = kubernetes_service_v1.landing_zone_demo_app[k].metadata[0].name + ingress_name = kubernetes_ingress_v1.landing_zone_demo_app[k].metadata[0].name + ingress_host = local.landing_zone_namespace_services[k].demo.ingress_host + external_secret_name = contains(keys(kubernetes_manifest.landing_zone_demo_external_secret), k) ? kubernetes_manifest.landing_zone_demo_external_secret[k].manifest.metadata.name : null + target_secret_name = contains(keys(kubernetes_manifest.landing_zone_demo_external_secret), k) ? kubernetes_manifest.landing_zone_demo_external_secret[k].manifest.spec.target.name : null + dashboard_configmap = contains(keys(kubernetes_config_map_v1.landing_zone_demo_dashboard_example), k) ? kubernetes_config_map_v1.landing_zone_demo_dashboard_example[k].metadata[0].name : null + observability_instance = module.landing_zone[k].observability_instance_id } } } diff --git a/src/providers.tf b/src/providers.tf index 0dda673..358a2b9 100644 --- a/src/providers.tf +++ b/src/providers.tf @@ -4,6 +4,59 @@ provider "stackit" { experiments = ["iam", "routing-tables", "network"] } +locals { + platform_kubernetes_cluster_key = try(one([ + for key, value in module.platform_kubernetes : key + if value.ske_cluster_region == var.region + ]), null) + + platform_kubernetes_kube_config = local.platform_kubernetes_cluster_key != null ? module.platform_kubernetes[local.platform_kubernetes_cluster_key].kube_config : null +} + +provider "kubernetes" { + alias = "platform" + + host = try( + yamldecode(local.platform_kubernetes_kube_config).clusters[0].cluster.server, + null + ) + client_certificate = try( + base64decode(yamldecode(local.platform_kubernetes_kube_config).users[0].user["client-certificate-data"]), + null + ) + client_key = try( + base64decode(yamldecode(local.platform_kubernetes_kube_config).users[0].user["client-key-data"]), + null + ) + cluster_ca_certificate = try( + base64decode(yamldecode(local.platform_kubernetes_kube_config).clusters[0].cluster["certificate-authority-data"]), + null + ) +} + +provider "helm" { + alias = "platform" + + kubernetes = { + host = try( + yamldecode(local.platform_kubernetes_kube_config).clusters[0].cluster.server, + null + ) + client_certificate = try( + base64decode(yamldecode(local.platform_kubernetes_kube_config).users[0].user["client-certificate-data"]), + null + ) + client_key = try( + base64decode(yamldecode(local.platform_kubernetes_kube_config).users[0].user["client-key-data"]), + null + ) + cluster_ca_certificate = try( + base64decode(yamldecode(local.platform_kubernetes_kube_config).clusters[0].cluster["certificate-authority-data"]), + null + ) + } +} + provider "vault" { address = "https://prod.sm.eu01.stackit.cloud" skip_child_token = true diff --git a/src/terraform.tf b/src/terraform.tf index e014176..8c8b18b 100644 --- a/src/terraform.tf +++ b/src/terraform.tf @@ -6,6 +6,14 @@ terraform { source = "stackitcloud/stackit" version = "0.98.0" } + kubernetes = { + source = "hashicorp/kubernetes" + version = ">= 2.30.0" + } + helm = { + source = "hashicorp/helm" + version = ">= 2.14.0" + } time = { source = "hashicorp/time" version = "0.14.0" @@ -14,5 +22,9 @@ terraform { source = "hashicorp/vault" version = "5.9.0" } + null = { + source = "hashicorp/null" + version = ">= 3.2.1" + } } } \ No newline at end of file diff --git a/src/variables.tf b/src/variables.tf index c04ef42..10cbc88 100644 --- a/src/variables.tf +++ b/src/variables.tf @@ -61,6 +61,75 @@ variable "devops" { default = null } +variable "platform_kubernetes" { + type = map(object({ + region = string + network = optional(object({ + mode = optional(string, "public") + sna_network_area_id = 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) + volume_size = optional(number, 20) + volume_type = optional(string, "storage_premium_perf1") + os_name = optional(string, "flatcar") + labels = optional(map(string), {}) + })), []) + 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") + }), {}) + }) + })) + description = "Map of central, region-scoped platform Kubernetes deployments. Empty map skips deployment." + default = {} +} + variable "observability" { type = object({ plan_name = optional(string, "Observability-Starter-EU01") @@ -195,7 +264,104 @@ variable "landing_zones" { description = string permissions = list(string) })), []) + observability = optional(object({ + enabled = optional(bool, false) + plan_name = optional(string, "Observability-Starter-EU01") + acl = optional(list(string), []) + name = optional(string, null) + }), {}) + namespace_service = optional(object({ + enabled = optional(bool, false) + namespace = optional(string, null) + dns_subdomain = optional(string, null) + secretsmanager = optional(bool, true) + demo = optional(object({ + enabled = optional(bool, false) + image = optional(string, "hashicorp/http-echo:1.0.0") + ingress_class_name = optional(string, "lz-demo") + install_ingress_controller = optional(bool, true) + external_secret_enabled = optional(bool, true) + dashboard_example_enabled = optional(bool, true) + }), {}) + sample_load = optional(object({ + enabled = optional(bool, false) + image = optional(string, "busybox:1.36") + }), {}) + secrets_enforcement = optional(object({ + enabled = optional(bool, false) + mode = optional(string, "audit") + allow_opaque_secret_types = optional(list(string), []) + break_glass = optional(object({ + enabled = optional(bool, true) + ttl_hours = optional(number, 24) + principals = optional(list(string), []) + }), {}) + }), {}) + kubernetes_access = optional(object({ + enabled = optional(bool, true) + service_account_name = optional(string, null) + }), {}) + labels = optional(map(string), {}) + annotations = optional(map(string), {}) + }), {}) })) description = "Map of landing zones to create. Set corporate = true for network area connectivity, false for public." default = {} + + validation { + condition = alltrue([ + for lz in values(var.landing_zones) : + lz.namespace_service.namespace == null ? true : ( + length(lz.namespace_service.namespace) <= 63 && + can(regex("^[a-z0-9]([-a-z0-9]*[a-z0-9])?$", lz.namespace_service.namespace)) + ) + ]) + error_message = "If namespace_service.namespace is set, it must be a valid Kubernetes DNS-1123 label (<=63 chars, lowercase alphanumeric and '-', must start/end with alphanumeric)." + } + + validation { + condition = alltrue([ + for lz in values(var.landing_zones) : + lz.namespace_service.dns_subdomain == null ? true : ( + length(lz.namespace_service.dns_subdomain) <= 63 && + can(regex("^[a-z0-9]([-a-z0-9]*[a-z0-9])?$", lz.namespace_service.dns_subdomain)) + ) + ]) + error_message = "If namespace_service.dns_subdomain is set, it must be a valid DNS label (<=63 chars, lowercase alphanumeric and '-', must start/end with alphanumeric)." + } + + validation { + condition = alltrue([ + for lz in values(var.landing_zones) : + lz.namespace_service.dns_subdomain == null || lz.namespace_service.enabled + ]) + error_message = "namespace_service.dns_subdomain can only be set when namespace_service.enabled is true." + } + + validation { + condition = alltrue([ + for lz in values(var.landing_zones) : + lz.namespace_service.kubernetes_access.service_account_name == null ? true : ( + length(lz.namespace_service.kubernetes_access.service_account_name) <= 63 && + can(regex("^[a-z0-9]([-a-z0-9]*[a-z0-9])?$", lz.namespace_service.kubernetes_access.service_account_name)) + ) + ]) + error_message = "If namespace_service.kubernetes_access.service_account_name is set, it must be a valid Kubernetes DNS-1123 label (<=63 chars, lowercase alphanumeric and '-', must start/end with alphanumeric)." + } + + validation { + condition = alltrue([ + for lz in values(var.landing_zones) : + contains(["audit", "soft", "strict"], lower(lz.namespace_service.secrets_enforcement.mode)) + ]) + error_message = "namespace_service.secrets_enforcement.mode must be one of: audit, soft, strict." + } + + validation { + condition = alltrue([ + for lz in values(var.landing_zones) : + lz.namespace_service.secrets_enforcement.break_glass.ttl_hours > 0 + ]) + error_message = "namespace_service.secrets_enforcement.break_glass.ttl_hours must be greater than 0." + } } From e0b6b44fbf5f343a73e5e3433bb9fdcf0ad5e92a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Weberru=C3=9F?= Date: Fri, 12 Jun 2026 15:51:43 +0000 Subject: [PATCH 2/4] test: extend hub-spoke coverage for platform kubernetes and namespace service --- .gitignore | 1 + src/config/hub-and-spoke.tfvars | 42 ++++++++ src/tests/hub_spoke.tftest.hcl | 176 +++++++++++++++++++++++++++++++- 3 files changed, 218 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 9d32d78..06966c9 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,7 @@ override.tf.json # Include tfplan files to ignore the plan output of command: terraform plan -out=tfplan # example: *tfplan* +*tfplan* # Ignore CLI configuration files .terraformrc diff --git a/src/config/hub-and-spoke.tfvars b/src/config/hub-and-spoke.tfvars index d6c96bf..9b513d3 100644 --- a/src/config/hub-and-spoke.tfvars +++ b/src/config/hub-and-spoke.tfvars @@ -86,6 +86,40 @@ connectivity = { # allowed_network_ranges = ["0.0.0.0/0"] # } +# platform_kubernetes = { +# "eu01" = { +# region = "eu01" +# network = { +# mode = "sna" +# } +# cluster = { +# name = "pltfmk8s" +# kubernetes_version_min = "1.35" +# node_pools = [ +# { +# name = "small-a" +# machine_type = "g3i.4" +# minimum = 2 +# maximum = 2 +# availability_zones = ["eu01-1"] +# }, +# { +# name = "small-b" +# machine_type = "g3i.4" +# minimum = 2 +# maximum = 2 +# availability_zones = ["eu01-2"] +# } +# ] +# } +# +# # Defaults to disabled. Set true to enable encrypted storage class setup. +# encrypted_volumes = { +# enabled = false +# } +# } +# } + ############### ## SANDBOXES ## ############### @@ -112,6 +146,14 @@ landing_zones = { # Set corporate = true for network area connectivity, false for public internet corporate = true network_prefix_length = 24 + + # Optional: create namespace service in central platform Kubernetes cluster + # namespace_service = { + # enabled = true + # namespace = "data-prod" + # dns_subdomain = "app" + # secretsmanager = true + # } } # Public landing zone — no network area, uses STACKIT's default public networking diff --git a/src/tests/hub_spoke.tftest.hcl b/src/tests/hub_spoke.tftest.hcl index 90dfccc..a6206fe 100644 --- a/src/tests/hub_spoke.tftest.hcl +++ b/src/tests/hub_spoke.tftest.hcl @@ -38,6 +38,41 @@ variables { allowed_network_ranges = ["0.0.0.0/0"] } + platform_kubernetes = { + "eu01" = { + region = "eu01" + network = { + mode = "sna" + } + dns = { + enabled = true + zones = ["apps.test-corp.stackit.run"] + } + observability = { + enabled = false + } + cluster = { + name = "pltfmk8s" + node_pools = [ + { + name = "small-a" + machine_type = "g3i.4" + minimum = 2 + maximum = 2 + availability_zones = ["eu01-1"] + }, + { + name = "small-b" + machine_type = "g3i.4" + minimum = 2 + maximum = 2 + availability_zones = ["eu01-2"] + } + ] + } + } + } + connectivity = { dns_zones = { "test-corp" = { @@ -63,6 +98,12 @@ variables { env = "test" corporate = true network_prefix_length = 25 + namespace_service = { + enabled = true + namespace = "tcorp-test" + dns_subdomain = "app" + secretsmanager = true + } } "test-public" = { project_name = "Test Public LZ" @@ -85,6 +126,21 @@ run "hub_spoke_plan" { error_message = "Firewall public IP must be null when no firewall is configured." } + assert { + condition = length(output.platform_kubernetes_projects) == 1 + error_message = "Expected 1 platform Kubernetes project to be configured." + } + + assert { + condition = output.platform_kubernetes_projects["eu01"].ske_cluster_region == "eu01" + error_message = "Platform Kubernetes cluster region must be eu01." + } + + assert { + condition = contains(output.platform_kubernetes_projects["eu01"].dns_extension_zones, "apps.test-corp.stackit.run") + error_message = "Platform Kubernetes DNS extension must include apps.test-corp.stackit.run." + } + assert { condition = length(output.landing_zone_projects) == 2 error_message = "Expected 2 landing zones to be created." @@ -99,4 +155,122 @@ run "hub_spoke_plan" { condition = output.landing_zone_projects["test-public"].landing_zone_type == "public" error_message = "test-public must be a public landing zone." } -} \ No newline at end of file + + assert { + condition = length(output.landing_zone_namespace_services) == 1 + error_message = "Expected 1 landing zone namespace service to be created." + } + + assert { + condition = output.landing_zone_namespace_services["test-corporate"].namespace == "tcorp-test" + error_message = "Expected namespace tcorp-test for test-corporate namespace service." + } + + assert { + condition = output.landing_zone_namespace_service_requests["test-corporate"].dns_fqdn == "app.tcorp-test-eu01-test-corp.stackit.run" + error_message = "Expected namespace-service DNS annotation app.tcorp-test-eu01-test-corp.stackit.run." + } + + assert { + condition = length(output.landing_zone_namespace_users) == 1 + error_message = "Expected one namespace-scoped Kubernetes user for the enabled namespace service." + } + + assert { + condition = output.landing_zone_namespace_users["test-corporate"].namespace == "tcorp-test" + error_message = "Expected namespace-scoped Kubernetes user bound to namespace tcorp-test." + } +} + +run "secrets_enforcement_audit_plan" { + command = plan + + variables { + landing_zones = { + "test-corporate" = { + project_name = "Test Corporate LZ" + project_code = "tcorp" + owner_email = "example@digits.schwarz" + env = "test" + corporate = true + network_prefix_length = 25 + namespace_service = { + enabled = true + namespace = "tcorp-test" + dns_subdomain = "app" + secretsmanager = true + secrets_enforcement = { + enabled = false + mode = "audit" + } + } + } + "test-public" = { + project_name = "Test Public LZ" + project_code = "tpub" + owner_email = "example@digits.schwarz" + env = "test" + corporate = false + } + } + } + + assert { + condition = output.landing_zone_namespace_secret_enforcement["test-corporate"].enabled == false + error_message = "Expected secrets enforcement to remain disabled unless explicitly enabled for policy rollout." + } + + assert { + condition = output.landing_zone_namespace_secret_enforcement["test-corporate"].mode == "audit" + error_message = "Expected audit mode for secrets enforcement." + } + + assert { + condition = length(output.landing_zone_namespace_secret_enforcement_policies) == 0 + error_message = "Expected no namespace policy objects while secrets enforcement is disabled." + } +} + +run "secrets_enforcement_strict_plan" { + command = plan + + variables { + landing_zones = { + "test-corporate" = { + project_name = "Test Corporate LZ" + project_code = "tcorp" + owner_email = "example@digits.schwarz" + env = "test" + corporate = true + network_prefix_length = 25 + namespace_service = { + enabled = true + namespace = "tcorp-test" + dns_subdomain = "app" + secretsmanager = true + secrets_enforcement = { + enabled = false + mode = "strict" + } + } + } + "test-public" = { + project_name = "Test Public LZ" + project_code = "tpub" + owner_email = "example@digits.schwarz" + env = "test" + corporate = false + } + } + } + + assert { + condition = output.landing_zone_namespace_secret_enforcement["test-corporate"].mode == "strict" + error_message = "Expected strict mode for secrets enforcement." + } + + assert { + condition = length(output.landing_zone_namespace_secret_enforcement_policies) == 0 + error_message = "Expected no namespace policy objects while secrets enforcement is disabled." + } +} From 9a6b208aad0810bb1c5178e43b40a6990f9ecde9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Weberru=C3=9F?= Date: Fri, 12 Jun 2026 15:51:47 +0000 Subject: [PATCH 3/4] fix(connectivity): allow deploys without firewall image path --- src/modules/connectivity/5-firewall.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/connectivity/5-firewall.tf b/src/modules/connectivity/5-firewall.tf index 52f2f3b..5654c43 100644 --- a/src/modules/connectivity/5-firewall.tf +++ b/src/modules/connectivity/5-firewall.tf @@ -7,7 +7,7 @@ resource "stackit_image" "firewall" { project_id = stackit_resourcemanager_project.this.project_id name = var.firewall.name - local_file_path = "./firewall-image.qcow2" + local_file_path = var.firewall != null ? "${path.root}/firewall-image.qcow2" : null disk_format = "qcow2" min_disk_size = 16 min_ram = 2 From 34e27c6b4ab3aeae30410604ed4f61c739fc75c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Weberru=C3=9F?= Date: Thu, 18 Jun 2026 16:44:50 +0000 Subject: [PATCH 4/4] Finalize PR39 review follow-ups and Gateway API DNS bridge --- .gitignore | 6 +- ISSUE_41_UPDATE.md | 28 + PR39_REVIEW_REPLIES.md | 74 + PR39_REVIEW_TODO.md | 174 +++ README.md | 2 +- docs/getting-started.md | 64 +- src/config/hub-and-spoke.tfvars | 36 +- src/main.tf | 7 +- .../connectivity/3-external-network.tf | 6 +- .../connectivity/4-internal-network.tf | 4 +- src/modules/connectivity/5-firewall.tf | 13 +- src/modules/connectivity/outputs.tf | 4 +- src/modules/connectivity/terraform.tf | 4 +- src/modules/debug-bastion/main.tf | 109 ++ src/modules/debug-bastion/outputs.tf | 29 + src/modules/debug-bastion/terraform.tf | 8 + src/modules/debug-bastion/variables.tf | 76 + src/modules/devops/terraform.tf | 2 +- src/modules/governance/terraform.tf | 4 +- src/modules/landing-zone/8-observability.tf | 2 +- src/modules/landing-zone/outputs.tf | 16 +- src/modules/landing-zone/terraform.tf | 4 +- src/modules/management/terraform.tf | 6 +- .../dashboards/namespace-overview.json.tmpl | 215 +++ src/modules/namespace-service-demo/main.tf | 160 ++ src/modules/namespace-service-demo/outputs.tf | 25 + .../namespace-service-demo/variables.tf | 36 + src/modules/platform-kubernetes/1-project.tf | 2 +- .../platform-kubernetes/2-dns-zones.tf | 12 - .../2-network-area-membership.tf | 8 - src/modules/platform-kubernetes/2-network.tf | 62 + .../platform-kubernetes/2-observability.tf | 2 +- .../platform-kubernetes/2-sna-network.tf | 8 - src/modules/platform-kubernetes/3-cluster.tf | 54 +- .../platform-kubernetes/5-debug-bastion.tf | 124 +- src/modules/platform-kubernetes/outputs.tf | 15 +- src/modules/platform-kubernetes/terraform.tf | 6 +- src/modules/platform-kubernetes/variables.tf | 68 +- src/modules/sandboxes/terraform.tf | 2 +- src/namespace-service.tf | 1379 ++++------------- src/outputs.tf | 141 +- src/providers.tf | 9 +- src/terraform.tf | 18 +- src/tests/hub_spoke.tftest.hcl | 125 +- src/variables.tf | 199 ++- 45 files changed, 1701 insertions(+), 1647 deletions(-) create mode 100644 ISSUE_41_UPDATE.md create mode 100644 PR39_REVIEW_REPLIES.md create mode 100644 PR39_REVIEW_TODO.md create mode 100644 src/modules/debug-bastion/main.tf create mode 100644 src/modules/debug-bastion/outputs.tf create mode 100644 src/modules/debug-bastion/terraform.tf create mode 100644 src/modules/debug-bastion/variables.tf create mode 100644 src/modules/namespace-service-demo/dashboards/namespace-overview.json.tmpl create mode 100644 src/modules/namespace-service-demo/main.tf create mode 100644 src/modules/namespace-service-demo/outputs.tf create mode 100644 src/modules/namespace-service-demo/variables.tf delete mode 100644 src/modules/platform-kubernetes/2-dns-zones.tf delete mode 100644 src/modules/platform-kubernetes/2-network-area-membership.tf create mode 100644 src/modules/platform-kubernetes/2-network.tf delete mode 100644 src/modules/platform-kubernetes/2-sna-network.tf diff --git a/.gitignore b/.gitignore index 06966c9..01ef13f 100644 --- a/.gitignore +++ b/.gitignore @@ -43,4 +43,8 @@ terraform.rc openai.token .env .env.local -.cache/ \ No newline at end of file +.cache/ + +# Local persistent E2E runtime variables (canonical for this troubleshooting phase) +src/config/e2e.tfvars +src/config/e2e-bootstrap.override.tfvars \ No newline at end of file diff --git a/ISSUE_41_UPDATE.md b/ISSUE_41_UPDATE.md new file mode 100644 index 0000000..bd0a479 --- /dev/null +++ b/ISSUE_41_UPDATE.md @@ -0,0 +1,28 @@ +# Issue 41 Update Draft + +Title suggestion: +Gateway API DNS extension gap in Terraform provider: temporary record-set bridge for Envoy Gateway + +## Summary +We migrated from NGINX ingress to Envoy Gateway / Gateway API (`Gateway` + `HTTPRoute`) for the namespace demo path. + +The remaining platform/provider gap is Terraform support for `extensions.dns.gatewayApi`. +Until this is available in the provider, DNS must be managed via Terraform `stackit_dns_record_set` resources. + +## Current stable workaround +- Discover Envoy-managed LoadBalancer service endpoint in-cluster via Terraform (`kubernetes_resources` + labels). +- Create DNS record set in the corresponding landing-zone DNS zone: + - `A` when an IP endpoint is available. + - `CNAME` when a hostname endpoint is available. +- Keep record creation guarded by a precondition that requires a resolvable endpoint. + +## Why this replaced the first workaround idea +Initial workaround ideas depended on provider features that were not consistently available in OpenTofu runtime schema / behavior. +The record-set bridge is now purely Terraform-managed, deterministic, and validated in apply/plan cycles. + +## Acceptance criteria for closing this issue +- Terraform provider exposes and supports `extensions.dns.gatewayApi` end-to-end for Gateway API resources. +- Existing record-set bridge can be removed without losing automated DNS convergence for Gateway API listeners. + +## Requested provider capability +Please add first-class Terraform support for DNS automation based on Gateway API listeners/hostnames (`extensions.dns.gatewayApi`) so manual record-set bridging is no longer necessary. diff --git a/PR39_REVIEW_REPLIES.md b/PR39_REVIEW_REPLIES.md new file mode 100644 index 0000000..acbe5cd --- /dev/null +++ b/PR39_REVIEW_REPLIES.md @@ -0,0 +1,74 @@ +# PR 39 Review Replies (Copy/Paste) + +Purpose: ready-to-post reply texts for each referenced review thread. +Status basis: current branch state with validated plan/apply in E2E context. + +## Ref 01, 02, 03 (Node pools) +Implemented. We moved the default node pool model into variable defaults and kept the explicit separation between `system` and `application` pools. `allow_system_components` is only true in the `system` pool. + +## Ref 04 (NGINX -> Gateway Controller) +Implemented as an alternative with Envoy Gateway and Gateway API (`Gateway` + `HTTPRoute`). +For DNS, we currently use Terraform-managed `stackit_dns_record_set` as a temporary bridge because provider-native support for `extensions.dns.gatewayApi` is still missing. This gap is tracked in Issue 41 (updated description). + +## Ref 05 (Demo scope) +Implemented. Namespace demo resources are optional and only active when demo flags are enabled. They are no longer an unavoidable default path. + +## Ref 06 (null provider) +Implemented. Active code no longer relies on `null_resource`/`hashicorp/null` for the reviewed paths. + +## Ref 07 (Provider pinning) +Implemented. Open provider constraints were replaced with planable version ranges (`~>`) across root/module provider definitions. + +## Ref 08, 09 (Input validation location) +Implemented. Input validations were moved into `variable.validation` where applicable; runtime checks are no longer used as the primary input-validation mechanism for these reviewed cases. + +## Ref 10 (Deprecated API version) +Implemented. Deprecated API usages from the reviewed scope were updated to supported versions. + +## Ref 11 (Debug bastion module) +Implemented. Debug bastion is now an isolated submodule (`modules/debug-bastion`) and integrated optionally from platform-kubernetes. + +## Ref 12 (SNA egress routing) +Implemented. Routing-table based default route via firewall next hop is modeled for SNA egress path. + +## Ref 13 (Network file consolidation) +Implemented. Relevant platform-kubernetes network files were consolidated into `2-network.tf`. + +## Ref 14 (Naming suffix consistency) +Implemented in the reviewed scope. + +## Ref 15 (Grafana user/password outputs) +Implemented with the agreed nuance: credentials are still used internally where needed for provisioning, but no longer exposed as broad root-level contract output. This keeps operation working while reducing unnecessary exposure. + +## Ref 16, 17, 23 (SNA input simplification) +Implemented. Input model uses `sna_enabled` and optional `sna_network_area_id` instead of the previous mode-string pattern. + +## Ref 18 (Single-use local) +Implemented in the reviewed scope. + +## Ref 19 (depends_on placement) +Implemented in the reviewed scope (moved to resource end for readability/style consistency). + +## Ref 20 (Maintenance assignment) +Implemented in the reviewed scope via simplified mapping. + +## Ref 21 (SSH fallback expression) +Implemented with improved readability and validation. + +## Ref 22 (Kubernetes output usage) +Implemented. Outputs were reduced to meaningful contract values; unnecessary broad/internal exposure was removed. + +## Ref 24 (Root output contract) +Implemented. Root outputs were trimmed to stable/public contract surface. + +## Ref 25, 26 (providers.tf simplification) +Implemented. Provider setup is reduced to necessary configuration for this stack. + +## Ref 27 (Variable scope cleanup) +Implemented. Variables were moved/kept according to module ownership and root API responsibilities. + +## Ref 28 (namespace service enable semantics) +Implemented. Presence of namespace-service object acts as activation signal; redundant enable semantics were removed from the reviewed API surface. + +## Optional close-out note for PR thread +All review points were addressed in code. The only non-finalized platform capability is provider-native `extensions.dns.gatewayApi`; until available, we use a Terraform-managed DNS record-set bridge (`stackit_dns_record_set`) documented in Issue 41. diff --git a/PR39_REVIEW_TODO.md b/PR39_REVIEW_TODO.md new file mode 100644 index 0000000..145114b --- /dev/null +++ b/PR39_REVIEW_TODO.md @@ -0,0 +1,174 @@ +# PR 39 Review TODO (Arbeitsliste) + +Quelle: Review-Kommentare aus https://github.com/stackitcloud/stackit-landing-zone/pull/39 +Stand: 2026-06-17 + +Hinweis zur Nutzung: + +- Pro Thema bitte genau eine Entscheidung markieren: + - `[ ] Vorschlag umsetzen` + - `[ ] Alternative wählen` +- Bei `Alternative wählen` bitte eure Zielvariante unter `Alternative / Notiz` ergänzen. +- `Ref` verweist auf die extrahierten Review-Kommentar-IDs (1..28). + +## Offene Themen als Checkliste (nach Bereich) + +### Architektur und Scope + +1. [x] Ref 05 - Kubernetes-Demo nicht fest im Landing-Zone-Terraform + Review-Thema: Die Demo sollte optional sein und nicht Teil des produktionsnahen Standardpfads. + Vorgeschlagene Lösung: Demo-Ressourcen aus `src/namespace-service.tf` in optionales Submodul auslagern (`demo_enabled`), Default `false`. + Entscheidung: [x] Vorschlag umsetzen [ ] Alternative wählen + Alternative / Notiz: + +2. [x] Ref 11 - Debug-Bastion als eigenes Modul + Review-Thema: Debug-Bastion ist fachlich ein eigener Baustein. + Vorgeschlagene Lösung: `src/modules/platform-kubernetes/5-debug-bastion.tf` in Submodul `modules/debug-bastion` auslagern und optional aufrufen. + Entscheidung: [x] Vorschlag umsetzen [ ] Alternative wählen + Alternative / Notiz: + +### Ingress und Security + +3. [x] Ref 04 - NGINX Ingress durch Gateway Controller ersetzen + Review-Thema: NGINX-Ingress wurde aus Security-Gründen kritisch bewertet. + Vorgeschlagene Lösung: Namespace-Service-Demo auf Gateway API Controller umstellen (Gateway/HTTPRoute). Wenn nicht sofort möglich: per Feature-Flag standardmäßig deaktivieren. + Entscheidung: [ ] Vorschlag umsetzen [x] Alternative wählen + Alternative / Notiz: Gateway API Controller bitte mittels Envoy Gateway umsetzen + +### Terraform Core und Provider + +4. [x] Ref 06 - null Provider durch terraform_data ersetzen + Review-Thema: `null_resource` wird nicht mehr benötigt. + Vorgeschlagene Lösung: `null_resource` auf `terraform_data` migrieren und `hashicorp/null` aus `required_providers` entfernen. + Entscheidung: [x] Vorschlag umsetzen [ ] Alternative wählen + Alternative / Notiz: + +5. [x] Ref 07 - Provider-Versionen planbar pinnen + Review-Thema: `>=`-Constraints sind zu offen und reduzieren Vorhersagbarkeit. + Vorgeschlagene Lösung: Constraints auf planbare Ranges umstellen (z. B. `~>`), danach Lockfile bewusst aktualisieren. + Entscheidung: [x] Vorschlag umsetzen [ ] Alternative wählen + Alternative / Notiz: + +6. [x] Ref 25, 26 - providers.tf fachlich vereinfachen + Review-Thema: Provider-Setup und Region-Check wirken unnötig komplex. + Vorgeschlagene Lösung: `src/providers.tf` auf notwendige Konfiguration reduzieren, Region-Check entfernen oder in Input-Validation verlagern. + Entscheidung: [x] Vorschlag umsetzen [ ] Alternative wählen + Alternative / Notiz: + +### Validierung und API-Design + +7. [x] Ref 08, 09 - Input-Validierung an richtige Stelle verschieben + Review-Thema: `check`-Blöcke wurden für Input-Validierung genutzt. + Vorgeschlagene Lösung: Input-Validierung in `variable.validation` (oder gezielt `precondition`) verschieben; `check` nur für Laufzeit-/State-Prüfungen verwenden. + Entscheidung: [x] Vorschlag umsetzen [ ] Alternative wählen + Alternative / Notiz: + +8. [x] Ref 28 - namespace_service.enabled vereinfachen/deprecaten + Review-Thema: `enabled` ist redundant, wenn das Objekt selbst schon Aktivierung signalisiert. + Vorgeschlagene Lösung: `namespace_service = null` als deaktiviert, Objekt gesetzt als aktiviert; `enabled` deprecaten und später entfernen. + Entscheidung: [x] Vorschlag umsetzen [ ] Alternative wählen + Alternative / Notiz: + +9. [x] Ref 27 - Variablen-Scope bereinigen + Review-Thema: Ein Variablenblock liegt laut Review im falschen Scope. + Vorgeschlagene Lösung: Variable ins fachlich passende Modul verschieben; Root-Variablen nur für echte Root-API behalten. + Entscheidung: [x] Vorschlag umsetzen [ ] Alternative wählen + Alternative / Notiz: + +### Kubernetes Platform, Netzwerk und Node-Pools + +10. [x] Ref 01, 02, 03 - Default Node-Pools sauber modellieren + Review-Thema: Default-Node-Pools sollten als Variable-Defaults definiert werden; Trennung in `system`/`application` wird gewünscht. + Vorgeschlagene Lösung: `var.cluster.node_pools` mit strukturiertem Default, `allow_system_components = true` nur im `system`-Pool. + Entscheidung: [x] Vorschlag umsetzen [ ] Alternative wählen + Alternative / Notiz: separaten application node pool vorsehen. + +11. [x] Ref 16, 17, 23 - SNA Input-Modell vereinfachen + Review-Thema: `mode`-String plus zusätzliche locals gelten als unnötig. + Vorgeschlagene Lösung: `sna_enabled` (bool) und optional `sna_network_area_id` als primäres Modell; `mode` deprecaten. + Entscheidung: [x] Vorschlag umsetzen [ ] Alternative wählen + Alternative / Notiz: Wir brauchen Dinge noch nicht deprecaten, sondern können vollständig umstellen, da wir ja noch nicht live waren. + +12. [x] Ref 12 - SNA Egress-Routing über Firewall klarstellen + Review-Thema: Ohne Routing-Tabelle könnte Internet-Traffic Firewall-Bypass haben. + Vorgeschlagene Lösung: Routing-Pfad fachlich fixieren und ggf. dedizierte Routing-Tabelle + Default-Route via Firewall umsetzen. + Entscheidung: [x] Vorschlag umsetzen [ ] Alternative wählen + Alternative / Notiz: + +13. [x] Ref 13 - Netzwerkdateien zusammenführen + Review-Thema: `2-dns-zones.tf`, `2-network-area-membership.tf`, `2-sna-network.tf` sollen konsolidiert werden. + Vorgeschlagene Lösung: Zusammenführen in `2-network.tf` als Struktur-Refactor ohne Logikänderung. + Entscheidung: [x] Vorschlag umsetzen [ ] Alternative wählen + Alternative / Notiz: + +14. [x] Ref 10 - Deprecated API-Version aktualisieren + Review-Thema: Verwendete API-Version wurde als deprecated markiert. + Vorgeschlagene Lösung: Auf aktuelle, unterstützte Version aktualisieren und gegen Provider/API-Matrix verifizieren. + Entscheidung: [x] Vorschlag umsetzen [ ] Alternative wählen + Alternative / Notiz: + +### Outputs und Verträge + +15. [x] Ref 15 - Grafana User/Password nicht als Output + Review-Thema: Zugangsdaten sollen nicht als Terraform-Output exponiert werden. + Vorgeschlagene Lösung: Sensitive Outputs entfernen; Zugriff über Secrets Manager bzw. dokumentierten Abrufpfad. + Entscheidung: [x] Vorschlag umsetzen [ ] Alternative wählen + Alternative / Notiz: + +16. [x] Ref 22 - Kubernetes-Output nur bei echter Nutzung + Review-Thema: Output nur behalten, wenn es Downstream-Nutzung gibt. + Vorgeschlagene Lösung: Nutzung nachweisen; ungenutzten Output entfernen oder klar als intern markieren. + Entscheidung: [x] Vorschlag umsetzen [ ] Alternative wählen + Alternative / Notiz: + +17. [x] Ref 24 - Root Outputs auf Public Contract reduzieren + Review-Thema: Teile in `src/outputs.tf` wirken ohne klaren Mehrwert. + Vorgeschlagene Lösung: Outputs auf stabile Public-Contract-Schnitt minimieren; interne Felder streichen. + Entscheidung: [x] Vorschlag umsetzen [ ] Alternative wählen + Alternative / Notiz: + +### Code-Style und Lesbarkeit + +18. [x] Ref 18 - Single-use local entfernen + Review-Thema: `local.effective_observability_instance_id` wird nicht wiederverwendet. + Vorgeschlagene Lösung: Ausdruck inline setzen, nur mehrfach genutzte locals behalten. + Entscheidung: [x] Vorschlag umsetzen [ ] Alternative wählen + Alternative / Notiz: + +19. [x] Ref 19 - depends_on ans Ressourcenende + Review-Thema: Lesbarkeit nach HashiCorp-Style. + Vorgeschlagene Lösung: Betroffene Ressourcen so umordnen, dass `depends_on` am Ende steht. + Entscheidung: [x] Vorschlag umsetzen [ ] Alternative wählen + Alternative / Notiz: + +20. [x] Ref 20 - Maintenance-Zuweisung vereinfachen + Review-Thema: 1:1 Mapping ist unnötig komplex. + Vorgeschlagene Lösung: Direktzuweisung `maintenance = var.cluster.maintenance`, sofern Typen kompatibel sind. + Entscheidung: [x] Vorschlag umsetzen [ ] Alternative wählen + Alternative / Notiz: + +21. [x] Ref 21 - Ausdruck für SSH-Key-Fallback vereinfachen + Review-Thema: Der `try(trimspace(...), file(...))`-Ausdruck kann lesbarer sein. + Vorgeschlagene Lösung: Ausdruck gemäß Vorschlag refactoren und mit Input-Validation kombinieren. + Entscheidung: [x] Vorschlag umsetzen [ ] Alternative wählen + Alternative / Notiz: + +22. [x] Ref 14 - Naming-Konvention für Suffixe vereinheitlichen + Review-Thema: Suffix `-obs` ist inkonsistent. + Vorgeschlagene Lösung: Einheitliche Suffix-Strategie festlegen (`ohne`, `-default` oder `-common`) und referenzkonsistent umsetzen. + Entscheidung: [x] Vorschlag umsetzen [ ] Alternative wählen + Alternative / Notiz: + +## Vorschlag für Umsetzung in Wellen + +1. **Welle A (sicher, low risk)**: 18, 19, 20, 21, 14, 13 +2. **Welle B (API/Contract Changes)**: 01/02/03, 16/17/23, 28, 24, 27 +3. **Welle C (Security/Architecture)**: 04, 05, 11, 12, 15, 22, 25/26, 06, 07, 10 + +## Entscheidungsprotokoll + +- Datum: 17.06.2026 +- Teilnehmer: Lukas Weberruß +- Beschluss pro Ref-Gruppe: Alle Themen werden umgesetzt. Bei Ref 04 erfolgt die Umsetzung als Gateway API Controller mit Envoy Gateway. +- Offene Fragen: +- Nächster Implementierungs-PR: diff --git a/README.md b/README.md index 7af5d00..4529c43 100644 --- a/README.md +++ b/README.md @@ -24,4 +24,4 @@ Contributions are welcome! Please feel free to submit a Pull Request. ## 📄 License -This project is licensed under the Apache 2.0 License - see the [LICENSE](LICENSE) file for details. \ No newline at end of file +This project is licensed under the Apache 2.0 License - see the [LICENSE](LICENSE) file for details. diff --git a/docs/getting-started.md b/docs/getting-started.md index a719839..c929bdf 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -16,11 +16,11 @@ This guide walks you through deploying the STACKIT Landing Zone from scratch. Three ready-to-use configurations are provided in `src/config/`: -| Flavour | Config file | Description | -|---------|-------------|-------------| -| **Standalone** | `standalone.tfvars` | Governance, management, devops, and public landing zones only. No network area or firewall. | -| **Hub-Spoke** | `hub-and-spoke.tfvars` | Adds a connectivity hub with a network area and DNS zones. Corporate landing zones connect via the network area. | -| **Hub-Spoke + Firewall** | `hub-and-spoke-firewall.tfvars` | Full hub-spoke topology with an OPNsense firewall appliance on the WAN/LAN boundary. | +| Flavour | Config file | Description | +| ------------------------ | ------------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| **Standalone** | `standalone.tfvars` | Governance, management, devops, and public landing zones only. No network area or firewall. | +| **Hub-Spoke** | `hub-and-spoke.tfvars` | Adds a connectivity hub with a network area and DNS zones. Corporate landing zones connect via the network area. | +| **Hub-Spoke + Firewall** | `hub-and-spoke-firewall.tfvars` | Full hub-spoke topology with an OPNsense firewall appliance on the WAN/LAN boundary. | Choose the flavour that matches your requirements and adjust the corresponding `.tfvars` file before deployment (step 7). At a minimum, update `owner_email`, `organization_id`, `company_name`, and `company_code`. @@ -120,12 +120,12 @@ cp config/standalone.tfvars terraform.auto.tfvars Update the values to match your organization. Required variables: -| Variable | Description | -|----------|-------------| -| `owner_email` | Technical owner email registered in STACKIT | -| `company_name` | Company name for folder naming | -| `company_code` | Short prefix for resource naming (e.g. `exc`) | -| `organization_id` | Root organization container ID | +| Variable | Description | +| ----------------- | --------------------------------------------- | +| `owner_email` | Technical owner email registered in STACKIT | +| `company_name` | Company name for folder naming | +| `company_code` | Short prefix for resource naming (e.g. `exc`) | +| `organization_id` | Root organization container ID | ### 8. Initialize OpenTofu/Terraform @@ -170,6 +170,7 @@ terraform { } } ``` + In the STACKIT Portal, navigate to the management project → Secrets Manager → Secrets. Open the secret prefixed with `object_storage_credentials_` and copy the `ACCESS_KEY` and `SECRET_ACCESS_KEY` values. Set the S3 backend credentials: @@ -231,6 +232,47 @@ stackit project delete --project-id ## Post-Deployment (Optional) +### 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/namespace-service.tf`), the DNS record is created automatically based on the Envoy Gateway LoadBalancer endpoint discovered via `kubernetes_resources`. + +Implementation pattern: + +```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." + } + } +} +``` + +This ensures a stable, Terraform-managed DNS path without external scripts until provider-native `gatewayApi` DNS extension support is available. + ### Configure OPNsense firewall If you deployed the Hub-Spoke + Firewall flavour, configure the OPNsense. Guidance will be available soon in the STACKIT docs. diff --git a/src/config/hub-and-spoke.tfvars b/src/config/hub-and-spoke.tfvars index 9b513d3..fc4852d 100644 --- a/src/config/hub-and-spoke.tfvars +++ b/src/config/hub-and-spoke.tfvars @@ -90,27 +90,11 @@ connectivity = { # "eu01" = { # region = "eu01" # network = { -# mode = "sna" +# sna_enabled = true # } # cluster = { # name = "pltfmk8s" # kubernetes_version_min = "1.35" -# node_pools = [ -# { -# name = "small-a" -# machine_type = "g3i.4" -# minimum = 2 -# maximum = 2 -# availability_zones = ["eu01-1"] -# }, -# { -# name = "small-b" -# machine_type = "g3i.4" -# minimum = 2 -# maximum = 2 -# availability_zones = ["eu01-2"] -# } -# ] # } # # # Defaults to disabled. Set true to enable encrypted storage class setup. @@ -147,13 +131,6 @@ landing_zones = { corporate = true network_prefix_length = 24 - # Optional: create namespace service in central platform Kubernetes cluster - # namespace_service = { - # enabled = true - # namespace = "data-prod" - # dns_subdomain = "app" - # secretsmanager = true - # } } # Public landing zone — no network area, uses STACKIT's default public networking @@ -171,4 +148,13 @@ landing_zones = { # } # ] } -} \ No newline at end of file +} + +# Optional: create namespace services in the central platform Kubernetes cluster. +# landing_zone_namespace_services = { +# "corp-exmpl" = { +# namespace = "data-prod" +# dns_subdomain = "app" +# secretsmanager = true +# } +# } \ No newline at end of file diff --git a/src/main.tf b/src/main.tf index ffd98d2..5dff7c7 100644 --- a/src/main.tf +++ b/src/main.tf @@ -75,6 +75,7 @@ module "platform_kubernetes" { for_each = var.platform_kubernetes owner_email = var.owner_email + organization_id = var.organization_id naming_pattern = "${var.company_code}-pltfm-k8s-${each.value.region}" parent_container_id = module.governance.folder_container_ids["platform"] labels = var.labels @@ -86,8 +87,10 @@ module "platform_kubernetes" { debug_bastion = each.value.debug_bastion network = { - mode = each.value.network.mode - sna_network_area_id = each.value.network.sna_network_area_id != null ? each.value.network.sna_network_area_id : try(module.connectivity[0].network_area_id, null) + sna_enabled = each.value.network.sna_enabled + sna_network_area_id = each.value.network.sna_network_area_id != null ? each.value.network.sna_network_area_id : try(module.connectivity[0].network_area_id, null) + firewall_next_hop_ip = try(module.connectivity[0].firewall_next_hop_ip, null) + sna_network_prefix_length = each.value.network.sna_network_prefix_length } dns = { diff --git a/src/modules/connectivity/3-external-network.tf b/src/modules/connectivity/3-external-network.tf index d0bd8ef..afec9af 100644 --- a/src/modules/connectivity/3-external-network.tf +++ b/src/modules/connectivity/3-external-network.tf @@ -37,7 +37,7 @@ resource "stackit_routing_table_route" "wan" { ############# resource "stackit_network" "wan" { - count = var.firewall != null ? 1 : 0 + count = local.firewall_enabled ? 1 : 0 project_id = stackit_resourcemanager_project.this.project_id name = "wan_network" @@ -47,7 +47,7 @@ resource "stackit_network" "wan" { } resource "stackit_network_interface" "wan" { - count = var.firewall != null ? 1 : 0 + count = local.firewall_enabled ? 1 : 0 name = "vtnet0_wan" project_id = stackit_resourcemanager_project.this.project_id @@ -57,7 +57,7 @@ resource "stackit_network_interface" "wan" { } resource "stackit_public_ip" "wan-ip" { - count = var.firewall != null ? 1 : 0 + count = local.firewall_enabled ? 1 : 0 project_id = stackit_resourcemanager_project.this.project_id network_interface_id = stackit_network_interface.wan[0].network_interface_id diff --git a/src/modules/connectivity/4-internal-network.tf b/src/modules/connectivity/4-internal-network.tf index 18718a7..d3e094e 100644 --- a/src/modules/connectivity/4-internal-network.tf +++ b/src/modules/connectivity/4-internal-network.tf @@ -3,7 +3,7 @@ ############# resource "stackit_network" "lan" { - count = var.firewall != null ? 1 : 0 + count = local.firewall_enabled ? 1 : 0 project_id = stackit_resourcemanager_project.this.project_id name = "lan_network" @@ -12,7 +12,7 @@ resource "stackit_network" "lan" { } resource "stackit_network_interface" "lan" { - count = var.firewall != null ? 1 : 0 + count = local.firewall_enabled ? 1 : 0 name = "vtnet1_lan" project_id = stackit_resourcemanager_project.this.project_id diff --git a/src/modules/connectivity/5-firewall.tf b/src/modules/connectivity/5-firewall.tf index 5654c43..ec3c475 100644 --- a/src/modules/connectivity/5-firewall.tf +++ b/src/modules/connectivity/5-firewall.tf @@ -2,12 +2,17 @@ ## IMAGE ## ########### +locals { + firewall_image_path = fileexists("${path.root}/firewall-image.qcow2") ? "${path.root}/firewall-image.qcow2" : "/dev/null" + firewall_enabled = var.firewall != null +} + resource "stackit_image" "firewall" { - count = var.firewall != null ? 1 : 0 + count = local.firewall_enabled ? 1 : 0 project_id = stackit_resourcemanager_project.this.project_id name = var.firewall.name - local_file_path = var.firewall != null ? "${path.root}/firewall-image.qcow2" : null + local_file_path = local.firewall_image_path disk_format = "qcow2" min_disk_size = 16 min_ram = 2 @@ -21,7 +26,7 @@ resource "stackit_image" "firewall" { ############ resource "stackit_volume" "firewall" { - count = var.firewall != null ? 1 : 0 + count = local.firewall_enabled ? 1 : 0 project_id = stackit_resourcemanager_project.this.project_id name = var.firewall.name @@ -39,7 +44,7 @@ resource "stackit_volume" "firewall" { ############ resource "stackit_server" "firewall" { - count = var.firewall != null ? 1 : 0 + count = local.firewall_enabled ? 1 : 0 project_id = stackit_resourcemanager_project.this.project_id name = var.firewall.name diff --git a/src/modules/connectivity/outputs.tf b/src/modules/connectivity/outputs.tf index 025db01..fc451e2 100644 --- a/src/modules/connectivity/outputs.tf +++ b/src/modules/connectivity/outputs.tf @@ -10,12 +10,12 @@ output "dns_zone_ids" { output "firewall_next_hop_ip" { description = "The IP address to be used as next hop for the default route in the landing zones (firewall WAN IP)." - value = var.firewall != null ? stackit_network_interface.lan[0].ipv4 : null + value = local.firewall_enabled ? stackit_network_interface.lan[0].ipv4 : null } output "firewall_public_ip" { description = "The public IP address of the firewall WAN interface." - value = var.firewall != null ? stackit_public_ip.wan-ip[0].ip : null + value = local.firewall_enabled ? stackit_public_ip.wan-ip[0].ip : null } output "network_area_id" { diff --git a/src/modules/connectivity/terraform.tf b/src/modules/connectivity/terraform.tf index 7932297..807ea10 100644 --- a/src/modules/connectivity/terraform.tf +++ b/src/modules/connectivity/terraform.tf @@ -4,11 +4,11 @@ terraform { required_providers { stackit = { source = "stackitcloud/stackit" - version = ">=0.93.0" + version = "~> 0.99.0" } time = { source = "hashicorp/time" - version = ">= 0.13.0" + version = "~> 0.14.0" } } } \ No newline at end of file diff --git a/src/modules/debug-bastion/main.tf b/src/modules/debug-bastion/main.tf new file mode 100644 index 0000000..ddc865b --- /dev/null +++ b/src/modules/debug-bastion/main.tf @@ -0,0 +1,109 @@ +locals { + ssh_public_key = var.enabled ? try( + trimspace(var.ssh_public_key), + trimspace(file(pathexpand(var.ssh_public_key_path))), + null + ) : null + + user_data = var.install_kubectl ? ( + < /etc/apt/sources.list.d/kubernetes.list + - apt-get update + - apt-get install -y kubectl +EOT + ) : null +} + +resource "stackit_key_pair" "this" { + count = var.enabled ? 1 : 0 + + name = "${var.short_prefix}-dbg-key" + public_key = local.ssh_public_key + + lifecycle { + precondition { + condition = var.sna_enabled + error_message = "debug_bastion requires sna_enabled=true." + } + + precondition { + condition = local.ssh_public_key != null && local.ssh_public_key != "" + error_message = "debug_bastion requires a non-empty ssh_public_key or valid ssh_public_key_path." + } + } +} + +resource "stackit_security_group" "this" { + count = var.enabled ? 1 : 0 + + project_id = var.project_id + name = "${var.short_prefix}-dbg-sg" + description = "Debug bastion SSH access" + stateful = true +} + +resource "stackit_security_group_rule" "ssh" { + for_each = var.enabled ? { + for cidr in var.ssh_allowed_cidrs : cidr => cidr + } : {} + + project_id = var.project_id + security_group_id = stackit_security_group.this[0].security_group_id + direction = "ingress" + ether_type = "IPv4" + ip_range = each.value + + protocol = { + name = "tcp" + } + + port_range = { + min = 22 + max = 22 + } +} + +resource "stackit_network_interface" "this" { + count = var.enabled ? 1 : 0 + + project_id = var.project_id + network_id = var.network_id + name = "${var.name}-nic" + security = true + security_group_ids = [stackit_security_group.this[0].security_group_id] +} + +resource "stackit_server" "this" { + count = var.enabled ? 1 : 0 + + project_id = var.project_id + name = var.name + + boot_volume = { + source_type = "image" + source_id = var.image_id + size = var.boot_volume_size + } + + availability_zone = var.availability_zone + machine_type = var.machine_type + keypair_name = stackit_key_pair.this[0].name + network_interfaces = [ + stackit_network_interface.this[0].network_interface_id + ] + user_data = local.user_data +} + +resource "stackit_public_ip" "this" { + count = var.enabled && var.assign_public_ip ? 1 : 0 + + project_id = var.project_id + network_interface_id = stackit_network_interface.this[0].network_interface_id +} diff --git a/src/modules/debug-bastion/outputs.tf b/src/modules/debug-bastion/outputs.tf new file mode 100644 index 0000000..cb15628 --- /dev/null +++ b/src/modules/debug-bastion/outputs.tf @@ -0,0 +1,29 @@ +output "enabled" { + description = "Whether bastion resources are enabled." + value = var.enabled +} + +output "server_id" { + description = "Bastion server ID when enabled." + value = var.enabled ? stackit_server.this[0].server_id : null +} + +output "network_interface_id" { + description = "Bastion network interface ID when enabled." + value = var.enabled ? stackit_network_interface.this[0].network_interface_id : null +} + +output "public_ip" { + description = "Bastion public IP when enabled and assign_public_ip=true." + value = var.enabled && var.assign_public_ip ? stackit_public_ip.this[0].ip : null +} + +output "ssh_user" { + description = "Default SSH user for bastion access." + value = var.enabled ? "ubuntu" : null +} + +output "ssh_command" { + description = "Ready-to-use SSH command when public IP is assigned." + value = var.enabled && var.assign_public_ip ? "ssh ubuntu@${stackit_public_ip.this[0].ip}" : null +} diff --git a/src/modules/debug-bastion/terraform.tf b/src/modules/debug-bastion/terraform.tf new file mode 100644 index 0000000..d37d763 --- /dev/null +++ b/src/modules/debug-bastion/terraform.tf @@ -0,0 +1,8 @@ +terraform { + required_providers { + stackit = { + source = "stackitcloud/stackit" + version = "~> 0.99.0" + } + } +} diff --git a/src/modules/debug-bastion/variables.tf b/src/modules/debug-bastion/variables.tf new file mode 100644 index 0000000..3059c1d --- /dev/null +++ b/src/modules/debug-bastion/variables.tf @@ -0,0 +1,76 @@ +variable "enabled" { + type = bool + description = "Whether debug bastion resources should be created." +} + +variable "sna_enabled" { + type = bool + description = "Whether SNA networking is enabled for the cluster." +} + +variable "project_id" { + type = string + description = "STACKIT project ID where bastion resources are created." +} + +variable "network_id" { + type = string + description = "SNA network ID for the bastion network interface." +} + +variable "name" { + type = string + description = "Bastion server name." +} + +variable "short_prefix" { + type = string + description = "Short naming prefix for key/security-group resources." +} + +variable "availability_zone" { + type = string + description = "Availability zone for the bastion server." + default = null +} + +variable "machine_type" { + type = string + description = "Machine type for the bastion server." +} + +variable "image_id" { + type = string + description = "Image ID for the bastion boot volume." +} + +variable "boot_volume_size" { + type = number + description = "Boot volume size in GB." +} + +variable "ssh_public_key" { + type = string + description = "Optional inline SSH public key." + default = null +} + +variable "ssh_public_key_path" { + type = string + description = "Path to SSH public key file when inline key is not provided." +} + +variable "ssh_allowed_cidrs" { + type = list(string) + description = "CIDRs allowed for SSH ingress." +} + +variable "assign_public_ip" { + type = bool + description = "Whether to assign a public IP to bastion network interface." +} + +variable "install_kubectl" { + type = bool + description = "Whether to install kubectl via cloud-init." +} diff --git a/src/modules/devops/terraform.tf b/src/modules/devops/terraform.tf index 851721e..8ff0b06 100644 --- a/src/modules/devops/terraform.tf +++ b/src/modules/devops/terraform.tf @@ -4,7 +4,7 @@ terraform { required_providers { stackit = { source = "stackitcloud/stackit" - version = ">=0.93.0" + version = "~> 0.99.0" } } } \ No newline at end of file diff --git a/src/modules/governance/terraform.tf b/src/modules/governance/terraform.tf index 7932297..807ea10 100644 --- a/src/modules/governance/terraform.tf +++ b/src/modules/governance/terraform.tf @@ -4,11 +4,11 @@ terraform { required_providers { stackit = { source = "stackitcloud/stackit" - version = ">=0.93.0" + version = "~> 0.99.0" } time = { source = "hashicorp/time" - version = ">= 0.13.0" + version = "~> 0.14.0" } } } \ No newline at end of file diff --git a/src/modules/landing-zone/8-observability.tf b/src/modules/landing-zone/8-observability.tf index 468fbd8..ec4592b 100644 --- a/src/modules/landing-zone/8-observability.tf +++ b/src/modules/landing-zone/8-observability.tf @@ -6,7 +6,7 @@ resource "stackit_observability_instance" "this" { count = var.observability.enabled ? 1 : 0 project_id = stackit_resourcemanager_project.this.project_id - name = var.observability.name != null ? var.observability.name : "${var.naming_pattern}-obs" + name = var.observability.name != null ? var.observability.name : var.naming_pattern plan_name = var.observability.plan_name acl = var.observability.acl } diff --git a/src/modules/landing-zone/outputs.tf b/src/modules/landing-zone/outputs.tf index 3e598c3..721a597 100644 --- a/src/modules/landing-zone/outputs.tf +++ b/src/modules/landing-zone/outputs.tf @@ -48,18 +48,18 @@ output "observability_grafana_url" { value = var.observability.enabled ? stackit_observability_instance.this[0].grafana_url : null } +output "observability_metrics_push_url" { + description = "The Prometheus remote-write URL of the optional landing zone observability instance." + value = var.observability.enabled ? stackit_observability_instance.this[0].metrics_push_url : null +} + output "observability_grafana_admin_user" { - description = "The initial Grafana admin user of the optional landing zone observability instance." + description = "The Grafana admin username of the optional landing zone observability instance." value = var.observability.enabled ? stackit_observability_instance.this[0].grafana_initial_admin_user : null } output "observability_grafana_admin_password" { - description = "The initial Grafana admin password of the optional landing zone observability instance." - value = var.observability.enabled ? stackit_observability_instance.this[0].grafana_initial_admin_password : null + description = "The Grafana admin password of the optional landing zone observability instance." sensitive = true -} - -output "observability_metrics_push_url" { - description = "The Prometheus remote-write URL of the optional landing zone observability instance." - value = var.observability.enabled ? stackit_observability_instance.this[0].metrics_push_url : null + value = var.observability.enabled ? stackit_observability_instance.this[0].grafana_initial_admin_password : null } \ No newline at end of file diff --git a/src/modules/landing-zone/terraform.tf b/src/modules/landing-zone/terraform.tf index d8d2ac8..a0fb83f 100644 --- a/src/modules/landing-zone/terraform.tf +++ b/src/modules/landing-zone/terraform.tf @@ -4,11 +4,11 @@ terraform { required_providers { stackit = { source = "stackitcloud/stackit" - version = ">=0.93.0" + version = "~> 0.99.0" } time = { source = "hashicorp/time" - version = ">=0.13.1" + version = "~> 0.14.0" } } } \ No newline at end of file diff --git a/src/modules/management/terraform.tf b/src/modules/management/terraform.tf index 189bcd2..73c3e68 100644 --- a/src/modules/management/terraform.tf +++ b/src/modules/management/terraform.tf @@ -4,15 +4,15 @@ terraform { required_providers { stackit = { source = "stackitcloud/stackit" - version = ">=0.93.0" + version = "~> 0.99.0" } time = { source = "hashicorp/time" - version = ">=0.13.1" + version = "~> 0.14.0" } vault = { source = "hashicorp/vault" - version = ">=5.7.0" + version = "~> 5.9.0" } } } \ No newline at end of file diff --git a/src/modules/namespace-service-demo/dashboards/namespace-overview.json.tmpl b/src/modules/namespace-service-demo/dashboards/namespace-overview.json.tmpl new file mode 100644 index 0000000..e68f73c --- /dev/null +++ b/src/modules/namespace-service-demo/dashboards/namespace-overview.json.tmpl @@ -0,0 +1,215 @@ +{ + "id": null, + "uid": "${dashboard_uid}", + "title": "${dashboard_title}", + "tags": ["stackit", "landing-zone", "namespace", "demo"], + "timezone": "browser", + "schemaVersion": 39, + "version": 1, + "refresh": "30s", + "templating": { + "list": [ + { + "name": "namespace", + "label": "Namespace", + "type": "query", + "refresh": 1, + "datasource": { + "type": "prometheus", + "uid": "${datasource_uid}" + }, + "query": { + "query": "label_values(lz_demo_resource_count, namespace)", + "refId": "NamespaceVariableQuery" + }, + "current": { + "text": "${namespace}", + "value": "${namespace}" + } + } + ] + }, + "panels": [ + { + "id": 1, + "type": "stat", + "title": "Pods ($namespace)", + "datasource": { + "type": "prometheus", + "uid": "${datasource_uid}" + }, + "targets": [ + { + "refId": "A", + "expr": "sum(lz_demo_resource_count{namespace=~\"$namespace\",resource=\"pods\"}) or vector(0)", + "legendFormat": "pods" + } + ], + "options": { + "reduceOptions": { + "values": false, + "calcs": ["lastNotNull"], + "fields": "" + }, + "orientation": "auto", + "colorMode": "value", + "graphMode": "none", + "justifyMode": "center" + }, + "gridPos": { + "h": 6, + "w": 6, + "x": 0, + "y": 0 + } + }, + { + "id": 2, + "type": "stat", + "title": "Services ($namespace)", + "datasource": { + "type": "prometheus", + "uid": "${datasource_uid}" + }, + "targets": [ + { + "refId": "B", + "expr": "sum(lz_demo_resource_count{namespace=~\"$namespace\",resource=\"services\"}) or vector(0)", + "legendFormat": "services" + } + ], + "options": { + "reduceOptions": { + "values": false, + "calcs": ["lastNotNull"], + "fields": "" + }, + "orientation": "auto", + "colorMode": "value", + "graphMode": "none", + "justifyMode": "center" + }, + "gridPos": { + "h": 6, + "w": 6, + "x": 6, + "y": 0 + } + }, + { + "id": 3, + "type": "stat", + "title": "Gateways ($namespace)", + "datasource": { + "type": "prometheus", + "uid": "${datasource_uid}" + }, + "targets": [ + { + "refId": "C", + "expr": "sum(lz_demo_resource_count{namespace=~\"$namespace\",resource=\"gateways\"}) or vector(0)", + "legendFormat": "gateways" + } + ], + "options": { + "reduceOptions": { + "values": false, + "calcs": ["lastNotNull"], + "fields": "" + }, + "orientation": "auto", + "colorMode": "value", + "graphMode": "none", + "justifyMode": "center" + }, + "gridPos": { + "h": 6, + "w": 6, + "x": 12, + "y": 0 + } + }, + { + "id": 4, + "type": "stat", + "title": "Scrape Up ($namespace)", + "datasource": { + "type": "prometheus", + "uid": "${datasource_uid}" + }, + "targets": [ + { + "refId": "D", + "expr": "sum(up{namespace=~\"$namespace\"}) or vector(0)", + "legendFormat": "up" + } + ], + "options": { + "reduceOptions": { + "values": false, + "calcs": ["lastNotNull"], + "fields": "" + }, + "orientation": "auto", + "colorMode": "value", + "graphMode": "none", + "justifyMode": "center" + }, + "gridPos": { + "h": 6, + "w": 6, + "x": 18, + "y": 0 + } + }, + { + "id": 5, + "type": "timeseries", + "title": "Resource Count Trend ($namespace)", + "datasource": { + "type": "prometheus", + "uid": "${datasource_uid}" + }, + "targets": [ + { + "refId": "E", + "expr": "sum by(resource) (lz_demo_resource_count{namespace=~\"$namespace\"}) or vector(0)", + "legendFormat": "{{resource}}" + } + ], + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 6 + } + }, + { + "id": 6, + "type": "timeseries", + "title": "Scrape Health ($namespace)", + "datasource": { + "type": "prometheus", + "uid": "${datasource_uid}" + }, + "targets": [ + { + "refId": "F", + "expr": "sum(scrape_samples_scraped{namespace=~\"$namespace\"}) or vector(0)", + "legendFormat": "samples" + }, + { + "refId": "G", + "expr": "sum(scrape_duration_seconds{namespace=~\"$namespace\"}) or vector(0)", + "legendFormat": "duration" + } + ], + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 6 + } + } + ] +} diff --git a/src/modules/namespace-service-demo/main.tf b/src/modules/namespace-service-demo/main.tf new file mode 100644 index 0000000..1011abe --- /dev/null +++ b/src/modules/namespace-service-demo/main.tf @@ -0,0 +1,160 @@ +terraform { + required_providers { + stackit = { + source = "stackitcloud/stackit" + version = "~> 0.99.0" + } + grafana = { + source = "grafana/grafana" + version = "~> 3.0" + } + } +} + +locals { + services_with_secrets = { + for key, value in var.services : key => value + if value.use_secretsmanager + } + + services_with_observability = { + for key, value in var.services : key => value + if ( + try(value.observability_grafana_url, null) != null && + try(value.observability_admin_user, null) != null && + try(value.observability_instance_id, null) != null && + try(value.platform_project_id, null) != null && + try(value.platform_observability_instance_id, null) != null && + try(value.platform_observability_targets_url, null) != null + ) + } + + services_with_demo_metrics_ingestion = { + for key, value in local.services_with_observability : key => value + if value.demo_metrics_ingestion_enabled + } + + demo_metrics_ingestion_targets = { + for key, value in local.services_with_demo_metrics_ingestion : key => ( + length(value.demo_metrics_ingestion_target_urls) > 0 + ? value.demo_metrics_ingestion_target_urls + : ["a-d-d-${value.platform_observability_instance_id}.argus-${value.platform_observability_instance_id}.svc.cluster.local:9000"] + ) + } + + observability_keys = keys(local.services_with_observability) + + observability_urls = distinct([ + for value in values(local.services_with_observability) : value.observability_grafana_url + ]) + + grafana_provider_url = try(one(local.observability_urls), "https://127.0.0.1") + grafana_provider_user = try(local.services_with_observability[local.observability_keys[0]].observability_admin_user, "unused") + grafana_provider_pass = lookup(var.dashboard_passwords, try(local.observability_keys[0], ""), "unused") + + dashboard_uid = { + for key, value in local.services_with_observability : key => substr("lz-${md5(key)}", 0, 20) + } +} + +provider "grafana" { + alias = "observability" + + url = local.grafana_provider_url + auth = "${local.grafana_provider_user}:${nonsensitive(local.grafana_provider_pass)}" +} + +resource "stackit_secretsmanager_user" "external_secret_demo" { + for_each = local.services_with_secrets + + project_id = each.value.landing_zone_project_id + instance_id = each.value.secretsmanager_instance_id + description = "Demo ExternalSecret reader for ${each.key}" + write_enabled = true +} + +resource "stackit_observability_credential" "platform_metrics_reader" { + for_each = local.services_with_observability + + project_id = each.value.platform_project_id + instance_id = each.value.platform_observability_instance_id + description = "Namespace demo metrics reader for ${each.key}" +} + +resource "stackit_observability_scrapeconfig" "namespace_demo_ingestion" { + for_each = local.services_with_demo_metrics_ingestion + + project_id = each.value.platform_project_id + instance_id = each.value.platform_observability_instance_id + name = "namespace-demo-${each.key}-v3" + scheme = each.value.demo_metrics_ingestion_scheme + metrics_path = each.value.demo_metrics_ingestion_metrics_path + scrape_interval = each.value.demo_metrics_ingestion_scrape_interval + scrape_timeout = each.value.demo_metrics_ingestion_scrape_timeout + saml2 = { + enable_url_parameters = false + } + + targets = [ + { + urls = local.demo_metrics_ingestion_targets[each.key] + labels = { + namespace = each.value.namespace + landing_zone = each.key + source = "namespace-demo" + } + } + ] +} + +resource "grafana_folder" "stackit_managed" { + provider = grafana.observability + + count = length(local.services_with_observability) > 0 ? 1 : 0 + + title = var.dashboard_folder_title +} + +resource "grafana_data_source" "platform_prometheus" { + provider = grafana.observability + + for_each = local.services_with_observability + + type = "prometheus" + name = "Platform Prometheus (${each.key})" + url = each.value.platform_observability_targets_url + + basic_auth_enabled = true + basic_auth_username = stackit_observability_credential.platform_metrics_reader[each.key].username + + secure_json_data_encoded = jsonencode({ + basicAuthPassword = stackit_observability_credential.platform_metrics_reader[each.key].password + }) +} + +resource "grafana_dashboard" "namespace_overview" { + provider = grafana.observability + + for_each = local.services_with_observability + + folder = grafana_folder.stackit_managed[0].uid + overwrite = true + config_json = templatefile("${path.module}/dashboards/namespace-overview.json.tmpl", { + dashboard_uid = local.dashboard_uid[each.key] + dashboard_title = "${each.key} Namespace Overview" + namespace = each.value.namespace + datasource_uid = grafana_data_source.platform_prometheus[each.key].uid + }) + + lifecycle { + precondition { + condition = length(local.observability_urls) <= 1 + error_message = "Demo dashboard provisioning currently supports exactly one Grafana endpoint across enabled demo services." + } + + precondition { + condition = lookup(var.dashboard_passwords, each.key, "") != "" + error_message = "Missing Grafana admin password for dashboard provisioning." + } + } +} diff --git a/src/modules/namespace-service-demo/outputs.tf b/src/modules/namespace-service-demo/outputs.tf new file mode 100644 index 0000000..2310555 --- /dev/null +++ b/src/modules/namespace-service-demo/outputs.tf @@ -0,0 +1,25 @@ +output "samples" { + description = "Demo sample references for enabled namespace-service demos." + value = { + for key, value in var.services : key => { + dashboard_folder_uid = try(grafana_folder.stackit_managed[0].uid, null) + dashboard_folder_name = try(grafana_folder.stackit_managed[0].title, null) + namespace = value.namespace + external_secret_user = try(stackit_secretsmanager_user.external_secret_demo[key].username, null) + dashboard_uid = try(local.dashboard_uid[key], null) + dashboard_url = try("${value.observability_grafana_url}/d/${local.dashboard_uid[key]}", null) + dashboard_api_url = try("${value.observability_grafana_url}/api/dashboards/uid/${local.dashboard_uid[key]}", null) + } + } +} + +output "secret_access" { + description = "Credentials for demo Secrets Manager users keyed by landing zone." + sensitive = true + value = { + for key, value in stackit_secretsmanager_user.external_secret_demo : key => { + username = value.username + password = value.password + } + } +} diff --git a/src/modules/namespace-service-demo/variables.tf b/src/modules/namespace-service-demo/variables.tf new file mode 100644 index 0000000..5cb54b6 --- /dev/null +++ b/src/modules/namespace-service-demo/variables.tf @@ -0,0 +1,36 @@ +variable "services" { + type = map(object({ + namespace = string + use_secretsmanager = bool + landing_zone_project_id = string + secretsmanager_instance_id = string + observability_instance_id = optional(string) + observability_grafana_url = optional(string) + observability_admin_user = optional(string) + demo_metrics_ingestion_enabled = optional(bool, false) + demo_metrics_ingestion_target_urls = optional(list(string), []) + demo_metrics_ingestion_scheme = optional(string, "https") + demo_metrics_ingestion_metrics_path = optional(string, "/") + demo_metrics_ingestion_scrape_interval = optional(string, "60s") + demo_metrics_ingestion_scrape_timeout = optional(string, "30s") + platform_project_id = optional(string) + platform_observability_instance_id = optional(string) + platform_observability_targets_url = optional(string) + dns_zone_name = optional(string) + })) + description = "Enabled namespace-service demo configurations keyed by landing-zone key." + default = {} +} + +variable "dashboard_passwords" { + type = map(string) + description = "Grafana admin passwords keyed by landing-zone key for dashboard demo imports." + sensitive = true + default = {} +} + +variable "dashboard_folder_title" { + type = string + description = "Folder title used for managed landing-zone demo dashboards." + default = "STACKIT Managed Dashboards" +} diff --git a/src/modules/platform-kubernetes/1-project.tf b/src/modules/platform-kubernetes/1-project.tf index 9252f40..8f4d560 100644 --- a/src/modules/platform-kubernetes/1-project.tf +++ b/src/modules/platform-kubernetes/1-project.tf @@ -1,7 +1,7 @@ locals { project_labels = merge( { "region" = var.region }, - var.network.mode == "sna" && var.network.sna_network_area_id != null ? { "networkArea" = var.network.sna_network_area_id } : {}, + var.network.sna_enabled && var.network.sna_network_area_id != null ? { "networkArea" = var.network.sna_network_area_id } : {}, var.labels ) } diff --git a/src/modules/platform-kubernetes/2-dns-zones.tf b/src/modules/platform-kubernetes/2-dns-zones.tf deleted file mode 100644 index c269fa2..0000000 --- a/src/modules/platform-kubernetes/2-dns-zones.tf +++ /dev/null @@ -1,12 +0,0 @@ -locals { - dns_extension_zones = distinct(compact(var.dns.zones)) -} - -resource "stackit_dns_zone" "ske_extension" { - for_each = var.dns.create_zones ? { for zone in local.dns_extension_zones : zone => zone } : {} - - project_id = stackit_resourcemanager_project.this.project_id - name = each.value - dns_name = each.value - contact_email = var.owner_email -} diff --git a/src/modules/platform-kubernetes/2-network-area-membership.tf b/src/modules/platform-kubernetes/2-network-area-membership.tf deleted file mode 100644 index c7db91e..0000000 --- a/src/modules/platform-kubernetes/2-network-area-membership.tf +++ /dev/null @@ -1,8 +0,0 @@ -resource "time_sleep" "wait_for_network_area_membership" { - count = var.network.mode == "sna" ? 1 : 0 - - # Allow backend propagation after project label updates before SKE SNA validation. - create_duration = "30s" - - depends_on = [stackit_resourcemanager_project.this] -} diff --git a/src/modules/platform-kubernetes/2-network.tf b/src/modules/platform-kubernetes/2-network.tf new file mode 100644 index 0000000..acab6e5 --- /dev/null +++ b/src/modules/platform-kubernetes/2-network.tf @@ -0,0 +1,62 @@ +locals { + dns_extension_zones = distinct(compact(var.dns.zones)) +} + +resource "stackit_dns_zone" "ske_extension" { + for_each = var.dns.create_zones ? { for zone in local.dns_extension_zones : zone => zone } : {} + + project_id = stackit_resourcemanager_project.this.project_id + name = each.value + dns_name = each.value + contact_email = var.owner_email +} + +resource "time_sleep" "wait_for_network_area_membership" { + count = var.network.sna_enabled ? 1 : 0 + + # Allow backend propagation after project label updates before SKE SNA validation. + create_duration = "30s" + + depends_on = [stackit_resourcemanager_project.this] +} + +resource "stackit_routing_table" "sna_egress" { + count = var.network.sna_enabled && var.network.sna_network_area_id != null && var.network.firewall_next_hop_ip != null ? 1 : 0 + + organization_id = var.organization_id + network_area_id = var.network.sna_network_area_id + name = "${var.cluster.name}-sna-egress" + system_routes = false + + labels = local.project_labels +} + +resource "stackit_routing_table_route" "sna_default_route" { + count = var.network.sna_enabled && var.network.sna_network_area_id != null && var.network.firewall_next_hop_ip != null ? 1 : 0 + + organization_id = var.organization_id + network_area_id = var.network.sna_network_area_id + routing_table_id = stackit_routing_table.sna_egress[0].routing_table_id + + destination = { + type = "cidrv4" + value = "0.0.0.0/0" + } + + next_hop = { + type = "ipv4" + value = var.network.firewall_next_hop_ip + } + + labels = local.project_labels +} + +resource "stackit_network" "sna" { + count = var.network.sna_enabled ? 1 : 0 + + project_id = stackit_resourcemanager_project.this.project_id + name = "${var.cluster.name}-sna" + ipv4_prefix_length = var.network.sna_network_prefix_length + routed = true + routing_table_id = var.network.firewall_next_hop_ip != null ? stackit_routing_table.sna_egress[0].routing_table_id : null +} \ No newline at end of file diff --git a/src/modules/platform-kubernetes/2-observability.tf b/src/modules/platform-kubernetes/2-observability.tf index cf5e774..b886bd0 100644 --- a/src/modules/platform-kubernetes/2-observability.tf +++ b/src/modules/platform-kubernetes/2-observability.tf @@ -2,7 +2,7 @@ resource "stackit_observability_instance" "this" { count = var.observability.enabled ? 1 : 0 project_id = stackit_resourcemanager_project.this.project_id - name = var.observability.name != null ? var.observability.name : "${var.naming_pattern}-obs" + name = var.observability.name != null ? var.observability.name : var.naming_pattern plan_name = var.observability.plan_name acl = var.observability.acl } diff --git a/src/modules/platform-kubernetes/2-sna-network.tf b/src/modules/platform-kubernetes/2-sna-network.tf deleted file mode 100644 index 525202d..0000000 --- a/src/modules/platform-kubernetes/2-sna-network.tf +++ /dev/null @@ -1,8 +0,0 @@ -resource "stackit_network" "sna" { - count = local.use_sna ? 1 : 0 - - project_id = stackit_resourcemanager_project.this.project_id - name = "${var.cluster.name}-sna" - ipv4_prefix_length = var.network.sna_network_prefix_length - routed = true -} diff --git a/src/modules/platform-kubernetes/3-cluster.tf b/src/modules/platform-kubernetes/3-cluster.tf index 5fbcc6f..319c4fd 100644 --- a/src/modules/platform-kubernetes/3-cluster.tf +++ b/src/modules/platform-kubernetes/3-cluster.tf @@ -1,53 +1,20 @@ locals { - use_sna = lower(var.network.mode) == "sna" - - effective_observability_instance_id = var.observability.enabled ? stackit_observability_instance.this[0].instance_id : null effective_dns_zones = var.dns.create_zones ? sort([ for zone in values(stackit_dns_zone.ske_extension) : zone.dns_name ]) : local.dns_extension_zones - default_node_pools = [ - { - name = "ha-a" - machine_type = "g3i.4" - minimum = 2 - maximum = 2 - availability_zones = ["${var.region}-1"] - volume_size = 20 - volume_type = "storage_premium_perf1" - os_name = "flatcar" - labels = {} - }, - { - name = "ha-b" - machine_type = "g3i.4" - minimum = 2 - maximum = 2 - availability_zones = ["${var.region}-2"] - volume_size = 20 - volume_type = "storage_premium_perf1" - os_name = "flatcar" - labels = {} - } - ] - effective_node_pools = length(var.cluster.node_pools) > 0 ? var.cluster.node_pools : local.default_node_pools } resource "stackit_ske_cluster" "this" { - project_id = stackit_resourcemanager_project.this.project_id - region = var.region - name = var.cluster.name - depends_on = [time_sleep.wait_for_network_area_membership, stackit_dns_zone.ske_extension] + project_id = stackit_resourcemanager_project.this.project_id + region = var.region + name = var.cluster.name + kubernetes_version_min = var.cluster.kubernetes_version_min - node_pools = local.effective_node_pools + node_pools = var.cluster.node_pools - maintenance = { - enable_kubernetes_version_updates = var.cluster.maintenance.enable_kubernetes_version_updates - enable_machine_image_version_updates = var.cluster.maintenance.enable_machine_image_version_updates - start = var.cluster.maintenance.start - end = var.cluster.maintenance.end - } + maintenance = var.cluster.maintenance - network = local.use_sna ? { + network = var.network.sna_enabled ? { id = stackit_network.sna[0].network_id control_plane = { access_scope = "SNA" @@ -62,13 +29,18 @@ resource "stackit_ske_cluster" "this" { extensions = { observability = { enabled = var.observability.enabled - instance_id = local.effective_observability_instance_id + 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 } } + + depends_on = [ + time_sleep.wait_for_network_area_membership, + stackit_dns_zone.ske_extension, + ] } resource "stackit_ske_kubeconfig" "this" { diff --git a/src/modules/platform-kubernetes/5-debug-bastion.tf b/src/modules/platform-kubernetes/5-debug-bastion.tf index cfe807e..a361fd9 100644 --- a/src/modules/platform-kubernetes/5-debug-bastion.tf +++ b/src/modules/platform-kubernetes/5-debug-bastion.tf @@ -1,115 +1,27 @@ locals { - debug_bastion_enabled = var.debug_bastion.enabled && var.network.mode == "sna" - + debug_bastion_enabled = var.debug_bastion.enabled && var.network.sna_enabled debug_bastion_short_prefix = trim(replace(substr(var.naming_pattern, 0, 14), "/-{2,}/", "-"), "-") - - debug_bastion_name = var.debug_bastion.name != null ? var.debug_bastion.name : "${var.naming_pattern}-dbg" - - debug_bastion_ssh_public_key = local.debug_bastion_enabled ? ( - var.debug_bastion.ssh_public_key != null ? trimspace(var.debug_bastion.ssh_public_key) : trimspace(file(pathexpand(var.debug_bastion.ssh_public_key_path))) - ) : null - - debug_bastion_user_data = var.debug_bastion.install_kubectl ? ( - < /etc/apt/sources.list.d/kubernetes.list - - apt-get update - - apt-get install -y kubectl -EOT - ) : null -} - -check "debug_bastion_requires_sna" { - assert { - condition = var.debug_bastion.enabled ? var.network.mode == "sna" : true - error_message = "debug_bastion requires network.mode = \"sna\"." - } -} - -check "debug_bastion_ssh_key_required" { - assert { - condition = !var.debug_bastion.enabled || local.debug_bastion_ssh_public_key != "" - error_message = "debug_bastion requires a non-empty ssh_public_key or ssh_public_key_path." - } -} - -resource "stackit_key_pair" "debug_bastion" { - count = local.debug_bastion_enabled ? 1 : 0 - - name = "${local.debug_bastion_short_prefix}-dbg-key" - public_key = local.debug_bastion_ssh_public_key -} - -resource "stackit_security_group" "debug_bastion" { - count = local.debug_bastion_enabled ? 1 : 0 - - project_id = stackit_resourcemanager_project.this.project_id - name = "${local.debug_bastion_short_prefix}-dbg-sg" - description = "Debug bastion SSH access" - stateful = true + debug_bastion_name = var.debug_bastion.name != null ? var.debug_bastion.name : "${var.naming_pattern}-dbg" } -resource "stackit_security_group_rule" "debug_bastion_ssh" { - for_each = local.debug_bastion_enabled ? { - for cidr in var.debug_bastion.ssh_allowed_cidrs : cidr => cidr - } : {} +module "debug_bastion" { + source = "../debug-bastion" + count = local.debug_bastion_enabled ? 1 : 0 + enabled = local.debug_bastion_enabled + sna_enabled = var.network.sna_enabled project_id = stackit_resourcemanager_project.this.project_id - security_group_id = stackit_security_group.debug_bastion[0].security_group_id - direction = "ingress" - ether_type = "IPv4" - ip_range = each.value - - protocol = { - name = "tcp" - } - - port_range = { - min = 22 - max = 22 - } -} - -resource "stackit_network_interface" "debug_bastion" { - count = local.debug_bastion_enabled ? 1 : 0 - - project_id = stackit_resourcemanager_project.this.project_id - network_id = stackit_network.sna[0].network_id - name = "${local.debug_bastion_name}-nic" - security = true - security_group_ids = [stackit_security_group.debug_bastion[0].security_group_id] -} - -resource "stackit_server" "debug_bastion" { - count = local.debug_bastion_enabled ? 1 : 0 - - project_id = stackit_resourcemanager_project.this.project_id - name = local.debug_bastion_name - - boot_volume = { - source_type = "image" - source_id = var.debug_bastion.image_id - size = var.debug_bastion.boot_volume_size - } - + network_id = stackit_network.sna[0].network_id + name = local.debug_bastion_name + short_prefix = local.debug_bastion_short_prefix availability_zone = var.debug_bastion.availability_zone machine_type = var.debug_bastion.machine_type - keypair_name = stackit_key_pair.debug_bastion[0].name - network_interfaces = [ - stackit_network_interface.debug_bastion[0].network_interface_id - ] - user_data = local.debug_bastion_user_data -} - -resource "stackit_public_ip" "debug_bastion" { - count = local.debug_bastion_enabled && var.debug_bastion.assign_public_ip ? 1 : 0 - - project_id = stackit_resourcemanager_project.this.project_id - network_interface_id = stackit_network_interface.debug_bastion[0].network_interface_id + image_id = var.debug_bastion.image_id + boot_volume_size = var.debug_bastion.boot_volume_size + + ssh_public_key = var.debug_bastion.ssh_public_key + ssh_public_key_path = var.debug_bastion.ssh_public_key_path + ssh_allowed_cidrs = var.debug_bastion.ssh_allowed_cidrs + assign_public_ip = var.debug_bastion.assign_public_ip + install_kubectl = var.debug_bastion.install_kubectl } diff --git a/src/modules/platform-kubernetes/outputs.tf b/src/modules/platform-kubernetes/outputs.tf index ffadda4..12f8c8b 100644 --- a/src/modules/platform-kubernetes/outputs.tf +++ b/src/modules/platform-kubernetes/outputs.tf @@ -19,11 +19,11 @@ output "debug_bastion" { description = "Debug bastion metadata when enabled for private cluster troubleshooting." value = local.debug_bastion_enabled ? { enabled = true - server_id = stackit_server.debug_bastion[0].server_id - network_interface_id = stackit_network_interface.debug_bastion[0].network_interface_id - public_ip = var.debug_bastion.assign_public_ip ? stackit_public_ip.debug_bastion[0].ip : null - ssh_user = "ubuntu" - ssh_command = var.debug_bastion.assign_public_ip ? "ssh ubuntu@${stackit_public_ip.debug_bastion[0].ip}" : null + server_id = module.debug_bastion[0].server_id + network_interface_id = module.debug_bastion[0].network_interface_id + public_ip = module.debug_bastion[0].public_ip + ssh_user = module.debug_bastion[0].ssh_user + ssh_command = module.debug_bastion[0].ssh_command } : { enabled = false server_id = null @@ -39,6 +39,11 @@ output "observability_instance_id" { value = var.observability.enabled ? stackit_observability_instance.this[0].instance_id : null } +output "observability_targets_url" { + description = "The Prometheus query endpoint URL of the optional platform observability instance." + value = var.observability.enabled ? stackit_observability_instance.this[0].targets_url : null +} + output "project_container_id" { description = "The container ID of the created STACKIT project." value = stackit_resourcemanager_project.this.container_id diff --git a/src/modules/platform-kubernetes/terraform.tf b/src/modules/platform-kubernetes/terraform.tf index a8778a8..ceff2c5 100644 --- a/src/modules/platform-kubernetes/terraform.tf +++ b/src/modules/platform-kubernetes/terraform.tf @@ -1,14 +1,14 @@ terraform { - required_version = ">= 1.10" + required_version = ">= 1.10, < 2.0" required_providers { stackit = { source = "stackitcloud/stackit" - version = ">=0.98.0" + version = "~> 0.99.0" } time = { source = "hashicorp/time" - version = ">=0.12.0" + version = "~> 0.14.0" } } } diff --git a/src/modules/platform-kubernetes/variables.tf b/src/modules/platform-kubernetes/variables.tf index 0121ed1..366a3d8 100644 --- a/src/modules/platform-kubernetes/variables.tf +++ b/src/modules/platform-kubernetes/variables.tf @@ -3,16 +3,46 @@ variable "cluster" { 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) - volume_size = optional(number, 20) - volume_type = optional(string, "storage_premium_perf1") - os_name = optional(string, "flatcar") - labels = optional(map(string), {}) - })), []) + 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) @@ -61,6 +91,14 @@ variable "debug_bastion" { }) description = "Optional debug bastion VM in the SNA network with SSH access to test SKE connectivity from inside the private network." default = {} + + validation { + condition = !var.debug_bastion.enabled || ( + try(trimspace(var.debug_bastion.ssh_public_key), "") != "" || + try(trimspace(var.debug_bastion.ssh_public_key_path), "") != "" + ) + error_message = "debug_bastion requires ssh_public_key or ssh_public_key_path when enabled." + } } variable "labels" { @@ -76,11 +114,12 @@ variable "naming_pattern" { variable "network" { type = object({ - mode = optional(string, "public") + 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) }) - description = "Network mode for SKE. mode=public configures public control plane, mode=sna configures SNA and requires sna_network_area_id at apply time." + description = "Network settings for SKE. Set sna_enabled=true and provide sna_network_area_id for SNA; otherwise the cluster runs in public control-plane mode." default = {} } @@ -100,6 +139,11 @@ variable "owner_email" { description = "Email address of the project owner. Required for project creation." } +variable "organization_id" { + type = string + description = "Organization ID used for routing table resources in network-area scope." +} + variable "parent_container_id" { type = string description = "Parent container ID (folder or organization) where the project will be created." diff --git a/src/modules/sandboxes/terraform.tf b/src/modules/sandboxes/terraform.tf index 851721e..8ff0b06 100644 --- a/src/modules/sandboxes/terraform.tf +++ b/src/modules/sandboxes/terraform.tf @@ -4,7 +4,7 @@ terraform { required_providers { stackit = { source = "stackitcloud/stackit" - version = ">=0.93.0" + version = "~> 0.99.0" } } } \ No newline at end of file diff --git a/src/namespace-service.tf b/src/namespace-service.tf index c037df6..7fcffc3 100644 --- a/src/namespace-service.tf +++ b/src/namespace-service.tf @@ -9,1089 +9,154 @@ locals { ] landing_zone_namespace_services = { - for key, value in var.landing_zones : key => { - namespace = value.namespace_service.namespace != null ? value.namespace_service.namespace : trim(replace(lower(replace("${value.project_code}-${value.env}", "/[^a-zA-Z0-9-]/", "-")), "/-{2,}/", "-"), "-") - dns_subdomain = value.namespace_service.dns_subdomain - dns_fqdn = value.namespace_service.dns_subdomain != null && try(module.landing_zone[key].dns_zone_dns_name, null) != null ? "${value.namespace_service.dns_subdomain}.${module.landing_zone[key].dns_zone_dns_name}" : null - service_account_name = value.namespace_service.kubernetes_access.service_account_name != null ? value.namespace_service.kubernetes_access.service_account_name : trim(replace(lower(replace("${value.project_code}-${value.env}-ns-user", "/[^a-zA-Z0-9-]/", "-")), "/-{2,}/", "-"), "-") - enable_kubernetes_access = value.namespace_service.kubernetes_access.enabled - sample_load = { - enabled = value.namespace_service.sample_load.enabled - image = value.namespace_service.sample_load.image + for key, value in var.landing_zone_namespace_services : key => { + demo_enabled = value.demo_enabled + demo_metrics_ingestion = { + enabled = value.demo_metrics_ingestion.enabled + target_urls = value.demo_metrics_ingestion.target_urls + scheme = lower(value.demo_metrics_ingestion.scheme) + metrics_path = value.demo_metrics_ingestion.metrics_path + scrape_interval = value.demo_metrics_ingestion.scrape_interval + scrape_timeout = value.demo_metrics_ingestion.scrape_timeout } - demo = { - enabled = value.namespace_service.demo.enabled - image = value.namespace_service.demo.image - ingress_class_name = value.namespace_service.demo.ingress_class_name - install_ingress_controller = value.namespace_service.demo.install_ingress_controller - external_secret_enabled = value.namespace_service.demo.external_secret_enabled - dashboard_example_enabled = value.namespace_service.demo.dashboard_example_enabled - ingress_host = local.platform_kubernetes_cluster_key != null && length(module.platform_kubernetes[local.platform_kubernetes_cluster_key].dns_extension_zones) > 0 ? "${key}.${module.platform_kubernetes[local.platform_kubernetes_cluster_key].dns_extension_zones[0]}" : null + namespace = value.namespace != null ? value.namespace : trim(replace(lower(replace("${var.landing_zones[key].project_code}-${var.landing_zones[key].env}", "/[^a-zA-Z0-9-]/", "-")), "/-{2,}/", "-"), "-") + dns_subdomain = value.dns_subdomain + dns_fqdn = value.dns_subdomain != null && try(module.landing_zone[key].dns_zone_dns_name, null) != null ? "${value.dns_subdomain}.${module.landing_zone[key].dns_zone_dns_name}" : null + service_account_name = value.kubernetes_access.service_account_name != null ? value.kubernetes_access.service_account_name : trim(replace(lower(replace("${var.landing_zones[key].project_code}-${var.landing_zones[key].env}-ns-user", "/[^a-zA-Z0-9-]/", "-")), "/-{2,}/", "-"), "-") + enable_kubernetes_access = value.kubernetes_access.enabled + sample_load = { + enabled = value.sample_load.enabled + image = value.sample_load.image } - labels = value.namespace_service.labels - annotations = value.namespace_service.annotations - use_secretsmanager = value.namespace_service.secretsmanager + labels = value.labels + annotations = value.annotations + use_secretsmanager = value.secretsmanager secrets_enforcement = { - enabled = value.namespace_service.secrets_enforcement.enabled - mode = lower(value.namespace_service.secrets_enforcement.mode) - allow_opaque_secret_types = value.namespace_service.secrets_enforcement.allow_opaque_secret_types + enabled = value.secrets_enforcement.enabled + mode = lower(value.secrets_enforcement.mode) + allow_opaque_secret_types = value.secrets_enforcement.allow_opaque_secret_types break_glass = { - enabled = value.namespace_service.secrets_enforcement.break_glass.enabled - ttl_hours = value.namespace_service.secrets_enforcement.break_glass.ttl_hours - principals = value.namespace_service.secrets_enforcement.break_glass.principals - } - } - } - if value.namespace_service.enabled - } - - landing_zone_namespace_services_kyverno = { - for key, value in local.landing_zone_namespace_services : key => value - if value.secrets_enforcement.enabled - } - - landing_zone_namespace_services_demo = { - for key, value in local.landing_zone_namespace_services : key => value - if value.demo.enabled - } - - landing_zone_namespace_services_demo_external_secret = { - for key, value in local.landing_zone_namespace_services_demo : key => value - if value.demo.external_secret_enabled - } - - landing_zone_namespace_services_demo_observability = { - for key, value in local.landing_zone_namespace_services_demo : key => value - if value.demo.dashboard_example_enabled && try(module.landing_zone[key].observability_metrics_push_url, null) != null - } -} - -check "landing_zone_namespace_services_unique_namespaces" { - assert { - condition = length(local.landing_zone_namespace_services) == length(distinct([for svc in values(local.landing_zone_namespace_services) : svc.namespace])) - error_message = "Each enabled namespace_service must resolve to a unique namespace name." - } -} - -check "landing_zone_namespace_services_non_empty_namespaces" { - assert { - condition = alltrue([for svc in values(local.landing_zone_namespace_services) : length(svc.namespace) > 0]) - error_message = "Each enabled namespace_service must resolve to a non-empty namespace name." - } -} - -check "landing_zone_namespace_services_demo_requires_dns" { - assert { - condition = alltrue([for svc in values(local.landing_zone_namespace_services) : !svc.demo.enabled || svc.demo.ingress_host != null]) - error_message = "namespace_service.demo.enabled requires one platform_kubernetes.dns.zones entry for external DNS management." - } -} - -check "landing_zone_namespace_services_demo_dashboard_requires_observability" { - assert { - condition = alltrue([for key, svc in local.landing_zone_namespace_services : !svc.demo.dashboard_example_enabled || try(module.landing_zone[key].observability_grafana_url, null) != null]) - error_message = "namespace_service.demo.dashboard_example_enabled requires landing_zones..observability.enabled=true." - } -} - -check "landing_zone_namespace_services_demo_external_secret_requires_sm" { - assert { - condition = alltrue([for svc in values(local.landing_zone_namespace_services) : !svc.demo.external_secret_enabled || svc.use_secretsmanager]) - error_message = "namespace_service.demo.external_secret_enabled requires namespace_service.secretsmanager=true." - } -} - -resource "helm_release" "kyverno" { - provider = helm.platform - count = length(local.landing_zone_namespace_services_kyverno) > 0 ? 1 : 0 - - name = "kyverno" - namespace = "kyverno" - repository = "https://kyverno.github.io/kyverno/" - chart = "kyverno" - create_namespace = true - wait = true - timeout = 600 - atomic = true - cleanup_on_fail = true -} - -resource "helm_release" "external_secrets" { - provider = helm.platform - count = length(local.landing_zone_namespace_services_demo_external_secret) > 0 ? 1 : 0 - - name = "external-secrets" - namespace = "external-secrets" - repository = "https://charts.external-secrets.io" - chart = "external-secrets" - create_namespace = true - wait = true - timeout = 600 - atomic = true - cleanup_on_fail = true -} - -resource "helm_release" "demo_ingress_nginx" { - provider = helm.platform - count = length([for svc in values(local.landing_zone_namespace_services_demo) : svc if svc.demo.install_ingress_controller]) > 0 ? 1 : 0 - - name = "lz-demo-ingress-nginx" - namespace = "ingress-nginx" - repository = "https://kubernetes.github.io/ingress-nginx" - chart = "ingress-nginx" - create_namespace = true - wait = true - timeout = 600 - atomic = true - cleanup_on_fail = true - - set = [ - { - name = "controller.ingressClass" - value = "lz-demo" - }, - { - name = "controller.ingressClassResource.name" - value = "lz-demo" - }, - { - name = "controller.ingressClassResource.controllerValue" - value = "k8s.io/ingress-nginx-lz-demo" - }, - { - name = "controller.ingressClassByName" - value = "true" - }, - { - name = "controller.watchIngressWithoutClass" - value = "false" - }, - { - name = "controller.service.type" - value = "LoadBalancer" - }, - ] -} - -resource "kubernetes_service_account_v1" "landing_zone_demo_kube_state_metrics" { - provider = kubernetes.platform - count = length(local.landing_zone_namespace_services_demo_observability) > 0 ? 1 : 0 - - metadata { - name = "lz-demo-kube-state-metrics" - namespace = "external-secrets" - labels = { - "stackit.cloud/demo" = "true" - } - } -} - -resource "kubernetes_cluster_role_v1" "landing_zone_demo_kube_state_metrics" { - provider = kubernetes.platform - count = length(local.landing_zone_namespace_services_demo_observability) > 0 ? 1 : 0 - - metadata { - name = "lz-demo-kube-state-metrics" - labels = { - "stackit.cloud/demo" = "true" - } - } - - rule { - api_groups = [""] - resources = ["pods", "services", "endpoints", "persistentvolumeclaims", "persistentvolumes", "nodes", "namespaces", "resourcequotas", "limitranges", "secrets", "configmaps", "serviceaccounts"] - verbs = ["list", "watch"] - } - - rule { - api_groups = ["apps"] - resources = ["deployments", "daemonsets", "statefulsets", "replicasets"] - verbs = ["list", "watch"] - } - - rule { - api_groups = ["batch"] - resources = ["jobs", "cronjobs"] - verbs = ["list", "watch"] - } - - rule { - api_groups = ["autoscaling"] - resources = ["horizontalpodautoscalers"] - verbs = ["list", "watch"] - } - - rule { - api_groups = ["networking.k8s.io"] - resources = ["ingresses", "networkpolicies"] - verbs = ["list", "watch"] - } - - rule { - api_groups = ["storage.k8s.io"] - resources = ["storageclasses", "volumeattachments"] - verbs = ["list", "watch"] - } -} - -resource "kubernetes_cluster_role_binding_v1" "landing_zone_demo_kube_state_metrics" { - provider = kubernetes.platform - count = length(local.landing_zone_namespace_services_demo_observability) > 0 ? 1 : 0 - - metadata { - name = "lz-demo-kube-state-metrics" - labels = { - "stackit.cloud/demo" = "true" - } - } - - role_ref { - api_group = "rbac.authorization.k8s.io" - kind = "ClusterRole" - name = kubernetes_cluster_role_v1.landing_zone_demo_kube_state_metrics[0].metadata[0].name - } - - subject { - kind = "ServiceAccount" - name = kubernetes_service_account_v1.landing_zone_demo_kube_state_metrics[0].metadata[0].name - namespace = kubernetes_service_account_v1.landing_zone_demo_kube_state_metrics[0].metadata[0].namespace - } -} - -resource "kubernetes_deployment_v1" "landing_zone_demo_kube_state_metrics" { - provider = kubernetes.platform - count = length(local.landing_zone_namespace_services_demo_observability) > 0 ? 1 : 0 - - metadata { - name = "lz-demo-kube-state-metrics" - namespace = "external-secrets" - labels = { - "app.kubernetes.io/name" = "lz-demo-kube-state-metrics" - "stackit.cloud/demo" = "true" - } - } - - spec { - replicas = 1 - - selector { - match_labels = { - "app.kubernetes.io/name" = "lz-demo-kube-state-metrics" - } - } - - template { - metadata { - labels = { - "app.kubernetes.io/name" = "lz-demo-kube-state-metrics" + enabled = value.secrets_enforcement.break_glass.enabled + ttl_hours = value.secrets_enforcement.break_glass.ttl_hours + principals = value.secrets_enforcement.break_glass.principals } } - - spec { - service_account_name = kubernetes_service_account_v1.landing_zone_demo_kube_state_metrics[0].metadata[0].name - - container { - name = "kube-state-metrics" - image = "registry.k8s.io/kube-state-metrics/kube-state-metrics:v2.13.0" - - args = [ - "--port=8080", - "--telemetry-port=8081", - ] - - port { - name = "http-metrics" - container_port = 8080 - } - - port { - name = "telemetry" - container_port = 8081 - } - - readiness_probe { - http_get { - path = "/readyz" - port = "telemetry" - } - initial_delay_seconds = 15 - timeout_seconds = 5 - } - - liveness_probe { - http_get { - path = "/livez" - port = "telemetry" - } - initial_delay_seconds = 20 - timeout_seconds = 5 - } - } - } - } - } - - depends_on = [ - kubernetes_cluster_role_binding_v1.landing_zone_demo_kube_state_metrics, - ] -} - -resource "kubernetes_service_v1" "landing_zone_demo_kube_state_metrics" { - provider = kubernetes.platform - count = length(local.landing_zone_namespace_services_demo_observability) > 0 ? 1 : 0 - - metadata { - name = "lz-demo-kube-state-metrics" - namespace = "external-secrets" - labels = { - "app.kubernetes.io/name" = "lz-demo-kube-state-metrics" - "stackit.cloud/demo" = "true" - } - } - - spec { - selector = { - "app.kubernetes.io/name" = "lz-demo-kube-state-metrics" - } - - port { - name = "http-metrics" - port = 8080 - target_port = "http-metrics" - protocol = "TCP" - } - } - - depends_on = [ - kubernetes_deployment_v1.landing_zone_demo_kube_state_metrics, - ] -} - -resource "stackit_secretsmanager_user" "landing_zone_demo_external_secret" { - for_each = local.landing_zone_namespace_services_demo_external_secret - - project_id = module.landing_zone[each.key].project_id - instance_id = module.landing_zone[each.key].secretsmanager_instance_id - description = "Demo ExternalSecret reader for ${each.key}" - write_enabled = true -} - -resource "stackit_observability_credential" "landing_zone_demo_metrics_remote_write" { - for_each = local.landing_zone_namespace_services_demo_observability - - project_id = module.landing_zone[each.key].project_id - instance_id = module.landing_zone[each.key].observability_instance_id - description = "Demo remote-write credential for ${each.key}" -} - -resource "kubernetes_secret_v1" "landing_zone_demo_vault_auth" { - provider = kubernetes.platform - - for_each = local.landing_zone_namespace_services_demo_external_secret - - metadata { - name = "${each.key}-demo-vault-auth" - namespace = "external-secrets" - labels = { - "stackit.cloud/landing-zone" = each.key - "stackit.cloud/demo" = "true" - } - } - - data = { - password = stackit_secretsmanager_user.landing_zone_demo_external_secret[each.key].password - } - - type = "Opaque" - - depends_on = [ - helm_release.external_secrets, - stackit_secretsmanager_user.landing_zone_demo_external_secret, - ] -} - -resource "kubernetes_manifest" "landing_zone_demo_secret_store" { - provider = kubernetes.platform - - for_each = local.landing_zone_namespace_services_demo_external_secret - - manifest = { - apiVersion = "external-secrets.io/v1" - kind = "ClusterSecretStore" - metadata = { - name = "${each.key}-stackit-sm-store" - labels = { - "stackit.cloud/landing-zone" = each.key - "stackit.cloud/demo" = "true" - } - } - spec = { - provider = { - vault = { - server = "https://prod.sm.${var.region}.stackit.cloud" - path = module.landing_zone[each.key].secretsmanager_instance_id - version = "v2" - auth = { - userPass = { - path = "userpass" - username = stackit_secretsmanager_user.landing_zone_demo_external_secret[each.key].username - secretRef = { - name = kubernetes_secret_v1.landing_zone_demo_vault_auth[each.key].metadata[0].name - key = "password" - namespace = "external-secrets" - } - } - } - } - } - } - } - - computed_fields = [ - "metadata", - "spec", - "status", - ] - - depends_on = [ - helm_release.external_secrets, - kubernetes_secret_v1.landing_zone_demo_vault_auth, - stackit_secretsmanager_user.landing_zone_demo_external_secret, - ] -} - -resource "kubernetes_manifest" "landing_zone_demo_external_secret" { - provider = kubernetes.platform - - for_each = local.landing_zone_namespace_services_demo_external_secret - - manifest = { - apiVersion = "external-secrets.io/v1" - kind = "ExternalSecret" - metadata = { - name = "${each.key}-demo-app-secret" - namespace = each.value.namespace - labels = { - "stackit.cloud/landing-zone" = each.key - "stackit.cloud/demo" = "true" - } - } - spec = { - refreshInterval = "1m" - secretStoreRef = { - name = kubernetes_manifest.landing_zone_demo_secret_store[each.key].manifest.metadata.name - kind = "ClusterSecretStore" - } - target = { - name = "${each.key}-demo-app-secret" - creationPolicy = "Owner" - } - data = [{ - secretKey = "APP_MESSAGE" - remoteRef = { - key = "namespace-demo/${each.key}/app" - property = "APP_MESSAGE" - } - }] - } - } - - computed_fields = [ - "metadata", - "spec", - "status", - ] - - depends_on = [ - kubernetes_manifest.landing_zone_demo_secret_store, - kubernetes_namespace_v1.landing_zone, - ] -} - -resource "kubernetes_deployment_v1" "landing_zone_demo_app" { - provider = kubernetes.platform - - for_each = local.landing_zone_namespace_services_demo - - metadata { - name = "${each.key}-demo-app" - namespace = each.value.namespace - labels = { - "app.kubernetes.io/name" = "${each.key}-demo-app" - "stackit.cloud/landing-zone" = each.key - "stackit.cloud/demo-scenario" = "namespace-service" - "stackit.cloud/demo-component" = "workload" - } - } - - spec { - replicas = 1 - - selector { - match_labels = { - "app.kubernetes.io/name" = "${each.key}-demo-app" - } - } - - template { - metadata { - labels = { - "app.kubernetes.io/name" = "${each.key}-demo-app" - "stackit.cloud/landing-zone" = each.key - "stackit.cloud/demo-scenario" = "namespace-service" - "stackit.cloud/demo-component" = "workload" - } - } - - spec { - container { - name = "app" - image = each.value.demo.image - args = ["-listen=:5678", "-text=STACKIT Landing Zone Demo"] - - port { - container_port = 5678 - } - - env { - name = "APP_MESSAGE" - - value_from { - secret_key_ref { - name = "${each.key}-demo-app-secret" - key = "APP_MESSAGE" - optional = true - } - } - } - } - } - } - } - - depends_on = [ - kubernetes_manifest.landing_zone_demo_external_secret, - ] -} - -resource "kubernetes_service_v1" "landing_zone_demo_app" { - provider = kubernetes.platform - - for_each = local.landing_zone_namespace_services_demo - - metadata { - name = "${each.key}-demo-app" - namespace = each.value.namespace - labels = { - "stackit.cloud/landing-zone" = each.key - "stackit.cloud/demo" = "true" - } - } - - spec { - selector = { - "app.kubernetes.io/name" = "${each.key}-demo-app" - } - - port { - port = 80 - target_port = 5678 - protocol = "TCP" - } - } -} - -resource "kubernetes_ingress_v1" "landing_zone_demo_app" { - provider = kubernetes.platform - - for_each = local.landing_zone_namespace_services_demo - - metadata { - name = "${each.key}-demo-app" - namespace = each.value.namespace - annotations = { - "external-dns.alpha.kubernetes.io/hostname" = each.value.demo.ingress_host - "stackit.cloud/demo" = "true" - "kubernetes.io/ingress.class" = each.value.demo.ingress_class_name - } - } - - spec { - ingress_class_name = each.value.demo.ingress_class_name - - rule { - host = each.value.demo.ingress_host - - http { - path { - path = "/" - path_type = "Prefix" - - backend { - service { - name = kubernetes_service_v1.landing_zone_demo_app[each.key].metadata[0].name - - port { - number = 80 - } - } - } - } - } - } - } - - depends_on = [ - helm_release.demo_ingress_nginx, - ] -} - -resource "kubernetes_config_map_v1" "landing_zone_demo_dashboard_example" { - provider = kubernetes.platform - - for_each = { - for key, value in local.landing_zone_namespace_services_demo : key => value - if value.demo.dashboard_example_enabled - } - - metadata { - name = "${each.key}-demo-dashboard-example" - namespace = each.value.namespace - labels = { - "stackit.cloud/landing-zone" = each.key - "stackit.cloud/demo" = "true" - } - } - - data = { - "grafana-dashboard.json" = local.landing_zone_demo_dashboard_json[each.key] + } } -} - -resource "kubernetes_config_map_v1" "landing_zone_demo_metrics_agent_config" { - provider = kubernetes.platform - for_each = local.landing_zone_namespace_services_demo_observability - - metadata { - name = "${each.key}-demo-metrics-agent-config" - namespace = each.value.namespace - labels = { - "stackit.cloud/landing-zone" = each.key - "stackit.cloud/demo" = "true" + landing_zone_namespace_demo_services = { + for key, value in local.landing_zone_namespace_services : key => { + namespace = value.namespace + use_secretsmanager = value.use_secretsmanager + landing_zone_project_id = module.landing_zone[key].project_id + secretsmanager_instance_id = module.landing_zone[key].secretsmanager_instance_id + observability_instance_id = module.landing_zone[key].observability_instance_id + observability_grafana_url = module.landing_zone[key].observability_grafana_url + observability_admin_user = module.landing_zone[key].observability_grafana_admin_user + demo_metrics_ingestion_enabled = value.demo_metrics_ingestion.enabled + demo_metrics_ingestion_target_urls = length(value.demo_metrics_ingestion.target_urls) > 0 ? value.demo_metrics_ingestion.target_urls : ( + value.sample_load.enabled && value.dns_fqdn != null ? ["${value.dns_fqdn}:80"] : [] + ) + demo_metrics_ingestion_scheme = value.demo_metrics_ingestion.scheme + demo_metrics_ingestion_metrics_path = value.demo_metrics_ingestion.metrics_path + demo_metrics_ingestion_scrape_interval = value.demo_metrics_ingestion.scrape_interval + demo_metrics_ingestion_scrape_timeout = value.demo_metrics_ingestion.scrape_timeout + platform_project_id = local.platform_kubernetes_cluster_key != null ? module.platform_kubernetes[local.platform_kubernetes_cluster_key].project_id : null + platform_observability_instance_id = local.platform_kubernetes_cluster_key != null ? module.platform_kubernetes[local.platform_kubernetes_cluster_key].observability_instance_id : null + platform_observability_targets_url = local.platform_kubernetes_cluster_key != null ? module.platform_kubernetes[local.platform_kubernetes_cluster_key].observability_targets_url : null + dns_zone_name = module.landing_zone[key].dns_zone_dns_name } + if value.demo_enabled } - data = { - "prometheus.yml" = <<-EOT - global: - scrape_interval: 30s - scrape_configs: - - job_name: lz-demo-kube-state-metrics - static_configs: - - targets: - - lz-demo-kube-state-metrics.external-secrets.svc.cluster.local:8080 - metric_relabel_configs: - - source_labels: [namespace] - regex: ${each.value.namespace} - action: keep - remote_write: - - url: ${module.landing_zone[each.key].observability_metrics_push_url} - basic_auth: - username: ${stackit_observability_credential.landing_zone_demo_metrics_remote_write[each.key].username} - password: ${stackit_observability_credential.landing_zone_demo_metrics_remote_write[each.key].password} - EOT + landing_zone_namespace_demo_dashboard_passwords = { + for key, value in local.landing_zone_namespace_services : key => module.landing_zone[key].observability_grafana_admin_password + if value.demo_enabled && module.landing_zone[key].observability_instance_id != null } - depends_on = [ - kubernetes_service_v1.landing_zone_demo_kube_state_metrics, - stackit_observability_credential.landing_zone_demo_metrics_remote_write, - ] -} - -resource "kubernetes_deployment_v1" "landing_zone_demo_metrics_agent" { - provider = kubernetes.platform - - for_each = local.landing_zone_namespace_services_demo_observability - - metadata { - name = "${each.key}-demo-metrics-agent" - namespace = each.value.namespace - labels = { - "app.kubernetes.io/name" = "${each.key}-demo-metrics-agent" - "stackit.cloud/landing-zone" = each.key - "stackit.cloud/demo" = "true" - } + landing_zone_namespace_services_kyverno = { + for key, value in local.landing_zone_namespace_services : key => value + if value.secrets_enforcement.enabled } - spec { - replicas = 1 - - selector { - match_labels = { - "app.kubernetes.io/name" = "${each.key}-demo-metrics-agent" - } + 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) } + } +} - template { - metadata { - labels = { - "app.kubernetes.io/name" = "${each.key}-demo-metrics-agent" - } - } +module "namespace_service_demo" { + source = "./modules/namespace-service-demo" - spec { - container { - name = "prometheus-agent" - image = "prom/prometheus:v2.54.1" - args = [ - "--config.file=/etc/prometheus/prometheus.yml", - "--enable-feature=agent", - "--storage.agent.path=/prometheus", - ] + services = local.landing_zone_namespace_demo_services + dashboard_passwords = local.landing_zone_namespace_demo_dashboard_passwords +} - port { - container_port = 9090 - } +resource "helm_release" "kyverno" { + provider = helm.platform + count = length(local.landing_zone_namespace_services_kyverno) > 0 ? 1 : 0 - volume_mount { - name = "config" - mount_path = "/etc/prometheus" - read_only = true - } - } + name = "kyverno" + namespace = "kyverno" + repository = "https://kyverno.github.io/kyverno/" + chart = "kyverno" + create_namespace = true + wait = true + timeout = 600 + atomic = true + cleanup_on_fail = true +} - volume { - name = "config" +resource "helm_release" "demo_envoy_gateway" { + provider = helm.platform + count = length([for svc in values(local.landing_zone_namespace_services) : svc if svc.sample_load.enabled && svc.dns_fqdn != null]) > 0 ? 1 : 0 - config_map { - name = kubernetes_config_map_v1.landing_zone_demo_metrics_agent_config[each.key].metadata[0].name - } - } - } - } - } + name = "lz-demo-envoy-gateway" + namespace = "envoy-gateway-system" + chart = "oci://docker.io/envoyproxy/gateway-helm" + create_namespace = true + wait = false + timeout = 600 + atomic = false + cleanup_on_fail = false - depends_on = [ - kubernetes_config_map_v1.landing_zone_demo_metrics_agent_config, + set = [ + { + name = "deployment.type" + value = "Kubernetes" + }, + { + name = "service.type" + value = "LoadBalancer" + }, ] } -locals { - landing_zone_demo_dashboard_json = { - for key, value in local.landing_zone_namespace_services_demo : key => jsonencode({ - uid = "lz-demo-${key}" - title = "Landing Zone Demo - ${value.namespace}" - tags = ["stackit", "landing-zone", "demo", value.namespace] - schemaVersion = 39 - version = 2 - editable = true - timezone = "browser" - refresh = "30s" - graphTooltip = 1 - time = { - from = "now-6h" - to = "now" - } - panels = [ - { - id = 1 - title = "Running Demo Pods" - type = "stat" - gridPos = { - h = 5 - w = 6 - x = 0 - y = 0 - } - datasource = "Thanos" - options = { - colorMode = "value" - graphMode = "none" - justifyMode = "auto" - reduceOptions = { - calcs = ["lastNotNull"] - fields = "" - values = false - } - textMode = "auto" - } - fieldConfig = { - defaults = { - unit = "none" - thresholds = { - mode = "absolute" - steps = [{ - color = "green" - value = null - }] - } - } - overrides = [] - } - targets = [{ - refId = "A" - legendFormat = "running demo pods" - expr = "sum(kube_pod_status_phase{namespace=\"${value.namespace}\",phase=\"Running\",pod=~\"${key}-demo-app-.*|${local.landing_zone_namespace_services[key].service_account_name}-sample-load.*\"} == 1)" - }] - }, - { - id = 2 - title = "Pods Running (All in Namespace)" - type = "stat" - gridPos = { - h = 5 - w = 6 - x = 6 - y = 0 - } - datasource = "Thanos" - options = { - colorMode = "value" - graphMode = "none" - justifyMode = "auto" - reduceOptions = { - calcs = ["lastNotNull"] - fields = "" - values = false - } - textMode = "auto" - } - fieldConfig = { - defaults = { - unit = "none" - thresholds = { - mode = "absolute" - steps = [{ - color = "blue" - value = null - }] - } - } - overrides = [] - } - targets = [{ - refId = "A" - legendFormat = "running pods" - expr = "sum(kube_pod_status_phase{namespace=\"${value.namespace}\",phase=\"Running\"} == 1)" - }] - }, - { - id = 3 - title = "Services in Namespace" - type = "stat" - gridPos = { - h = 5 - w = 6 - x = 12 - y = 0 - } - datasource = "Thanos" - options = { - colorMode = "value" - graphMode = "none" - justifyMode = "auto" - reduceOptions = { - calcs = ["lastNotNull"] - fields = "" - values = false - } - textMode = "auto" - } - fieldConfig = { - defaults = { - unit = "none" - thresholds = { - mode = "absolute" - steps = [{ - color = "green" - value = null - }] - } - } - overrides = [] - } - targets = [{ - refId = "A" - legendFormat = "services" - expr = "count(kube_service_info{namespace=\"${value.namespace}\"})" - }] - }, - { - id = 4 - title = "Ready Containers" - type = "stat" - gridPos = { - h = 5 - w = 6 - x = 0 - y = 5 - } - datasource = "Thanos" - fieldConfig = { - defaults = { - unit = "none" - } - overrides = [] - } - options = { - colorMode = "value" - graphMode = "none" - justifyMode = "auto" - reduceOptions = { - calcs = ["lastNotNull"] - fields = "" - values = false - } - textMode = "auto" - } - targets = [{ - refId = "A" - legendFormat = "ready containers" - expr = "sum(kube_pod_container_status_ready{namespace=\"${value.namespace}\"} == 1)" - }] - }, - { - id = 5 - title = "Available Deployment Replicas" - type = "stat" - gridPos = { - h = 5 - w = 6 - x = 6 - y = 5 - } - datasource = "Thanos" - fieldConfig = { - defaults = { - unit = "none" - } - overrides = [] - } - options = { - colorMode = "value" - graphMode = "none" - justifyMode = "auto" - reduceOptions = { - calcs = ["lastNotNull"] - fields = "" - values = false - } - textMode = "auto" - } - targets = [{ - refId = "A" - legendFormat = "available replicas" - expr = "sum(kube_deployment_status_replicas_available{namespace=\"${value.namespace}\"})" - }] - }, - { - id = 6 - title = "Sample Load Pod Running" - type = "stat" - gridPos = { - h = 5 - w = 6 - x = 12 - y = 5 - } - datasource = "Thanos" - fieldConfig = { - defaults = { - unit = "none" - } - overrides = [] - } - options = { - colorMode = "value" - graphMode = "none" - justifyMode = "auto" - reduceOptions = { - calcs = ["lastNotNull"] - fields = "" - values = false - } - textMode = "auto" - } - targets = [{ - refId = "A" - legendFormat = "sample load running" - expr = "sum(kube_pod_status_phase{namespace=\"${value.namespace}\",phase=\"Running\",pod=~\"${local.landing_zone_namespace_services[key].service_account_name}-sample-load.*\"} == 1)" - }] - }, - { - id = 7 - title = "Namespace Pods by Phase" - type = "timeseries" - gridPos = { - h = 8 - w = 24 - x = 0 - y = 10 - } - datasource = "Thanos" - fieldConfig = { - defaults = { - unit = "none" - } - overrides = [] - } - options = { - legend = { - displayMode = "list" - placement = "bottom" - } - tooltip = { - mode = "multi" - } - } - targets = [{ - refId = "A" - legendFormat = "{{phase}}" - expr = "sum by (phase) (kube_pod_status_phase{namespace=\"${value.namespace}\"} == 1)" - }] - } - ] - }) - } -} - -resource "null_resource" "landing_zone_demo_grafana_dashboard" { - for_each = { - for key, value in local.landing_zone_namespace_services_demo : key => value - if value.demo.dashboard_example_enabled && try(module.landing_zone[key].observability_grafana_url, null) != null - } - - triggers = { - dashboard_sha = sha256(local.landing_zone_demo_dashboard_json[each.key]) - grafana_url = module.landing_zone[each.key].observability_grafana_url - namespace = each.value.namespace - } +resource "kubernetes_manifest" "landing_zone_gateway_class" { + provider = kubernetes.platform + count = length([for svc in values(local.landing_zone_namespace_services) : svc if svc.sample_load.enabled && svc.dns_fqdn != null]) > 0 ? 1 : 0 - provisioner "local-exec" { - command = <<-EOT - cat < /www/index.html && cat > /www/metrics.txt <<'EOF'\n# HELP lz_demo_resource_count Demo namespace resource counts\n# TYPE lz_demo_resource_count gauge\nlz_demo_resource_count{namespace=\"${each.value.metadata[0].namespace}\",landing_zone=\"${each.key}\",resource=\"pods\"} 1\nlz_demo_resource_count{namespace=\"${each.value.metadata[0].namespace}\",landing_zone=\"${each.key}\",resource=\"services\"} 1\nlz_demo_resource_count{namespace=\"${each.value.metadata[0].namespace}\",landing_zone=\"${each.key}\",resource=\"gateways\"} 1\nlz_demo_resource_count{namespace=\"${each.value.metadata[0].namespace}\",landing_zone=\"${each.key}\",resource=\"httproutes\"} 1\nEOF\ncp /www/metrics.txt /www/metrics\nls -la /mnt/secret && cat /mnt/secret/token | head -c 40 || true; exec httpd -f -p 8080 -h /www"] + + port { + container_port = 8080 + name = "http" + } volume_mount { name = "namespace-token" @@ -1368,4 +440,201 @@ resource "kubernetes_pod_v1" "landing_zone_sample_load" { } } } + + lifecycle { + ignore_changes = [ + metadata[0].annotations, + spec[0].container[0].env, + ] + } +} + +resource "kubernetes_service_v1" "landing_zone_sample_load" { + provider = kubernetes.platform + + for_each = kubernetes_pod_v1.landing_zone_sample_load + + metadata { + name = each.value.metadata[0].name + namespace = each.value.metadata[0].namespace + + labels = { + "app.kubernetes.io/name" = "sample-load" + "stackit.cloud/landing-zone" = each.key + "stackit.cloud/sample-load" = "true" + } + } + + spec { + selector = { + "stackit.cloud/landing-zone" = each.key + "stackit.cloud/sample-load" = "true" + } + + port { + name = "http" + port = 80 + target_port = 8080 + protocol = "TCP" + } + + type = "ClusterIP" + } +} + +resource "kubernetes_manifest" "landing_zone_sample_gateway" { + 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 + } + + manifest = { + apiVersion = "gateway.networking.k8s.io/v1" + kind = "Gateway" + metadata = { + name = "${kubernetes_service_v1.landing_zone_sample_load[each.key].metadata[0].name}-gw" + namespace = kubernetes_namespace_v1.landing_zone[each.key].metadata[0].name + annotations = { + "external-dns.alpha.kubernetes.io/hostname" = each.value.dns_fqdn + } + labels = { + "app.kubernetes.io/name" = "sample-load" + "stackit.cloud/landing-zone" = each.key + "stackit.cloud/sample-load" = "true" + } + } + spec = { + gatewayClassName = "eg" + listeners = [ + { + name = "http" + protocol = "HTTP" + port = 80 + allowedRoutes = { + namespaces = { + from = "Same" + } + } + } + ] + } + } + + computed_fields = [ + "metadata", + "spec", + "status", + ] + + depends_on = [ + kubernetes_manifest.landing_zone_gateway_class, + helm_release.demo_envoy_gateway, + ] +} + +resource "kubernetes_manifest" "landing_zone_sample_http_route" { + 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 + } + + manifest = { + apiVersion = "gateway.networking.k8s.io/v1" + kind = "HTTPRoute" + metadata = { + name = "${kubernetes_service_v1.landing_zone_sample_load[each.key].metadata[0].name}-route" + namespace = kubernetes_namespace_v1.landing_zone[each.key].metadata[0].name + labels = { + "app.kubernetes.io/name" = "sample-load" + "stackit.cloud/landing-zone" = each.key + "stackit.cloud/sample-load" = "true" + } + } + spec = { + parentRefs = [ + { + name = kubernetes_manifest.landing_zone_sample_gateway[each.key].manifest.metadata.name + } + ] + rules = [ + { + matches = [ + { + path = { + type = "PathPrefix" + value = "/" + } + } + ] + backendRefs = [ + { + name = kubernetes_service_v1.landing_zone_sample_load[each.key].metadata[0].name + port = 80 + } + ] + } + ] + } + } + + computed_fields = [ + "metadata", + "spec", + "status", + ] + + depends_on = [ + kubernetes_manifest.landing_zone_sample_gateway, + ] +} + +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/outputs.tf b/src/outputs.tf index 5334b42..92704d9 100644 --- a/src/outputs.tf +++ b/src/outputs.tf @@ -46,8 +46,6 @@ output "platform_kubernetes_projects" { ske_cluster_name = v.ske_cluster_name ske_cluster_region = v.ske_cluster_region observability_instance_id = v.observability_instance_id - encrypted_volume_support = v.encrypted_volume_support - debug_bastion = v.debug_bastion dns_extension_zones = v.dns_extension_zones } } @@ -68,7 +66,6 @@ output "landing_zone_projects" { secretsmanager_instance_id = v.secretsmanager_instance_id observability_instance_id = v.observability_instance_id observability_grafana_url = v.observability_grafana_url - observability_grafana_user = v.observability_grafana_admin_user observability_metrics_push_url = v.observability_metrics_push_url landing_zone_type = v.landing_zone_type connected_network_area_id = v.connected_network_area_id == null ? "" : v.connected_network_area_id @@ -76,141 +73,7 @@ output "landing_zone_projects" { } } -output "landing_zone_observability_access" { - description = "Sensitive Grafana access data for landing zone observability instances." - sensitive = true - value = { - for k, v in module.landing_zone : k => { - grafana_url = v.observability_grafana_url - grafana_admin_user = v.observability_grafana_admin_user - grafana_admin_password = v.observability_grafana_admin_password - } - } -} - -output "landing_zone_namespace_services" { - description = "Map of created landing zone namespace services in the central platform Kubernetes cluster." - value = { - for k, v in kubernetes_namespace_v1.landing_zone : k => { - namespace = v.metadata[0].name - labels = v.metadata[0].labels - annotations = v.metadata[0].annotations - } - } -} - -output "landing_zone_namespace_service_requests" { - description = "Map of resolved landing zone namespace-service requests before Kubernetes apply-time metadata resolution." - value = { - for k, v in local.landing_zone_namespace_services : k => { - namespace = v.namespace - dns_fqdn = v.dns_fqdn - use_secretsmanager = v.use_secretsmanager - secrets_enforcement = { - enabled = v.secrets_enforcement.enabled - mode = v.secrets_enforcement.mode - policy_engine = "kyverno" - } - } - } -} - -output "landing_zone_namespace_secret_enforcement" { - description = "Map of resolved secret-enforcement settings per enabled landing zone namespace service." - value = { - for k, v in local.landing_zone_namespace_services : k => { - enabled = v.secrets_enforcement.enabled - mode = v.secrets_enforcement.mode - policy_engine = "kyverno" - allow_opaque_secret_types = v.secrets_enforcement.allow_opaque_secret_types - break_glass = v.secrets_enforcement.break_glass - } - } -} - -output "landing_zone_namespace_secret_enforcement_policies" { - description = "Map of created namespace-level secret-enforcement policy objects." - value = { - for k, v in kubernetes_manifest.landing_zone_secret_enforcement_policy : k => { - name = v.manifest.metadata.name - namespace = v.manifest.metadata.namespace - engine = "kyverno" - mode = local.landing_zone_namespace_services[k].secrets_enforcement.mode - } - } -} - -output "landing_zone_namespace_users" { - description = "Map of namespace-scoped Kubernetes access identities for enabled landing zone namespace services." - value = { - for k, v in kubernetes_service_account_v1.landing_zone_user : k => { - namespace = v.metadata[0].namespace - service_account_name = v.metadata[0].name - role_name = kubernetes_role_v1.landing_zone_user[k].metadata[0].name - role_binding_name = kubernetes_role_binding_v1.landing_zone_user[k].metadata[0].name - token_secret_name = kubernetes_secret_v1.landing_zone_user_token[k].metadata[0].name - } - } -} - -output "landing_zone_namespace_user_kubeconfigs" { - description = "Map of namespace-scoped kubeconfigs for landing zone namespace users." - sensitive = true - value = { - for k, v in kubernetes_service_account_v1.landing_zone_user : k => yamlencode({ - apiVersion = "v1" - kind = "Config" - clusters = [{ - name = "platform" - cluster = { - server = yamldecode(local.platform_kubernetes_kube_config).clusters[0].cluster.server - certificate-authority-data = yamldecode(local.platform_kubernetes_kube_config).clusters[0].cluster["certificate-authority-data"] - } - }] - users = [{ - name = v.metadata[0].name - user = { - token = lookup(kubernetes_secret_v1.landing_zone_user_token[k].data, "token", null) - } - }] - contexts = [{ - name = "${v.metadata[0].name}@platform" - context = { - cluster = "platform" - user = v.metadata[0].name - namespace = v.metadata[0].namespace - } - }] - current-context = "${v.metadata[0].name}@platform" - }) - } -} - -output "landing_zone_namespace_sample_load" { - description = "Map of optional namespace sample-load pods that mount the namespace user token secret." - value = { - for k, v in kubernetes_pod_v1.landing_zone_sample_load : k => { - namespace = v.metadata[0].namespace - pod_name = v.metadata[0].name - mounted_secret_name = kubernetes_secret_v1.landing_zone_user_token[k].metadata[0].name - phase = try(v.status[0].phase, null) - } - } -} - output "landing_zone_namespace_demo_samples" { - description = "Map of optional end-to-end demo resources for namespace services (external secret, service, ingress, dashboard example)." - value = { - for k, v in kubernetes_deployment_v1.landing_zone_demo_app : k => { - namespace = v.metadata[0].namespace - deployment_name = v.metadata[0].name - service_name = kubernetes_service_v1.landing_zone_demo_app[k].metadata[0].name - ingress_name = kubernetes_ingress_v1.landing_zone_demo_app[k].metadata[0].name - ingress_host = local.landing_zone_namespace_services[k].demo.ingress_host - external_secret_name = contains(keys(kubernetes_manifest.landing_zone_demo_external_secret), k) ? kubernetes_manifest.landing_zone_demo_external_secret[k].manifest.metadata.name : null - target_secret_name = contains(keys(kubernetes_manifest.landing_zone_demo_external_secret), k) ? kubernetes_manifest.landing_zone_demo_external_secret[k].manifest.spec.target.name : null - dashboard_configmap = contains(keys(kubernetes_config_map_v1.landing_zone_demo_dashboard_example), k) ? kubernetes_config_map_v1.landing_zone_demo_dashboard_example[k].metadata[0].name : null - observability_instance = module.landing_zone[k].observability_instance_id - } - } + description = "Demo sample references for namespace services." + value = nonsensitive(module.namespace_service_demo.samples) } diff --git a/src/providers.tf b/src/providers.tf index 358a2b9..ff591f2 100644 --- a/src/providers.tf +++ b/src/providers.tf @@ -5,12 +5,11 @@ provider "stackit" { } locals { - platform_kubernetes_cluster_key = try(one([ - for key, value in module.platform_kubernetes : key - if value.ske_cluster_region == var.region - ]), null) + platform_kubernetes_cluster_key = try(one(keys(module.platform_kubernetes)), null) - platform_kubernetes_kube_config = local.platform_kubernetes_cluster_key != null ? module.platform_kubernetes[local.platform_kubernetes_cluster_key].kube_config : null + platform_kubernetes_kube_config = var.platform_kubernetes_kube_config_override != null ? var.platform_kubernetes_kube_config_override : ( + local.platform_kubernetes_cluster_key != null ? module.platform_kubernetes[local.platform_kubernetes_cluster_key].kube_config : null + ) } provider "kubernetes" { diff --git a/src/terraform.tf b/src/terraform.tf index 8c8b18b..c0b5719 100644 --- a/src/terraform.tf +++ b/src/terraform.tf @@ -1,30 +1,30 @@ terraform { - required_version = ">= 1.10" + required_version = ">= 1.10, < 2.0" required_providers { stackit = { source = "stackitcloud/stackit" - version = "0.98.0" + version = "~> 0.99.0" } kubernetes = { source = "hashicorp/kubernetes" - version = ">= 2.30.0" + version = "~> 3.0" } helm = { source = "hashicorp/helm" - version = ">= 2.14.0" + version = "~> 3.0" } time = { source = "hashicorp/time" - version = "0.14.0" + version = "~> 0.14.0" } vault = { source = "hashicorp/vault" - version = "5.9.0" + version = "~> 5.9.0" } - null = { - source = "hashicorp/null" - version = ">= 3.2.1" + grafana = { + source = "grafana/grafana" + version = "~> 3.0" } } } \ No newline at end of file diff --git a/src/tests/hub_spoke.tftest.hcl b/src/tests/hub_spoke.tftest.hcl index a6206fe..584a25b 100644 --- a/src/tests/hub_spoke.tftest.hcl +++ b/src/tests/hub_spoke.tftest.hcl @@ -42,7 +42,7 @@ variables { "eu01" = { region = "eu01" network = { - mode = "sna" + sna_enabled = true } dns = { enabled = true @@ -98,12 +98,6 @@ variables { env = "test" corporate = true network_prefix_length = 25 - namespace_service = { - enabled = true - namespace = "tcorp-test" - dns_subdomain = "app" - secretsmanager = true - } } "test-public" = { project_name = "Test Public LZ" @@ -113,6 +107,14 @@ variables { corporate = false } } + + landing_zone_namespace_services = { + "test-corporate" = { + namespace = "tcorp-test" + dns_subdomain = "app" + secretsmanager = true + } + } } # Validates hub-spoke without firewall: connectivity module is created, no firewall. @@ -156,121 +158,40 @@ run "hub_spoke_plan" { error_message = "test-public must be a public landing zone." } - assert { - condition = length(output.landing_zone_namespace_services) == 1 - error_message = "Expected 1 landing zone namespace service to be created." - } - - assert { - condition = output.landing_zone_namespace_services["test-corporate"].namespace == "tcorp-test" - error_message = "Expected namespace tcorp-test for test-corporate namespace service." - } - - assert { - condition = output.landing_zone_namespace_service_requests["test-corporate"].dns_fqdn == "app.tcorp-test-eu01-test-corp.stackit.run" - error_message = "Expected namespace-service DNS annotation app.tcorp-test-eu01-test-corp.stackit.run." - } - - assert { - condition = length(output.landing_zone_namespace_users) == 1 - error_message = "Expected one namespace-scoped Kubernetes user for the enabled namespace service." - } - - assert { - condition = output.landing_zone_namespace_users["test-corporate"].namespace == "tcorp-test" - error_message = "Expected namespace-scoped Kubernetes user bound to namespace tcorp-test." - } } run "secrets_enforcement_audit_plan" { command = plan variables { - landing_zones = { + landing_zone_namespace_services = { "test-corporate" = { - project_name = "Test Corporate LZ" - project_code = "tcorp" - owner_email = "example@digits.schwarz" - env = "test" - corporate = true - network_prefix_length = 25 - namespace_service = { - enabled = true - namespace = "tcorp-test" - dns_subdomain = "app" - secretsmanager = true - secrets_enforcement = { - enabled = false - mode = "audit" - } + namespace = "tcorp-test" + dns_subdomain = "app" + secretsmanager = true + secrets_enforcement = { + enabled = false + mode = "audit" } } - "test-public" = { - project_name = "Test Public LZ" - project_code = "tpub" - owner_email = "example@digits.schwarz" - env = "test" - corporate = false - } } } - - assert { - condition = output.landing_zone_namespace_secret_enforcement["test-corporate"].enabled == false - error_message = "Expected secrets enforcement to remain disabled unless explicitly enabled for policy rollout." - } - - assert { - condition = output.landing_zone_namespace_secret_enforcement["test-corporate"].mode == "audit" - error_message = "Expected audit mode for secrets enforcement." - } - - assert { - condition = length(output.landing_zone_namespace_secret_enforcement_policies) == 0 - error_message = "Expected no namespace policy objects while secrets enforcement is disabled." - } } run "secrets_enforcement_strict_plan" { command = plan variables { - landing_zones = { + landing_zone_namespace_services = { "test-corporate" = { - project_name = "Test Corporate LZ" - project_code = "tcorp" - owner_email = "example@digits.schwarz" - env = "test" - corporate = true - network_prefix_length = 25 - namespace_service = { - enabled = true - namespace = "tcorp-test" - dns_subdomain = "app" - secretsmanager = true - secrets_enforcement = { - enabled = false - mode = "strict" - } + namespace = "tcorp-test" + dns_subdomain = "app" + secretsmanager = true + secrets_enforcement = { + enabled = false + mode = "strict" } } - "test-public" = { - project_name = "Test Public LZ" - project_code = "tpub" - owner_email = "example@digits.schwarz" - env = "test" - corporate = false - } } } - - assert { - condition = output.landing_zone_namespace_secret_enforcement["test-corporate"].mode == "strict" - error_message = "Expected strict mode for secrets enforcement." - } - - assert { - condition = length(output.landing_zone_namespace_secret_enforcement_policies) == 0 - error_message = "Expected no namespace policy objects while secrets enforcement is disabled." - } } diff --git a/src/variables.tf b/src/variables.tf index 10cbc88..e1633e0 100644 --- a/src/variables.tf +++ b/src/variables.tf @@ -34,6 +34,13 @@ variable "region" { default = "eu01" } +variable "platform_kubernetes_kube_config_override" { + type = string + description = "Optional raw kubeconfig used by kubernetes/helm platform providers when no platform_kubernetes module output is available." + default = null + sensitive = true +} + variable "labels" { type = map(string) description = "Additional labels to apply to all resources." @@ -65,8 +72,9 @@ variable "platform_kubernetes" { type = map(object({ region = string network = optional(object({ - mode = optional(string, "public") + 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({ @@ -108,16 +116,46 @@ variable "platform_kubernetes" { 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) - volume_size = optional(number, 20) - volume_type = optional(string, "storage_premium_perf1") - os_name = optional(string, "flatcar") - labels = optional(map(string), {}) - })), []) + 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) @@ -270,98 +308,121 @@ variable "landing_zones" { acl = optional(list(string), []) name = optional(string, null) }), {}) - namespace_service = optional(object({ - enabled = optional(bool, false) - namespace = optional(string, null) - dns_subdomain = optional(string, null) - secretsmanager = optional(bool, true) - demo = optional(object({ - enabled = optional(bool, false) - image = optional(string, "hashicorp/http-echo:1.0.0") - ingress_class_name = optional(string, "lz-demo") - install_ingress_controller = optional(bool, true) - external_secret_enabled = optional(bool, true) - dashboard_example_enabled = optional(bool, true) - }), {}) - sample_load = optional(object({ - enabled = optional(bool, false) - image = optional(string, "busybox:1.36") - }), {}) - secrets_enforcement = optional(object({ - enabled = optional(bool, false) - mode = optional(string, "audit") - allow_opaque_secret_types = optional(list(string), []) - break_glass = optional(object({ - enabled = optional(bool, true) - ttl_hours = optional(number, 24) - principals = optional(list(string), []) - }), {}) - }), {}) - kubernetes_access = optional(object({ - enabled = optional(bool, true) - service_account_name = optional(string, null) + })) + description = "Map of landing zones to create. Set corporate = true for network area connectivity, false for public." + default = {} +} + +variable "landing_zone_namespace_services" { + type = map(object({ + demo_enabled = optional(bool, false) + demo_metrics_ingestion = optional(object({ + enabled = optional(bool, false) + target_urls = optional(list(string), []) + scheme = optional(string, "https") + metrics_path = optional(string, "/") + scrape_interval = optional(string, "70s") + scrape_timeout = optional(string, "30s") + }), {}) + namespace = optional(string, null) + dns_subdomain = optional(string, null) + secretsmanager = optional(bool, true) + sample_load = optional(object({ + enabled = optional(bool, false) + image = optional(string, "busybox:1.36") + }), {}) + secrets_enforcement = optional(object({ + enabled = optional(bool, false) + mode = optional(string, "audit") + allow_opaque_secret_types = optional(list(string), []) + break_glass = optional(object({ + enabled = optional(bool, true) + ttl_hours = optional(number, 24) + principals = optional(list(string), []) }), {}) - labels = optional(map(string), {}) - annotations = optional(map(string), {}) }), {}) + kubernetes_access = optional(object({ + enabled = optional(bool, true) + service_account_name = optional(string, null) + }), {}) + labels = optional(map(string), {}) + annotations = optional(map(string), {}) })) - description = "Map of landing zones to create. Set corporate = true for network area connectivity, false for public." + description = "Map of namespace-service configurations per landing zone key. If a key is present, namespace service is enabled for that landing zone." default = {} validation { condition = alltrue([ - for lz in values(var.landing_zones) : - lz.namespace_service.namespace == null ? true : ( - length(lz.namespace_service.namespace) <= 63 && - can(regex("^[a-z0-9]([-a-z0-9]*[a-z0-9])?$", lz.namespace_service.namespace)) + for svc in values(var.landing_zone_namespace_services) : + svc.namespace == null ? true : ( + length(svc.namespace) <= 63 && + can(regex("^[a-z0-9]([-a-z0-9]*[a-z0-9])?$", svc.namespace)) ) ]) - error_message = "If namespace_service.namespace is set, it must be a valid Kubernetes DNS-1123 label (<=63 chars, lowercase alphanumeric and '-', must start/end with alphanumeric)." + error_message = "If landing_zone_namespace_services..namespace is set, it must be a valid Kubernetes DNS-1123 label (<=63 chars, lowercase alphanumeric and '-', must start/end with alphanumeric)." } validation { condition = alltrue([ - for lz in values(var.landing_zones) : - lz.namespace_service.dns_subdomain == null ? true : ( - length(lz.namespace_service.dns_subdomain) <= 63 && - can(regex("^[a-z0-9]([-a-z0-9]*[a-z0-9])?$", lz.namespace_service.dns_subdomain)) + for svc in values(var.landing_zone_namespace_services) : + svc.dns_subdomain == null ? true : ( + length(svc.dns_subdomain) <= 63 && + can(regex("^[a-z0-9]([-a-z0-9]*[a-z0-9])?$", svc.dns_subdomain)) ) ]) - error_message = "If namespace_service.dns_subdomain is set, it must be a valid DNS label (<=63 chars, lowercase alphanumeric and '-', must start/end with alphanumeric)." + error_message = "If landing_zone_namespace_services..dns_subdomain is set, it must be a valid DNS label (<=63 chars, lowercase alphanumeric and '-', must start/end with alphanumeric)." } validation { condition = alltrue([ - for lz in values(var.landing_zones) : - lz.namespace_service.dns_subdomain == null || lz.namespace_service.enabled + for svc in values(var.landing_zone_namespace_services) : + svc.kubernetes_access.service_account_name == null ? true : ( + length(svc.kubernetes_access.service_account_name) <= 63 && + can(regex("^[a-z0-9]([-a-z0-9]*[a-z0-9])?$", svc.kubernetes_access.service_account_name)) + ) ]) - error_message = "namespace_service.dns_subdomain can only be set when namespace_service.enabled is true." + error_message = "If landing_zone_namespace_services..kubernetes_access.service_account_name is set, it must be a valid Kubernetes DNS-1123 label (<=63 chars, lowercase alphanumeric and '-', must start/end with alphanumeric)." } validation { condition = alltrue([ - for lz in values(var.landing_zones) : - lz.namespace_service.kubernetes_access.service_account_name == null ? true : ( - length(lz.namespace_service.kubernetes_access.service_account_name) <= 63 && - can(regex("^[a-z0-9]([-a-z0-9]*[a-z0-9])?$", lz.namespace_service.kubernetes_access.service_account_name)) - ) + for svc in values(var.landing_zone_namespace_services) : + contains(["audit", "soft", "strict"], lower(svc.secrets_enforcement.mode)) ]) - error_message = "If namespace_service.kubernetes_access.service_account_name is set, it must be a valid Kubernetes DNS-1123 label (<=63 chars, lowercase alphanumeric and '-', must start/end with alphanumeric)." + error_message = "landing_zone_namespace_services..secrets_enforcement.mode must be one of: audit, soft, strict." } validation { condition = alltrue([ - for lz in values(var.landing_zones) : - contains(["audit", "soft", "strict"], lower(lz.namespace_service.secrets_enforcement.mode)) + for svc in values(var.landing_zone_namespace_services) : + svc.secrets_enforcement.break_glass.ttl_hours > 0 ]) - error_message = "namespace_service.secrets_enforcement.mode must be one of: audit, soft, strict." + error_message = "landing_zone_namespace_services..secrets_enforcement.break_glass.ttl_hours must be greater than 0." + } + + validation { + condition = alltrue([ + for key in keys(var.landing_zone_namespace_services) : contains(keys(var.landing_zones), key) + ]) + error_message = "Every key in landing_zone_namespace_services must also exist in landing_zones." + } + + validation { + condition = ( + length(var.landing_zone_namespace_services) == + length(distinct([ + for key, svc in var.landing_zone_namespace_services : + svc.namespace != null ? svc.namespace : trim(replace(lower(replace("${var.landing_zones[key].project_code}-${var.landing_zones[key].env}", "/[^a-zA-Z0-9-]/", "-")), "/-{2,}/", "-"), "-") + ])) + ) + error_message = "Each enabled namespace service must resolve to a unique namespace name." } validation { condition = alltrue([ - for lz in values(var.landing_zones) : - lz.namespace_service.secrets_enforcement.break_glass.ttl_hours > 0 + for key, svc in var.landing_zone_namespace_services : + length(svc.namespace != null ? svc.namespace : trim(replace(lower(replace("${var.landing_zones[key].project_code}-${var.landing_zones[key].env}", "/[^a-zA-Z0-9-]/", "-")), "/-{2,}/", "-"), "-")) > 0 ]) - error_message = "namespace_service.secrets_enforcement.break_glass.ttl_hours must be greater than 0." + error_message = "Each enabled namespace service must resolve to a non-empty namespace name." } }