diff --git a/.gitignore b/.gitignore index 9d32d78..01ef13f 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 @@ -42,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 d6c96bf..fc4852d 100644 --- a/src/config/hub-and-spoke.tfvars +++ b/src/config/hub-and-spoke.tfvars @@ -86,6 +86,24 @@ connectivity = { # allowed_network_ranges = ["0.0.0.0/0"] # } +# platform_kubernetes = { +# "eu01" = { +# region = "eu01" +# network = { +# sna_enabled = true +# } +# cluster = { +# name = "pltfmk8s" +# kubernetes_version_min = "1.35" +# } +# +# # Defaults to disabled. Set true to enable encrypted storage class setup. +# encrypted_volumes = { +# enabled = false +# } +# } +# } + ############### ## SANDBOXES ## ############### @@ -112,6 +130,7 @@ landing_zones = { # Set corporate = true for network area connectivity, false for public internet corporate = true network_prefix_length = 24 + } # Public landing zone — no network area, uses STACKIT's default public networking @@ -129,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 a776587..5dff7c7 100644 --- a/src/main.tf +++ b/src/main.tf @@ -66,6 +66,40 @@ 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 + 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 + 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 = { + 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 = { + 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 +132,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/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 cceebd6..261d71d 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" @@ -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 52f2f3b..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 = "./firewall-image.qcow2" + 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 2d1c1ad..14c3e4b 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 LAN 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 new file mode 100644 index 0000000..ec4592b --- /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 + 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..721a597 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_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 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 Grafana admin password of the optional landing zone observability instance." + sensitive = true + 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/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/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 new file mode 100644 index 0000000..8f4d560 --- /dev/null +++ b/src/modules/platform-kubernetes/1-project.tf @@ -0,0 +1,22 @@ +locals { + project_labels = merge( + { "region" = var.region }, + var.network.sna_enabled && 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-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 new file mode 100644 index 0000000..b886bd0 --- /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 + plan_name = var.observability.plan_name + acl = var.observability.acl +} diff --git a/src/modules/platform-kubernetes/3-cluster.tf b/src/modules/platform-kubernetes/3-cluster.tf new file mode 100644 index 0000000..319c4fd --- /dev/null +++ b/src/modules/platform-kubernetes/3-cluster.tf @@ -0,0 +1,54 @@ +locals { + effective_dns_zones = var.dns.create_zones ? sort([ + for zone in values(stackit_dns_zone.ske_extension) : zone.dns_name + ]) : local.dns_extension_zones +} + +resource "stackit_ske_cluster" "this" { + 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 = var.cluster.node_pools + + maintenance = var.cluster.maintenance + + network = var.network.sna_enabled ? { + 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 = 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" { + 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..a361fd9 --- /dev/null +++ b/src/modules/platform-kubernetes/5-debug-bastion.tf @@ -0,0 +1,27 @@ +locals { + 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" +} + +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 + 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 + 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/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..12f8c8b --- /dev/null +++ b/src/modules/platform-kubernetes/outputs.tf @@ -0,0 +1,76 @@ +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 = 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 + 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 "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 +} + +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..ceff2c5 --- /dev/null +++ b/src/modules/platform-kubernetes/terraform.tf @@ -0,0 +1,14 @@ +terraform { + required_version = ">= 1.10, < 2.0" + + required_providers { + stackit = { + source = "stackitcloud/stackit" + version = "~> 0.99.0" + } + time = { + source = "hashicorp/time" + version = "~> 0.14.0" + } + } +} diff --git a/src/modules/platform-kubernetes/variables.tf b/src/modules/platform-kubernetes/variables.tf new file mode 100644 index 0000000..366a3d8 --- /dev/null +++ b/src/modules/platform-kubernetes/variables.tf @@ -0,0 +1,170 @@ +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) + allow_system_components = optional(bool, false) + volume_size = optional(number, 20) + volume_type = optional(string, "storage_premium_perf1") + os_name = optional(string, "flatcar") + labels = optional(map(string), {}) + })), [ + { + name = "system" + machine_type = "g3i.4" + minimum = 2 + maximum = 2 + availability_zones = ["eu01-1"] + allow_system_components = true + volume_size = 20 + volume_type = "storage_premium_perf1" + os_name = "flatcar" + labels = { + "workload-role" = "system" + } + }, + { + name = "application" + machine_type = "g3i.4" + minimum = 2 + maximum = 2 + availability_zones = ["eu01-2"] + allow_system_components = false + volume_size = 20 + volume_type = "storage_premium_perf1" + os_name = "flatcar" + labels = { + "workload-role" = "application" + } + } + ]) + maintenance = optional(object({ + enable_kubernetes_version_updates = optional(bool, true) + enable_machine_image_version_updates = optional(bool, true) + start = optional(string, "01:00:00Z") + end = optional(string, "02:00:00Z") + }), {}) + }) + 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 = {} + + 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" { + 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({ + 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 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 = {} +} + +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 "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." +} + +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/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 new file mode 100644 index 0000000..7fcffc3 --- /dev/null +++ b/src/namespace-service.tf @@ -0,0 +1,640 @@ +############################# +## 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_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 + } + 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.labels + annotations = value.annotations + use_secretsmanager = value.secretsmanager + secrets_enforcement = { + 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.secrets_enforcement.break_glass.enabled + ttl_hours = value.secrets_enforcement.break_glass.ttl_hours + principals = value.secrets_enforcement.break_glass.principals + } + } + } + } + + 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 + } + + 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 + } + + landing_zone_namespace_services_kyverno = { + for key, value in local.landing_zone_namespace_services : key => value + if value.secrets_enforcement.enabled + } + + sample_gateway_lb_endpoint_by_key = { + for key, data in data.kubernetes_resources.landing_zone_sample_gateway_service : key => { + ip = try(one(data.objects).status.loadBalancer.ingress[0].ip, null) + hostname = try(one(data.objects).status.loadBalancer.ingress[0].hostname, null) + } + } +} + +module "namespace_service_demo" { + source = "./modules/namespace-service-demo" + + services = local.landing_zone_namespace_demo_services + dashboard_passwords = local.landing_zone_namespace_demo_dashboard_passwords +} + +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" "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 + + 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 + + set = [ + { + name = "deployment.type" + value = "Kubernetes" + }, + { + name = "service.type" + value = "LoadBalancer" + }, + ] +} + +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 + + manifest = { + apiVersion = "gateway.networking.k8s.io/v1" + kind = "GatewayClass" + metadata = { + name = "eg" + } + spec = { + controllerName = "gateway.envoyproxy.io/gatewayclass-controller" + } + } + + computed_fields = [ + "metadata", + "spec", + "status", + ] + + depends_on = [ + helm_release.demo_envoy_gateway, + ] +} + +resource "kubernetes_manifest" "landing_zone_secret_enforcement_policy" { + provider = kubernetes.platform + + for_each = local.landing_zone_namespace_services_kyverno + + manifest = { + apiVersion = "kyverno.io/v1" + kind = "Policy" + metadata = { + name = "stackit-secrets-enforcement" + namespace = each.value.namespace + labels = { + "stackit.cloud/landing-zone" = each.key + } + annotations = { + "stackit.cloud/secrets-enforcement-mode" = each.value.secrets_enforcement.mode + } + } + spec = { + validationFailureAction = each.value.secrets_enforcement.mode == "audit" ? "Audit" : "Enforce" + background = false + rules = [ + { + name = "deny-direct-secret-management" + match = { + any = [{ + resources = { + kinds = ["Secret"] + operations = each.value.secrets_enforcement.mode == "strict" ? ["CREATE", "UPDATE"] : ["CREATE"] + } + }] + } + exclude = { + any = [{ + subjects = [ + for principal in distinct(concat( + local.secrets_enforcement_default_exempt_principals, + each.value.secrets_enforcement.break_glass.enabled ? each.value.secrets_enforcement.break_glass.principals : [] + )) : { + kind = "User" + name = principal + } + ] + }] + } + validate = { + message = "Direct Kubernetes Secret management is blocked. Use the approved Secrets Manager integration path." + deny = { + conditions = { + all = [ + { + key = "{{ request.object.type || 'Opaque' }}" + operator = "AnyNotIn" + value = each.value.secrets_enforcement.allow_opaque_secret_types + } + ] + } + } + } + } + ] + } + } + + computed_fields = [ + "metadata", + "spec", + "status", + ] + + depends_on = [ + kubernetes_namespace_v1.landing_zone, + helm_release.kyverno, + ] +} + +resource "kubernetes_namespace_v1" "landing_zone" { + provider = kubernetes.platform + + for_each = local.landing_zone_namespace_services + + metadata { + name = each.value.namespace + + labels = merge( + { + "stackit.cloud/landing-zone" = each.key + }, + each.value.labels + ) + + annotations = merge( + { + "stackit.cloud/landing-zone-key" = each.key + }, + each.value.dns_fqdn != null ? { + "stackit.cloud/dns-fqdn" = each.value.dns_fqdn + } : {}, + each.value.use_secretsmanager ? { + "stackit.cloud/secretsmanager-instance-id" = module.landing_zone[each.key].secretsmanager_instance_id + } : {}, + each.value.secrets_enforcement.enabled ? { + "stackit.cloud/secrets-enforcement-enabled" = "true" + "stackit.cloud/secrets-enforcement-mode" = each.value.secrets_enforcement.mode + "stackit.cloud/secrets-policy-engine" = "kyverno" + } : {}, + each.value.annotations + ) + } + + lifecycle { + precondition { + condition = local.platform_kubernetes_cluster_key != null + error_message = "Namespace service requires one platform_kubernetes deployment." + } + + precondition { + condition = each.value.dns_subdomain == null || try(module.landing_zone[each.key].dns_zone_dns_name, null) != null + error_message = "landing_zone_namespace_services..dns_subdomain requires the landing zone to have a DNS zone." + } + } +} + +resource "kubernetes_service_account_v1" "landing_zone_user" { + provider = kubernetes.platform + + for_each = { + for key, value in local.landing_zone_namespace_services : key => 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 = { + "app.kubernetes.io/name" = "sample-load" + "stackit.cloud/landing-zone" = each.key + "stackit.cloud/sample-load" = "true" + } + } + + spec { + restart_policy = "Never" + enable_service_links = false + + container { + name = "sample" + image = local.landing_zone_namespace_services[each.key].sample_load.image + command = ["sh", "-c", "mkdir -p /www && printf 'ok\\nlanding_zone=%s\\nnamespace=%s\\n' '${each.key}' '${each.value.metadata[0].namespace}' > /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" + 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 + } + } + } + + 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 647d98e..92704d9 100644 --- a/src/outputs.tf +++ b/src/outputs.tf @@ -37,6 +37,20 @@ 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 + 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 +60,20 @@ 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_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_namespace_demo_samples" { + 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 0dda673..ff591f2 100644 --- a/src/providers.tf +++ b/src/providers.tf @@ -4,6 +4,58 @@ provider "stackit" { experiments = ["iam", "routing-tables", "network"] } +locals { + platform_kubernetes_cluster_key = try(one(keys(module.platform_kubernetes)), 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" { + 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..c0b5719 100644 --- a/src/terraform.tf +++ b/src/terraform.tf @@ -1,18 +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 = "~> 3.0" + } + helm = { + source = "hashicorp/helm" + 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" + } + 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 90dfccc..584a25b 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 = { + sna_enabled = true + } + 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" = { @@ -72,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. @@ -85,6 +128,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 +157,41 @@ 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 + +} + +run "secrets_enforcement_audit_plan" { + command = plan + + variables { + landing_zone_namespace_services = { + "test-corporate" = { + namespace = "tcorp-test" + dns_subdomain = "app" + secretsmanager = true + secrets_enforcement = { + enabled = false + mode = "audit" + } + } + } + } +} + +run "secrets_enforcement_strict_plan" { + command = plan + + variables { + landing_zone_namespace_services = { + "test-corporate" = { + namespace = "tcorp-test" + dns_subdomain = "app" + secretsmanager = true + secrets_enforcement = { + enabled = false + mode = "strict" + } + } + } + } +} diff --git a/src/variables.tf b/src/variables.tf index c04ef42..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." @@ -61,6 +68,106 @@ variable "devops" { default = null } +variable "platform_kubernetes" { + type = map(object({ + region = string + network = optional(object({ + sna_enabled = optional(bool, false) + sna_network_area_id = optional(string, null) + firewall_next_hop_ip = optional(string, null) + sna_network_prefix_length = optional(number, 24) + }), {}) + dns = optional(object({ + enabled = optional(bool, true) + create_zones = optional(bool, true) + zones = optional(list(string), []) + }), {}) + observability = optional(object({ + enabled = optional(bool, true) + plan_name = optional(string, "Observability-Starter-EU01") + acl = optional(list(string), []) + name = optional(string, null) + }), {}) + encrypted_volumes = optional(object({ + enabled = optional(bool, false) + storage_class_name = optional(string, "stackit-encrypted-premium") + kms_keyring_name = optional(string, "ske-volume-keyring") + kms_key_name = optional(string, "ske-volume-key") + kms_key_version = optional(string, "1") + }), {}) + debug_bastion = optional(object({ + enabled = optional(bool, false) + name = optional(string, null) + availability_zone = optional(string, null) + machine_type = optional(string, "g2i.1") + image_id = optional(string, "7b10e105-295b-4369-b6e0-567ec940a02b") + boot_volume_size = optional(number, 20) + ssh_public_key = optional(string, null) + ssh_public_key_path = optional(string, "~/.ssh/id_rsa.pub") + ssh_allowed_cidrs = optional(list(string), ["0.0.0.0/0"]) + assign_public_ip = optional(bool, true) + install_kubectl = optional(bool, true) + }), {}) + role_assignments = optional(list(object({ + role = string + subject = string + })), []) + cluster = object({ + name = string + kubernetes_version_min = optional(string, null) + node_pools = optional(list(object({ + name = string + machine_type = string + minimum = number + maximum = number + availability_zones = list(string) + allow_system_components = optional(bool, false) + volume_size = optional(number, 20) + volume_type = optional(string, "storage_premium_perf1") + os_name = optional(string, "flatcar") + labels = optional(map(string), {}) + })), [ + { + name = "system" + machine_type = "g3i.4" + minimum = 2 + maximum = 2 + availability_zones = ["eu01-1"] + allow_system_components = true + volume_size = 20 + volume_type = "storage_premium_perf1" + os_name = "flatcar" + labels = { + "workload-role" = "system" + } + }, + { + name = "application" + machine_type = "g3i.4" + minimum = 2 + maximum = 2 + availability_zones = ["eu01-2"] + allow_system_components = false + volume_size = 20 + volume_type = "storage_premium_perf1" + os_name = "flatcar" + labels = { + "workload-role" = "application" + } + } + ]) + maintenance = optional(object({ + enable_kubernetes_version_updates = optional(bool, true) + enable_machine_image_version_updates = optional(bool, true) + start = optional(string, "01:00:00Z") + end = optional(string, "02:00:00Z") + }), {}) + }) + })) + 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 +302,127 @@ 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) + }), {}) })) 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), []) + }), {}) + }), {}) + 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 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 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 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 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 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 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 = "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 svc in values(var.landing_zone_namespace_services) : + contains(["audit", "soft", "strict"], lower(svc.secrets_enforcement.mode)) + ]) + error_message = "landing_zone_namespace_services..secrets_enforcement.mode must be one of: audit, soft, strict." + } + + validation { + condition = alltrue([ + for svc in values(var.landing_zone_namespace_services) : + svc.secrets_enforcement.break_glass.ttl_hours > 0 + ]) + 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 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 = "Each enabled namespace service must resolve to a non-empty namespace name." + } +}