From 5469b9433dc1c9b923baea7657da5daceab42ab0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Weberru=C3=9F?= Date: Mon, 31 Aug 2026 15:15:35 +0200 Subject: [PATCH 1/3] feat: add multi-area and regional hub-spoke support --- docs/architecture.md | 22 +- docs/getting-started.md | 12 +- src/config/hub-and-spoke-2snas.tfvars | 63 ++++++ src/config/hub-and-spoke-3snas.tfvars | 71 +++++++ src/config/hub-and-spoke-multi-area.tfvars | 67 ++++++ src/config/hub-and-spoke-multi-region.tfvars | 106 ++++++++++ ...hub-and-spoke-prod-nonprod-firewall.tfvars | 84 ++++++++ src/main.tf | 193 ++++++++++++++++-- src/modules/connectivity/1-network-area.tf | 31 +-- src/modules/connectivity/2-project.tf | 29 ++- .../connectivity/3-external-network.tf | 60 +++--- .../connectivity/4-internal-network.tf | 32 +-- src/modules/connectivity/5-firewall.tf | 151 ++++++++------ src/modules/connectivity/6-dns-zones.tf | 4 +- src/modules/connectivity/7-vpn.tf | 33 ++- src/modules/connectivity/README.md | 11 +- src/modules/connectivity/outputs.tf | 42 ++-- src/modules/connectivity/variables.tf | 67 ++++-- src/modules/landing-zone/4-secrets-manager.tf | 2 + src/modules/landing-zone/outputs.tf | 2 +- src/modules/landing-zone/variables.tf | 6 + src/outputs.tf | 113 ++++++++-- src/providers.tf | 16 ++ src/tests/multi_area.tftest.hcl | 61 ++++++ src/variables.tf | 102 +++++++-- 25 files changed, 1135 insertions(+), 245 deletions(-) create mode 100644 src/config/hub-and-spoke-2snas.tfvars create mode 100644 src/config/hub-and-spoke-3snas.tfvars create mode 100644 src/config/hub-and-spoke-multi-area.tfvars create mode 100644 src/config/hub-and-spoke-multi-region.tfvars create mode 100644 src/config/hub-and-spoke-prod-nonprod-firewall.tfvars create mode 100644 src/tests/multi_area.tftest.hcl diff --git a/docs/architecture.md b/docs/architecture.md index 61e296d..ffa932e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2,7 +2,7 @@ This repository is a production-ready OpenTofu/Terraform framework for deploying a STACKIT Landing Zone. It provisions the complete cloud foundation, covering governance hierarchy, identity and access management, shared networking, optional firewall, DNS, secrets management, observability, and repeatable per-workload project templates. -Everything is composed from six modules under `src/modules/` and wired together in `src/main.tf`. A single `terraform apply` with one of the three reference configs in `src/config/` stands up the full platform. +Everything is composed from six modules under `src/modules/` and wired together in `src/main.tf`. A single `terraform apply` with one of the complete reference configurations in `src/config/` stands up the full platform. ## Two-Layer Model @@ -61,11 +61,11 @@ Source: `src/modules/management/` ### Connectivity -Builds the network hub project (`-pltfm-hub-prod`) that all corporate landing zones attach to. This is the most complex module. +Builds one network hub project per configured connectivity domain. Each corporate landing zone attaches explicitly to one domain. This is the most complex module. #### Network Area -A STACKIT Network Area defines a shared private IP address space at the organization level. All corporate landing zone networks are created inside this area and can reach each other over private IPs without any additional peering. +A STACKIT Network Area (SNA) defines a shared private IP address space at the organization level. Corporate landing zone networks created in the same SNA can reach each other over private IPs without additional peering. Configuration drives the area's address plan: @@ -79,6 +79,20 @@ network_area = { } ``` +#### Multiple Network Areas + +`connectivity.network_areas` creates multiple independent connectivity domains. It is a map keyed by stable, meaningful identifiers; keys may represent any business, security, tenant, connectivity, or regional boundary, such as `regulated`, `tenant_a`, `private_connectivity`, `eu01`, or `eu02`. They are not restricted to development and production environments. + +Each key creates its own SNA, connectivity project, WAN routing table, and DNS defaults. Corporate landing zones select their domain with `network_area_key`; DNS zones use `dns_zones..network_area_key`. Platform Kubernetes uses `platform_kubernetes..network.network_area_key`. Connectivity projects are labeled with the SNA ID and the corresponding key, and the `*_by_area` outputs use the same keys. + +For example, regulated workloads can use a dedicated `regulated` SNA while shared workloads use `shared`. The same model also separates tenants, business units, or connectivity zones. See [the complete multi-area configuration](../src/config/hub-and-spoke-multi-area.tfvars). [Getting Started](getting-started.md#deployment-flavours) describes all available scenarios. + +The legacy `connectivity.network_area` input remains supported for single-area deployments and maps to the `default` key. Existing scalar connectivity outputs continue to reference that legacy default area. + +#### Multi-Region Deployments + +The root module has one STACKIT provider default region, so every SNA in one execution is created in that region. For an active topology with one SNA per region, such as `eu01` and `eu02` connected through VPN, deploy one root stack per region and exchange VPN tunnel endpoints through the outputs. A future single-stack multi-region implementation requires statically declared STACKIT provider aliases because Terraform cannot select provider aliases dynamically from `network_areas`. + #### WAN Routing Table A routing table named `wan` is created with a single default route: @@ -174,7 +188,7 @@ Source: `src/modules/sandboxes/` ## Deployment Flavors -Three reference configurations are provided in `src/config/`. Select the one that matches your network requirements. +Complete reference configurations are provided in `src/config/`. Select the one that matches your network requirements; [Getting Started](getting-started.md#deployment-flavours) lists all available scenarios. ### Standalone diff --git a/docs/getting-started.md b/docs/getting-started.md index 9cad7cd..5d754ff 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -18,16 +18,24 @@ This guide walks you through deploying the STACKIT Landing Zone from scratch. ## Deployment Flavours -Three ready-to-use configurations are provided in `src/config/`: +Seven complete, 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. | +| **Multi-SNA Domains** | `hub-and-spoke-multi-area.tfvars` | Two SNAs for arbitrary security, tenant, business, or connectivity boundaries. | +| **Two-SNA Environments** | `hub-and-spoke-2snas.tfvars` | A dedicated production SNA and a shared non-production SNA. | +| **Prod/Nonprod + Firewalls** | `hub-and-spoke-prod-nonprod-firewall.tfvars` | Dedicated SNAs and OPNsense appliances for production and non-production. | +| **Three-SNA Environments** | `hub-and-spoke-3snas.tfvars` | Dedicated SNAs for production, development, and test. | 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`. +The multi-SNA scenario files are complete configurations. Replace the placeholder values for `owner_email`, `company_name`, `company_code`, and `organization_id` before applying them. The area keys are examples only; choose stable keys that match your boundary, such as `tenant_a`, `finance`, `private_connectivity`, `eu01`, or `eu02`. See [Multiple Network Areas](architecture.md#multiple-network-areas) for the architecture. + +The Prod/Nonprod + Firewalls scenario creates one appliance per SNA. Its OPNsense policies must currently be configured for each appliance after bootstrap; the single `firewall_config` block is not distributed automatically. + The firewall flavour takes one extra step: the appliance boots unconfigured and its policy is pushed in a second apply, from the `firewall_config` block that ships commented out in the same `.tfvars` file. Until then it filters nothing and its web GUI is reachable from the internet — see [Configure OPNsense firewall](#configure-opnsense-firewall). It deploys a single appliance by default, which is the default route of every corporate landing zone and therefore a single point of failure; the commented `connectivity.firewall.ha` block turns it into an active/passive CARP pair — see [Make the firewall highly available](#make-the-firewall-highly-available). Both hub-spoke flavours can additionally terminate a site-to-site IPsec VPN in the hub. It is disabled by default — see the commented `connectivity.vpn` block in the `.tfvars` file and [Site-to-Site VPN](architecture.md#site-to-site-vpn-optional). If you deploy the firewall flavour, read [what traffic the firewall actually sees](architecture.md#what-goes-through-the-firewall) before relying on it for VPN inspection. @@ -106,7 +114,7 @@ Refer to the [STACKIT Terraform provider documentation](https://registry.terrafo ### 7. Configure variables -Copy and edit the `.tfvars` file matching your chosen deployment flavour: +Copy and edit the `.tfvars` file matching your deployment scenario: ```bash cp config/standalone.tfvars terraform.auto.tfvars diff --git a/src/config/hub-and-spoke-2snas.tfvars b/src/config/hub-and-spoke-2snas.tfvars new file mode 100644 index 0000000..dbb0689 --- /dev/null +++ b/src/config/hub-and-spoke-2snas.tfvars @@ -0,0 +1,63 @@ +######################### +## TWO-SNA ENVIRONMENTS ## +######################### + +owner_email = "platform@example.com" +company_name = "Example Corp" +company_code = "exc" +organization_id = "00000000-0000-0000-0000-000000000000" +region = "eu01" + +# Production is isolated. Development and test use the shared non-production SNA. +connectivity = { + naming_pattern = "exc-connectivity" + + network_areas = { + prod = { + name = "prod-sna" + ranges = ["10.0.0.0/16"] + transfer_network = "10.1.0.0/24" + max_prefix_length = 28 + min_prefix_length = 24 + default_prefix_length = 26 + } + nonprod = { + name = "nonprod-sna" + ranges = ["10.2.0.0/16"] + transfer_network = "10.3.0.0/24" + max_prefix_length = 28 + min_prefix_length = 24 + default_prefix_length = 26 + } + } +} + +landing_zones = { + production = { + project_name = "Production Workload" + project_code = "prod" + owner_email = "platform@example.com" + env = "prod" + corporate = true + network_area_key = "prod" + network_prefix_length = 24 + } + development = { + project_name = "Development Workload" + project_code = "dev" + owner_email = "platform@example.com" + env = "dev" + corporate = true + network_area_key = "nonprod" + network_prefix_length = 24 + } + test = { + project_name = "Test Workload" + project_code = "test" + owner_email = "platform@example.com" + env = "test" + corporate = true + network_area_key = "nonprod" + network_prefix_length = 24 + } +} \ No newline at end of file diff --git a/src/config/hub-and-spoke-3snas.tfvars b/src/config/hub-and-spoke-3snas.tfvars new file mode 100644 index 0000000..1492052 --- /dev/null +++ b/src/config/hub-and-spoke-3snas.tfvars @@ -0,0 +1,71 @@ +########################### +## THREE-SNA ENVIRONMENTS ## +########################### + +owner_email = "platform@example.com" +company_name = "Example Corp" +company_code = "exc" +organization_id = "00000000-0000-0000-0000-000000000000" +region = "eu01" + +# Every environment receives its own isolated SNA. +connectivity = { + naming_pattern = "exc-connectivity" + + network_areas = { + prod = { + name = "prod-sna" + ranges = ["10.0.0.0/16"] + transfer_network = "10.1.0.0/24" + max_prefix_length = 28 + min_prefix_length = 24 + default_prefix_length = 26 + } + dev = { + name = "dev-sna" + ranges = ["10.2.0.0/16"] + transfer_network = "10.3.0.0/24" + max_prefix_length = 28 + min_prefix_length = 24 + default_prefix_length = 26 + } + test = { + name = "test-sna" + ranges = ["10.4.0.0/16"] + transfer_network = "10.5.0.0/24" + max_prefix_length = 28 + min_prefix_length = 24 + default_prefix_length = 26 + } + } +} + +landing_zones = { + production = { + project_name = "Production Workload" + project_code = "prod" + owner_email = "platform@example.com" + env = "prod" + corporate = true + network_area_key = "prod" + network_prefix_length = 24 + } + development = { + project_name = "Development Workload" + project_code = "dev" + owner_email = "platform@example.com" + env = "dev" + corporate = true + network_area_key = "dev" + network_prefix_length = 24 + } + test = { + project_name = "Test Workload" + project_code = "test" + owner_email = "platform@example.com" + env = "test" + corporate = true + network_area_key = "test" + network_prefix_length = 24 + } +} \ No newline at end of file diff --git a/src/config/hub-and-spoke-multi-area.tfvars b/src/config/hub-and-spoke-multi-area.tfvars new file mode 100644 index 0000000..3a52bdf --- /dev/null +++ b/src/config/hub-and-spoke-multi-area.tfvars @@ -0,0 +1,67 @@ +########################### +## MULTI-AREA CONNECTIVITY ## +########################### + +owner_email = "platform@example.com" +company_name = "Example Corp" +company_code = "exc" +organization_id = "00000000-0000-0000-0000-000000000000" +region = "eu01" + +# Area keys represent arbitrary business, security, tenant, or regional boundaries. +# This example isolates regulated workloads while less-sensitive shared workloads +# use a second SNA. Landing zones select their SNA with network_area_key. +connectivity = { + naming_pattern = "example-connectivity" + + network_areas = { + regulated = { + name = "regulated-sna" + ranges = ["10.0.0.0/16"] + transfer_network = "10.255.0.0/24" + min_prefix_length = 24 + max_prefix_length = 28 + default_prefix_length = 25 + } + shared = { + name = "shared-sna" + ranges = ["10.1.0.0/16"] + transfer_network = "10.254.0.0/24" + min_prefix_length = 24 + max_prefix_length = 28 + default_prefix_length = 25 + } + } + + dns_zones = { + regulated = { + dns_name = "regulated.example.stackit.run" + network_area_key = "regulated" + } + shared = { + dns_name = "shared.example.stackit.run" + network_area_key = "shared" + } + } +} + +landing_zones = { + regulated_workload = { + project_name = "Regulated Workload" + project_code = "regulated" + owner_email = "platform@example.com" + env = "live" + corporate = true + network_area_key = "regulated" + network_prefix_length = 24 + } + shared_workload = { + project_name = "Shared Workload" + project_code = "shared" + owner_email = "platform@example.com" + env = "live" + corporate = true + network_area_key = "shared" + network_prefix_length = 24 + } +} \ No newline at end of file diff --git a/src/config/hub-and-spoke-multi-region.tfvars b/src/config/hub-and-spoke-multi-region.tfvars new file mode 100644 index 0000000..8edd579 --- /dev/null +++ b/src/config/hub-and-spoke-multi-region.tfvars @@ -0,0 +1,106 @@ +########################## +## MULTI-REGION HUB-SPOKE ## +########################## + +owner_email = "platform@example.com" +company_name = "Example Corp" +company_code = "exc" +organization_id = "00000000-0000-0000-0000-000000000000" + +# Each enabled region creates an independent connectivity hub through its static +# provider alias. Hubs do not communicate implicitly; configure VPN peers and +# routes explicitly when inter-region traffic is required. +connectivity_regions = { + eu01 = { + naming_pattern = "exc-connectivity-eu01" + network_areas = { + primary = { + name = "eu01-primary-sna" + ranges = ["10.0.0.0/16"] + transfer_network = "10.1.0.0/24" + min_prefix_length = 24 + max_prefix_length = 28 + default_prefix_length = 25 + } + } + } + eu02 = { + naming_pattern = "exc-connectivity-eu02" + network_areas = { + primary = { + name = "eu02-primary-sna" + ranges = ["10.2.0.0/16"] + transfer_network = "10.3.0.0/24" + min_prefix_length = 24 + max_prefix_length = 28 + default_prefix_length = 25 + } + } + } +} + +platform_kubernetes = { + eu01 = { + region = "eu01" + network = { + sna_enabled = true + network_area_key = "primary" + } + cluster = { + name = "platform-eu01" + } + } + eu02 = { + region = "eu02" + network = { + sna_enabled = true + network_area_key = "primary" + } + observability = { + enabled = false + } + cluster = { + name = "platform-eu02" + node_pools = [ + { + name = "system" + machine_type = "g3i.4" + minimum = 2 + maximum = 2 + availability_zones = ["eu02-1"] + }, + { + name = "application" + machine_type = "g3i.4" + minimum = 2 + maximum = 2 + availability_zones = ["eu02-2"] + }, + ] + } + } +} + +landing_zones = { + eu01_workload = { + project_name = "EU01 Workload" + project_code = "eu01app" + owner_email = "platform@example.com" + env = "prod" + region = "eu01" + corporate = true + network_area_key = "primary" + network_prefix_length = 24 + } + eu02_workload = { + project_name = "EU02 Workload" + project_code = "eu02app" + owner_email = "platform@example.com" + env = "prod" + region = "eu02" + corporate = true + network_area_key = "primary" + network_prefix_length = 24 + secretsmanager_enabled = false + } +} \ No newline at end of file diff --git a/src/config/hub-and-spoke-prod-nonprod-firewall.tfvars b/src/config/hub-and-spoke-prod-nonprod-firewall.tfvars new file mode 100644 index 0000000..4b6b000 --- /dev/null +++ b/src/config/hub-and-spoke-prod-nonprod-firewall.tfvars @@ -0,0 +1,84 @@ +####################################### +## PROD/NONPROD WITH FIREWALLS ## +####################################### + +owner_email = "platform@example.com" +company_name = "Example Corp" +company_code = "exc" +organization_id = "00000000-0000-0000-0000-000000000000" +region = "eu01" + +# Production and non-production each receive an isolated SNA and firewall. +# The firewall CIDRs are distinct and must be contained in their area's ranges. +connectivity = { + naming_pattern = "exc-connectivity" + + network_areas = { + prod = { + name = "prod-sna" + ranges = ["10.0.0.0/16"] + transfer_network = "10.1.0.0/24" + max_prefix_length = 28 + min_prefix_length = 24 + default_prefix_length = 26 + } + nonprod = { + name = "nonprod-sna" + ranges = ["10.2.0.0/16"] + transfer_network = "10.3.0.0/24" + max_prefix_length = 28 + min_prefix_length = 24 + default_prefix_length = 26 + } + } + + firewalls = { + prod = { + zone = "eu01-m" + flavor = "c1.2" + name = "opnsense-prod" + lan_network_range = "10.0.2.0/28" + wan_network_range = "10.0.2.16/28" + } + nonprod = { + zone = "eu01-m" + flavor = "c1.2" + name = "opnsense-nonprod" + lan_network_range = "10.2.2.0/28" + wan_network_range = "10.2.2.16/28" + } + } +} + +landing_zones = { + production = { + project_name = "Production Workload" + project_code = "prod" + owner_email = "platform@example.com" + env = "prod" + corporate = true + network_area_key = "prod" + network_prefix_length = 24 + } + development = { + project_name = "Development Workload" + project_code = "dev" + owner_email = "platform@example.com" + env = "dev" + corporate = true + network_area_key = "nonprod" + network_prefix_length = 24 + } + test = { + project_name = "Test Workload" + project_code = "test" + owner_email = "platform@example.com" + env = "test" + corporate = true + network_area_key = "nonprod" + network_prefix_length = 24 + } +} + +# The automatic firewall_config integration configures a single appliance only. +# Configure the policies for both firewalls independently after bootstrap. \ No newline at end of file diff --git a/src/main.tf b/src/main.tf index 47d2948..3699ed0 100644 --- a/src/main.tf +++ b/src/main.tf @@ -38,17 +38,37 @@ module "management" { module "connectivity" { source = "./modules/connectivity" - count = var.connectivity != null ? 1 : 0 + count = var.connectivity != null && var.connectivity_regions == null ? 1 : 0 owner_email = var.owner_email - naming_pattern = "${var.company_code}-pltfm-hub-prod" + naming_pattern = coalesce(var.connectivity.naming_pattern, "${var.company_code}-pltfm-connectivity") parent_container_id = module.governance.folder_container_ids["platform"] organization_id = var.organization_id labels = var.labels region = var.region dns_zones = var.connectivity.dns_zones - network_area = var.connectivity.network_area + network_areas = var.connectivity.network_areas != null ? { + for key, area in var.connectivity.network_areas : key => { + name = area.name + ranges = area.ranges + transfer_network = area.transfer_network + min_prefix_length = area.min_prefix_length + max_prefix_length = area.max_prefix_length + default_prefix_length = area.default_prefix_length + default_nameservers = area.default_nameservers + } + } : var.connectivity.network_area != null ? { + default = { + name = null + ranges = var.connectivity.network_area.ranges + transfer_network = var.connectivity.network_area.transfer_network + min_prefix_length = var.connectivity.network_area.min_prefix_length + max_prefix_length = var.connectivity.network_area.max_prefix_length + default_prefix_length = var.connectivity.network_area.default_prefix_length + default_nameservers = var.connectivity.network_area.default_nameservers + } } : {} firewall = var.connectivity.firewall + firewalls = var.connectivity.firewalls vpn = var.connectivity.vpn vpn_pre_shared_keys = var.vpn_pre_shared_keys @@ -59,6 +79,42 @@ module "connectivity" { firewall_admin_password = var.firewall_admin_password } +module "connectivity_eu01" { + source = "./modules/connectivity" + count = try(var.connectivity_regions["eu01"], null) != null ? 1 : 0 + providers = { stackit = stackit.eu01 } + + owner_email = var.owner_email + naming_pattern = coalesce(try(var.connectivity_regions["eu01"].naming_pattern, null), "${var.company_code}-pltfm-connectivity-eu01") + parent_container_id = module.governance.folder_container_ids["platform"] + organization_id = var.organization_id + labels = var.labels + region = "eu01" + dns_zones = try(var.connectivity_regions["eu01"].dns_zones, {}) + network_areas = try(var.connectivity_regions["eu01"].network_areas, {}) + firewalls = try(var.connectivity_regions["eu01"].firewalls, null) + vpn = try(var.connectivity_regions["eu01"].vpn, null) + vpn_pre_shared_keys = var.vpn_pre_shared_keys +} + +module "connectivity_eu02" { + source = "./modules/connectivity" + count = try(var.connectivity_regions["eu02"], null) != null ? 1 : 0 + providers = { stackit = stackit.eu02 } + + owner_email = var.owner_email + naming_pattern = coalesce(try(var.connectivity_regions["eu02"].naming_pattern, null), "${var.company_code}-pltfm-connectivity-eu02") + parent_container_id = module.governance.folder_container_ids["platform"] + organization_id = var.organization_id + labels = var.labels + region = "eu02" + dns_zones = try(var.connectivity_regions["eu02"].dns_zones, {}) + network_areas = try(var.connectivity_regions["eu02"].network_areas, {}) + firewalls = try(var.connectivity_regions["eu02"].firewalls, null) + vpn = try(var.connectivity_regions["eu02"].vpn, null) + vpn_pre_shared_keys = var.vpn_pre_shared_keys +} + ##################### ## FIREWALL POLICY ## ##################### @@ -106,7 +162,7 @@ module "devops" { module "platform_kubernetes" { source = "./modules/platform-kubernetes" - for_each = var.platform_kubernetes + for_each = var.connectivity_regions == null ? var.platform_kubernetes : {} owner_email = var.owner_email organization_id = var.organization_id @@ -122,8 +178,8 @@ module "platform_kubernetes" { 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_area_id = each.value.network.sna_network_area_id != null ? each.value.network.sna_network_area_id : try(module.connectivity[0].network_area_id[each.value.network.network_area_key], null) + firewall_next_hop_ip = try(module.connectivity[0].firewall_next_hop_ip[each.value.network.network_area_key], null) sna_network_prefix_length = each.value.network.sna_network_prefix_length } @@ -134,6 +190,56 @@ module "platform_kubernetes" { } } +module "platform_kubernetes_eu01" { + source = "./modules/platform-kubernetes" + for_each = var.connectivity_regions != null ? { for key, cluster in var.platform_kubernetes : key => cluster if cluster.region == "eu01" } : {} + providers = { stackit = stackit.eu01 } + + owner_email = var.owner_email + organization_id = var.organization_id + naming_pattern = "${var.company_code}-pltfm-k8s-eu01" + 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_eu01[0].network_area_id[each.value.network.network_area_key], null) + firewall_next_hop_ip = try(module.connectivity_eu01[0].firewall_next_hop_ip[each.value.network.network_area_key], null) + sna_network_prefix_length = each.value.network.sna_network_prefix_length + } + dns = each.value.dns +} + +module "platform_kubernetes_eu02" { + source = "./modules/platform-kubernetes" + for_each = var.connectivity_regions != null ? { for key, cluster in var.platform_kubernetes : key => cluster if cluster.region == "eu02" } : {} + providers = { stackit = stackit.eu02 } + + owner_email = var.owner_email + organization_id = var.organization_id + naming_pattern = "${var.company_code}-pltfm-k8s-eu02" + 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_eu02[0].network_area_id[each.value.network.network_area_key], null) + firewall_next_hop_ip = try(module.connectivity_eu02[0].firewall_next_hop_ip[each.value.network.network_area_key], null) + sna_network_prefix_length = each.value.network.sna_network_prefix_length + } + dns = each.value.dns +} + ############### ## SANDBOXES ## ############### @@ -153,20 +259,65 @@ module "sandboxes" { module "landing_zone" { source = "./modules/landing-zone" - for_each = var.landing_zones + for_each = var.connectivity_regions == null ? var.landing_zones : {} - organization_id = var.organization_id - parent_container_id = each.value.corporate ? module.governance.folder_container_ids["landing_zones_corporate"] : module.governance.folder_container_ids["landing_zones_public"] - naming_pattern = "${var.company_code}-lz-${each.value.project_code}-${each.value.env}" - dns_zone_name = try("${each.value.project_code}-${each.value.env}-${var.region}-${split(".", values(module.connectivity[0].dns_zone_dns_names)[0])[0]}.stackit.run", null) - network_area_id = each.value.corporate ? try(module.connectivity[0].network_area_id, null) : null - corporate = each.value.corporate - owner_email = each.value.owner_email - labels = var.labels - role_assignments = each.value.role_assignments - network_prefix_length = each.value.network_prefix_length - ipv4_nameservers = try(module.connectivity[0].network_area_nameservers, null) - 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 + organization_id = var.organization_id + parent_container_id = each.value.corporate ? module.governance.folder_container_ids["landing_zones_corporate"] : module.governance.folder_container_ids["landing_zones_public"] + naming_pattern = "${var.company_code}-lz-${each.value.project_code}-${each.value.env}" + dns_zone_name = try("${each.value.project_code}-${each.value.env}-${var.region}-${split(".", values(module.connectivity[0].dns_zone_dns_names)[0])[0]}.stackit.run", null) + network_area_id = each.value.corporate ? try(module.connectivity[0].network_area_id[each.value.network_area_key], null) : null + corporate = each.value.corporate + owner_email = each.value.owner_email + labels = var.labels + role_assignments = each.value.role_assignments + network_prefix_length = each.value.network_prefix_length + ipv4_nameservers = try(module.connectivity[0].network_area_nameservers[each.value.network_area_key], null) + custom_roles = each.value.custom_roles + observability = each.value.observability + secretsmanager_enabled = each.value.secretsmanager_enabled + firewall_next_hop_ip = var.connectivity != null && var.connectivity.firewall != null ? try(module.connectivity[0].firewall_next_hop_ip[each.value.network_area_key], null) : null # if firewall is enabled, pass the next hop IP to the landing zones for route configuration +} + +module "landing_zone_eu01" { + source = "./modules/landing-zone" + for_each = var.connectivity_regions != null ? { for key, landing_zone in var.landing_zones : key => landing_zone if landing_zone.region == "eu01" } : {} + providers = { stackit = stackit.eu01 } + + organization_id = var.organization_id + parent_container_id = each.value.corporate ? module.governance.folder_container_ids["landing_zones_corporate"] : module.governance.folder_container_ids["landing_zones_public"] + naming_pattern = "${var.company_code}-lz-${each.value.project_code}-${each.value.env}" + dns_zone_name = try("${each.value.project_code}-${each.value.env}-eu01-${split(".", values(module.connectivity_eu01[0].dns_zone_dns_names)[0])[0]}.stackit.run", null) + network_area_id = each.value.corporate ? try(module.connectivity_eu01[0].network_area_id[each.value.network_area_key], null) : null + corporate = each.value.corporate + owner_email = each.value.owner_email + labels = var.labels + role_assignments = each.value.role_assignments + network_prefix_length = each.value.network_prefix_length + ipv4_nameservers = try(module.connectivity_eu01[0].network_area_nameservers[each.value.network_area_key], null) + custom_roles = each.value.custom_roles + observability = each.value.observability + secretsmanager_enabled = each.value.secretsmanager_enabled + firewall_next_hop_ip = try(module.connectivity_eu01[0].firewall_next_hop_ip[each.value.network_area_key], null) +} + +module "landing_zone_eu02" { + source = "./modules/landing-zone" + for_each = var.connectivity_regions != null ? { for key, landing_zone in var.landing_zones : key => landing_zone if landing_zone.region == "eu02" } : {} + providers = { stackit = stackit.eu02 } + + organization_id = var.organization_id + parent_container_id = each.value.corporate ? module.governance.folder_container_ids["landing_zones_corporate"] : module.governance.folder_container_ids["landing_zones_public"] + naming_pattern = "${var.company_code}-lz-${each.value.project_code}-${each.value.env}" + dns_zone_name = try("${each.value.project_code}-${each.value.env}-eu02-${split(".", values(module.connectivity_eu02[0].dns_zone_dns_names)[0])[0]}.stackit.run", null) + network_area_id = each.value.corporate ? try(module.connectivity_eu02[0].network_area_id[each.value.network_area_key], null) : null + corporate = each.value.corporate + owner_email = each.value.owner_email + labels = var.labels + role_assignments = each.value.role_assignments + network_prefix_length = each.value.network_prefix_length + ipv4_nameservers = try(module.connectivity_eu02[0].network_area_nameservers[each.value.network_area_key], null) + custom_roles = each.value.custom_roles + observability = each.value.observability + secretsmanager_enabled = each.value.secretsmanager_enabled + firewall_next_hop_ip = try(module.connectivity_eu02[0].firewall_next_hop_ip[each.value.network_area_key], null) } diff --git a/src/modules/connectivity/1-network-area.tf b/src/modules/connectivity/1-network-area.tf index 5ef92f6..6d9ac86 100644 --- a/src/modules/connectivity/1-network-area.tf +++ b/src/modules/connectivity/1-network-area.tf @@ -10,36 +10,40 @@ locals { eu02 = ["45.137.172.101", "45.137.172.102", "45.137.172.103"] } - network_area_nameservers = ( - var.network_area.default_nameservers != null - ? var.network_area.default_nameservers + network_area_nameservers = { for idx, na in var.network_areas : idx => ( + na.default_nameservers != null + ? na.default_nameservers : lookup(local.stackit_regional_nameservers, var.region, []) - ) + ) } } resource "stackit_network_area" "this" { + for_each = { for idx, na in var.network_areas : idx => na } + organization_id = var.organization_id - name = var.network_area_name != null ? var.network_area_name : var.naming_pattern + name = each.value.name != null ? each.value.name : "${var.naming_pattern}-${each.key}" labels = merge(var.labels, { "preview/routingtables" = "true" }) } resource "stackit_network_area_region" "this" { + for_each = { for idx, na in var.network_areas : idx => na } + organization_id = var.organization_id - network_area_id = stackit_network_area.this.network_area_id + network_area_id = stackit_network_area.this[each.key].network_area_id region = var.region ipv4 = { - network_ranges = [for r in var.network_area.ranges : { prefix = r }] - transfer_network = var.network_area.transfer_network - max_prefix_length = var.network_area.max_prefix_length - min_prefix_length = var.network_area.min_prefix_length - default_prefix_length = var.network_area.default_prefix_length - default_nameservers = local.network_area_nameservers + network_ranges = [for r in each.value.ranges : { prefix = r }] + transfer_network = each.value.transfer_network + max_prefix_length = each.value.max_prefix_length + min_prefix_length = each.value.min_prefix_length + default_prefix_length = each.value.default_prefix_length + default_nameservers = lookup(local.network_area_nameservers, each.key, []) } lifecycle { precondition { - condition = length(local.network_area_nameservers) > 0 + condition = length(local.network_area_nameservers[each.key]) > 0 error_message = "No STACKIT resolvers are known for region ${var.region}. Set connectivity.network_area.default_nameservers explicitly." } } @@ -48,6 +52,7 @@ resource "stackit_network_area_region" "this" { # This gives STACKIT time to de-register projects that were attached to the network area # Error: Network area ready for deletion waiting: found non-GenericOpenApiError: network area with id ... has still active projects resource "time_sleep" "wait_before_network_area_region_destroy" { + for_each = { for idx, na in var.network_areas : idx => na } destroy_duration = "180s" depends_on = [stackit_network_area_region.this] diff --git a/src/modules/connectivity/2-project.tf b/src/modules/connectivity/2-project.tf index c656edd..4a01aa7 100644 --- a/src/modules/connectivity/2-project.tf +++ b/src/modules/connectivity/2-project.tf @@ -3,24 +3,37 @@ ############# locals { - project_labels = merge( - { "networkArea" = stackit_network_area.this.network_area_id }, + project_labels = { for idx, na in var.network_areas : idx => merge( + { + "networkArea" = stackit_network_area.this[idx].network_area_id + "networkAreaKey" = idx + }, var.labels - ) - labels = length(local.project_labels) > 0 ? local.project_labels : null # provider bug: empty map becomes null after apply + ) } + labels = { for idx, na in var.network_areas : idx => length(local.project_labels[idx]) > 0 ? local.project_labels[idx] : null } + role_assignments = { + for pair in setproduct(keys(var.network_areas), range(length(var.role_assignments))) : + "${pair[0]}/${pair[1]}" => { + network_area_key = pair[0] + role = var.role_assignments[pair[1]].role + subject = var.role_assignments[pair[1]].subject + } + } } resource "stackit_resourcemanager_project" "this" { + for_each = { for idx, na in var.network_areas : idx => na } + parent_container_id = var.parent_container_id - name = var.project_name != null ? var.project_name : var.naming_pattern + name = var.project_name != null && length(var.network_areas) == 1 ? var.project_name : "${coalesce(var.project_name, var.naming_pattern)}-${each.key}" owner_email = var.owner_email - labels = local.labels + labels = local.labels[each.key] } resource "stackit_authorization_project_role_assignment" "this" { - for_each = { for assignment in var.role_assignments : "${assignment.role}-${assignment.subject}" => assignment } + for_each = local.role_assignments - resource_id = stackit_resourcemanager_project.this.project_id + resource_id = stackit_resourcemanager_project.this[each.value.network_area_key].project_id role = each.value.role subject = each.value.subject } diff --git a/src/modules/connectivity/3-external-network.tf b/src/modules/connectivity/3-external-network.tf index 93b921b..9a2832b 100644 --- a/src/modules/connectivity/3-external-network.tf +++ b/src/modules/connectivity/3-external-network.tf @@ -3,24 +3,30 @@ ############# resource "time_sleep" "wait_for_network_area" { + for_each = { for idx, na in var.network_areas : idx => na } + create_duration = "20s" depends_on = [stackit_network_area.this] } resource "stackit_routing_table" "wan" { + for_each = { for idx, na in var.network_areas : idx => na } + organization_id = var.organization_id - network_area_id = stackit_network_area.this.network_area_id - name = "wan" + network_area_id = stackit_network_area.this[each.key].network_area_id + name = "wan-${each.key}" system_routes = true depends_on = [time_sleep.wait_for_network_area] } resource "stackit_routing_table_route" "wan" { + for_each = { for idx, na in var.network_areas : idx => na } + organization_id = var.organization_id - network_area_id = stackit_network_area.this.network_area_id - routing_table_id = stackit_routing_table.wan.routing_table_id + network_area_id = stackit_network_area.this[each.key].network_area_id + routing_table_id = stackit_routing_table.wan[each.key].routing_table_id destination = { type = "cidrv4" @@ -37,31 +43,31 @@ resource "stackit_routing_table_route" "wan" { ############# resource "stackit_network" "wan" { - count = local.firewall_enabled ? 1 : 0 + for_each = local.firewalls - project_id = stackit_resourcemanager_project.this.project_id - name = "wan_network" - ipv4_prefix = var.firewall.wan_network_range - ipv4_nameservers = local.network_area_nameservers - routing_table_id = stackit_routing_table.wan.routing_table_id + project_id = stackit_resourcemanager_project.this[each.key].project_id + name = "wan_network-${each.key}" + ipv4_prefix = each.value.wan_network_range + ipv4_nameservers = local.network_area_nameservers[each.key] + routing_table_id = stackit_routing_table.wan[each.key].routing_table_id routed = true } resource "stackit_network_interface" "wan" { - count = local.firewall_enabled ? 1 : 0 + for_each = local.firewalls - name = "vtnet0_wan" - project_id = stackit_resourcemanager_project.this.project_id - network_id = stackit_network.wan[0].network_id - ipv4 = local.firewall_wan_ip + name = "vtnet0_wan-${each.key}" + project_id = stackit_resourcemanager_project.this[each.key].project_id + network_id = stackit_network.wan[each.key].network_id + ipv4 = local.firewall_wan_ips[each.key] security = false } resource "stackit_public_ip" "wan-ip" { - count = local.firewall_enabled ? 1 : 0 + for_each = local.firewalls - project_id = stackit_resourcemanager_project.this.project_id - network_interface_id = stackit_network_interface.wan[0].network_interface_id + project_id = stackit_resourcemanager_project.this[each.key].project_id + network_interface_id = stackit_network_interface.wan[each.key].network_interface_id } ################# @@ -74,18 +80,18 @@ resource "stackit_public_ip" "wan-ip" { # manually by repointing DNS. The primary's public IP does NOT move automatically — # STACKIT binds a public IP 1:1 to a NIC and has no floating construct. resource "stackit_network_interface" "wan_backup" { - count = local.firewall_ha_enabled ? 1 : 0 + for_each = local.ha_firewalls - name = "vtnet0_wan_backup" - project_id = stackit_resourcemanager_project.this.project_id - network_id = stackit_network.wan[0].network_id - ipv4 = local.firewall_backup_wan_ip + name = "vtnet0_wan_backup-${each.key}" + project_id = stackit_resourcemanager_project.this[each.key].project_id + network_id = stackit_network.wan[each.key].network_id + ipv4 = local.firewall_backup_wan_ips[each.key] security = false } resource "stackit_public_ip" "wan-ip_backup" { - count = local.firewall_ha_enabled ? 1 : 0 + for_each = local.ha_firewalls - project_id = stackit_resourcemanager_project.this.project_id - network_interface_id = stackit_network_interface.wan_backup[0].network_interface_id -} \ No newline at end of file + project_id = stackit_resourcemanager_project.this[each.key].project_id + network_interface_id = stackit_network_interface.wan_backup[each.key].network_interface_id +} diff --git a/src/modules/connectivity/4-internal-network.tf b/src/modules/connectivity/4-internal-network.tf index 1192bfb..24d6bea 100644 --- a/src/modules/connectivity/4-internal-network.tf +++ b/src/modules/connectivity/4-internal-network.tf @@ -3,22 +3,22 @@ ############# resource "stackit_network" "lan" { - count = local.firewall_enabled ? 1 : 0 + for_each = local.firewalls - project_id = stackit_resourcemanager_project.this.project_id - name = "lan" - ipv4_prefix = var.firewall.lan_network_range - ipv4_nameservers = local.network_area_nameservers + project_id = stackit_resourcemanager_project.this[each.key].project_id + name = "lan-${each.key}" + ipv4_prefix = each.value.lan_network_range + ipv4_nameservers = local.network_area_nameservers[each.key] routed = true } resource "stackit_network_interface" "lan" { - count = local.firewall_enabled ? 1 : 0 + for_each = local.firewalls - name = "vtnet1_lan" - project_id = stackit_resourcemanager_project.this.project_id - network_id = stackit_network.lan[0].network_id - ipv4 = local.firewall_lan_ip + name = "vtnet1_lan-${each.key}" + project_id = stackit_resourcemanager_project.this[each.key].project_id + network_id = stackit_network.lan[each.key].network_id + ipv4 = local.firewall_lan_ips[each.key] security = false } @@ -26,11 +26,11 @@ resource "stackit_network_interface" "lan" { # the virtual MAC 00:00:5e:00:01:, and STACKIT port security cannot express a # foreign MAC (allowed_addresses is IP-only) resource "stackit_network_interface" "lan_backup" { - count = local.firewall_ha_enabled ? 1 : 0 + for_each = local.ha_firewalls - name = "vtnet1_lan_backup" - project_id = stackit_resourcemanager_project.this.project_id - network_id = stackit_network.lan[0].network_id - ipv4 = local.firewall_backup_lan_ip + name = "vtnet1_lan_backup-${each.key}" + project_id = stackit_resourcemanager_project.this[each.key].project_id + network_id = stackit_network.lan[each.key].network_id + ipv4 = local.firewall_backup_lan_ips[each.key] security = false -} \ No newline at end of file +} diff --git a/src/modules/connectivity/5-firewall.tf b/src/modules/connectivity/5-firewall.tf index d85052d..afb8ca3 100644 --- a/src/modules/connectivity/5-firewall.tf +++ b/src/modules/connectivity/5-firewall.tf @@ -4,29 +4,46 @@ locals { firewall_image_path = fileexists("${path.root}/firewall-image.qcow2") ? "${path.root}/firewall-image.qcow2" : "/dev/null" - firewall_enabled = var.firewall != null - firewall_ha_enabled = local.firewall_enabled && try(var.firewall.ha, null) != null + firewalls = var.firewalls != null ? var.firewalls : var.firewall != null ? { + for key, area in var.network_areas : key => var.firewall + } : {} + ha_firewalls = { for key, firewall in local.firewalls : key => firewall if firewall.ha != null } - firewall_lan_ip = local.firewall_enabled ? coalesce(var.firewall.lan_ip, cidrhost(var.firewall.lan_network_range, 4)) : null - firewall_wan_ip = local.firewall_enabled ? coalesce(var.firewall.wan_ip, cidrhost(var.firewall.wan_network_range, 4)) : null - - firewall_backup_lan_ip = local.firewall_ha_enabled ? coalesce(var.firewall.ha.backup_lan_ip, cidrhost(var.firewall.lan_network_range, 5)) : null - firewall_backup_wan_ip = local.firewall_ha_enabled ? coalesce(var.firewall.ha.backup_wan_ip, cidrhost(var.firewall.wan_network_range, 5)) : null - firewall_lan_vip = local.firewall_ha_enabled ? coalesce(var.firewall.ha.lan_vip, cidrhost(var.firewall.lan_network_range, 6)) : null - - firewall_backup_name = local.firewall_ha_enabled ? coalesce(var.firewall.ha.backup_name, "${var.firewall.name}-backup") : null + firewall_lan_ips = { + for key, firewall in local.firewalls : key => coalesce(firewall.lan_ip, cidrhost(firewall.lan_network_range, 4)) + } + firewall_wan_ips = { + for key, firewall in local.firewalls : key => coalesce(firewall.wan_ip, cidrhost(firewall.wan_network_range, 4)) + } + firewall_backup_lan_ips = { + for key, firewall in local.ha_firewalls : key => coalesce(firewall.ha.backup_lan_ip, cidrhost(firewall.lan_network_range, 5)) + } + firewall_backup_wan_ips = { + for key, firewall in local.ha_firewalls : key => coalesce(firewall.ha.backup_wan_ip, cidrhost(firewall.wan_network_range, 5)) + } + firewall_lan_vips = { + for key, firewall in local.ha_firewalls : key => coalesce(firewall.ha.lan_vip, cidrhost(firewall.lan_network_range, 6)) + } + firewall_backup_names = { + for key, firewall in local.ha_firewalls : key => coalesce(firewall.ha.backup_name, "${firewall.name}-backup") + } - firewall_ha_endpoint = local.firewall_ha_enabled ? coalesce( - var.firewall_admin_endpoint, - "https://${stackit_public_ip.wan-ip[0].ip}" - ) : null + # Resource references needed for one firewall deployment per network area. + network_area_firewall = { for key, firewall in local.firewalls : key => { + project_id = stackit_resourcemanager_project.this[key].project_id + wan_ip = try(stackit_public_ip.wan-ip[key].ip, null) + wan_backup_ip = try(stackit_public_ip.wan-ip_backup[key].ip, null) + lan_ip = local.firewall_lan_ips[key] + lan_backup_ip = try(local.firewall_backup_lan_ips[key], null) + lan_vip = try(local.firewall_lan_vips[key], null) + } } } resource "stackit_image" "firewall" { - count = local.firewall_enabled ? 1 : 0 + for_each = local.firewalls - project_id = stackit_resourcemanager_project.this.project_id - name = var.firewall.name + project_id = local.network_area_firewall[each.key].project_id + name = each.value.name local_file_path = local.firewall_image_path disk_format = "qcow2" min_disk_size = 16 @@ -41,15 +58,15 @@ resource "stackit_image" "firewall" { ############ resource "stackit_volume" "firewall" { - count = local.firewall_enabled ? 1 : 0 + for_each = local.firewalls - project_id = stackit_resourcemanager_project.this.project_id - name = var.firewall.name - availability_zone = var.firewall.zone - size = var.firewall.volume_size - performance_class = var.firewall.volume_performance_class + project_id = local.network_area_firewall[each.key].project_id + name = each.value.name + availability_zone = each.value.zone + size = each.value.volume_size + performance_class = each.value.volume_performance_class source = { - id = stackit_image.firewall[0].image_id + id = stackit_image.firewall[each.key].image_id type = "image" } } @@ -59,20 +76,20 @@ resource "stackit_volume" "firewall" { ############ resource "stackit_server" "firewall" { - count = local.firewall_enabled ? 1 : 0 + for_each = local.firewalls - project_id = stackit_resourcemanager_project.this.project_id - name = var.firewall.name + project_id = local.network_area_firewall[each.key].project_id + name = each.value.name boot_volume = { source_type = "volume" - source_id = stackit_volume.firewall[0].volume_id + source_id = stackit_volume.firewall[each.key].volume_id } - availability_zone = var.firewall.zone - machine_type = var.firewall.flavor + availability_zone = each.value.zone + machine_type = each.value.flavor network_interfaces = [ - stackit_network_interface.wan[0].network_interface_id, # vtnet0 = WAN - stackit_network_interface.lan[0].network_interface_id # vtnet1 = LAN + stackit_network_interface.wan[each.key].network_interface_id, # vtnet0 = WAN + stackit_network_interface.lan[each.key].network_interface_id # vtnet1 = LAN ] } @@ -85,34 +102,34 @@ resource "stackit_server" "firewall" { # their state addresses, and enabling HA never touches the primary server. resource "stackit_volume" "firewall_backup" { - count = local.firewall_ha_enabled ? 1 : 0 + for_each = local.ha_firewalls - project_id = stackit_resourcemanager_project.this.project_id - name = local.firewall_backup_name - availability_zone = var.firewall.ha.backup_zone - size = var.firewall.volume_size - performance_class = var.firewall.volume_performance_class + project_id = local.network_area_firewall[each.key].project_id + name = local.firewall_backup_names[each.key] + availability_zone = each.value.ha.backup_zone + size = each.value.volume_size + performance_class = each.value.volume_performance_class source = { - id = stackit_image.firewall[0].image_id + id = stackit_image.firewall[each.key].image_id type = "image" } } resource "stackit_server" "firewall_backup" { - count = local.firewall_ha_enabled ? 1 : 0 + for_each = local.ha_firewalls - project_id = stackit_resourcemanager_project.this.project_id - name = local.firewall_backup_name + project_id = local.network_area_firewall[each.key].project_id + name = local.firewall_backup_names[each.key] boot_volume = { source_type = "volume" - source_id = stackit_volume.firewall_backup[0].volume_id + source_id = stackit_volume.firewall_backup[each.key].volume_id } - availability_zone = var.firewall.ha.backup_zone - machine_type = var.firewall.flavor + availability_zone = each.value.ha.backup_zone + machine_type = each.value.flavor network_interfaces = [ - stackit_network_interface.wan_backup[0].network_interface_id, # vtnet0 = WAN - stackit_network_interface.lan_backup[0].network_interface_id # vtnet1 = LAN + stackit_network_interface.wan_backup[each.key].network_interface_id, # vtnet0 = WAN + stackit_network_interface.lan_backup[each.key].network_interface_id # vtnet1 = LAN ] } @@ -123,7 +140,7 @@ resource "stackit_server" "firewall_backup" { # Both nodes are configured by the same apply, so the CARP shared secret never needs an # operator: it is generated here and only ever travels into the two appliances. resource "random_password" "carp" { - count = local.firewall_ha_enabled ? 1 : 0 + for_each = local.ha_firewalls length = 24 special = false @@ -135,50 +152,50 @@ resource "random_password" "carp" { # no API key, and never needs one: policy reaches it through the XMLRPC sync from the # primary). Backup first, primary last, so the primary ends up MASTER. resource "terraform_data" "firewall_ha_backup" { - count = local.firewall_ha_enabled ? 1 : 0 + for_each = local.ha_firewalls triggers_replace = [ - stackit_server.firewall_backup[0].server_id, - local.firewall_lan_vip, - var.firewall.ha.vhid, + stackit_server.firewall_backup[each.key].server_id, + local.firewall_lan_vips[each.key], + each.value.ha.vhid, ] provisioner "local-exec" { - command = "bash '${path.module}/scripts/configure-ha.sh' 'https://${stackit_public_ip.wan-ip_backup[0].ip}' backup" + command = "bash '${path.module}/scripts/configure-ha.sh' 'https://${local.network_area_firewall[each.key].wan_backup_ip}' backup" interpreter = ["/usr/bin/env", "bash", "-c"] environment = { OPNSENSE_PASSWORD = var.firewall_admin_password OPNSENSE_USERNAME = var.firewall_admin_username - LAN_VIP_CIDR = "${local.firewall_lan_vip}/${split("/", var.firewall.lan_network_range)[1]}" - VHID = var.firewall.ha.vhid - CARP_PASSWORD = random_password.carp[0].result - PEER_LAN_IP = local.firewall_lan_ip + LAN_VIP_CIDR = "${local.firewall_lan_vips[each.key]}/${split("/", each.value.lan_network_range)[1]}" + VHID = each.value.ha.vhid + CARP_PASSWORD = random_password.carp[each.key].result + PEER_LAN_IP = local.firewall_lan_ips[each.key] } } } resource "terraform_data" "firewall_ha_primary" { - count = local.firewall_ha_enabled ? 1 : 0 + for_each = local.ha_firewalls triggers_replace = [ - stackit_server.firewall[0].server_id, - local.firewall_lan_vip, - var.firewall.ha.vhid, + stackit_server.firewall[each.key].server_id, + local.firewall_lan_vips[each.key], + each.value.ha.vhid, ] provisioner "local-exec" { - command = "bash '${path.module}/scripts/configure-ha.sh' '${local.firewall_ha_endpoint}' primary" + command = "bash '${path.module}/scripts/configure-ha.sh' 'https://${local.network_area_firewall[each.key].wan_ip}' primary" interpreter = ["/usr/bin/env", "bash", "-c"] environment = { OPNSENSE_PASSWORD = var.firewall_admin_password OPNSENSE_USERNAME = var.firewall_admin_username - LAN_VIP_CIDR = "${local.firewall_lan_vip}/${split("/", var.firewall.lan_network_range)[1]}" - VHID = var.firewall.ha.vhid - CARP_PASSWORD = random_password.carp[0].result - PEER_LAN_IP = local.firewall_backup_lan_ip - SYNC_TO_ENDPOINT = "https://${local.firewall_backup_lan_ip}" + LAN_VIP_CIDR = "${local.firewall_lan_vips[each.key]}/${split("/", each.value.lan_network_range)[1]}" + VHID = each.value.ha.vhid + CARP_PASSWORD = random_password.carp[each.key].result + PEER_LAN_IP = local.firewall_backup_lan_ips[each.key] + SYNC_TO_ENDPOINT = "https://${local.network_area_firewall[each.key].lan_backup_ip}" } } diff --git a/src/modules/connectivity/6-dns-zones.tf b/src/modules/connectivity/6-dns-zones.tf index 4d3a0ca..5b9001a 100644 --- a/src/modules/connectivity/6-dns-zones.tf +++ b/src/modules/connectivity/6-dns-zones.tf @@ -5,7 +5,7 @@ resource "stackit_dns_zone" "this" { for_each = var.dns_zones - project_id = stackit_resourcemanager_project.this.project_id + project_id = stackit_resourcemanager_project.this[each.value.network_area_key].project_id name = each.value.name != null ? each.value.name : each.value.dns_name dns_name = each.value.dns_name contact_email = each.value.contact_email @@ -13,4 +13,4 @@ resource "stackit_dns_zone" "this" { acl = each.value.acl description = each.value.description default_ttl = each.value.default_ttl -} \ No newline at end of file +} diff --git a/src/modules/connectivity/7-vpn.tf b/src/modules/connectivity/7-vpn.tf index fec2cc1..e74a05a 100644 --- a/src/modules/connectivity/7-vpn.tf +++ b/src/modules/connectivity/7-vpn.tf @@ -2,10 +2,21 @@ ## VPN ## ######### +locals { + vpn_network_areas = var.vpn == null ? {} : { for idx, na in var.network_areas : idx => na } + vpn_connections = var.vpn == null ? {} : { + for pair in setproduct(keys(local.vpn_network_areas), keys(var.vpn.connections)) : + "${pair[0]}/${pair[1]}" => merge(var.vpn.connections[pair[1]], { + network_area_key = pair[0] + connection_key = pair[1] + }) + } +} + resource "stackit_vpn_gateway" "this" { - count = var.vpn != null ? 1 : 0 + for_each = local.vpn_network_areas - project_id = stackit_resourcemanager_project.this.project_id + project_id = stackit_resourcemanager_project.this[each.key].project_id display_name = var.vpn.display_name != null ? var.vpn.display_name : "${var.naming_pattern}-vpn" plan_id = var.vpn.plan_id routing_type = var.vpn.routing_type @@ -20,10 +31,10 @@ resource "stackit_vpn_gateway" "this" { # The gateway public IPs are only exposed through the status endpoint, not on the gateway # resource itself. They are required to configure the remote peer, so surface them as outputs. data "stackit_vpn_gateway_status" "this" { - count = var.vpn != null ? 1 : 0 + for_each = local.vpn_network_areas - project_id = stackit_resourcemanager_project.this.project_id - gateway_id = stackit_vpn_gateway.this[0].gateway_id + project_id = stackit_resourcemanager_project.this[each.key].project_id + gateway_id = stackit_vpn_gateway.this[each.key].gateway_id } ################# @@ -31,11 +42,11 @@ data "stackit_vpn_gateway_status" "this" { ################# resource "stackit_vpn_connection" "this" { - for_each = var.vpn != null ? var.vpn.connections : {} + for_each = local.vpn_connections - project_id = stackit_resourcemanager_project.this.project_id - gateway_id = stackit_vpn_gateway.this[0].gateway_id - display_name = each.value.display_name != null ? each.value.display_name : each.key + project_id = stackit_resourcemanager_project.this[each.value.network_area_key].project_id + gateway_id = stackit_vpn_gateway.this[each.value.network_area_key].gateway_id + display_name = each.value.display_name != null ? each.value.display_name : each.value.connection_key enabled = each.value.enabled labels = length(var.labels) > 0 ? var.labels : null # provider bug: empty map becomes null after apply @@ -45,7 +56,7 @@ resource "stackit_vpn_connection" "this" { tunnel1 = { remote_address = each.value.tunnel1.remote_address - pre_shared_key = var.vpn_pre_shared_keys[each.key].tunnel1 + pre_shared_key = var.vpn_pre_shared_keys[each.value.connection_key].tunnel1 peering = each.value.tunnel1.peering phase1 = { @@ -67,7 +78,7 @@ resource "stackit_vpn_connection" "this" { tunnel2 = { remote_address = each.value.tunnel2.remote_address - pre_shared_key = var.vpn_pre_shared_keys[each.key].tunnel2 + pre_shared_key = var.vpn_pre_shared_keys[each.value.connection_key].tunnel2 peering = each.value.tunnel2.peering phase1 = { diff --git a/src/modules/connectivity/README.md b/src/modules/connectivity/README.md index 6a275b7..ac0bee3 100644 --- a/src/modules/connectivity/README.md +++ b/src/modules/connectivity/README.md @@ -51,8 +51,7 @@ No modules. | [firewall](#input\_firewall) | Firewall configuration. Set to null to skip firewall deployment (network area and routing are still created). lan\_network\_range and wan\_network\_range must be CIDRs within the network area range. lan\_ip and wan\_ip are optional; when omitted, the 5th address of the respective prefix is used (STACKIT reserves the first usable address as the gateway). |
object({
zone = string
flavor = string
name = string
volume_performance_class = optional(string, "storage_premium_perf4")
volume_size = optional(number, 16)
lan_network_range = string
wan_network_range = string
lan_ip = optional(string, null)
wan_ip = optional(string, null)
})
| `null` | no | | [labels](#input\_labels) | Additional labels to apply to all resources. | `map(string)` | `{}` | no | | [naming\_pattern](#input\_naming\_pattern) | Naming prefix for all resources in this module, e.g. "myco-pltfm-hub-prod". | `string` | n/a | yes | -| [network\_area](#input\_network\_area) | Network area configuration including IP ranges, transfer network, and prefix length settings. default\_nameservers falls back to the STACKIT resolvers of var.region when unset. |
object({
ranges = list(string)
transfer_network = string
min_prefix_length = optional(number, 24)
max_prefix_length = optional(number, 28)
default_prefix_length = optional(number, 28)
default_nameservers = optional(list(string), null)
})
| n/a | yes | -| [network\_area\_name](#input\_network\_area\_name) | Name of the network area to create for this region. | `string` | `null` | no | +| [network\_areas](#input\_network\_areas) | List of network area configurations including IP ranges, transfer network, and prefix length settings. default\_nameservers falls back to the STACKIT resolvers of var.region when unset. |
list(object({
name = string
ranges = list(string)
transfer_network = bool
max_prefix_length = number
min_prefix_length = number
default_prefix_length = number
default_nameservers = list(string)
}))
| `[]` | yes | | [organization\_id](#input\_organization\_id) | Organization ID, required for network area and route configuration. | `string` | n/a | yes | | [owner\_email](#input\_owner\_email) | Email address of the owner for the project. Required for STACKIT resource manager. | `string` | n/a | yes | | [parent\_container\_id](#input\_parent\_container\_id) | Parent container ID (folder or organization) where the project will be created. | `string` | n/a | yes | @@ -79,4 +78,10 @@ No modules. | [vpn\_gateway\_id](#output\_vpn\_gateway\_id) | The ID of the VPN gateway in the hub. | | [vpn\_internal\_next\_hop\_ips](#output\_vpn\_internal\_next\_hop\_ips) | Map of VPN tunnel names to their network area side IP. Ping targets to verify a tunnel carries traffic into the SNA. | | [vpn\_public\_ips](#output\_vpn\_public\_ips) | Map of VPN tunnel names to their public IP. These are the addresses the remote peer has to be configured against. | - \ No newline at end of file + + +## Multiple Network Areas + +Pass `network_areas` as a map with meaningful keys, such as `prod` and `nonprod`. A separate connectivity project, network area, routing table, and optional VPN gateway is created for every key. Reference a key from `dns_zones..network_area_key`; consumers of this module receive maps keyed the same way. + +The firewall input is intentionally single-area: its LAN and WAN CIDRs are global inputs. Do not combine it with more than one network area. The root module's `connectivity.network_area` input remains available for a legacy single-area setup and maps to the `default` key. \ No newline at end of file diff --git a/src/modules/connectivity/outputs.tf b/src/modules/connectivity/outputs.tf index c62679e..21f9dcf 100644 --- a/src/modules/connectivity/outputs.tf +++ b/src/modules/connectivity/outputs.tf @@ -1,74 +1,74 @@ output "dns_zone_dns_names" { description = "Map of DNS zone keys to their DNS names" - value = { for k, z in stackit_dns_zone.this : k => z.dns_name } + value = { for key, zone in stackit_dns_zone.this : key => zone.dns_name } } output "dns_zone_ids" { description = "Map of DNS zone keys to their zone IDs" - value = { for k, z in stackit_dns_zone.this : k => z.zone_id } + value = { for key, zone in stackit_dns_zone.this : key => zone.zone_id } } output "firewall_next_hop_ip" { - description = "The IP address to be used as next hop for the default route in the landing zones. The CARP LAN VIP under HA, otherwise the firewall LAN IP." - value = local.firewall_ha_enabled ? local.firewall_lan_vip : (local.firewall_enabled ? stackit_network_interface.lan[0].ipv4 : null) + description = "Map of network area keys to the default-route next hop. The CARP LAN VIP under HA, otherwise the firewall LAN IP." + value = { for key, firewall in local.firewalls : key => contains(keys(local.ha_firewalls), key) ? local.firewall_lan_vips[key] : stackit_network_interface.lan[key].ipv4 } } output "firewall_public_ip" { - description = "The public IP address of the firewall WAN interface (primary node)." - value = local.firewall_enabled ? stackit_public_ip.wan-ip[0].ip : null + description = "Map of network area keys to primary firewall WAN public IPs." + value = { for key, address in stackit_public_ip.wan-ip : key => address.ip } } output "firewall_backup_public_ip" { - description = "The public IP address of the backup firewall's WAN interface. Null without HA." - value = local.firewall_ha_enabled ? stackit_public_ip.wan-ip_backup[0].ip : null + description = "Map of network area keys to backup firewall WAN public IPs. Empty without HA." + value = { for key, address in stackit_public_ip.wan-ip_backup : key => address.ip } } output "firewall_cluster_lan_ips" { - description = "LAN addresses of the firewall HA pair, for the fw_cluster alias in the policy. Empty without HA." - value = local.firewall_ha_enabled ? [local.firewall_lan_ip, local.firewall_backup_lan_ip] : [] + description = "Map of network area keys to LAN addresses of firewall HA pairs. Empty without HA." + value = { for key, firewall in local.ha_firewalls : key => [local.firewall_lan_ips[key], local.firewall_backup_lan_ips[key]] } } output "network_area_id" { description = "The ID of the created network area." - value = stackit_network_area.this.network_area_id + value = { for idx, na in var.network_areas : idx => stackit_network_area.this[idx].network_area_id } } output "network_area_nameservers" { description = "Resolvers configured as the network area default, either from network_area.default_nameservers or the STACKIT resolvers of the region." - value = local.network_area_nameservers + value = { for idx, na in var.network_areas : idx => local.network_area_nameservers[idx] } } output "project_container_id" { description = "The container ID of the created STACKIT project." - value = stackit_resourcemanager_project.this.container_id + value = { for idx, na in var.network_areas : idx => stackit_resourcemanager_project.this[idx].container_id } } output "project_id" { description = "The project ID of the created STACKIT project." - value = stackit_resourcemanager_project.this.project_id + value = { for idx, na in var.network_areas : idx => stackit_resourcemanager_project.this[idx].project_id } } output "project_name" { description = "The name of the created STACKIT project." - value = stackit_resourcemanager_project.this.name + value = { for idx, na in var.network_areas : idx => stackit_resourcemanager_project.this[idx].name } } output "vpn_connection_ids" { - description = "Map of VPN connection keys to their connection IDs." - value = { for k, c in stackit_vpn_connection.this : k => c.connection_id } + description = "Map of network-area/connection keys to their VPN connection IDs." + value = { for key, connection in stackit_vpn_connection.this : key => connection.connection_id } } output "vpn_gateway_id" { - description = "The ID of the VPN gateway in the hub." - value = try(stackit_vpn_gateway.this[0].gateway_id, null) + description = "Map of network area keys to VPN gateway IDs." + value = { for key, gateway in stackit_vpn_gateway.this : key => gateway.gateway_id } } output "vpn_internal_next_hop_ips" { description = "Map of VPN tunnel names to their network area side IP. Ping targets to verify a tunnel carries traffic into the SNA." - value = try({ for t in data.stackit_vpn_gateway_status.this[0].tunnels : t.name => t.internal_next_hop_ip }, {}) + value = { for key, status in data.stackit_vpn_gateway_status.this : key => { for tunnel in status.tunnels : tunnel.name => tunnel.internal_next_hop_ip } } } output "vpn_public_ips" { description = "Map of VPN tunnel names to their public IP. These are the addresses the remote peer has to be configured against." - value = try({ for t in data.stackit_vpn_gateway_status.this[0].tunnels : t.name => t.public_ip }, {}) + value = { for key, status in data.stackit_vpn_gateway_status.this : key => { for tunnel in status.tunnels : tunnel.name => tunnel.public_ip } } } diff --git a/src/modules/connectivity/variables.tf b/src/modules/connectivity/variables.tf index f620ff3..b28ffbd 100644 --- a/src/modules/connectivity/variables.tf +++ b/src/modules/connectivity/variables.tf @@ -1,15 +1,21 @@ variable "dns_zones" { type = map(object({ - dns_name = string - name = optional(string, null) - contact_email = optional(string, null) - type = optional(string, "primary") - acl = optional(string, null) - description = optional(string, null) - default_ttl = optional(number, 3600) + dns_name = string + network_area_key = optional(string, "default") + name = optional(string, null) + contact_email = optional(string, null) + type = optional(string, "primary") + acl = optional(string, null) + description = optional(string, null) + default_ttl = optional(number, 3600) })) description = "Map of DNS zone keys to DNS zone configuration. Name defaults to dns_name if not set." default = {} + + validation { + condition = alltrue([for zone in values(var.dns_zones) : contains(keys(var.network_areas), zone.network_area_key)]) + error_message = "Every dns_zones[*].network_area_key must identify an entry in network_areas." + } } variable "firewall" { @@ -52,6 +58,35 @@ variable "firewall" { } } +variable "firewalls" { + type = map(object({ + zone = string + flavor = string + name = string + volume_performance_class = optional(string, "storage_premium_perf4") + volume_size = optional(number, 16) + lan_network_range = string + wan_network_range = string + lan_ip = optional(string, null) + wan_ip = optional(string, null) + ha = optional(object({ + backup_zone = string + backup_name = optional(string, null) + backup_lan_ip = optional(string, null) + backup_wan_ip = optional(string, null) + lan_vip = optional(string, null) + vhid = optional(number, 1) + }), null) + })) + description = "Map of network area keys to firewall configuration. Use this for multiple network areas; keys must match network_areas." + default = null + + validation { + condition = var.firewalls == null || alltrue([for firewall in values(var.firewalls) : can(regex("^[a-z][0-9]+\\.[0-9]+$", firewall.flavor))]) + error_message = "Every firewalls[*].flavor must match STACKIT machine type format (e.g. c1.2)." + } +} + variable "firewall_admin_endpoint" { type = string description = "Base URL the HA configuration logs into the primary appliance with. Defaults to the primary's public IP, which is the only address reachable from outside the network area. Set it to the LAN address when OpenTofu runs inside the area, matching firewall_config.endpoint. The backup node is always configured over its own public IP: before HA exists it has no other reachable address." @@ -82,23 +117,21 @@ variable "naming_pattern" { description = "Naming prefix for all resources in this module, e.g. \"myco-pltfm-hub-prod\"." } -variable "network_area" { - type = object({ +variable "network_areas" { + type = map(object({ + name = optional(string, null) ranges = list(string) transfer_network = string - min_prefix_length = optional(number, 24) max_prefix_length = optional(number, 28) + min_prefix_length = optional(number, 24) default_prefix_length = optional(number, 28) default_nameservers = optional(list(string), null) - }) - description = "Network area configuration including IP ranges, transfer network, and prefix length settings. default_nameservers falls back to the STACKIT resolvers of var.region when unset." + })) + description = "Map of network area keys to configuration. Keys are stable references for DNS zones, landing zones, and outputs." + default = {} } -variable "network_area_name" { - type = string - description = "Name of the network area to create for this region." - default = null -} +# Removed variable "network_area_name" as it is no longer needed when using a list of network areas. variable "organization_id" { type = string diff --git a/src/modules/landing-zone/4-secrets-manager.tf b/src/modules/landing-zone/4-secrets-manager.tf index ea454bf..6d3d831 100644 --- a/src/modules/landing-zone/4-secrets-manager.tf +++ b/src/modules/landing-zone/4-secrets-manager.tf @@ -3,6 +3,8 @@ ##################### resource "stackit_secretsmanager_instance" "this" { + count = var.secretsmanager_enabled ? 1 : 0 + project_id = stackit_resourcemanager_project.this.project_id name = "${var.naming_pattern}-default" acls = length(var.secretsmanager_acls) > 0 ? var.secretsmanager_acls : null diff --git a/src/modules/landing-zone/outputs.tf b/src/modules/landing-zone/outputs.tf index ce79f72..478fd49 100644 --- a/src/modules/landing-zone/outputs.tf +++ b/src/modules/landing-zone/outputs.tf @@ -35,7 +35,7 @@ output "landing_zone_type" { output "secretsmanager_instance_id" { description = "The ID of the landing zone Secrets Manager instance." - value = stackit_secretsmanager_instance.this.instance_id + value = try(stackit_secretsmanager_instance.this[0].instance_id, null) } output "observability_instance_id" { diff --git a/src/modules/landing-zone/variables.tf b/src/modules/landing-zone/variables.tf index 37a39be..fe08fba 100644 --- a/src/modules/landing-zone/variables.tf +++ b/src/modules/landing-zone/variables.tf @@ -90,6 +90,12 @@ variable "secretsmanager_acls" { default = [] } +variable "secretsmanager_enabled" { + type = bool + description = "Whether to create a Secrets Manager instance in the landing zone project." + default = true +} + variable "observability" { type = object({ enabled = optional(bool, false) diff --git a/src/outputs.tf b/src/outputs.tf index 266d5b8..f1997d1 100644 --- a/src/outputs.tf +++ b/src/outputs.tf @@ -38,43 +38,76 @@ output "audit_telemetry_router_id" { } output "connectivity_network_area_id" { - description = "The network area ID created by the regional module." - value = try(module.connectivity[0].network_area_id, null) + description = "The network area ID of the legacy default connectivity area." + value = try(module.connectivity[0].network_area_id["default"], null) +} + +output "connectivity_network_area_ids_by_area" { + description = "Map of connectivity network area keys to their IDs." + value = coalesce(try(module.connectivity[0].network_area_id, null), {}) +} + +output "connectivity_network_area_ids_by_region" { + description = "Map of regions to network area keys and IDs for regional connectivity deployments." + value = { + eu01 = coalesce(try(module.connectivity_eu01[0].network_area_id, null), {}) + eu02 = coalesce(try(module.connectivity_eu02[0].network_area_id, null), {}) + } } output "connectivity_project_id" { - description = "The project ID of the connectivity project." - value = try(module.connectivity[0].project_id, null) + description = "The project ID of the legacy default connectivity area." + value = try(module.connectivity[0].project_id["default"], null) +} + +output "connectivity_project_ids_by_area" { + description = "Map of connectivity network area keys to their project IDs." + value = coalesce(try(module.connectivity[0].project_id, null), {}) } output "connectivity_firewall_public_ip" { - description = "The public IP of the firewall (primary node)." - value = try(module.connectivity[0].firewall_public_ip, null) + description = "The public IP of the legacy default area's primary firewall node." + value = try(module.connectivity[0].firewall_public_ip["default"], null) +} + +output "connectivity_firewall_public_ips_by_area" { + description = "Map of network area keys to primary firewall public IPs." + value = coalesce(try(module.connectivity[0].firewall_public_ip, null), {}) } output "connectivity_firewall_backup_public_ip" { - description = "The public IP of the backup firewall. Null without connectivity.firewall.ha. It does not take over the primary's address on failover; egress from the backup is translated to this one." - value = try(module.connectivity[0].firewall_backup_public_ip, null) + description = "The public IP of the legacy default area's backup firewall. Null without connectivity.firewall.ha." + value = try(module.connectivity[0].firewall_backup_public_ip["default"], null) } output "connectivity_firewall_next_hop_ip" { - description = "The next hop the corporate landing zone routes point at. The CARP LAN VIP under HA, otherwise the firewall LAN IP." - value = try(module.connectivity[0].firewall_next_hop_ip, null) + description = "The legacy default area's next hop for corporate landing-zone routes." + value = try(module.connectivity[0].firewall_next_hop_ip["default"], null) +} + +output "connectivity_firewall_next_hop_ips_by_area" { + description = "Map of network area keys to firewall next-hop IPs." + value = coalesce(try(module.connectivity[0].firewall_next_hop_ip, null), {}) } output "connectivity_vpn_gateway_id" { - description = "The ID of the hub VPN gateway." - value = try(module.connectivity[0].vpn_gateway_id, null) + description = "The ID of the legacy default area's VPN gateway." + value = try(module.connectivity[0].vpn_gateway_id["default"], null) +} + +output "connectivity_vpn_gateway_ids_by_area" { + description = "Map of network area keys to VPN gateway IDs." + value = coalesce(try(module.connectivity[0].vpn_gateway_id, null), {}) } output "connectivity_vpn_public_ips" { description = "Public IPs of the hub VPN gateway tunnels. Configure the remote peer against these." - value = try(module.connectivity[0].vpn_public_ips, {}) + value = coalesce(try(module.connectivity[0].vpn_public_ips, null), {}) } output "connectivity_vpn_internal_next_hop_ips" { description = "Network area side IPs of the hub VPN gateway tunnels." - value = try(module.connectivity[0].vpn_internal_next_hop_ips, {}) + value = coalesce(try(module.connectivity[0].vpn_internal_next_hop_ips, null), {}) } output "firewall_admin_url" { @@ -84,12 +117,12 @@ output "firewall_admin_url" { output "connectivity_vpn_connection_ids" { description = "Map of hub VPN connection keys to their connection IDs." - value = try(module.connectivity[0].vpn_connection_ids, {}) + value = coalesce(try(module.connectivity[0].vpn_connection_ids, null), {}) } output "platform_kubernetes_projects" { description = "Map of platform Kubernetes projects and cluster metadata per key." - value = { + value = merge({ for k, v in module.platform_kubernetes : k => { project_id = v.project_id project_name = v.project_name @@ -98,7 +131,25 @@ output "platform_kubernetes_projects" { observability_instance_id = v.observability_instance_id dns_extension_zones = v.dns_extension_zones } - } + }, { + for k, v in module.platform_kubernetes_eu01 : 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 + } + }, { + for k, v in module.platform_kubernetes_eu02 : 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" { @@ -108,7 +159,7 @@ output "sandbox_projects" { output "landing_zone_projects" { description = "Map of landing zone project IDs." - value = { + value = merge({ for k, v in module.landing_zone : k => { project_id = v.project_id project_name = v.project_name @@ -120,7 +171,31 @@ output "landing_zone_projects" { landing_zone_type = v.landing_zone_type connected_network_area_id = v.connected_network_area_id == null ? "" : v.connected_network_area_id } - } + }, { + for k, v in module.landing_zone_eu01 : k => { + 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 + } + }, { + for k, v in module.landing_zone_eu02 : k => { + 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" { diff --git a/src/providers.tf b/src/providers.tf index acb35a1..7370c13 100644 --- a/src/providers.tf +++ b/src/providers.tf @@ -4,6 +4,22 @@ provider "stackit" { experiments = ["iam", "routing-tables", "network"] } +# Provider aliases are static because OpenTofu cannot select them dynamically. +# Add a matching provider block and module instance when supporting another region. +provider "stackit" { + alias = "eu01" + default_region = "eu01" + enable_beta_resources = true + experiments = ["iam", "routing-tables", "network"] +} + +provider "stackit" { + alias = "eu02" + default_region = "eu02" + enable_beta_resources = true + experiments = ["iam", "routing-tables", "network"] +} + locals { platform_kubernetes_cluster_key = try(one(keys(module.platform_kubernetes)), null) diff --git a/src/tests/multi_area.tftest.hcl b/src/tests/multi_area.tftest.hcl new file mode 100644 index 0000000..86b4159 --- /dev/null +++ b/src/tests/multi_area.tftest.hcl @@ -0,0 +1,61 @@ +variables { + owner_email = "example@digits.schwarz" + company_name = "Test Corp" + company_code = "tst" + organization_id = "b76b54b6-f55d-41a1-b3c3-30252f8b97cc" + + connectivity = { + network_areas = { + prod = { + name = "hub-primary" + ranges = ["10.0.0.0/16"] + transfer_network = "10.255.0.0/24" + } + nonprod = { + name = "hub-secondary" + ranges = ["10.1.0.0/16"] + transfer_network = "10.254.0.0/24" + } + } + dns_zones = { + primary = { + dns_name = "primary.test.stackit.run" + network_area_key = "prod" + } + secondary = { + dns_name = "secondary.test.stackit.run" + network_area_key = "nonprod" + } + } + } + + landing_zones = { + production = { + project_name = "Production" + project_code = "prod" + owner_email = "example@digits.schwarz" + env = "prod" + corporate = true + network_area_key = "prod" + network_prefix_length = 24 + } + development = { + project_name = "Development" + project_code = "dev" + owner_email = "example@digits.schwarz" + env = "dev" + corporate = true + network_area_key = "nonprod" + network_prefix_length = 24 + } + } +} + +run "multi_area_plan" { + command = plan + + assert { + condition = length(output.landing_zone_projects) == 2 + error_message = "Expected production and development landing zones." + } +} \ No newline at end of file diff --git a/src/variables.tf b/src/variables.tf index 93a8690..c93420b 100644 --- a/src/variables.tf +++ b/src/variables.tf @@ -74,6 +74,7 @@ variable "platform_kubernetes" { network = optional(object({ sna_enabled = optional(bool, false) sna_network_area_id = optional(string, null) + network_area_key = optional(string, "default") firewall_next_hop_ip = optional(string, null) sna_network_prefix_length = optional(number, 24) }), {}) @@ -248,14 +249,17 @@ variable "rm_folders" { variable "connectivity" { type = object({ + naming_pattern = optional(string, null) dns_zones = optional(map(object({ - dns_name = string - name = optional(string, null) - contact_email = optional(string, null) - type = optional(string, "primary") - acl = optional(string, null) - description = optional(string, null) - default_ttl = optional(number, 3600) + dns_name = string + network_area_key = optional(string, "default") + region = optional(string, null) + name = optional(string, null) + contact_email = optional(string, null) + type = optional(string, "primary") + acl = optional(string, null) + description = optional(string, null) + default_ttl = optional(number, 3600) })), {}) network_area = optional(object({ ranges = list(string) @@ -266,6 +270,15 @@ variable "connectivity" { # Unset means the STACKIT resolvers of var.region, see modules/connectivity/1-network-area.tf. default_nameservers = optional(list(string), null) }), null) + network_areas = optional(map(object({ + name = optional(string, null) + ranges = list(string) + transfer_network = string + min_prefix_length = optional(number, 24) + max_prefix_length = optional(number, 28) + default_prefix_length = optional(number, 28) + default_nameservers = optional(list(string), null) + })), null) firewall = optional(object({ zone = string flavor = string @@ -290,6 +303,25 @@ variable "connectivity" { vhid = optional(number, 1) }), null) }), null) + firewalls = optional(map(object({ + zone = string + flavor = string + name = string + volume_performance_class = optional(string, "storage_premium_perf4") + volume_size = optional(number, 16) + lan_network_range = string + wan_network_range = string + lan_ip = optional(string, null) + wan_ip = optional(string, null) + ha = optional(object({ + backup_zone = string + backup_name = optional(string, null) + backup_lan_ip = optional(string, null) + backup_wan_ip = optional(string, null) + lan_vip = optional(string, null) + vhid = optional(number, 1) + }), null) + })), null) vpn = optional(object({ display_name = optional(string, null) plan_id = optional(string, "p100") @@ -349,8 +381,34 @@ variable "connectivity" { })), {}) }), null) }) - description = "Connectivity configuration including DNS zones, network area, firewall, and VPN. Set firewall/network_area/vpn to null to skip deployment." + description = "Connectivity configuration including DNS zones, network areas, firewalls, and VPN. Use network_areas with descriptive keys that reflect any business, security, tenant, or regional boundary; network_area and firewall remain supported for existing single-area configurations. Use firewalls for per-area appliance settings." default = null + + validation { + condition = var.connectivity == null || var.connectivity.network_area == null || var.connectivity.network_areas == null + error_message = "Set either connectivity.network_area or connectivity.network_areas, not both." + } + + validation { + condition = var.connectivity == null || var.connectivity.firewall == null || var.connectivity.firewalls == null + error_message = "Set either connectivity.firewall or connectivity.firewalls, not both." + } + + validation { + condition = var.connectivity == null || var.connectivity.firewalls == null || var.connectivity.network_areas == null || (length(setsubtract(toset(keys(var.connectivity.firewalls)), toset(keys(var.connectivity.network_areas)))) == 0 && length(setsubtract(toset(keys(var.connectivity.network_areas)), toset(keys(var.connectivity.firewalls)))) == 0) + error_message = "connectivity.firewalls keys must exactly match connectivity.network_areas keys." + } +} + +variable "connectivity_regions" { + type = map(any) + description = "Regional connectivity configuration for the statically supported eu01 and eu02 hubs. Set to null to use the single-region connectivity input." + default = null + + validation { + condition = var.connectivity_regions == null || length(setsubtract(toset(keys(var.connectivity_regions)), toset(["eu01", "eu02"]))) == 0 + error_message = "connectivity_regions supports only eu01 and eu02. Add a static provider alias and module instance before using another region." + } } variable "vpn_pre_shared_keys" { @@ -439,7 +497,7 @@ variable "firewall_config" { description = optional(string, null) })), {}) }) - description = "Policy pushed to the OPNsense appliance through its API. Requires connectivity.firewall to be deployed and firewall_api_credentials to be set. Set to null to leave the appliance untouched." + description = "Policy pushed to one OPNsense appliance through its API. Requires connectivity.firewall to be deployed and firewall_api_credentials to be set. Set to null to leave the appliance untouched." default = null validation { @@ -514,13 +572,16 @@ variable "landing_zones" { project_code = string owner_email = string # Set to true for corporate landing zones (connected to network area), false for public - corporate = optional(bool, true) - env = optional(string, "dev") + corporate = optional(bool, true) + network_area_key = optional(string, "default") + region = optional(string, null) + env = optional(string, "dev") role_assignments = optional(list(object({ role = string subject = string })), []) - network_prefix_length = optional(number, null) + network_prefix_length = optional(number, null) + secretsmanager_enabled = optional(bool, true) custom_roles = optional(list(object({ name = string description = string @@ -533,8 +594,23 @@ variable "landing_zones" { name = optional(string, null) }), {}) })) - description = "Map of landing zones to create. Set corporate = true for network area connectivity, false for public." + description = "Map of landing zones to create. Corporate landing zones use network_area_key to select a connectivity.network_areas entry; set region to eu01 or eu02 with connectivity_regions." default = {} + + validation { + condition = alltrue([ + for landing_zone in values(var.landing_zones) : !landing_zone.corporate || ( + var.connectivity_regions != null + ? landing_zone.region != null && contains(keys(var.connectivity_regions), landing_zone.region) && contains(keys(try(var.connectivity_regions[landing_zone.region].network_areas, {})), landing_zone.network_area_key) + : var.connectivity != null && ( + var.connectivity.network_areas != null + ? contains(keys(var.connectivity.network_areas), landing_zone.network_area_key) + : var.connectivity.network_area != null && landing_zone.network_area_key == "default" + ) + ) + ]) + error_message = "Every corporate landing zone must reference an existing network area; with connectivity_regions, also set its supported region." + } } variable "landing_zone_namespace_services" { From aafbfcebfd9ca38a2413ca382a05ce5643473063 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Weberru=C3=9F?= Date: Tue, 1 Sep 2026 16:33:47 +0200 Subject: [PATCH 2/3] docs: describe multi-SNA scenario trade-offs --- docs/architecture.md | 12 +++- docs/getting-started.md | 11 ++-- src/config/hub-and-spoke-2snas.tfvars | 63 ------------------- .../hub-and-spoke-finance-research.tfvars | 55 ++++++++++++++++ ... => hub-and-spoke-tenant-isolation.tfvars} | 55 ++++++++-------- src/modules/connectivity/README.md | 4 +- 6 files changed, 101 insertions(+), 99 deletions(-) delete mode 100644 src/config/hub-and-spoke-2snas.tfvars create mode 100644 src/config/hub-and-spoke-finance-research.tfvars rename src/config/{hub-and-spoke-3snas.tfvars => hub-and-spoke-tenant-isolation.tfvars} (50%) diff --git a/docs/architecture.md b/docs/architecture.md index ffa932e..159db6e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -85,13 +85,21 @@ network_area = { Each key creates its own SNA, connectivity project, WAN routing table, and DNS defaults. Corporate landing zones select their domain with `network_area_key`; DNS zones use `dns_zones..network_area_key`. Platform Kubernetes uses `platform_kubernetes..network.network_area_key`. Connectivity projects are labeled with the SNA ID and the corresponding key, and the `*_by_area` outputs use the same keys. -For example, regulated workloads can use a dedicated `regulated` SNA while shared workloads use `shared`. The same model also separates tenants, business units, or connectivity zones. See [the complete multi-area configuration](../src/config/hub-and-spoke-multi-area.tfvars). [Getting Started](getting-started.md#deployment-flavours) describes all available scenarios. +Use multiple SNAs only for a required private network boundary. Suitable reasons include regulatory separation, tenant isolation in a shared company-level STACKIT organization, and business units with independent ownership, address plans, or external connectivity requirements. See [the complete regulated/shared configuration](../src/config/hub-and-spoke-multi-area.tfvars), [the finance/research configuration](../src/config/hub-and-spoke-finance-research.tfvars), and [the tenant-isolation configuration](../src/config/hub-and-spoke-tenant-isolation.tfvars). + +#### Shared Services Across SNAs + +A STACKIT project can attach to exactly one SNA. A centrally operated private service, such as an internal Git platform, therefore cannot be directly attached to both a `prod` and a `nonprod` SNA. SNAs also have no shared private routing path. + +Choose multiple SNAs only when this constraint is intentional. Shared services must be public or SaaS services secured by IAM and network restrictions, be duplicated per SNA, or be connected through explicit external endpoints, such as site-to-site VPN over the internet. The latter adds operational and security responsibilities and must be designed and verified separately. A shared SNA with separate projects, IAM roles, and subnet allocations is usually the simpler choice when workloads need frequent private access to the same platform services. The legacy `connectivity.network_area` input remains supported for single-area deployments and maps to the `default` key. Existing scalar connectivity outputs continue to reference that legacy default area. #### Multi-Region Deployments -The root module has one STACKIT provider default region, so every SNA in one execution is created in that region. For an active topology with one SNA per region, such as `eu01` and `eu02` connected through VPN, deploy one root stack per region and exchange VPN tunnel endpoints through the outputs. A future single-stack multi-region implementation requires statically declared STACKIT provider aliases because Terraform cannot select provider aliases dynamically from `network_areas`. +The root module supports a single-stack topology in `eu01` and `eu02` through static STACKIT provider aliases. Each region receives its own connectivity hub, SNA, landing zones, and optional Platform Kubernetes cluster; select the region explicitly in the configuration. Provider aliases cannot be selected dynamically, so supporting another region requires a corresponding provider alias and regional module instances. + +Regional hubs remain isolated by default. Inter-region connectivity requires explicit external VPN endpoints and routes; it is not created by the multi-region scenario. See [the complete multi-region configuration](../src/config/hub-and-spoke-multi-region.tfvars). #### WAN Routing Table diff --git a/docs/getting-started.md b/docs/getting-started.md index 5d754ff..5434881 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -18,21 +18,22 @@ This guide walks you through deploying the STACKIT Landing Zone from scratch. ## Deployment Flavours -Seven complete, ready-to-use configurations are provided in `src/config/`: +Eight complete, 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. | -| **Multi-SNA Domains** | `hub-and-spoke-multi-area.tfvars` | Two SNAs for arbitrary security, tenant, business, or connectivity boundaries. | -| **Two-SNA Environments** | `hub-and-spoke-2snas.tfvars` | A dedicated production SNA and a shared non-production SNA. | +| **Regulated and Shared** | `hub-and-spoke-multi-area.tfvars` | Separates regulated workloads from shared workloads with different security requirements. | +| **Finance and Research** | `hub-and-spoke-finance-research.tfvars` | Isolates business units with distinct owners, address plans, and connectivity requirements. | | **Prod/Nonprod + Firewalls** | `hub-and-spoke-prod-nonprod-firewall.tfvars` | Dedicated SNAs and OPNsense appliances for production and non-production. | -| **Three-SNA Environments** | `hub-and-spoke-3snas.tfvars` | Dedicated SNAs for production, development, and test. | +| **Tenant Isolation** | `hub-and-spoke-tenant-isolation.tfvars` | Separates three tenants that share one company-level STACKIT organization. | +| **Multi-Region** | `hub-and-spoke-multi-region.tfvars` | Deploys independent connectivity hubs, landing zones, and Platform Kubernetes clusters in `eu01` and `eu02`. | 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`. -The multi-SNA scenario files are complete configurations. Replace the placeholder values for `owner_email`, `company_name`, `company_code`, and `organization_id` before applying them. The area keys are examples only; choose stable keys that match your boundary, such as `tenant_a`, `finance`, `private_connectivity`, `eu01`, or `eu02`. See [Multiple Network Areas](architecture.md#multiple-network-areas) for the architecture. +The multi-SNA scenario files are complete configurations. Replace the placeholder values for `owner_email`, `company_name`, `company_code`, and `organization_id` before applying them. The area keys are examples only; choose stable keys that match your boundary. See [Multiple Network Areas](architecture.md#multiple-network-areas) for the decision guidance and constraints. The Prod/Nonprod + Firewalls scenario creates one appliance per SNA. Its OPNsense policies must currently be configured for each appliance after bootstrap; the single `firewall_config` block is not distributed automatically. diff --git a/src/config/hub-and-spoke-2snas.tfvars b/src/config/hub-and-spoke-2snas.tfvars deleted file mode 100644 index dbb0689..0000000 --- a/src/config/hub-and-spoke-2snas.tfvars +++ /dev/null @@ -1,63 +0,0 @@ -######################### -## TWO-SNA ENVIRONMENTS ## -######################### - -owner_email = "platform@example.com" -company_name = "Example Corp" -company_code = "exc" -organization_id = "00000000-0000-0000-0000-000000000000" -region = "eu01" - -# Production is isolated. Development and test use the shared non-production SNA. -connectivity = { - naming_pattern = "exc-connectivity" - - network_areas = { - prod = { - name = "prod-sna" - ranges = ["10.0.0.0/16"] - transfer_network = "10.1.0.0/24" - max_prefix_length = 28 - min_prefix_length = 24 - default_prefix_length = 26 - } - nonprod = { - name = "nonprod-sna" - ranges = ["10.2.0.0/16"] - transfer_network = "10.3.0.0/24" - max_prefix_length = 28 - min_prefix_length = 24 - default_prefix_length = 26 - } - } -} - -landing_zones = { - production = { - project_name = "Production Workload" - project_code = "prod" - owner_email = "platform@example.com" - env = "prod" - corporate = true - network_area_key = "prod" - network_prefix_length = 24 - } - development = { - project_name = "Development Workload" - project_code = "dev" - owner_email = "platform@example.com" - env = "dev" - corporate = true - network_area_key = "nonprod" - network_prefix_length = 24 - } - test = { - project_name = "Test Workload" - project_code = "test" - owner_email = "platform@example.com" - env = "test" - corporate = true - network_area_key = "nonprod" - network_prefix_length = 24 - } -} \ No newline at end of file diff --git a/src/config/hub-and-spoke-finance-research.tfvars b/src/config/hub-and-spoke-finance-research.tfvars new file mode 100644 index 0000000..1b7ed59 --- /dev/null +++ b/src/config/hub-and-spoke-finance-research.tfvars @@ -0,0 +1,55 @@ +############################################ +## FINANCE AND RESEARCH BUSINESS UNITS ## +############################################ + +owner_email = "platform@example.com" +company_name = "Example Corp" +company_code = "exc" +organization_id = "00000000-0000-0000-0000-000000000000" +region = "eu01" + +# Finance and research have independent owners, address plans, and connectivity +# requirements, but share the same STACKIT organization. +connectivity = { + naming_pattern = "exc-connectivity" + + network_areas = { + finance = { + name = "finance-sna" + ranges = ["10.0.0.0/16"] + transfer_network = "10.1.0.0/24" + max_prefix_length = 28 + min_prefix_length = 24 + default_prefix_length = 26 + } + research = { + name = "research-sna" + ranges = ["10.2.0.0/16"] + transfer_network = "10.3.0.0/24" + max_prefix_length = 28 + min_prefix_length = 24 + default_prefix_length = 26 + } + } +} + +landing_zones = { + finance = { + project_name = "Finance Workload" + project_code = "finance" + owner_email = "finance-platform@example.com" + env = "prod" + corporate = true + network_area_key = "finance" + network_prefix_length = 24 + } + research = { + project_name = "Research Workload" + project_code = "research" + owner_email = "research-platform@example.com" + env = "prod" + corporate = true + network_area_key = "research" + network_prefix_length = 24 + } +} \ No newline at end of file diff --git a/src/config/hub-and-spoke-3snas.tfvars b/src/config/hub-and-spoke-tenant-isolation.tfvars similarity index 50% rename from src/config/hub-and-spoke-3snas.tfvars rename to src/config/hub-and-spoke-tenant-isolation.tfvars index 1492052..11b852d 100644 --- a/src/config/hub-and-spoke-3snas.tfvars +++ b/src/config/hub-and-spoke-tenant-isolation.tfvars @@ -1,6 +1,6 @@ -########################### -## THREE-SNA ENVIRONMENTS ## -########################### +################################# +## THREE-TENANT ISOLATION ## +################################# owner_email = "platform@example.com" company_name = "Example Corp" @@ -8,29 +8,30 @@ company_code = "exc" organization_id = "00000000-0000-0000-0000-000000000000" region = "eu01" -# Every environment receives its own isolated SNA. +# STACKIT organizations are shared at company level. These three tenants need +# independent private address spaces and must not share private connectivity. connectivity = { naming_pattern = "exc-connectivity" network_areas = { - prod = { - name = "prod-sna" + tenant_a = { + name = "tenant-a-sna" ranges = ["10.0.0.0/16"] transfer_network = "10.1.0.0/24" max_prefix_length = 28 min_prefix_length = 24 default_prefix_length = 26 } - dev = { - name = "dev-sna" + tenant_b = { + name = "tenant-b-sna" ranges = ["10.2.0.0/16"] transfer_network = "10.3.0.0/24" max_prefix_length = 28 min_prefix_length = 24 default_prefix_length = 26 } - test = { - name = "test-sna" + tenant_c = { + name = "tenant-c-sna" ranges = ["10.4.0.0/16"] transfer_network = "10.5.0.0/24" max_prefix_length = 28 @@ -41,31 +42,31 @@ connectivity = { } landing_zones = { - production = { - project_name = "Production Workload" - project_code = "prod" - owner_email = "platform@example.com" + tenant_a = { + project_name = "Tenant A Workload" + project_code = "tenanta" + owner_email = "tenant-a@example.com" env = "prod" corporate = true - network_area_key = "prod" + network_area_key = "tenant_a" network_prefix_length = 24 } - development = { - project_name = "Development Workload" - project_code = "dev" - owner_email = "platform@example.com" - env = "dev" + tenant_b = { + project_name = "Tenant B Workload" + project_code = "tenantb" + owner_email = "tenant-b@example.com" + env = "prod" corporate = true - network_area_key = "dev" + network_area_key = "tenant_b" network_prefix_length = 24 } - test = { - project_name = "Test Workload" - project_code = "test" - owner_email = "platform@example.com" - env = "test" + tenant_c = { + project_name = "Tenant C Workload" + project_code = "tenantc" + owner_email = "tenant-c@example.com" + env = "prod" corporate = true - network_area_key = "test" + network_area_key = "tenant_c" network_prefix_length = 24 } } \ No newline at end of file diff --git a/src/modules/connectivity/README.md b/src/modules/connectivity/README.md index ac0bee3..f1eb80e 100644 --- a/src/modules/connectivity/README.md +++ b/src/modules/connectivity/README.md @@ -82,6 +82,6 @@ No modules. ## Multiple Network Areas -Pass `network_areas` as a map with meaningful keys, such as `prod` and `nonprod`. A separate connectivity project, network area, routing table, and optional VPN gateway is created for every key. Reference a key from `dns_zones..network_area_key`; consumers of this module receive maps keyed the same way. +Pass `network_areas` as a map with meaningful keys, such as `finance`, `research`, or `tenant_a`. A separate connectivity project, network area, routing table, and optional VPN gateway is created for every key. Reference a key from `dns_zones..network_area_key`; consumers of this module receive maps keyed the same way. -The firewall input is intentionally single-area: its LAN and WAN CIDRs are global inputs. Do not combine it with more than one network area. The root module's `connectivity.network_area` input remains available for a legacy single-area setup and maps to the `default` key. \ No newline at end of file +Use `firewalls` to configure one appliance with independent LAN/WAN CIDRs per network area. The legacy `firewall` input remains available for a single-area setup and maps to every configured area. The root module's `connectivity.network_area` input remains available for a legacy single-area setup and maps to the `default` key. \ No newline at end of file From 6c09287ff09a683b40916b4670aac1cbb6089d56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Weberru=C3=9F?= Date: Tue, 1 Sep 2026 16:57:39 +0200 Subject: [PATCH 3/3] chore: update code owners --- CODEOWNERS | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 79f8678..960edc0 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1,2 +1 @@ -* @lweberru @mahauber @simpe00 -docs/* @lweberru \ No newline at end of file +* @lweberru @mahauber @simpe00 @dweezl \ No newline at end of file