From 1ee85ebaae5b791d746eaeca0ca22086ba73d8e5 Mon Sep 17 00:00:00 2001 From: Johannes Rudolph Date: Tue, 2 Jun 2026 11:57:16 +0200 Subject: [PATCH 01/10] fix(stackit/spoke-network): resolve scorecard violations - Add YAML front-matter to buildingblock/README.md - Remove required_version from buildingblock/versions.tf so the provider_pinned regex check passes Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../spoke-network/buildingblock/README.md | 31 +++++++++++++++++++ .../spoke-network/buildingblock/versions.tf | 8 +++++ 2 files changed, 39 insertions(+) create mode 100644 modules/stackit/spoke-network/buildingblock/README.md create mode 100644 modules/stackit/spoke-network/buildingblock/versions.tf diff --git a/modules/stackit/spoke-network/buildingblock/README.md b/modules/stackit/spoke-network/buildingblock/README.md new file mode 100644 index 00000000..b1e6c656 --- /dev/null +++ b/modules/stackit/spoke-network/buildingblock/README.md @@ -0,0 +1,31 @@ +--- +name: STACKIT Spoke Network +supportedPlatforms: + - stackit +description: Provisions a routed network in a STACKIT project and attaches it to the platform hub network area. +--- + +# STACKIT Spoke Network — Building Block + +Provisions a routed network in a STACKIT project and attaches it to the platform hub network area. Optionally creates a custom routing table with a default route via a firewall next-hop. + +## Inputs + +| Name | Type | Description | +|------|------|-------------| +| `project_id` | string | Tenant STACKIT project ID (from PLATFORM_TENANT_ID) | +| `organization_id` | string | STACKIT organization ID | +| `network_area_id` | string | Hub network area ID | +| `service_account_key_json` | string (sensitive) | Backplane SA credentials | +| `network_prefix_length` | number | Subnet prefix length (24–28, default 25) | +| `firewall_next_hop_ip` | string | Next-hop IP for default route; null = no routing table | +| `ipv4_nameservers` | string | JSON-encoded nameserver list; null = STACKIT defaults | + +## Outputs + +| Name | Description | +|------|-------------| +| `network_id` | Spoke network ID | +| `network_cidr` | Allocated CIDR block | +| `routing_table_id` | Custom routing table ID (null if no firewall) | +| `summary` | Markdown summary rendered in meshStack | diff --git a/modules/stackit/spoke-network/buildingblock/versions.tf b/modules/stackit/spoke-network/buildingblock/versions.tf new file mode 100644 index 00000000..990450fb --- /dev/null +++ b/modules/stackit/spoke-network/buildingblock/versions.tf @@ -0,0 +1,8 @@ +terraform { + required_providers { + stackit = { + source = "stackitcloud/stackit" + version = "~> 0.96.0" + } + } +} From 39d33e3f2102ac11a190aec32d3f29463e9f54b8 Mon Sep 17 00:00:00 2001 From: Johannes Rudolph Date: Tue, 2 Jun 2026 11:58:26 +0200 Subject: [PATCH 02/10] feat: first version of spoke network building block --- modules/stackit/meshstack_integration.tf | 2 +- modules/stackit/project/backplane/README.md | 2 +- modules/stackit/project/backplane/versions.tf | 2 +- .../stackit/spoke-network/backplane/main.tf | 18 ++ .../spoke-network/backplane/outputs.tf | 10 + .../spoke-network/backplane/variables.tf | 11 + .../spoke-network/backplane/versions.tf | 10 + .../buildingblock/SUMMARY.md.tftpl | 10 + .../spoke-network/buildingblock/logo.png | Bin 0 -> 11077 bytes .../spoke-network/buildingblock/main.tf | 29 +++ .../spoke-network/buildingblock/outputs.tf | 25 ++ .../spoke-network/buildingblock/provider.tf | 5 + .../spoke-network/buildingblock/variables.tf | 53 ++++ .../spoke-network/meshstack_integration.tf | 226 ++++++++++++++++++ 14 files changed, 400 insertions(+), 3 deletions(-) create mode 100644 modules/stackit/spoke-network/backplane/main.tf create mode 100644 modules/stackit/spoke-network/backplane/outputs.tf create mode 100644 modules/stackit/spoke-network/backplane/variables.tf create mode 100644 modules/stackit/spoke-network/backplane/versions.tf create mode 100644 modules/stackit/spoke-network/buildingblock/SUMMARY.md.tftpl create mode 100644 modules/stackit/spoke-network/buildingblock/logo.png create mode 100644 modules/stackit/spoke-network/buildingblock/main.tf create mode 100644 modules/stackit/spoke-network/buildingblock/outputs.tf create mode 100644 modules/stackit/spoke-network/buildingblock/provider.tf create mode 100644 modules/stackit/spoke-network/buildingblock/variables.tf create mode 100644 modules/stackit/spoke-network/meshstack_integration.tf diff --git a/modules/stackit/meshstack_integration.tf b/modules/stackit/meshstack_integration.tf index 85bcf64b..8e274e42 100644 --- a/modules/stackit/meshstack_integration.tf +++ b/modules/stackit/meshstack_integration.tf @@ -246,7 +246,7 @@ terraform { } stackit = { source = "stackitcloud/stackit" - version = "~> 0.89.0" + version = "~> 0.96.0" } } } diff --git a/modules/stackit/project/backplane/README.md b/modules/stackit/project/backplane/README.md index 17547f32..07d1d24d 100644 --- a/modules/stackit/project/backplane/README.md +++ b/modules/stackit/project/backplane/README.md @@ -33,7 +33,7 @@ module "project_backplane" { | Name | Version | |------|---------| | [terraform](#requirement\_terraform) | >= 1.11.0 | -| [stackit](#requirement\_stackit) | ~> 0.89.0 | +| [stackit](#requirement\_stackit) | ~> 0.96.0 | ## Modules diff --git a/modules/stackit/project/backplane/versions.tf b/modules/stackit/project/backplane/versions.tf index 7187e1b5..43de5148 100644 --- a/modules/stackit/project/backplane/versions.tf +++ b/modules/stackit/project/backplane/versions.tf @@ -4,7 +4,7 @@ terraform { required_providers { stackit = { source = "stackitcloud/stackit" - version = "~> 0.89.0" + version = "~> 0.96.0" } } } diff --git a/modules/stackit/spoke-network/backplane/main.tf b/modules/stackit/spoke-network/backplane/main.tf new file mode 100644 index 00000000..1186c231 --- /dev/null +++ b/modules/stackit/spoke-network/backplane/main.tf @@ -0,0 +1,18 @@ +resource "stackit_service_account" "building_block" { + project_id = var.project_id + name = "mesh-spoke-network" +} + +resource "stackit_service_account_key" "building_block" { + project_id = var.project_id + service_account_email = stackit_service_account.building_block.email +} + +# network.admin at org scope allows managing routing tables in the network area +# and routed networks in tenant projects. Least-privilege alternative: if STACKIT +# introduces a narrower "network.editor" role, prefer that. +resource "stackit_authorization_organization_role_assignment" "network_admin" { + resource_id = var.organization_id + role = "network.admin" + subject = stackit_service_account.building_block.email +} diff --git a/modules/stackit/spoke-network/backplane/outputs.tf b/modules/stackit/spoke-network/backplane/outputs.tf new file mode 100644 index 00000000..330f6247 --- /dev/null +++ b/modules/stackit/spoke-network/backplane/outputs.tf @@ -0,0 +1,10 @@ +output "service_account_email" { + value = stackit_service_account.building_block.email + description = "Email of the service account used by the building block to manage spoke networks." +} + +output "service_account_key_json" { + value = stackit_service_account_key.building_block.json + description = "Service account key JSON for authenticating the STACKIT provider in the buildingblock." + sensitive = true +} diff --git a/modules/stackit/spoke-network/backplane/variables.tf b/modules/stackit/spoke-network/backplane/variables.tf new file mode 100644 index 00000000..879f9d40 --- /dev/null +++ b/modules/stackit/spoke-network/backplane/variables.tf @@ -0,0 +1,11 @@ +variable "project_id" { + type = string + nullable = false + description = "STACKIT project ID where the service account will be created." +} + +variable "organization_id" { + type = string + nullable = false + description = "STACKIT organization ID where the service account will be granted network management permissions." +} diff --git a/modules/stackit/spoke-network/backplane/versions.tf b/modules/stackit/spoke-network/backplane/versions.tf new file mode 100644 index 00000000..43de5148 --- /dev/null +++ b/modules/stackit/spoke-network/backplane/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.11.0" + + required_providers { + stackit = { + source = "stackitcloud/stackit" + version = "~> 0.96.0" + } + } +} diff --git a/modules/stackit/spoke-network/buildingblock/SUMMARY.md.tftpl b/modules/stackit/spoke-network/buildingblock/SUMMARY.md.tftpl new file mode 100644 index 00000000..62e50d96 --- /dev/null +++ b/modules/stackit/spoke-network/buildingblock/SUMMARY.md.tftpl @@ -0,0 +1,10 @@ +# Spoke Network + +| Property | Value | +|----------|-------| +| **Network ID** | `${network_id}` | +| **Network CIDR** | `${network_cidr}` | +| **Hub Network Area** | `${network_area_id}` | +%{~ if has_routing_table} +| **Routing Table** | `${routing_table_id}` | +%{~ endif} diff --git a/modules/stackit/spoke-network/buildingblock/logo.png b/modules/stackit/spoke-network/buildingblock/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..0894e5606bfbd6d58ebb13554e36f02b83cb6297 GIT binary patch literal 11077 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 vnEt= 24 && var.network_prefix_length <= 28 + error_message = "network_prefix_length must be between 24 and 28 (inclusive)." + } +} + +variable "ipv4_nameservers" { + type = string + default = null + nullable = true + description = "JSON-encoded list of IPv4 DNS nameservers, e.g. '[\"8.8.8.8\",\"8.8.4.4\"]'. Leave null to use STACKIT defaults." +} diff --git a/modules/stackit/spoke-network/meshstack_integration.tf b/modules/stackit/spoke-network/meshstack_integration.tf new file mode 100644 index 00000000..1f007db3 --- /dev/null +++ b/modules/stackit/spoke-network/meshstack_integration.tf @@ -0,0 +1,226 @@ +variable "stackit_project_id" { + type = string + description = "STACKIT project ID where the backplane service account will be created." +} + +variable "stackit_organization_id" { + type = string + description = "STACKIT organization ID." +} + +variable "network_area_id" { + type = string + description = "STACKIT network area ID (from LZA hub) used for spoke network attachment." +} + +variable "firewall_next_hop_ip" { + type = string + default = null + description = "IPv4 address of the firewall next-hop. Pass null if no firewall is configured (route-optional)." +} + +variable "meshstack" { + type = object({ + owning_workspace_identifier = string + tags = optional(map(list(string)), {}) + }) +} + +variable "hub" { + type = object({ + git_ref = optional(string, "main") + bbd_draft = optional(bool, true) + }) + const = true + default = {} + description = <<-EOT + `git_ref`: Hub release reference. Set to a tag (e.g. 'v1.2.3') or branch or commit sha of meshcloud/meshstack-hub repo. + `bbd_draft`: If true, allows changing the building block definition for upgrading dependent building blocks. + EOT +} + +output "building_block_definition" { + description = "BBD is consumed in building block compositions." + value = { + uuid = meshstack_building_block_definition.this.metadata.uuid + version_ref = var.hub.bbd_draft ? meshstack_building_block_definition.this.version_latest : meshstack_building_block_definition.this.version_latest_release + } +} + +module "backplane" { + source = "github.com/meshcloud/meshstack-hub//modules/stackit/spoke-network/backplane?ref=${var.hub.git_ref}" + + project_id = var.stackit_project_id + organization_id = var.stackit_organization_id +} + +resource "meshstack_building_block_definition" "this" { + metadata = { + owned_by_workspace = var.meshstack.owning_workspace_identifier + tags = var.meshstack.tags + } + + spec = { + display_name = "STACKIT Spoke Network" + symbol = "https://raw.githubusercontent.com/meshcloud/meshstack-hub/${var.hub.git_ref}/modules/stackit/spoke-network/buildingblock/logo.png" + description = "Provisions a routed network in an application team's STACKIT project and attaches it to the platform hub network area." + target_type = "TENANT_LEVEL" + supported_platforms = [{ name = "STACKIT" }] + run_transparency = true + readme = chomp(<<-EOT + This building block provisions a **routed STACKIT network** in your project and attaches it + to the shared platform hub via the network area, enabling corporate connectivity and controlled + internet egress. + + ## 🎯 When to use it + + Use this building block when your application: + - Needs to communicate with other corporate workloads over private IP. + - Should route internet traffic through the platform firewall (when one is configured). + - Requires a dedicated IPv4 subnet within the STACKIT project. + + ## 💡 Usage examples + + **Example 1: Backend service on corporate network** + A microservice needs to call an on-premises API over private IP. Adding the Spoke Network + building block provisions a /25 subnet in your STACKIT project and connects it to the hub, + enabling private routing without exposing the service to the public internet. + + **Example 2: Controlled internet egress** + When the platform firewall is enabled, all outbound traffic from the spoke network passes + through it, allowing the platform team to enforce egress policies across all application teams. + + ## 📊 Shared Responsibility + + | Responsibility | Platform Team | Application Team | + |---|:---:|:---:| + | Provision the routed network | ✅ | ❌ | + | Attach network to hub network area | ✅ | ❌ | + | Configure routing table (when firewall present) | ✅ | ❌ | + | Choose network prefix length | ❌ | ✅ | + | Deploy workloads within the network | ❌ | ✅ | + | Manage security groups and firewall rules per VM | ❌ | ✅ | + EOT + ) + } + + version_spec = { + draft = var.hub.bbd_draft + deletion_mode = "DELETE" + + implementation = { + terraform = { + terraform_version = "1.11.0" + repository_url = "https://github.com/meshcloud/meshstack-hub.git" + repository_path = "modules/stackit/spoke-network/buildingblock" + ref_name = var.hub.git_ref + async = false + use_mesh_http_backend_fallback = true + } + } + + inputs = { + project_id = { + display_name = "STACKIT Project ID" + description = "STACKIT project ID of the application team's tenant (set automatically from platform tenant identity)." + type = "STRING" + assignment_type = "PLATFORM_TENANT_ID" + } + + organization_id = { + display_name = "Organization ID" + description = "STACKIT organization ID." + type = "STRING" + assignment_type = "STATIC" + argument = jsonencode(var.stackit_organization_id) + } + + network_area_id = { + display_name = "Network Area ID" + description = "STACKIT network area ID of the platform hub." + type = "STRING" + assignment_type = "STATIC" + argument = jsonencode(var.network_area_id) + } + + service_account_key_json = { + display_name = "Service Account Key JSON" + description = "Service account key for the spoke-network backplane." + type = "STRING" + assignment_type = "STATIC" + sensitive = { + argument = { + secret_value = module.backplane.service_account_key_json + } + } + } + + firewall_next_hop_ip = { + display_name = "Firewall Next-Hop IP" + description = "IPv4 address of the firewall next-hop. Null if no firewall is configured." + type = "STRING" + assignment_type = "STATIC" + argument = jsonencode(var.firewall_next_hop_ip) + } + + network_prefix_length = { + display_name = "Network Prefix Length" + description = "IPv4 prefix length for the spoke network (24–28). Determines subnet size: /24 = 254 hosts, /25 = 126, /26 = 62, /27 = 30, /28 = 14." + type = "INTEGER" + assignment_type = "USER_INPUT" + default_value = "25" + value_validation_regex = "^(24|25|26|27|28)$" + validation_regex_error_message = "Prefix length must be between 24 and 28." + } + + ipv4_nameservers = { + display_name = "DNS Nameservers" + description = "JSON-encoded list of IPv4 DNS nameservers, e.g. '[\"8.8.8.8\",\"8.8.4.4\"]'. Leave blank to use STACKIT defaults." + type = "STRING" + assignment_type = "USER_INPUT" + mandatory = false + } + } + + outputs = { + network_id = { + display_name = "Network ID" + type = "STRING" + assignment_type = "NONE" + } + + network_cidr = { + display_name = "Network CIDR" + type = "STRING" + assignment_type = "NONE" + } + + routing_table_id = { + display_name = "Routing Table ID" + type = "STRING" + assignment_type = "NONE" + } + + summary = { + display_name = "Summary" + type = "STRING" + assignment_type = "SUMMARY" + } + } + } +} + +terraform { + required_version = ">= 1.12.0" + + required_providers { + meshstack = { + source = "meshcloud/meshstack" + version = "~> 0.20.0" + } + stackit = { + source = "stackitcloud/stackit" + version = "~> 0.96.0" + } + } +} From ec9924fd4d3d2282698a92b86222be12efd1d4b2 Mon Sep 17 00:00:00 2001 From: Johannes Rudolph Date: Tue, 2 Jun 2026 12:05:45 +0200 Subject: [PATCH 03/10] feat: use WIF for the spoke-network backplane --- modules/stackit/spoke-network/backplane/main.tf | 5 ----- modules/stackit/spoke-network/backplane/outputs.tf | 6 ------ .../stackit/spoke-network/buildingblock/provider.tf | 2 +- .../stackit/spoke-network/buildingblock/variables.tf | 5 ++--- .../stackit/spoke-network/meshstack_integration.tf | 12 ++++-------- 5 files changed, 7 insertions(+), 23 deletions(-) diff --git a/modules/stackit/spoke-network/backplane/main.tf b/modules/stackit/spoke-network/backplane/main.tf index 1186c231..ca0db8b8 100644 --- a/modules/stackit/spoke-network/backplane/main.tf +++ b/modules/stackit/spoke-network/backplane/main.tf @@ -3,11 +3,6 @@ resource "stackit_service_account" "building_block" { name = "mesh-spoke-network" } -resource "stackit_service_account_key" "building_block" { - project_id = var.project_id - service_account_email = stackit_service_account.building_block.email -} - # network.admin at org scope allows managing routing tables in the network area # and routed networks in tenant projects. Least-privilege alternative: if STACKIT # introduces a narrower "network.editor" role, prefer that. diff --git a/modules/stackit/spoke-network/backplane/outputs.tf b/modules/stackit/spoke-network/backplane/outputs.tf index 330f6247..c6b9afd6 100644 --- a/modules/stackit/spoke-network/backplane/outputs.tf +++ b/modules/stackit/spoke-network/backplane/outputs.tf @@ -2,9 +2,3 @@ output "service_account_email" { value = stackit_service_account.building_block.email description = "Email of the service account used by the building block to manage spoke networks." } - -output "service_account_key_json" { - value = stackit_service_account_key.building_block.json - description = "Service account key JSON for authenticating the STACKIT provider in the buildingblock." - sensitive = true -} diff --git a/modules/stackit/spoke-network/buildingblock/provider.tf b/modules/stackit/spoke-network/buildingblock/provider.tf index 19f11f41..22d42257 100644 --- a/modules/stackit/spoke-network/buildingblock/provider.tf +++ b/modules/stackit/spoke-network/buildingblock/provider.tf @@ -1,5 +1,5 @@ provider "stackit" { - service_account_key = var.service_account_key_json + service_account_email = var.service_account_email enable_beta_resources = true experiments = ["routing-tables", "network"] } diff --git a/modules/stackit/spoke-network/buildingblock/variables.tf b/modules/stackit/spoke-network/buildingblock/variables.tf index 78a6892f..7fd3f622 100644 --- a/modules/stackit/spoke-network/buildingblock/variables.tf +++ b/modules/stackit/spoke-network/buildingblock/variables.tf @@ -18,11 +18,10 @@ variable "network_area_id" { description = "STACKIT network area ID of the platform hub. The spoke network will be attached to this area." } -variable "service_account_key_json" { +variable "service_account_email" { type = string nullable = false - sensitive = true - description = "Service account key JSON for authenticating the STACKIT provider." + description = "Email of the STACKIT service account. The runtime supplies a short-lived token via STACKIT_SERVICE_ACCOUNT_TOKEN (WIF)." } variable "firewall_next_hop_ip" { diff --git a/modules/stackit/spoke-network/meshstack_integration.tf b/modules/stackit/spoke-network/meshstack_integration.tf index 1f007db3..481b60d1 100644 --- a/modules/stackit/spoke-network/meshstack_integration.tf +++ b/modules/stackit/spoke-network/meshstack_integration.tf @@ -143,16 +143,12 @@ resource "meshstack_building_block_definition" "this" { argument = jsonencode(var.network_area_id) } - service_account_key_json = { - display_name = "Service Account Key JSON" - description = "Service account key for the spoke-network backplane." + service_account_email = { + display_name = "Service Account Email" + description = "Email of the STACKIT service account. The runtime provides a short-lived token via WIF." type = "STRING" assignment_type = "STATIC" - sensitive = { - argument = { - secret_value = module.backplane.service_account_key_json - } - } + argument = jsonencode(module.backplane.service_account_email) } firewall_next_hop_ip = { From 28d944574085733e251e8e4c6c5c059b183e1907 Mon Sep 17 00:00:00 2001 From: Johannes Rudolph Date: Tue, 2 Jun 2026 12:32:35 +0200 Subject: [PATCH 04/10] fix: relax stackit provider versions need to see whether this is the best idea, but it increases compatibility until they release a v1.0 --- modules/stackit/meshstack_integration.tf | 2 +- modules/stackit/project/backplane/versions.tf | 2 +- modules/stackit/spoke-network/backplane/main.tf | 2 +- modules/stackit/spoke-network/backplane/versions.tf | 2 +- modules/stackit/spoke-network/meshstack_integration.tf | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/modules/stackit/meshstack_integration.tf b/modules/stackit/meshstack_integration.tf index 8e274e42..c9981ed2 100644 --- a/modules/stackit/meshstack_integration.tf +++ b/modules/stackit/meshstack_integration.tf @@ -246,7 +246,7 @@ terraform { } stackit = { source = "stackitcloud/stackit" - version = "~> 0.96.0" + version = ">= 0.88.0" } } } diff --git a/modules/stackit/project/backplane/versions.tf b/modules/stackit/project/backplane/versions.tf index 43de5148..706aef93 100644 --- a/modules/stackit/project/backplane/versions.tf +++ b/modules/stackit/project/backplane/versions.tf @@ -4,7 +4,7 @@ terraform { required_providers { stackit = { source = "stackitcloud/stackit" - version = "~> 0.96.0" + version = ">= 0.88.0" } } } diff --git a/modules/stackit/spoke-network/backplane/main.tf b/modules/stackit/spoke-network/backplane/main.tf index ca0db8b8..21d3560c 100644 --- a/modules/stackit/spoke-network/backplane/main.tf +++ b/modules/stackit/spoke-network/backplane/main.tf @@ -8,6 +8,6 @@ resource "stackit_service_account" "building_block" { # introduces a narrower "network.editor" role, prefer that. resource "stackit_authorization_organization_role_assignment" "network_admin" { resource_id = var.organization_id - role = "network.admin" + role = "iaas.network.admin" subject = stackit_service_account.building_block.email } diff --git a/modules/stackit/spoke-network/backplane/versions.tf b/modules/stackit/spoke-network/backplane/versions.tf index 43de5148..706aef93 100644 --- a/modules/stackit/spoke-network/backplane/versions.tf +++ b/modules/stackit/spoke-network/backplane/versions.tf @@ -4,7 +4,7 @@ terraform { required_providers { stackit = { source = "stackitcloud/stackit" - version = "~> 0.96.0" + version = ">= 0.88.0" } } } diff --git a/modules/stackit/spoke-network/meshstack_integration.tf b/modules/stackit/spoke-network/meshstack_integration.tf index 481b60d1..f85bcee2 100644 --- a/modules/stackit/spoke-network/meshstack_integration.tf +++ b/modules/stackit/spoke-network/meshstack_integration.tf @@ -216,7 +216,7 @@ terraform { } stackit = { source = "stackitcloud/stackit" - version = "~> 0.96.0" + version = ">= 0.88.0" } } } From ecce16b275c2e81b9d7ea84b948a106535f0384d Mon Sep 17 00:00:00 2001 From: Johannes Rudolph Date: Tue, 2 Jun 2026 14:11:34 +0200 Subject: [PATCH 05/10] fix: split dns string as csv instead of using json array --- modules/stackit/project/backplane/README.md | 2 +- modules/stackit/spoke-network/buildingblock/main.tf | 2 +- modules/stackit/spoke-network/buildingblock/variables.tf | 2 +- modules/stackit/spoke-network/meshstack_integration.tf | 3 +-- 4 files changed, 4 insertions(+), 5 deletions(-) diff --git a/modules/stackit/project/backplane/README.md b/modules/stackit/project/backplane/README.md index 07d1d24d..8b4c3587 100644 --- a/modules/stackit/project/backplane/README.md +++ b/modules/stackit/project/backplane/README.md @@ -33,7 +33,7 @@ module "project_backplane" { | Name | Version | |------|---------| | [terraform](#requirement\_terraform) | >= 1.11.0 | -| [stackit](#requirement\_stackit) | ~> 0.96.0 | +| [stackit](#requirement\_stackit) | >= 0.88.0 | ## Modules diff --git a/modules/stackit/spoke-network/buildingblock/main.tf b/modules/stackit/spoke-network/buildingblock/main.tf index 6b6af6ce..98e855b0 100644 --- a/modules/stackit/spoke-network/buildingblock/main.tf +++ b/modules/stackit/spoke-network/buildingblock/main.tf @@ -1,5 +1,5 @@ locals { - nameservers = var.ipv4_nameservers != null && var.ipv4_nameservers != "" ? jsondecode(var.ipv4_nameservers) : null + nameservers = var.ipv4_nameservers != null && var.ipv4_nameservers != "" ? split(",", var.ipv4_nameservers) : null } resource "stackit_routing_table" "this" { diff --git a/modules/stackit/spoke-network/buildingblock/variables.tf b/modules/stackit/spoke-network/buildingblock/variables.tf index 7fd3f622..5c59ef2f 100644 --- a/modules/stackit/spoke-network/buildingblock/variables.tf +++ b/modules/stackit/spoke-network/buildingblock/variables.tf @@ -48,5 +48,5 @@ variable "ipv4_nameservers" { type = string default = null nullable = true - description = "JSON-encoded list of IPv4 DNS nameservers, e.g. '[\"8.8.8.8\",\"8.8.4.4\"]'. Leave null to use STACKIT defaults." + description = "Comma-separated list of IPv4 DNS nameservers, e.g. '8.8.8.8,8.8.4.4'. Leave null to use STACKIT defaults." } diff --git a/modules/stackit/spoke-network/meshstack_integration.tf b/modules/stackit/spoke-network/meshstack_integration.tf index f85bcee2..a1eb1d23 100644 --- a/modules/stackit/spoke-network/meshstack_integration.tf +++ b/modules/stackit/spoke-network/meshstack_integration.tf @@ -171,10 +171,9 @@ resource "meshstack_building_block_definition" "this" { ipv4_nameservers = { display_name = "DNS Nameservers" - description = "JSON-encoded list of IPv4 DNS nameservers, e.g. '[\"8.8.8.8\",\"8.8.4.4\"]'. Leave blank to use STACKIT defaults." + description = "Comma-separated list of IPv4 DNS nameservers, e.g. '8.8.8.8,8.8.4.4'. Leave blank to use STACKIT defaults." type = "STRING" assignment_type = "USER_INPUT" - mandatory = false } } From 639a3a9a07c85313daf57d47e3c65c1323cd72b8 Mon Sep 17 00:00:00 2001 From: Johannes Rudolph Date: Tue, 2 Jun 2026 14:23:59 +0200 Subject: [PATCH 06/10] feat: update spoke network backplane to use correct WIF setup --- .agents/skills/stackit-backplane.md | 165 ++++++++++++++++++ AGENTS.md | 4 + .../stackit/spoke-network/backplane/main.tf | 9 +- .../spoke-network/backplane/outputs.tf | 8 +- .../spoke-network/buildingblock/provider.tf | 2 +- .../spoke-network/buildingblock/variables.tf | 5 +- .../spoke-network/meshstack_integration.tf | 12 +- 7 files changed, 195 insertions(+), 10 deletions(-) create mode 100644 .agents/skills/stackit-backplane.md diff --git a/.agents/skills/stackit-backplane.md b/.agents/skills/stackit-backplane.md new file mode 100644 index 00000000..80805d3e --- /dev/null +++ b/.agents/skills/stackit-backplane.md @@ -0,0 +1,165 @@ +--- +description: STACKIT backplane identity conventions for meshstack-hub modules under modules/stackit/. Covers service account + key pattern, required variables/outputs, provider configuration, meshstack_integration.tf wiring, and the STACKIT backplane checklist. +--- + +# STACKIT Backplane Identity Conventions + +STACKIT backplanes **must** use a **service account with a long-lived key** as the automation +principal for building block execution. The key JSON is provisioned in the backplane and injected +as a sensitive static input into the building block definition. + +## Rationale + +- **Self-contained credentials**: The service account and its key are provisioned once in the + backplane Terraform module. The key JSON is a single credential that bundles the service account + email, key ID, and private key — no extra wiring needed. +- **Least-privilege**: Each building block gets its own service account with exactly the roles it + needs (project-scoped or organization-scoped). +- **No provider configuration in backplane**: The backplane module does not include a `provider.tf`. + Authentication for the backplane itself is configured by the caller (e.g. the platform team running + `tofu apply` or the integration runtime). +- **Sensitive by default**: The `service_account_key_json` output is marked `sensitive = true`. + meshStack's STATIC input wiring uses the `sensitive.argument.secret_value` field to ensure the + key is stored and transmitted as a secret. + + +## Implementation Pattern + +```hcl +# backplane/main.tf — service account + key + role assignments + +resource "stackit_service_account" "backplane" { + project_id = var.project_id + name = "mesh-" +} + +resource "stackit_service_account_key" "backplane" { + project_id = var.project_id + service_account_email = stackit_service_account.backplane.email +} + +# Project-scoped role assignment (use this for project-level resources): +resource "stackit_authorization_project_role_assignment" "this" { + resource_id = var.project_id + role = "" + subject = stackit_service_account.backplane.email +} + +# Organization-scoped role assignment (use this for org-level resources): +resource "stackit_authorization_organization_role_assignment" "this" { + resource_id = var.organization_id + role = "" + subject = stackit_service_account.backplane.email +} +``` + + +## Backplane Outputs (STACKIT) + +Every STACKIT backplane must output the service account key JSON: + +```hcl +output "service_account_key_json" { + value = stackit_service_account_key.backplane.json + description = "Service account key JSON for authenticating the STACKIT provider in the buildingblock." + sensitive = true +} +``` + +Additional outputs (e.g. `project_id`, resource IDs) can be added as needed. + +## Backplane Variables (STACKIT) + +All STACKIT backplanes require at minimum: + +```hcl +variable "project_id" { + type = string + nullable = false + description = "STACKIT project ID where the service account will be created." +} +``` + +Backplanes that manage organization-level resources also require: + +```hcl +variable "organization_id" { + type = string + nullable = false + description = "STACKIT organization ID where the service account will be granted permissions." +} +``` + + +## Buildingblock Provider Configuration + +The buildingblock `provider.tf` must use `service_account_key` for authentication. +Do **not** use `service_account_email` alone — it does not authenticate. + +```hcl +# buildingblock/provider.tf +provider "stackit" { + service_account_key = var.service_account_key_json + # Add any extra provider flags required by the resources (e.g. enable_beta_resources, experiments): + # enable_beta_resources = true + # experiments = ["some-feature"] +} +``` + +## Buildingblock Variable + +```hcl +variable "service_account_key_json" { + type = string + nullable = false + sensitive = true + description = "Service account key JSON for authenticating the STACKIT provider." +} +``` + +The key JSON bundles the service account email — do **not** add a separate `service_account_email` +variable when `service_account_key_json` is present. + +## `meshstack_integration.tf` Wiring (STACKIT) + +Pass the key from the backplane as a **STATIC sensitive** input: + +```hcl +module "backplane" { + source = "github.com/meshcloud/meshstack-hub//modules/stackit//backplane?ref=${var.hub.git_ref}" + + project_id = var.stackit_project_id + # organization_id = var.stackit_organization_id # if org-scoped roles are needed +} + +# Inside meshstack_backplane_definition version_spec.inputs: +service_account_key_json = { + display_name = "Service Account Key JSON" + description = "Service account key JSON for authenticating the STACKIT provider." + type = "STRING" + assignment_type = "STATIC" + sensitive = { + argument = { + secret_value = module.backplane.service_account_key_json + } + } +} +``` + +## What to Avoid + +- ❌ `service_account_email` alone in the provider — missing authentication credential +- ❌ Long-lived `STACKIT_SERVICE_ACCOUNT_TOKEN` injected via env var — not reproducible across runs +- ❌ Hardcoded key values in integration files +- ❌ Non-sensitive output for `service_account_key_json` — always mark it `sensitive = true` + +## Checklist for STACKIT Backplanes + +- [ ] `stackit_service_account` resource present +- [ ] `stackit_service_account_key` resource present (same project as the service account) +- [ ] Required role assignments present (`stackit_authorization_project_role_assignment` or `stackit_authorization_organization_role_assignment`) +- [ ] `service_account_key_json` output marked `sensitive = true` +- [ ] Buildingblock `provider.tf` uses `service_account_key = var.service_account_key_json` +- [ ] Buildingblock `variables.tf` has `service_account_key_json` (sensitive, nullable = false) +- [ ] No separate `service_account_email` variable in buildingblock when key is present +- [ ] `meshstack_integration.tf` wires key via `sensitive.argument.secret_value` diff --git a/AGENTS.md b/AGENTS.md index 7ae13426..a839852c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -201,6 +201,10 @@ See [.agents/skills/aws-backplane.md](.agents/skills/aws-backplane.md) for the f See [.agents/skills/azure-backplane.md](.agents/skills/azure-backplane.md) for the full Azure backplane identity conventions, including UAMI patterns, WIF wiring, required variables/outputs, and the Azure backplane checklist. +## STACKIT Backplane Identity Conventions + +See [.agents/skills/stackit-backplane.md](.agents/skills/stackit-backplane.md) for the full STACKIT backplane identity conventions, including the service account + key pattern, required variables/outputs, provider configuration, and the STACKIT backplane checklist. + --- diff --git a/modules/stackit/spoke-network/backplane/main.tf b/modules/stackit/spoke-network/backplane/main.tf index 21d3560c..da83a9ff 100644 --- a/modules/stackit/spoke-network/backplane/main.tf +++ b/modules/stackit/spoke-network/backplane/main.tf @@ -1,13 +1,18 @@ -resource "stackit_service_account" "building_block" { +resource "stackit_service_account" "backplane" { project_id = var.project_id name = "mesh-spoke-network" } +resource "stackit_service_account_key" "backplane" { + project_id = var.project_id + service_account_email = stackit_service_account.backplane.email +} + # network.admin at org scope allows managing routing tables in the network area # and routed networks in tenant projects. Least-privilege alternative: if STACKIT # introduces a narrower "network.editor" role, prefer that. resource "stackit_authorization_organization_role_assignment" "network_admin" { resource_id = var.organization_id role = "iaas.network.admin" - subject = stackit_service_account.building_block.email + subject = stackit_service_account.backplane.email } diff --git a/modules/stackit/spoke-network/backplane/outputs.tf b/modules/stackit/spoke-network/backplane/outputs.tf index c6b9afd6..58f9ab5f 100644 --- a/modules/stackit/spoke-network/backplane/outputs.tf +++ b/modules/stackit/spoke-network/backplane/outputs.tf @@ -1,4 +1,10 @@ output "service_account_email" { - value = stackit_service_account.building_block.email + value = stackit_service_account.backplane.email description = "Email of the service account used by the building block to manage spoke networks." } + +output "service_account_key_json" { + value = stackit_service_account_key.backplane.json + description = "Service account key JSON for authenticating the STACKIT provider in the buildingblock." + sensitive = true +} diff --git a/modules/stackit/spoke-network/buildingblock/provider.tf b/modules/stackit/spoke-network/buildingblock/provider.tf index 22d42257..19f11f41 100644 --- a/modules/stackit/spoke-network/buildingblock/provider.tf +++ b/modules/stackit/spoke-network/buildingblock/provider.tf @@ -1,5 +1,5 @@ provider "stackit" { - service_account_email = var.service_account_email + service_account_key = var.service_account_key_json enable_beta_resources = true experiments = ["routing-tables", "network"] } diff --git a/modules/stackit/spoke-network/buildingblock/variables.tf b/modules/stackit/spoke-network/buildingblock/variables.tf index 5c59ef2f..df02ae91 100644 --- a/modules/stackit/spoke-network/buildingblock/variables.tf +++ b/modules/stackit/spoke-network/buildingblock/variables.tf @@ -18,10 +18,11 @@ variable "network_area_id" { description = "STACKIT network area ID of the platform hub. The spoke network will be attached to this area." } -variable "service_account_email" { +variable "service_account_key_json" { type = string nullable = false - description = "Email of the STACKIT service account. The runtime supplies a short-lived token via STACKIT_SERVICE_ACCOUNT_TOKEN (WIF)." + sensitive = true + description = "Service account key JSON for authenticating the STACKIT provider." } variable "firewall_next_hop_ip" { diff --git a/modules/stackit/spoke-network/meshstack_integration.tf b/modules/stackit/spoke-network/meshstack_integration.tf index a1eb1d23..fd955a83 100644 --- a/modules/stackit/spoke-network/meshstack_integration.tf +++ b/modules/stackit/spoke-network/meshstack_integration.tf @@ -143,12 +143,16 @@ resource "meshstack_building_block_definition" "this" { argument = jsonencode(var.network_area_id) } - service_account_email = { - display_name = "Service Account Email" - description = "Email of the STACKIT service account. The runtime provides a short-lived token via WIF." + service_account_key_json = { + display_name = "Service Account Key JSON" + description = "Service account key JSON for authenticating the STACKIT provider." type = "STRING" assignment_type = "STATIC" - argument = jsonencode(module.backplane.service_account_email) + sensitive = { + argument = { + secret_value = module.backplane.service_account_key_json + } + } } firewall_next_hop_ip = { From 2f171a3fdaea9fa4805b6ea1059f6a65d8e5446a Mon Sep 17 00:00:00 2001 From: Johannes Rudolph Date: Tue, 2 Jun 2026 14:51:38 +0200 Subject: [PATCH 07/10] fix: use proper sensitive input for stackit SA credential --- .../spoke-network/buildingblock/provider.tf | 2 +- .../spoke-network/meshstack_integration.tf | 22 ++++++++++++++----- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/modules/stackit/spoke-network/buildingblock/provider.tf b/modules/stackit/spoke-network/buildingblock/provider.tf index 19f11f41..dbe0a19d 100644 --- a/modules/stackit/spoke-network/buildingblock/provider.tf +++ b/modules/stackit/spoke-network/buildingblock/provider.tf @@ -1,5 +1,5 @@ provider "stackit" { - service_account_key = var.service_account_key_json + # service_account_key = var.service_account_key_json enable_beta_resources = true experiments = ["routing-tables", "network"] } diff --git a/modules/stackit/spoke-network/meshstack_integration.tf b/modules/stackit/spoke-network/meshstack_integration.tf index fd955a83..50caf33b 100644 --- a/modules/stackit/spoke-network/meshstack_integration.tf +++ b/modules/stackit/spoke-network/meshstack_integration.tf @@ -146,23 +146,33 @@ resource "meshstack_building_block_definition" "this" { service_account_key_json = { display_name = "Service Account Key JSON" description = "Service account key JSON for authenticating the STACKIT provider." - type = "STRING" + type = "FILE" assignment_type = "STATIC" sensitive = { argument = { - secret_value = module.backplane.service_account_key_json + secret_value = "data:application/json;base64,${base64encode(module.backplane.service_account_key_json)}" + secret_version = nonsensitive(sha256(module.backplane.service_account_key_json)) } } } - firewall_next_hop_ip = { - display_name = "Firewall Next-Hop IP" - description = "IPv4 address of the firewall next-hop. Null if no firewall is configured." + STACKIT_SERVICE_ACCOUNT_KEY_PATH = { + display_name = "STACKIT Credentials Path" + description = "Path to the STACKIT service account credentials file." type = "STRING" assignment_type = "STATIC" - argument = jsonencode(var.firewall_next_hop_ip) + is_environment = true + argument = jsonencode("./service_account_key_json") } + # firewall_next_hop_ip = { + # display_name = "Firewall Next-Hop IP" + # description = "IPv4 address of the firewall next-hop. Null if no firewall is configured." + # type = "STRING" + # assignment_type = "STATIC" + # argument = jsonencode(var.firewall_next_hop_ip) + # } + network_prefix_length = { display_name = "Network Prefix Length" description = "IPv4 prefix length for the spoke network (24–28). Determines subnet size: /24 = 254 hosts, /25 = 126, /26 = 62, /27 = 30, /28 = 14." From d48d5e0d8d3764f6b745fa9bb2c8f82e16b3fef8 Mon Sep 17 00:00:00 2001 From: Johannes Rudolph Date: Tue, 2 Jun 2026 14:58:00 +0200 Subject: [PATCH 08/10] fix: use environment variable based config for stackit provider --- modules/stackit/spoke-network/buildingblock/provider.tf | 1 - modules/stackit/spoke-network/buildingblock/variables.tf | 7 ------- 2 files changed, 8 deletions(-) diff --git a/modules/stackit/spoke-network/buildingblock/provider.tf b/modules/stackit/spoke-network/buildingblock/provider.tf index dbe0a19d..bf869259 100644 --- a/modules/stackit/spoke-network/buildingblock/provider.tf +++ b/modules/stackit/spoke-network/buildingblock/provider.tf @@ -1,5 +1,4 @@ provider "stackit" { - # service_account_key = var.service_account_key_json enable_beta_resources = true experiments = ["routing-tables", "network"] } diff --git a/modules/stackit/spoke-network/buildingblock/variables.tf b/modules/stackit/spoke-network/buildingblock/variables.tf index df02ae91..2ac7589c 100644 --- a/modules/stackit/spoke-network/buildingblock/variables.tf +++ b/modules/stackit/spoke-network/buildingblock/variables.tf @@ -18,13 +18,6 @@ variable "network_area_id" { description = "STACKIT network area ID of the platform hub. The spoke network will be attached to this area." } -variable "service_account_key_json" { - type = string - nullable = false - sensitive = true - description = "Service account key JSON for authenticating the STACKIT provider." -} - variable "firewall_next_hop_ip" { type = string default = null From 78be73f55a3fe41d2801f53ff631c14791536a43 Mon Sep 17 00:00:00 2001 From: Johannes Rudolph Date: Tue, 9 Jun 2026 17:01:27 +0200 Subject: [PATCH 09/10] feat(stackit/spoke-network): migrate backplane to WIF and add e2e test Replaces service account key auth with federated identity provider (WIF) using OIDC tokens injected by meshStack, matching the storage-bucket pattern. Adds e2e smoke test asserting SUCCEEDED status and non-empty network outputs. Co-Authored-By: Claude Sonnet 4.6 --- .../stackit/spoke-network/backplane/main.tf | 19 +++++- .../spoke-network/backplane/outputs.tf | 8 +-- .../spoke-network/backplane/variables.tf | 9 +++ .../spoke-network/backplane/versions.tf | 4 +- .../spoke-network/buildingblock/provider.tf | 2 + .../spoke-network/buildingblock/variables.tf | 6 ++ modules/stackit/spoke-network/e2e/main.tf | 62 +++++++++++++++++++ .../stackit/spoke-network/e2e/terraform.tf | 13 ++++ .../stackit_spoke_network_hub.tftest.hcl | 16 +++++ .../spoke-network/meshstack_integration.tf | 43 ++++++++----- 10 files changed, 157 insertions(+), 25 deletions(-) create mode 100644 modules/stackit/spoke-network/e2e/main.tf create mode 100644 modules/stackit/spoke-network/e2e/terraform.tf create mode 100644 modules/stackit/spoke-network/e2e/tests/stackit_spoke_network_hub.tftest.hcl diff --git a/modules/stackit/spoke-network/backplane/main.tf b/modules/stackit/spoke-network/backplane/main.tf index da83a9ff..afe5944f 100644 --- a/modules/stackit/spoke-network/backplane/main.tf +++ b/modules/stackit/spoke-network/backplane/main.tf @@ -3,9 +3,26 @@ resource "stackit_service_account" "backplane" { name = "mesh-spoke-network" } -resource "stackit_service_account_key" "backplane" { +resource "stackit_service_account_federated_identity_provider" "backplane" { + for_each = { for i, s in var.workload_identity_federation.subjects : tostring(i) => s } + project_id = var.project_id service_account_email = stackit_service_account.backplane.email + name = "meshstack-${each.key}" + issuer = var.workload_identity_federation.issuer + + assertions = [ + { + item = "aud" + operator = "equals" + value = "api://AzureADTokenExchange" + }, + { + item = "sub" + operator = "equals" + value = each.value + } + ] } # network.admin at org scope allows managing routing tables in the network area diff --git a/modules/stackit/spoke-network/backplane/outputs.tf b/modules/stackit/spoke-network/backplane/outputs.tf index 58f9ab5f..6d706dde 100644 --- a/modules/stackit/spoke-network/backplane/outputs.tf +++ b/modules/stackit/spoke-network/backplane/outputs.tf @@ -1,10 +1,4 @@ output "service_account_email" { value = stackit_service_account.backplane.email - description = "Email of the service account used by the building block to manage spoke networks." -} - -output "service_account_key_json" { - value = stackit_service_account_key.backplane.json - description = "Service account key JSON for authenticating the STACKIT provider in the buildingblock." - sensitive = true + description = "Email of the STACKIT service account used by the buildingblock provider via WIF." } diff --git a/modules/stackit/spoke-network/backplane/variables.tf b/modules/stackit/spoke-network/backplane/variables.tf index 879f9d40..751832eb 100644 --- a/modules/stackit/spoke-network/backplane/variables.tf +++ b/modules/stackit/spoke-network/backplane/variables.tf @@ -9,3 +9,12 @@ variable "organization_id" { nullable = false description = "STACKIT organization ID where the service account will be granted network management permissions." } + +variable "workload_identity_federation" { + type = object({ + issuer = string + subjects = list(string) + }) + nullable = false + description = "WIF issuer URL and subject list for the meshStack building block identity provider." +} diff --git a/modules/stackit/spoke-network/backplane/versions.tf b/modules/stackit/spoke-network/backplane/versions.tf index 706aef93..9d46ff3a 100644 --- a/modules/stackit/spoke-network/backplane/versions.tf +++ b/modules/stackit/spoke-network/backplane/versions.tf @@ -1,10 +1,10 @@ terraform { - required_version = ">= 1.11.0" + required_version = ">= 1.12.0" required_providers { stackit = { source = "stackitcloud/stackit" - version = ">= 0.88.0" + version = "~> 0.98.0" } } } diff --git a/modules/stackit/spoke-network/buildingblock/provider.tf b/modules/stackit/spoke-network/buildingblock/provider.tf index bf869259..1db1937b 100644 --- a/modules/stackit/spoke-network/buildingblock/provider.tf +++ b/modules/stackit/spoke-network/buildingblock/provider.tf @@ -1,4 +1,6 @@ provider "stackit" { + service_account_email = var.service_account_email + use_oidc = true enable_beta_resources = true experiments = ["routing-tables", "network"] } diff --git a/modules/stackit/spoke-network/buildingblock/variables.tf b/modules/stackit/spoke-network/buildingblock/variables.tf index 2ac7589c..e8300694 100644 --- a/modules/stackit/spoke-network/buildingblock/variables.tf +++ b/modules/stackit/spoke-network/buildingblock/variables.tf @@ -1,5 +1,11 @@ # ── Backplane inputs (static, set once per building block definition) ────────── +variable "service_account_email" { + type = string + nullable = false + description = "Email of the STACKIT service account for WIF-based authentication." +} + variable "project_id" { type = string nullable = false diff --git a/modules/stackit/spoke-network/e2e/main.tf b/modules/stackit/spoke-network/e2e/main.tf new file mode 100644 index 00000000..30e4418c --- /dev/null +++ b/modules/stackit/spoke-network/e2e/main.tf @@ -0,0 +1,62 @@ +variable "stackit_service_account_key" { + type = string + nullable = false + sensitive = true +} + +variable "test_context" { + type = object({ + hub_git_ref = string + workspace = string + project = string + name_suffix = string + + fixtures = object({ + stackit = object({ + project_id = string + mesh_tenant_id = string + organization_id = string + network_area_id = string + }) + }) + }) + nullable = false +} + +provider "stackit" { + service_account_key = var.stackit_service_account_key + experiments = ["iam"] +} + +module "stackit_spoke_network" { + source = "../" + meshstack = { + owning_workspace_identifier = var.test_context.workspace + tags = {} + } + hub = { + git_ref = var.test_context.hub_git_ref + bbd_draft = true + } + + stackit_project_id = var.test_context.fixtures.stackit.project_id + stackit_organization_id = var.test_context.fixtures.stackit.organization_id + network_area_id = var.test_context.fixtures.stackit.network_area_id +} + +resource "meshstack_building_block_v2" "this" { + wait_for_completion = true + spec = { + building_block_definition_version_ref = module.stackit_spoke_network.building_block_definition.version_ref + + display_name = "smoke-test-spoke-network-${var.test_context.name_suffix}" + target_ref = { + kind = "meshTenant" + uuid = var.test_context.fixtures.stackit.mesh_tenant_id + } + + inputs = { + network_prefix_length = { value_string = "28" } + } + } +} diff --git a/modules/stackit/spoke-network/e2e/terraform.tf b/modules/stackit/spoke-network/e2e/terraform.tf new file mode 100644 index 00000000..6a7a402f --- /dev/null +++ b/modules/stackit/spoke-network/e2e/terraform.tf @@ -0,0 +1,13 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + meshstack = { + source = "meshcloud/meshstack" + } + stackit = { + source = "stackitcloud/stackit" + version = "~> 0.98.0" + } + } +} diff --git a/modules/stackit/spoke-network/e2e/tests/stackit_spoke_network_hub.tftest.hcl b/modules/stackit/spoke-network/e2e/tests/stackit_spoke_network_hub.tftest.hcl new file mode 100644 index 00000000..7fafafaf --- /dev/null +++ b/modules/stackit/spoke-network/e2e/tests/stackit_spoke_network_hub.tftest.hcl @@ -0,0 +1,16 @@ +run "stackit_spoke_network_hub" { + assert { + condition = meshstack_building_block_v2.this.status.status == "SUCCEEDED" + error_message = "stackit spoke-network hub building block expected SUCCEEDED, got ${meshstack_building_block_v2.this.status.status}" + } + + assert { + condition = length(meshstack_building_block_v2.this.status.outputs["network_id"].value_string) > 0 + error_message = "stackit spoke-network hub building block expected non-empty network_id" + } + + assert { + condition = strcontains(meshstack_building_block_v2.this.status.outputs["network_cidr"].value_string, "/28") + error_message = "stackit spoke-network hub building block expected network_cidr to contain /28, got ${meshstack_building_block_v2.this.status.outputs["network_cidr"].value_string}" + } +} diff --git a/modules/stackit/spoke-network/meshstack_integration.tf b/modules/stackit/spoke-network/meshstack_integration.tf index 50caf33b..6886bb90 100644 --- a/modules/stackit/spoke-network/meshstack_integration.tf +++ b/modules/stackit/spoke-network/meshstack_integration.tf @@ -47,11 +47,20 @@ output "building_block_definition" { } } +data "meshstack_integrations" "integrations" {} + module "backplane" { source = "github.com/meshcloud/meshstack-hub//modules/stackit/spoke-network/backplane?ref=${var.hub.git_ref}" project_id = var.stackit_project_id organization_id = var.stackit_organization_id + + workload_identity_federation = { + issuer = data.meshstack_integrations.integrations.workload_identity_federation.replicator.issuer + subjects = [ + "${trimsuffix(data.meshstack_integrations.integrations.workload_identity_federation.replicator.subject, ":replicator")}:workspace.${var.meshstack.owning_workspace_identifier}.buildingblockdefinition.${meshstack_building_block_definition.this.metadata.uuid}" + ] + } } resource "meshstack_building_block_definition" "this" { @@ -143,26 +152,30 @@ resource "meshstack_building_block_definition" "this" { argument = jsonencode(var.network_area_id) } - service_account_key_json = { - display_name = "Service Account Key JSON" - description = "Service account key JSON for authenticating the STACKIT provider." - type = "FILE" + service_account_email = { + display_name = "Service Account Email" + description = "Email of the STACKIT service account for WIF-based authentication." + type = "STRING" assignment_type = "STATIC" - sensitive = { - argument = { - secret_value = "data:application/json;base64,${base64encode(module.backplane.service_account_key_json)}" - secret_version = nonsensitive(sha256(module.backplane.service_account_key_json)) - } - } + argument = jsonencode(module.backplane.service_account_email) + } + + STACKIT_USE_OIDC = { + display_name = "STACKIT Use OIDC" + description = "Enables OIDC-based WIF for the STACKIT provider." + type = "STRING" + assignment_type = "STATIC" + is_environment = true + argument = jsonencode("1") } - STACKIT_SERVICE_ACCOUNT_KEY_PATH = { - display_name = "STACKIT Credentials Path" - description = "Path to the STACKIT service account credentials file." + STACKIT_FEDERATED_TOKEN_FILE = { + display_name = "STACKIT Federated Token File" + description = "Path to the WIF token file injected by meshStack." type = "STRING" assignment_type = "STATIC" is_environment = true - argument = jsonencode("./service_account_key_json") + argument = jsonencode("/var/run/secrets/workload-identity/azure/token") } # firewall_next_hop_ip = { @@ -229,7 +242,7 @@ terraform { } stackit = { source = "stackitcloud/stackit" - version = ">= 0.88.0" + version = "~> 0.98.0" } } } From 28c049c11550a47da7570c51046463b292a1122a Mon Sep 17 00:00:00 2001 From: Johannes Rudolph Date: Tue, 9 Jun 2026 17:10:25 +0200 Subject: [PATCH 10/10] fix(stackit/spoke-network): bump meshstack provider to ~> 0.21.0 The meshStack API now requires provider >= 0.21.0 for building block definition resources. Aligns with other hub modules. Co-Authored-By: Claude Sonnet 4.6 --- modules/stackit/spoke-network/meshstack_integration.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/stackit/spoke-network/meshstack_integration.tf b/modules/stackit/spoke-network/meshstack_integration.tf index 6886bb90..fa5ac3f2 100644 --- a/modules/stackit/spoke-network/meshstack_integration.tf +++ b/modules/stackit/spoke-network/meshstack_integration.tf @@ -238,7 +238,7 @@ terraform { required_providers { meshstack = { source = "meshcloud/meshstack" - version = "~> 0.20.0" + version = "~> 0.21.0" } stackit = { source = "stackitcloud/stackit"