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/3] 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/3] 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/3] 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