From b3e56c5285da2b7579dc6d8de7d2dcc85ac010c2 Mon Sep 17 00:00:00 2001 From: Florian Nowarre Date: Mon, 24 Aug 2026 11:56:17 +0200 Subject: [PATCH 01/11] chore: Update backplane spoke-network --- .../azure/spoke-network/backplane/README.md | 83 +++++++++----- modules/azure/spoke-network/backplane/main.tf | 103 ++++++++++++++---- .../azure/spoke-network/backplane/outputs.tf | 37 +++++-- .../azure/spoke-network/backplane/provider.tf | 3 + .../spoke-network/backplane/variables.tf | 25 ++++- .../azure/spoke-network/backplane/versions.tf | 5 +- 6 files changed, 190 insertions(+), 66 deletions(-) create mode 100644 modules/azure/spoke-network/backplane/provider.tf diff --git a/modules/azure/spoke-network/backplane/README.md b/modules/azure/spoke-network/backplane/README.md index 3c35e16f..12d46542 100644 --- a/modules/azure/spoke-network/backplane/README.md +++ b/modules/azure/spoke-network/backplane/README.md @@ -1,24 +1,47 @@ -# Azure Connect +# Azure Spoke Network — Backplane -This documentation is intended as a reference documentation for platform engineers using this module. +This documentation is intended as reference documentation for platform engineers deploying this backplane. -## Permissions +## What this backplane provisions -This is a complex building block backplane that requires permission across the central network hub as well as into the -target subscription for creating a spoke network. This backplane thus needs to work with multiple `azurerm` terraform providers. +The spoke network building block is special: it peers a newly created spoke vnet into a central +network hub, and a vnet peering must be created on **both** sides of the connection. The automation +identity therefore needs permissions in two places: -We establish a clear shared responsibility boundary in the target subscription by -deploying a `connectivity` resource group to target subscription. This resource group is exclusively owned by the connectivity building block backplane SPN. +- the **landing zone scope** (`var.scope`), where the spoke resource group, vnet and the spoke side + of the peering are created, and +- the **hub scope** (`var.hub_scope`), where the hub vnet lives and the hub side of the peering is created. -An Azure Policy confines the access of the SPN to that resource group. +This backplane creates a single **User-Assigned Managed Identity (UAMI)** as the automation principal +and grants it two custom role definitions: + +| Role definition | Scope | Purpose | +| ---------------------- | --------------- | ---------------------------------------------------------------------- | +| `-deploy` | `var.scope` | Manage the spoke resource group + vnet, hand ownership to the tenant, create the spoke side of the peering. | +| `-deploy-hub` | `var.hub_scope` | Read the hub vnet/resource group and create the hub side of the peering. | + +Because a single identity holds `Microsoft.Network/virtualNetworks/peer/action` at **both** scopes, +the cross-scope `LinkedAuthorizationFailed` error that a split spoke/hub identity would hit when +creating the linked peering is avoided. + +The UAMI authenticates via **workload identity federation** (no secrets to rotate), federated against +the meshStack replicator subject(s) passed in `var.workload_identity_federation`. + +## Operational notes + +- `var.scope` is typically the management group that parents all landing zones; `var.hub_scope` is + typically the hub subscription (or a management group containing it). +- The identity is granted `Microsoft.Authorization/roleAssignments/*` at the spoke scope because the + building block hands ownership of the spoke resource group to the tenant. Consider confining the + identity to the connectivity resource group with an Azure Policy if you need a tighter boundary. ## Requirements | Name | Version | -|------|---------| -| [terraform](#requirement\_terraform) | >= 1.0 | -| [azurerm](#requirement\_azurerm) | 4.11.0 | +| ---- | ------- | +| [terraform](#requirement\_terraform) | >= 1.3.0 | +| [azurerm](#requirement\_azurerm) | >= 4.36.0 | ## Modules @@ -27,26 +50,34 @@ No modules. ## Resources | Name | Type | -|------|------| -| [azurerm_role_assignment.buildingblock_deploy_hub](https://registry.terraform.io/providers/hashicorp/azurerm/4.11.0/docs/resources/role_assignment) | resource | -| [azurerm_role_definition.buildingblock_deploy_hub](https://registry.terraform.io/providers/hashicorp/azurerm/4.11.0/docs/resources/role_definition) | resource | -| [azurerm_subscription.current](https://registry.terraform.io/providers/hashicorp/azurerm/4.11.0/docs/data-sources/subscription) | data source | +| ---- | ---- | +| [azurerm_federated_identity_credential.backplane](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/federated_identity_credential) | resource | +| [azurerm_resource_group.backplane](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/resource_group) | resource | +| [azurerm_role_assignment.backplane](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/role_assignment) | resource | +| [azurerm_role_assignment.backplane_hub](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/role_assignment) | resource | +| [azurerm_role_definition.backplane](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/role_definition) | resource | +| [azurerm_role_definition.backplane_hub](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/role_definition) | resource | +| [azurerm_user_assigned_identity.backplane](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/user_assigned_identity) | resource | ## Inputs | Name | Description | Type | Default | Required | -|------|-------------|------|---------|:--------:| -| [name](#input\_name) | name of the building block, used for naming resources | `string` | n/a | yes | -| [principal\_ids](#input\_principal\_ids) | set of principal ids that will be granted permissions to deploy the building block | `set(string)` | n/a | yes | -| [scope](#input\_scope) | Scope where the building block should be deployable, typically the parent of all Landing Zones. | `string` | n/a | yes | +| ---- | ----------- | ---- | ------- | :------: | +| [hub\_scope](#input\_hub\_scope) | Scope where the hub vnet lives (management group or subscription ID). The identity is granted vnet peering permissions here so it can peer the spoke into the hub. | `string` | n/a | yes | +| [location](#input\_location) | Azure region for the UAMI resource group. | `string` | n/a | yes | +| [name](#input\_name) | Name for the building block identity, resource group and role definitions. | `string` | n/a | yes | +| [scope](#input\_scope) | Scope where the spoke network can be deployed (management group or subscription ID), typically the parent of all landing zones. | `string` | n/a | yes | +| [workload\_identity\_federation](#input\_workload\_identity\_federation) | WIF issuer and subjects for federated authentication of the automation identity. |
object({
issuer = string
subjects = list(string)
})
| n/a | yes | ## Outputs | Name | Description | -|------|-------------| -| [role\_assignment\_ids](#output\_role\_assignment\_ids) | The IDs of the role assignments for the service principals. | -| [role\_assignment\_principal\_ids](#output\_role\_assignment\_principal\_ids) | The principal IDs of the service principals that have been assigned the role. | -| [role\_definition\_id](#output\_role\_definition\_id) | The ID of the role definition that enables deployment of the Connectivity building block to the hub. | -| [role\_definition\_name](#output\_role\_definition\_name) | The name of the role definition that enables deployment of the Connectivity building block to the hub. | -| [scope](#output\_scope) | The scope where the role definition and role assignments are applied. | - \ No newline at end of file +| ---- | ----------- | +| [hub\_role\_definition\_id](#output\_hub\_role\_definition\_id) | The ID of the role definition that enables peering the spoke into the hub vnet. | +| [hub\_role\_definition\_name](#output\_hub\_role\_definition\_name) | The name of the role definition that enables peering the spoke into the hub vnet. | +| [hub\_scope](#output\_hub\_scope) | The scope where the hub peering role definition and role assignment are applied. | +| [identity](#output\_identity) | The managed identity used as the automation principal for this building block. | +| [role\_definition\_id](#output\_role\_definition\_id) | The ID of the role definition that enables deployment of the spoke network to landing zone subscriptions. | +| [role\_definition\_name](#output\_role\_definition\_name) | The name of the role definition that enables deployment of the spoke network to landing zone subscriptions. | +| [scope](#output\_scope) | The scope where the spoke deploy role definition and role assignment are applied. | + diff --git a/modules/azure/spoke-network/backplane/main.tf b/modules/azure/spoke-network/backplane/main.tf index 12e986f8..654614ae 100644 --- a/modules/azure/spoke-network/backplane/main.tf +++ b/modules/azure/spoke-network/backplane/main.tf @@ -1,38 +1,101 @@ -data "azurerm_subscription" "current" { +resource "azurerm_resource_group" "backplane" { + name = var.name + location = var.location +} + +resource "azurerm_user_assigned_identity" "backplane" { + name = var.name + location = var.location + resource_group_name = azurerm_resource_group.backplane.name +} + +resource "azurerm_federated_identity_credential" "backplane" { + for_each = { for i, s in var.workload_identity_federation.subjects : tostring(i) => s } + + name = "subject-${each.key}" + user_assigned_identity_id = azurerm_user_assigned_identity.backplane.id + audience = ["api://AzureADTokenExchange"] + issuer = var.workload_identity_federation.issuer + subject = each.value } # -# Hub Deploy Roles +# Spoke deploy role — grants the automation identity everything it needs on the +# subscriptions/management group that hosts the spoke landing zones: manage the +# spoke resource group and vnet, hand out ownership on the spoke RG, and create +# the spoke side of the vnet peering. # - -# note: this role will be assigned using the access role above -resource "azurerm_role_definition" "buildingblock_deploy_hub" { - name = "buildingblock-${var.name}-deploy-hub" - description = "Enables deployment of the ${var.name} building block to the hub" - scope = data.azurerm_subscription.current.id # assume we are running in the hub subscription anyway +resource "azurerm_role_definition" "backplane" { + name = "${var.name}-deploy" + description = "Enables deployment of the ${var.name} spoke network building block to landing zone subscriptions" + scope = var.scope permissions { actions = [ - "Microsoft.Resources/subscriptions/resourceGroups/*", + # Register resource providers in Azure Resource Manager + "*/register/action", + "Microsoft.Resources/subscriptions/providers/read", + + # Spoke resource group + "Microsoft.Resources/subscriptions/resourceGroups/read", + "Microsoft.Resources/subscriptions/resourceGroups/write", + "Microsoft.Resources/subscriptions/resourceGroups/delete", + + # Spoke virtual network + subnets + peering "Microsoft.Network/virtualNetworks/read", "Microsoft.Network/virtualNetworks/write", "Microsoft.Network/virtualNetworks/delete", - "Microsoft.Network/virtualNetworks/subnets/*", - "Microsoft.Network/virtualNetworks/virtualNetworkPeerings/*", + "Microsoft.Network/virtualNetworks/subnets/read", + "Microsoft.Network/virtualNetworks/subnets/write", + "Microsoft.Network/virtualNetworks/subnets/delete", + "Microsoft.Network/virtualNetworks/virtualNetworkPeerings/read", + "Microsoft.Network/virtualNetworks/virtualNetworkPeerings/write", + "Microsoft.Network/virtualNetworks/virtualNetworkPeerings/delete", "Microsoft.Network/virtualNetworks/peer/action", - # Permission we need to activate/register required Resource Providers - "Microsoft.Resources/subscriptions/providers/read", - "*/register/action", + # The building block hands ownership of the spoke resource group to the tenant + "Microsoft.Authorization/roleAssignments/read", + "Microsoft.Authorization/roleAssignments/write", + "Microsoft.Authorization/roleAssignments/delete", ] } } -resource "azurerm_role_assignment" "buildingblock_deploy_hub" { - for_each = var.principal_ids +# +# Hub peering role — the spoke network building block peers *both* sides of the +# connection, so the same identity also needs rights where the hub lives: read +# the hub vnet/resource group and create the hub side of the peering. +# +# Note: creating a peering requires `peer/action` on *both* linked vnets. Because +# a single identity holds `peer/action` at the spoke scope (above) and at the hub +# scope (here), the cross-scope `LinkedAuthorizationFailed` that a split identity +# would hit is avoided. +# +resource "azurerm_role_definition" "backplane_hub" { + name = "${var.name}-deploy-hub" + description = "Enables the ${var.name} spoke network building block to peer into the hub vnet" + scope = var.hub_scope + + permissions { + actions = [ + "Microsoft.Resources/subscriptions/resourceGroups/read", + "Microsoft.Network/virtualNetworks/read", + "Microsoft.Network/virtualNetworks/virtualNetworkPeerings/read", + "Microsoft.Network/virtualNetworks/virtualNetworkPeerings/write", + "Microsoft.Network/virtualNetworks/virtualNetworkPeerings/delete", + "Microsoft.Network/virtualNetworks/peer/action", + ] + } +} + +resource "azurerm_role_assignment" "backplane" { + scope = var.scope + role_definition_id = azurerm_role_definition.backplane.role_definition_resource_id + principal_id = azurerm_user_assigned_identity.backplane.principal_id +} - role_definition_id = azurerm_role_definition.buildingblock_deploy_hub.role_definition_resource_id - description = azurerm_role_definition.buildingblock_deploy_hub.description - principal_id = each.key - scope = data.azurerm_subscription.current.id # assume we are running in the spoke subscription anyway +resource "azurerm_role_assignment" "backplane_hub" { + scope = var.hub_scope + role_definition_id = azurerm_role_definition.backplane_hub.role_definition_resource_id + principal_id = azurerm_user_assigned_identity.backplane.principal_id } diff --git a/modules/azure/spoke-network/backplane/outputs.tf b/modules/azure/spoke-network/backplane/outputs.tf index c21b53e3..c5937562 100644 --- a/modules/azure/spoke-network/backplane/outputs.tf +++ b/modules/azure/spoke-network/backplane/outputs.tf @@ -1,25 +1,38 @@ +output "identity" { + value = { + client_id = azurerm_user_assigned_identity.backplane.client_id + principal_id = azurerm_user_assigned_identity.backplane.principal_id + tenant_id = azurerm_user_assigned_identity.backplane.tenant_id + } + description = "The managed identity used as the automation principal for this building block." +} + output "role_definition_id" { - value = azurerm_role_definition.buildingblock_deploy_hub.id - description = "The ID of the role definition that enables deployment of the Connectivity building block to the hub." + value = azurerm_role_definition.backplane.id + description = "The ID of the role definition that enables deployment of the spoke network to landing zone subscriptions." } output "role_definition_name" { - value = azurerm_role_definition.buildingblock_deploy_hub.name - description = "The name of the role definition that enables deployment of the Connectivity building block to the hub." + value = azurerm_role_definition.backplane.name + description = "The name of the role definition that enables deployment of the spoke network to landing zone subscriptions." } -output "role_assignment_ids" { - value = { for id in var.principal_ids : id => azurerm_role_assignment.buildingblock_deploy_hub[id].id } - description = "The IDs of the role assignments for the service principals." +output "hub_role_definition_id" { + value = azurerm_role_definition.backplane_hub.id + description = "The ID of the role definition that enables peering the spoke into the hub vnet." } -output "role_assignment_principal_ids" { - value = { for id in var.principal_ids : id => azurerm_role_assignment.buildingblock_deploy_hub[id].principal_id } - description = "The principal IDs of the service principals that have been assigned the role." +output "hub_role_definition_name" { + value = azurerm_role_definition.backplane_hub.name + description = "The name of the role definition that enables peering the spoke into the hub vnet." } output "scope" { - value = data.azurerm_subscription.current.id - description = "The scope where the role definition and role assignments are applied." + value = var.scope + description = "The scope where the spoke deploy role definition and role assignment are applied." } +output "hub_scope" { + value = var.hub_scope + description = "The scope where the hub peering role definition and role assignment are applied." +} diff --git a/modules/azure/spoke-network/backplane/provider.tf b/modules/azure/spoke-network/backplane/provider.tf new file mode 100644 index 00000000..ab91b248 --- /dev/null +++ b/modules/azure/spoke-network/backplane/provider.tf @@ -0,0 +1,3 @@ +provider "azurerm" { + features {} +} diff --git a/modules/azure/spoke-network/backplane/variables.tf b/modules/azure/spoke-network/backplane/variables.tf index 5f9226b2..ef7126b2 100644 --- a/modules/azure/spoke-network/backplane/variables.tf +++ b/modules/azure/spoke-network/backplane/variables.tf @@ -1,7 +1,7 @@ variable "name" { type = string nullable = false - description = "name of the building block, used for naming resources" + description = "Name for the building block identity, resource group and role definitions." validation { condition = can(regex("^[-a-z0-9]+$", var.name)) error_message = "Only alphanumeric lowercase characters and dashes are allowed" @@ -11,11 +11,26 @@ variable "name" { variable "scope" { type = string nullable = false - description = "Scope where the building block should be deployable, typically the parent of all Landing Zones." + description = "Scope where the spoke network can be deployed (management group or subscription ID), typically the parent of all landing zones." } -variable "principal_ids" { - type = set(string) +variable "hub_scope" { + type = string + nullable = false + description = "Scope where the hub vnet lives (management group or subscription ID). The identity is granted vnet peering permissions here so it can peer the spoke into the hub." +} + +variable "location" { + type = string + nullable = false + description = "Azure region for the UAMI resource group." +} + +variable "workload_identity_federation" { + type = object({ + issuer = string + subjects = list(string) + }) nullable = false - description = "set of principal ids that will be granted permissions to deploy the building block" + description = "WIF issuer and subjects for federated authentication of the automation identity." } diff --git a/modules/azure/spoke-network/backplane/versions.tf b/modules/azure/spoke-network/backplane/versions.tf index c6f89ab4..653c1354 100644 --- a/modules/azure/spoke-network/backplane/versions.tf +++ b/modules/azure/spoke-network/backplane/versions.tf @@ -1,11 +1,10 @@ terraform { - required_version = ">= 1.0" + required_version = ">= 1.3.0" required_providers { azurerm = { source = "hashicorp/azurerm" - version = "4.11.0" + version = ">= 4.36.0" } } } - From 38cd5882603329f7c106bd6f6108f89b26d4a76b Mon Sep 17 00:00:00 2001 From: Florian Nowarre Date: Tue, 25 Aug 2026 10:23:16 +0200 Subject: [PATCH 02/11] chore: Update buildingblock spoke-network --- .../spoke-network/buildingblock/README.md | 21 ++++++++++--------- .../spoke-network/buildingblock/provider.tf | 14 +++++++++++++ .../spoke-network/buildingblock/variables.tf | 16 ++++++++++---- .../spoke-network/buildingblock/versions.tf | 7 +++---- 4 files changed, 40 insertions(+), 18 deletions(-) create mode 100644 modules/azure/spoke-network/buildingblock/provider.tf diff --git a/modules/azure/spoke-network/buildingblock/README.md b/modules/azure/spoke-network/buildingblock/README.md index cdcea27f..e6cfcd5c 100644 --- a/modules/azure/spoke-network/buildingblock/README.md +++ b/modules/azure/spoke-network/buildingblock/README.md @@ -13,9 +13,9 @@ This enables use cases like on-premise connectivity and managed internet egress ## Requirements | Name | Version | -|------|---------| -| [terraform](#requirement\_terraform) | >= 1.0 | -| [azurerm](#requirement\_azurerm) | >= 4.11.0 | +| ---- | ------- | +| [terraform](#requirement\_terraform) | >= 1.3.0 | +| [azurerm](#requirement\_azurerm) | >= 4.36.0 | | [time](#requirement\_time) | >= 0.12.1 | ## Modules @@ -25,7 +25,7 @@ No modules. ## Resources | Name | Type | -|------|------| +| ---- | ---- | | [azurerm_resource_group.spoke_rg](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/resource_group) | resource | | [azurerm_role_assignment.spoke_rg](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/role_assignment) | resource | | [azurerm_virtual_network.spoke_vnet](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/virtual_network) | resource | @@ -40,20 +40,21 @@ No modules. ## Inputs | Name | Description | Type | Default | Required | -|------|-------------|------|---------|:--------:| +| ---- | ----------- | ---- | ------- | :------: | | [address\_space](#input\_address\_space) | Address space of the virtual network in CIDR notation | `string` | n/a | yes | | [azure\_delay\_seconds](#input\_azure\_delay\_seconds) | Number of additional seconds to wait between Azure API operations to mitigate eventual consistency issues in order to increase automation reliabilty. | `number` | `30` | no | -| [hub\_rg](#input\_hub\_rg) | value | `any` | n/a | yes | -| [hub\_vnet](#input\_hub\_vnet) | n/a | `any` | n/a | yes | -| [location](#input\_location) | n/a | `any` | n/a | yes | +| [hub\_rg](#input\_hub\_rg) | Name of the resource group that contains the hub vnet to peer into. | `string` | n/a | yes | +| [hub\_subscription\_id](#input\_hub\_subscription\_id) | The ID of the subscription that hosts the hub vnet. | `string` | n/a | yes | +| [hub\_vnet](#input\_hub\_vnet) | Name of the hub vnet to peer the spoke into. | `string` | n/a | yes | +| [location](#input\_location) | Azure region where the spoke resource group and vnet are created. | `string` | n/a | yes | | [name](#input\_name) | name of the virtual spoke network. This name is used as the basis to generate resource names for vnets and peerings. | `string` | n/a | yes | | [spoke\_owner\_principal\_id](#input\_spoke\_owner\_principal\_id) | Principal id that will become owner of the spokes. Defaults to the client\_id of the spoke azurerm provider. | `string` | `null` | no | | [spoke\_rg\_name](#input\_spoke\_rg\_name) | name of the resource group to deploy for hosting the spoke vnet | `string` | `"connectivity"` | no | -| [subscription\_id](#input\_subscription\_id) | The ID of the subscription that you want to deploy the spoke to | `string` | n/a | yes | +| [subscription\_id](#input\_subscription\_id) | The ID of the subscription that you want to deploy the spoke to (used by the spoke azurerm provider). | `string` | n/a | yes | ## Outputs | Name | Description | -|------|-------------| +| ---- | ----------- | | [vnet\_id](#output\_vnet\_id) | The ID of the virtual network created by this module. | \ No newline at end of file diff --git a/modules/azure/spoke-network/buildingblock/provider.tf b/modules/azure/spoke-network/buildingblock/provider.tf new file mode 100644 index 00000000..f5074ff9 --- /dev/null +++ b/modules/azure/spoke-network/buildingblock/provider.tf @@ -0,0 +1,14 @@ +# Both providers authenticate as the same backplane UAMI via workload identity +# federation (ARM_CLIENT_ID / ARM_USE_OIDC / ARM_OIDC_TOKEN_FILE_PATH env vars). +# They differ only in the target subscription: the spoke landing zone vs. the hub. +provider "azurerm" { + features {} + alias = "spoke" + subscription_id = var.subscription_id +} + +provider "azurerm" { + features {} + alias = "hub" + subscription_id = var.hub_subscription_id +} diff --git a/modules/azure/spoke-network/buildingblock/variables.tf b/modules/azure/spoke-network/buildingblock/variables.tf index a63ac7c0..f7a133dd 100644 --- a/modules/azure/spoke-network/buildingblock/variables.tf +++ b/modules/azure/spoke-network/buildingblock/variables.tf @@ -1,11 +1,21 @@ variable "hub_rg" { - description = "value" + type = string + description = "Name of the resource group that contains the hub vnet to peer into." } variable "hub_vnet" { + type = string + description = "Name of the hub vnet to peer the spoke into." +} + +variable "hub_subscription_id" { + type = string + description = "The ID of the subscription that hosts the hub vnet." } variable "location" { + type = string + description = "Azure region where the spoke resource group and vnet are created." } variable "name" { @@ -24,11 +34,9 @@ variable "address_space" { description = "Address space of the virtual network in CIDR notation" } -# this variable is supposed to be used by an injected config.tf file for configuring the azurerm provider -# tflint-ignore: terraform_unused_declarations variable "subscription_id" { type = string - description = "The ID of the subscription that you want to deploy the spoke to" + description = "The ID of the subscription that you want to deploy the spoke to (used by the spoke azurerm provider)." } variable "spoke_owner_principal_id" { diff --git a/modules/azure/spoke-network/buildingblock/versions.tf b/modules/azure/spoke-network/buildingblock/versions.tf index b4daaa9c..2362b97f 100644 --- a/modules/azure/spoke-network/buildingblock/versions.tf +++ b/modules/azure/spoke-network/buildingblock/versions.tf @@ -1,12 +1,11 @@ terraform { - required_version = ">= 1.0" + required_version = ">= 1.3.0" required_providers { azurerm = { - source = "hashicorp/azurerm" - version = ">= 4.11.0" - configuration_aliases = [azurerm.spoke, azurerm.hub] + source = "hashicorp/azurerm" + version = ">= 4.36.0" } time = { From 7d7d5150b5ca4a865515d3315da011cb15ff3db3 Mon Sep 17 00:00:00 2001 From: Florian Nowarre Date: Tue, 25 Aug 2026 10:50:25 +0200 Subject: [PATCH 03/11] chore: adding summary --- .../spoke-network/buildingblock/README.md | 1 + .../spoke-network/buildingblock/outputs.tf | 31 +++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/modules/azure/spoke-network/buildingblock/README.md b/modules/azure/spoke-network/buildingblock/README.md index e6cfcd5c..dd0d4877 100644 --- a/modules/azure/spoke-network/buildingblock/README.md +++ b/modules/azure/spoke-network/buildingblock/README.md @@ -56,5 +56,6 @@ No modules. | Name | Description | | ---- | ----------- | +| [summary](#output\_summary) | Markdown summary of the created spoke network and its hub peering. | | [vnet\_id](#output\_vnet\_id) | The ID of the virtual network created by this module. | \ No newline at end of file diff --git a/modules/azure/spoke-network/buildingblock/outputs.tf b/modules/azure/spoke-network/buildingblock/outputs.tf index 6ced751e..393ac081 100644 --- a/modules/azure/spoke-network/buildingblock/outputs.tf +++ b/modules/azure/spoke-network/buildingblock/outputs.tf @@ -2,4 +2,35 @@ output "vnet_id" { description = "The ID of the virtual network created by this module." value = azurerm_virtual_network.spoke_vnet.id +} + +output "summary" { + description = "Markdown summary of the created spoke network and its hub peering." + value = <<-EOT + # Spoke Network: **${azurerm_virtual_network.spoke_vnet.name}** + + Your spoke VNet has been created and peered into the central network hub. + + ## Details + + | Property | Value | + |----------|-------| + | **VNet Name** | `${azurerm_virtual_network.spoke_vnet.name}` | + | **Address Space** | `${var.address_space}` | + | **Resource Group** | `${azurerm_resource_group.spoke_rg.name}` | + | **Location** | `${azurerm_resource_group.spoke_rg.location}` | + | **VNet ID** | `${azurerm_virtual_network.spoke_vnet.id}` | + | **Portal** | [Open in Azure Portal](https://portal.azure.com/#@${data.azurerm_client_config.spoke.tenant_id}/resource${azurerm_virtual_network.spoke_vnet.id}/overview) | + + ## Hub Peering + + The spoke is peered bidirectionally with the central hub. + + | Property | Value | + |----------|-------| + | **Hub VNet** | `${data.azurerm_virtual_network.hub_vnet.name}` | + | **Hub Resource Group** | `${data.azurerm_resource_group.hub_rg.name}` | + | **Peering (spoke → hub)** | `${azurerm_virtual_network_peering.spoke_hub_peer.name}` | + | **Peering (hub → spoke)** | `${azurerm_virtual_network_peering.hub_spoke_peer.name}` | + EOT } \ No newline at end of file From a98911da5ae4b74fca19eb50d5066899d7f342da Mon Sep 17 00:00:00 2001 From: Florian Nowarre Date: Tue, 25 Aug 2026 11:04:30 +0200 Subject: [PATCH 04/11] chore: adding pre-run script --- .../spoke-network/buildingblock/README.md | 4 --- .../azure/spoke-network/buildingblock/main.tf | 36 ++++++------------- .../spoke-network/buildingblock/variables.tf | 6 ---- .../spoke-network/buildingblock/versions.tf | 5 --- 4 files changed, 10 insertions(+), 41 deletions(-) diff --git a/modules/azure/spoke-network/buildingblock/README.md b/modules/azure/spoke-network/buildingblock/README.md index dd0d4877..5de1ef83 100644 --- a/modules/azure/spoke-network/buildingblock/README.md +++ b/modules/azure/spoke-network/buildingblock/README.md @@ -16,7 +16,6 @@ This enables use cases like on-premise connectivity and managed internet egress | ---- | ------- | | [terraform](#requirement\_terraform) | >= 1.3.0 | | [azurerm](#requirement\_azurerm) | >= 4.36.0 | -| [time](#requirement\_time) | >= 0.12.1 | ## Modules @@ -31,8 +30,6 @@ No modules. | [azurerm_virtual_network.spoke_vnet](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/virtual_network) | resource | | [azurerm_virtual_network_peering.hub_spoke_peer](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/virtual_network_peering) | resource | | [azurerm_virtual_network_peering.spoke_hub_peer](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/virtual_network_peering) | resource | -| [time_sleep.wait_before_peering](https://registry.terraform.io/providers/hashicorp/time/latest/docs/resources/sleep) | resource | -| [time_sleep.wait_for_spoke_rg_role](https://registry.terraform.io/providers/hashicorp/time/latest/docs/resources/sleep) | resource | | [azurerm_client_config.spoke](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/data-sources/client_config) | data source | | [azurerm_resource_group.hub_rg](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/data-sources/resource_group) | data source | | [azurerm_virtual_network.hub_vnet](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/data-sources/virtual_network) | data source | @@ -42,7 +39,6 @@ No modules. | Name | Description | Type | Default | Required | | ---- | ----------- | ---- | ------- | :------: | | [address\_space](#input\_address\_space) | Address space of the virtual network in CIDR notation | `string` | n/a | yes | -| [azure\_delay\_seconds](#input\_azure\_delay\_seconds) | Number of additional seconds to wait between Azure API operations to mitigate eventual consistency issues in order to increase automation reliabilty. | `number` | `30` | no | | [hub\_rg](#input\_hub\_rg) | Name of the resource group that contains the hub vnet to peer into. | `string` | n/a | yes | | [hub\_subscription\_id](#input\_hub\_subscription\_id) | The ID of the subscription that hosts the hub vnet. | `string` | n/a | yes | | [hub\_vnet](#input\_hub\_vnet) | Name of the hub vnet to peer the spoke into. | `string` | n/a | yes | diff --git a/modules/azure/spoke-network/buildingblock/main.tf b/modules/azure/spoke-network/buildingblock/main.tf index 20c5505a..dbe7da43 100644 --- a/modules/azure/spoke-network/buildingblock/main.tf +++ b/modules/azure/spoke-network/buildingblock/main.tf @@ -1,7 +1,3 @@ -locals { - azure_delay = "${var.azure_delay_seconds}s" -} - data "azurerm_client_config" "spoke" { provider = azurerm.spoke } @@ -28,23 +24,19 @@ resource "azurerm_role_assignment" "spoke_rg" { scope = azurerm_resource_group.spoke_rg.id } -# Wait for the role assignment to become effective, azure is eventually consistent and thus sometimes flaky -# even though the azurerm_role_assignment provider tries to cover for that, it's not always effective. -# Example: -# > Error: checking for presence of existing Virtual Network (Subscription: "b53caa89-ba38-4f04-a0b2-f989c98d4add" -# > Resource Group Name: "connectivity" -# > Virtual Network Name: "vnet-demo-app-vnet"): network.VirtualNetworksClient#Get: Failure responding to request: StatusCode=403 -- Original Error: autorest/azure: Service returned an error. Status=403 Code="AuthorizationFailed" Message="The client 'dca119ef-092d-41ce-83d3-920d4eda271a' with object id 'dca119ef-092d-41ce-83d3-920d4eda271a' does not have authorization to perform action 'Microsoft.Network/virtualNetworks/read' over scope '/subscriptions/b53caa89-ba38-4f04-a0b2-f989c98d4add/resourceGroups/connectivity/providers/Microsoft.Network/virtualNetworks/vnet-demo-app-vnet' or the scope is invalid. If access was recently granted, please refresh your credentials." -resource "time_sleep" "wait_for_spoke_rg_role" { - depends_on = [azurerm_role_assignment.spoke_rg] - create_duration = local.azure_delay -} - # # 3. deploy the actual network # +# Azure is eventually consistent: the Owner role assignment above is not always +# effective immediately, which previously caused a 403 on the vnet read/create. +# Rather than sleeping, the pre-run script (prerun.sh) applies the role +# assignment and this vnet as separate, targeted `tofu apply -target` steps +# before the main run. Each targeted apply is a hard boundary, so by the time +# the main run creates the peering, the assignment has propagated and the vnet +# is committed. See the building block's pre_run_script in meshstack_integration.tf. resource "azurerm_virtual_network" "spoke_vnet" { provider = azurerm.spoke - depends_on = [time_sleep.wait_for_spoke_rg_role] + depends_on = [azurerm_role_assignment.spoke_rg] name = "${var.name}-vnet" location = azurerm_resource_group.spoke_rg.location @@ -67,15 +59,8 @@ data "azurerm_virtual_network" "hub_vnet" { resource_group_name = data.azurerm_resource_group.hub_rg.name } -# azure sometimes does not find the new spoke vnet for creating the peer, hence also giving this a delay -resource "time_sleep" "wait_before_peering" { - depends_on = [azurerm_virtual_network.spoke_vnet] - create_duration = local.azure_delay -} - resource "azurerm_virtual_network_peering" "spoke_hub_peer" { - provider = azurerm.spoke - depends_on = [time_sleep.wait_before_peering] + provider = azurerm.spoke name = var.name resource_group_name = azurerm_resource_group.spoke_rg.name @@ -84,8 +69,7 @@ resource "azurerm_virtual_network_peering" "spoke_hub_peer" { } resource "azurerm_virtual_network_peering" "hub_spoke_peer" { - provider = azurerm.hub - depends_on = [time_sleep.wait_before_peering] + provider = azurerm.hub name = var.name resource_group_name = data.azurerm_resource_group.hub_rg.name diff --git a/modules/azure/spoke-network/buildingblock/variables.tf b/modules/azure/spoke-network/buildingblock/variables.tf index f7a133dd..e77733ae 100644 --- a/modules/azure/spoke-network/buildingblock/variables.tf +++ b/modules/azure/spoke-network/buildingblock/variables.tf @@ -43,10 +43,4 @@ variable "spoke_owner_principal_id" { type = string description = "Principal id that will become owner of the spokes. Defaults to the client_id of the spoke azurerm provider." default = null -} - -variable "azure_delay_seconds" { - type = number - description = "Number of additional seconds to wait between Azure API operations to mitigate eventual consistency issues in order to increase automation reliabilty." - default = 30 } \ No newline at end of file diff --git a/modules/azure/spoke-network/buildingblock/versions.tf b/modules/azure/spoke-network/buildingblock/versions.tf index 2362b97f..618b4c61 100644 --- a/modules/azure/spoke-network/buildingblock/versions.tf +++ b/modules/azure/spoke-network/buildingblock/versions.tf @@ -7,10 +7,5 @@ terraform { source = "hashicorp/azurerm" version = ">= 4.36.0" } - - time = { - source = "hashicorp/time" - version = ">= 0.12.1" - } } } From 49a438068c232697a668b803ca232340d48e99a6 Mon Sep 17 00:00:00 2001 From: Florian Nowarre Date: Tue, 25 Aug 2026 11:33:32 +0200 Subject: [PATCH 05/11] chore: adding meshstack_integration --- .../spoke-network/meshstack_integration.tf | 308 ++++++++++++++++++ 1 file changed, 308 insertions(+) create mode 100644 modules/azure/spoke-network/meshstack_integration.tf diff --git a/modules/azure/spoke-network/meshstack_integration.tf b/modules/azure/spoke-network/meshstack_integration.tf new file mode 100644 index 00000000..b0e6622f --- /dev/null +++ b/modules/azure/spoke-network/meshstack_integration.tf @@ -0,0 +1,308 @@ +variable "azure_hub_subscription_id" { + type = string + description = "PROVIDER TARGET: hub subscription the azurerm provider reads the hub vnet from and creates the hub-side peering in. Bare GUID (e.g. '92eae5db-...'), NOT a '/subscriptions/...' path. Same sub as azure_hub_scope in a simple setup, but different format/purpose (that one is the RBAC scope)." + default = "92eae5db-f627-4428-827f-9261eab6e7e7" +} + +variable "azure_scope" { + type = string + description = "RBAC SCOPE: where the spoke deploy role is granted. Full resource path — a management group ('/providers/Microsoft.Management/managementGroups/') or a subscription ('/subscriptions/'). Typically the parent of all landing zones. Not to be confused with azure_subscription_id (the provider target GUID)." + default = "/providers/Microsoft.Management/managementGroups/9110cc87-a5b1-4121-85ca-784ab55d7082" +} + +variable "azure_hub_scope" { + type = string + description = "RBAC SCOPE: where the hub peering role is granted. Full resource path — a management group ('/providers/Microsoft.Management/managementGroups/') or a subscription ('/subscriptions/') containing the hub vnet. Same sub as azure_hub_subscription_id in a simple setup, but this is the full path (RBAC scope), that one is the bare GUID (provider target)." + default = "/subscriptions/92eae5db-f627-4428-827f-9261eab6e7e7" +} + +variable "azure_location" { + type = string + default = "germanywestcentral" + description = "Default Azure region where the spoke resource group and vnet are created." +} + +variable "azure_hub_resource_group_name" { + type = string + description = "Name of the resource group that contains the hub vnet to peer into." + default = "meshcloud-dev-hub-vnet-rg" +} + +variable "azure_hub_vnet_name" { + type = string + description = "Name of the hub vnet to peer the spoke into." + default = "meshcloud-dev-hub-vnet" + +} + +variable "azure_spoke_resource_group_name" { + type = string + default = "connectivity" + description = "Name of the resource group created in the spoke subscription to host the spoke vnet." +} + +variable "backplane_name" { + type = string + default = "azure-spoke-network" + description = "Name for the backplane resources (identity, resource group, role definitions). Must match pattern ^[-a-z0-9]+$." +} + +variable "notification_subscribers" { + type = list(string) + default = [] + description = "List of email addresses to notify on building block lifecycle events." +} + +variable "meshstack" { + type = object({ + owning_workspace_identifier = string + tags = optional(map(list(string)), {}) + }) + description = "Shared meshStack context. Tags are optional and propagated to building block definition metadata." +} + +variable "hub" { + type = object({ + git_ref = optional(string, "main") + bbd_draft = optional(bool, true) + }) + const = true + default = { + git_ref = "feature/update-spoke-backplane" + bbd_draft = true + } + description = <<-EOT + `git_ref`: Hub release reference. Set to a tag (e.g. 'v1.2.3') or branch or commit sha of the meshstack-hub repo. + `bbd_draft`: If true, the building block definition version is kept in draft mode, which allows changing it (useful during development in LCF/ICF). + 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 + } +} + +data "meshstack_integrations" "integrations" {} + +module "backplane" { + source = "github.com/meshcloud/meshstack-hub//modules/azure/spoke-network/backplane?ref=${var.hub.git_ref}" + + name = var.backplane_name + scope = var.azure_scope + hub_scope = var.azure_hub_scope + location = var.azure_location + + 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" { + metadata = { + owned_by_workspace = var.meshstack.owning_workspace_identifier + tags = var.meshstack.tags + } + + spec = { + display_name = "Azure Spoke Network" + description = "Provisions a spoke VNet in the tenant's Azure subscription and peers it into a central network hub for on-premise connectivity and managed internet egress." + support_url = "mailto:support@meshcloud.io" + documentation_url = "https://hub.meshcloud.io/platforms/azure/definitions/azure-spoke-network" + notification_subscribers = var.notification_subscribers + symbol = "https://raw.githubusercontent.com/meshcloud/meshstack-hub/main/modules/azure/spoke-network/buildingblock/logo.png" + target_type = "TENANT_LEVEL" + supported_platforms = [{ name = "AZURE" }] + + readme = chomp(<<-EOT + This building block provisions a managed **spoke VNet** in your Azure subscription and peers it into a central network hub, giving your workloads secure connectivity to on-premise networks and managed internet egress. + + ## 🎯 When to use it + + Use this building block when your Azure application needs to reach on-premise systems (databases, APIs) or must route egress traffic through the central hub. It creates a dedicated resource group and vnet in your subscription and establishes the bidirectional peering with the hub for you. + + ## 📋 Examples + + 1. A team migrating an application to Azure that still depends on an on-premise database requests a spoke network to obtain a routed, hub-connected vnet. + 2. A workload that must send all internet-bound traffic through the central firewall gets connectivity by peering its spoke into the hub. + + ## Shared Responsibilities + + | Responsibility | Platform Team | Application Team | + | ------------------------------------------------ | :-----------: | :--------------: | + | Operate the central network hub and its SLA | ✅ | ❌ | + | Provision the spoke vnet and hub peering | ✅ | ❌ | + | Choose the spoke network name and address space | ❌ | ✅ | + | Deploy workloads into the spoke vnet | ❌ | ✅ | + | Be mindful of traffic across the hub connection | ❌ | ✅ | + EOT + ) + } + + version_spec = { + draft = var.hub.bbd_draft + + deletion_mode = "DELETE" + + implementation = { + terraform = { + terraform_version = "1.9.0" + repository_url = "https://github.com/meshcloud/meshstack-hub.git" + repository_path = "modules/azure/spoke-network/buildingblock" + ref_name = var.hub.git_ref + use_mesh_http_backend_fallback = true + + # Runs after `tofu init`, before the main plan/apply. + # Works around Azure's eventual consistency without sleeping: it applies + # the Owner role assignment and the spoke vnet as separate, targeted + # `tofu apply -target` steps. Each targeted apply is a hard commit + # boundary, so by the time the main run creates the hub peering the role + # assignment has propagated and the vnet is committed and visible. + # https://docs.meshcloud.io/concepts/building-block/#pre-run-script-opentofu + pre_run_script = chomp(<<-SH + run_mode="$1" + + # Only pre-provision on APPLY. DESTROY and DETECT are handled by the main run. + if [ "$run_mode" != "APPLY" ]; then + echo "Run mode '$run_mode': nothing to pre-apply, skipping." + exit 0 + fi + + echo "Pre-applying spoke resource group + Owner role assignment (target 1/2)..." + tofu apply -input=false -auto-approve -no-color -target=azurerm_role_assignment.spoke_rg + + echo "Pre-applying spoke vnet (target 2/2)..." + tofu apply -input=false -auto-approve -no-color -target=azurerm_virtual_network.spoke_vnet + + echo "Prerequisites applied; the main run will now create the hub peering." + SH + ) + } + } + + inputs = { + ARM_CLIENT_ID = { + type = "STRING" + display_name = "ARM Client ID" + description = "Client ID of the managed identity used to authenticate with Azure." + assignment_type = "STATIC" + is_environment = true + argument = jsonencode(module.backplane.identity.client_id) + } + ARM_TENANT_ID = { + type = "STRING" + display_name = "ARM Tenant ID" + description = "Azure Entra tenant ID for authentication." + assignment_type = "STATIC" + is_environment = true + argument = jsonencode(var.azure_tenant_id) + } + ARM_USE_OIDC = { + type = "STRING" + display_name = "ARM Use OIDC" + description = "Enables OIDC-based workload identity federation for the Azure provider." + assignment_type = "STATIC" + is_environment = true + argument = jsonencode("true") + } + ARM_OIDC_TOKEN_FILE_PATH = { + type = "STRING" + display_name = "ARM OIDC Token File Path" + description = "Path to the OIDC token file used for workload identity federation authentication." + assignment_type = "STATIC" + is_environment = true + argument = jsonencode("/var/run/secrets/workload-identity/azure/token") + } + subscription_id = { + type = "STRING" + display_name = "Spoke Subscription ID" + description = "The Azure subscription where the spoke resource group and vnet are created." + assignment_type = "PLATFORM_TENANT_ID" + } + hub_subscription_id = { + type = "STRING" + display_name = "Hub Subscription ID" + description = "The Azure subscription that hosts the hub vnet." + assignment_type = "STATIC" + argument = jsonencode(var.azure_hub_subscription_id) + } + hub_rg = { + type = "STRING" + display_name = "Hub Resource Group" + description = "Name of the resource group that contains the hub vnet." + assignment_type = "STATIC" + argument = jsonencode(var.azure_hub_resource_group_name) + } + hub_vnet = { + type = "STRING" + display_name = "Hub VNet" + description = "Name of the hub vnet to peer into." + assignment_type = "STATIC" + argument = jsonencode(var.azure_hub_vnet_name) + } + spoke_rg_name = { + type = "STRING" + display_name = "Spoke Resource Group" + description = "Name of the resource group created in the spoke subscription to host the spoke vnet." + assignment_type = "STATIC" + argument = jsonencode(var.azure_spoke_resource_group_name) + } + location = { + type = "STRING" + display_name = "Location" + description = "The Azure region where the spoke resource group and vnet are created." + assignment_type = "STATIC" + argument = jsonencode(var.azure_location) + } + name = { + type = "STRING" + display_name = "Spoke Network Name" + description = "Name of the spoke network. Used as the basis for the vnet and peering resource names." + assignment_type = "PROJECT_IDENTIFIER" + } + address_space = { + type = "STRING" + display_name = "Address Space" + description = "Address space of the spoke virtual network in CIDR notation, e.g. '10.123.0.0/24'." + assignment_type = "USER_INPUT" + value_validation_regex = "^([0-9]{1,3}\\.){3}[0-9]{1,3}/[0-9]{1,2}$" + validation_regex_error_message = "Address space must be a valid IPv4 CIDR range, e.g. '10.123.0.0/24'." + } + } + + outputs = { + vnet_id = { + type = "STRING" + display_name = "Spoke VNet ID" + description = "The Azure resource ID of the created spoke virtual network." + assignment_type = "NONE" + } + summary = { + type = "STRING" + display_name = "Summary" + description = "Markdown summary of the created spoke network and its hub peering." + assignment_type = "SUMMARY" + } + } + } +} + +terraform { + required_version = ">= 1.12.0" + + required_providers { + meshstack = { + source = "meshcloud/meshstack" + version = ">= 0.21.0" + } + azurerm = { + source = "hashicorp/azurerm" + version = ">= 4.36.0" + } + } +} From 988a4d4b6b064e033f50c16027788fd2f7ff6b1e Mon Sep 17 00:00:00 2001 From: Florian Nowarre Date: Tue, 25 Aug 2026 11:36:06 +0200 Subject: [PATCH 06/11] chore: wrong peering output --- modules/azure/spoke-network/buildingblock/outputs.tf | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/modules/azure/spoke-network/buildingblock/outputs.tf b/modules/azure/spoke-network/buildingblock/outputs.tf index 393ac081..7782887c 100644 --- a/modules/azure/spoke-network/buildingblock/outputs.tf +++ b/modules/azure/spoke-network/buildingblock/outputs.tf @@ -24,13 +24,11 @@ output "summary" { ## Hub Peering - The spoke is peered bidirectionally with the central hub. + The spoke is peered bidirectionally with the central hub `${data.azurerm_virtual_network.hub_vnet.name}` (resource group `${data.azurerm_resource_group.hub_rg.name}`). - | Property | Value | - |----------|-------| - | **Hub VNet** | `${data.azurerm_virtual_network.hub_vnet.name}` | - | **Hub Resource Group** | `${data.azurerm_resource_group.hub_rg.name}` | - | **Peering (spoke → hub)** | `${azurerm_virtual_network_peering.spoke_hub_peer.name}` | - | **Peering (hub → spoke)** | `${azurerm_virtual_network_peering.hub_spoke_peer.name}` | + | Direction | From | To | + |-----------|------|----| + | Spoke → Hub | `${azurerm_virtual_network.spoke_vnet.name}` | `${data.azurerm_virtual_network.hub_vnet.name}` | + | Hub → Spoke | `${data.azurerm_virtual_network.hub_vnet.name}` | `${azurerm_virtual_network.spoke_vnet.name}` | EOT } \ No newline at end of file From 4c7d23895f5327548b0f94e4622457349340411e Mon Sep 17 00:00:00 2001 From: Florian Nowarre Date: Tue, 25 Aug 2026 11:41:53 +0200 Subject: [PATCH 07/11] chore: remove default values --- .../buildingblock/tests/connectivity.tftest.hcl | 7 +++---- modules/azure/spoke-network/meshstack_integration.tf | 6 ------ 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/modules/azure/spoke-network/buildingblock/tests/connectivity.tftest.hcl b/modules/azure/spoke-network/buildingblock/tests/connectivity.tftest.hcl index 91a10f5f..9be1cc48 100644 --- a/modules/azure/spoke-network/buildingblock/tests/connectivity.tftest.hcl +++ b/modules/azure/spoke-network/buildingblock/tests/connectivity.tftest.hcl @@ -5,10 +5,9 @@ variables { - spoke_rg_name = "connectivity" - name = "terraform-test" - address_space = "10.123.123.0/24" - azure_delay_seconds = 0 # we have a betterway to reduce flakiness, see the run "deploy_rg" step below + spoke_rg_name = "connectivity" + name = "terraform-test" + address_space = "10.123.123.0/24" } run "setup_tests" { diff --git a/modules/azure/spoke-network/meshstack_integration.tf b/modules/azure/spoke-network/meshstack_integration.tf index b0e6622f..e9d7ac8e 100644 --- a/modules/azure/spoke-network/meshstack_integration.tf +++ b/modules/azure/spoke-network/meshstack_integration.tf @@ -1,19 +1,16 @@ variable "azure_hub_subscription_id" { type = string description = "PROVIDER TARGET: hub subscription the azurerm provider reads the hub vnet from and creates the hub-side peering in. Bare GUID (e.g. '92eae5db-...'), NOT a '/subscriptions/...' path. Same sub as azure_hub_scope in a simple setup, but different format/purpose (that one is the RBAC scope)." - default = "92eae5db-f627-4428-827f-9261eab6e7e7" } variable "azure_scope" { type = string description = "RBAC SCOPE: where the spoke deploy role is granted. Full resource path — a management group ('/providers/Microsoft.Management/managementGroups/') or a subscription ('/subscriptions/'). Typically the parent of all landing zones. Not to be confused with azure_subscription_id (the provider target GUID)." - default = "/providers/Microsoft.Management/managementGroups/9110cc87-a5b1-4121-85ca-784ab55d7082" } variable "azure_hub_scope" { type = string description = "RBAC SCOPE: where the hub peering role is granted. Full resource path — a management group ('/providers/Microsoft.Management/managementGroups/') or a subscription ('/subscriptions/') containing the hub vnet. Same sub as azure_hub_subscription_id in a simple setup, but this is the full path (RBAC scope), that one is the bare GUID (provider target)." - default = "/subscriptions/92eae5db-f627-4428-827f-9261eab6e7e7" } variable "azure_location" { @@ -25,14 +22,11 @@ variable "azure_location" { variable "azure_hub_resource_group_name" { type = string description = "Name of the resource group that contains the hub vnet to peer into." - default = "meshcloud-dev-hub-vnet-rg" } variable "azure_hub_vnet_name" { type = string description = "Name of the hub vnet to peer the spoke into." - default = "meshcloud-dev-hub-vnet" - } variable "azure_spoke_resource_group_name" { From fcff6472a57fee973ebf5e7ee0ae7ca78b70a5a9 Mon Sep 17 00:00:00 2001 From: Florian Nowarre Date: Tue, 25 Aug 2026 11:46:38 +0200 Subject: [PATCH 08/11] chore: pre-commit --- modules/azure/spoke-network/backplane/README.md | 8 ++++---- modules/azure/spoke-network/buildingblock/README.md | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/modules/azure/spoke-network/backplane/README.md b/modules/azure/spoke-network/backplane/README.md index 12d46542..80dc5761 100644 --- a/modules/azure/spoke-network/backplane/README.md +++ b/modules/azure/spoke-network/backplane/README.md @@ -39,7 +39,7 @@ the meshStack replicator subject(s) passed in `var.workload_identity_federation` ## Requirements | Name | Version | -| ---- | ------- | +|------|---------| | [terraform](#requirement\_terraform) | >= 1.3.0 | | [azurerm](#requirement\_azurerm) | >= 4.36.0 | @@ -50,7 +50,7 @@ No modules. ## Resources | Name | Type | -| ---- | ---- | +|------|------| | [azurerm_federated_identity_credential.backplane](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/federated_identity_credential) | resource | | [azurerm_resource_group.backplane](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/resource_group) | resource | | [azurerm_role_assignment.backplane](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/role_assignment) | resource | @@ -62,7 +62,7 @@ No modules. ## Inputs | Name | Description | Type | Default | Required | -| ---- | ----------- | ---- | ------- | :------: | +|------|-------------|------|---------|:--------:| | [hub\_scope](#input\_hub\_scope) | Scope where the hub vnet lives (management group or subscription ID). The identity is granted vnet peering permissions here so it can peer the spoke into the hub. | `string` | n/a | yes | | [location](#input\_location) | Azure region for the UAMI resource group. | `string` | n/a | yes | | [name](#input\_name) | Name for the building block identity, resource group and role definitions. | `string` | n/a | yes | @@ -72,7 +72,7 @@ No modules. ## Outputs | Name | Description | -| ---- | ----------- | +|------|-------------| | [hub\_role\_definition\_id](#output\_hub\_role\_definition\_id) | The ID of the role definition that enables peering the spoke into the hub vnet. | | [hub\_role\_definition\_name](#output\_hub\_role\_definition\_name) | The name of the role definition that enables peering the spoke into the hub vnet. | | [hub\_scope](#output\_hub\_scope) | The scope where the hub peering role definition and role assignment are applied. | diff --git a/modules/azure/spoke-network/buildingblock/README.md b/modules/azure/spoke-network/buildingblock/README.md index 5de1ef83..8d519357 100644 --- a/modules/azure/spoke-network/buildingblock/README.md +++ b/modules/azure/spoke-network/buildingblock/README.md @@ -13,7 +13,7 @@ This enables use cases like on-premise connectivity and managed internet egress ## Requirements | Name | Version | -| ---- | ------- | +|------|---------| | [terraform](#requirement\_terraform) | >= 1.3.0 | | [azurerm](#requirement\_azurerm) | >= 4.36.0 | @@ -24,7 +24,7 @@ No modules. ## Resources | Name | Type | -| ---- | ---- | +|------|------| | [azurerm_resource_group.spoke_rg](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/resource_group) | resource | | [azurerm_role_assignment.spoke_rg](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/role_assignment) | resource | | [azurerm_virtual_network.spoke_vnet](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/virtual_network) | resource | @@ -37,7 +37,7 @@ No modules. ## Inputs | Name | Description | Type | Default | Required | -| ---- | ----------- | ---- | ------- | :------: | +|------|-------------|------|---------|:--------:| | [address\_space](#input\_address\_space) | Address space of the virtual network in CIDR notation | `string` | n/a | yes | | [hub\_rg](#input\_hub\_rg) | Name of the resource group that contains the hub vnet to peer into. | `string` | n/a | yes | | [hub\_subscription\_id](#input\_hub\_subscription\_id) | The ID of the subscription that hosts the hub vnet. | `string` | n/a | yes | @@ -51,7 +51,7 @@ No modules. ## Outputs | Name | Description | -| ---- | ----------- | +|------|-------------| | [summary](#output\_summary) | Markdown summary of the created spoke network and its hub peering. | | [vnet\_id](#output\_vnet\_id) | The ID of the virtual network created by this module. | \ No newline at end of file From 0a64b4e1730e7a2aa75994fc45dfcb8fc6cdea4f Mon Sep 17 00:00:00 2001 From: Florian Nowarre Date: Tue, 25 Aug 2026 11:50:29 +0200 Subject: [PATCH 09/11] chore: Update version --- modules/azure/spoke-network/meshstack_integration.tf | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/azure/spoke-network/meshstack_integration.tf b/modules/azure/spoke-network/meshstack_integration.tf index e9d7ac8e..54e7cfe4 100644 --- a/modules/azure/spoke-network/meshstack_integration.tf +++ b/modules/azure/spoke-network/meshstack_integration.tf @@ -62,7 +62,7 @@ variable "hub" { }) const = true default = { - git_ref = "feature/update-spoke-backplane" + git_ref = "main" bbd_draft = true } description = <<-EOT @@ -145,7 +145,7 @@ resource "meshstack_building_block_definition" "this" { implementation = { terraform = { - terraform_version = "1.9.0" + terraform_version = "1.12.0" repository_url = "https://github.com/meshcloud/meshstack-hub.git" repository_path = "modules/azure/spoke-network/buildingblock" ref_name = var.hub.git_ref From 79d067cfbed22742444aceab1c79b7036ccf941b Mon Sep 17 00:00:00 2001 From: Florian Nowarre Date: Wed, 26 Aug 2026 08:11:13 +0200 Subject: [PATCH 10/11] chore: Update version --- modules/azure/spoke-network/backplane/README.md | 14 ++++++++++---- modules/azure/spoke-network/backplane/provider.tf | 4 ++++ modules/azure/spoke-network/backplane/variables.tf | 6 ++++++ 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/modules/azure/spoke-network/backplane/README.md b/modules/azure/spoke-network/backplane/README.md index 80dc5761..f6271aad 100644 --- a/modules/azure/spoke-network/backplane/README.md +++ b/modules/azure/spoke-network/backplane/README.md @@ -29,6 +29,11 @@ the meshStack replicator subject(s) passed in `var.workload_identity_federation` ## Operational notes +- `var.subscription_id` controls **where the UAMI and its resource group are created** — typically the + hub subscription, so the automation identity lives in a stable, platform-owned place. This is + independent of `var.scope`/`var.hub_scope`, which only scope the role definitions/assignments. + Deploy the backplane once per hub environment (e.g. `hub-dev`, `hub-prod`), passing that + environment's subscription. - `var.scope` is typically the management group that parents all landing zones; `var.hub_scope` is typically the hub subscription (or a management group containing it). - The identity is granted `Microsoft.Authorization/roleAssignments/*` at the spoke scope because the @@ -39,7 +44,7 @@ the meshStack replicator subject(s) passed in `var.workload_identity_federation` ## Requirements | Name | Version | -|------|---------| +| ---- | ------- | | [terraform](#requirement\_terraform) | >= 1.3.0 | | [azurerm](#requirement\_azurerm) | >= 4.36.0 | @@ -50,7 +55,7 @@ No modules. ## Resources | Name | Type | -|------|------| +| ---- | ---- | | [azurerm_federated_identity_credential.backplane](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/federated_identity_credential) | resource | | [azurerm_resource_group.backplane](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/resource_group) | resource | | [azurerm_role_assignment.backplane](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/role_assignment) | resource | @@ -62,17 +67,18 @@ No modules. ## Inputs | Name | Description | Type | Default | Required | -|------|-------------|------|---------|:--------:| +| ---- | ----------- | ---- | ------- | :------: | | [hub\_scope](#input\_hub\_scope) | Scope where the hub vnet lives (management group or subscription ID). The identity is granted vnet peering permissions here so it can peer the spoke into the hub. | `string` | n/a | yes | | [location](#input\_location) | Azure region for the UAMI resource group. | `string` | n/a | yes | | [name](#input\_name) | Name for the building block identity, resource group and role definitions. | `string` | n/a | yes | | [scope](#input\_scope) | Scope where the spoke network can be deployed (management group or subscription ID), typically the parent of all landing zones. | `string` | n/a | yes | +| [subscription\_id](#input\_subscription\_id) | Subscription (bare GUID) where the UAMI and its resource group are created. Typically the hub subscription so the identity lives in a stable, platform-owned place. Deploy the backplane once per hub environment (e.g. hub-dev, hub-prod) with the respective subscription. | `string` | n/a | yes | | [workload\_identity\_federation](#input\_workload\_identity\_federation) | WIF issuer and subjects for federated authentication of the automation identity. |
object({
issuer = string
subjects = list(string)
})
| n/a | yes | ## Outputs | Name | Description | -|------|-------------| +| ---- | ----------- | | [hub\_role\_definition\_id](#output\_hub\_role\_definition\_id) | The ID of the role definition that enables peering the spoke into the hub vnet. | | [hub\_role\_definition\_name](#output\_hub\_role\_definition\_name) | The name of the role definition that enables peering the spoke into the hub vnet. | | [hub\_scope](#output\_hub\_scope) | The scope where the hub peering role definition and role assignment are applied. | diff --git a/modules/azure/spoke-network/backplane/provider.tf b/modules/azure/spoke-network/backplane/provider.tf index ab91b248..893dc90b 100644 --- a/modules/azure/spoke-network/backplane/provider.tf +++ b/modules/azure/spoke-network/backplane/provider.tf @@ -1,3 +1,7 @@ provider "azurerm" { features {} + + # The UAMI + its resource group are created in this subscription. Role + # definitions/assignments are unaffected — they use their explicit `scope`. + subscription_id = var.subscription_id } diff --git a/modules/azure/spoke-network/backplane/variables.tf b/modules/azure/spoke-network/backplane/variables.tf index ef7126b2..7edb0d98 100644 --- a/modules/azure/spoke-network/backplane/variables.tf +++ b/modules/azure/spoke-network/backplane/variables.tf @@ -20,6 +20,12 @@ variable "hub_scope" { description = "Scope where the hub vnet lives (management group or subscription ID). The identity is granted vnet peering permissions here so it can peer the spoke into the hub." } +variable "subscription_id" { + type = string + nullable = false + description = "Subscription (bare GUID) where the UAMI and its resource group are created. Typically the hub subscription so the identity lives in a stable, platform-owned place. Deploy the backplane once per hub environment (e.g. hub-dev, hub-prod) with the respective subscription." +} + variable "location" { type = string nullable = false From c2a4bc84245f015ebec7939f6ff027cf9cfd1dc7 Mon Sep 17 00:00:00 2001 From: Florian Nowarre Date: Wed, 26 Aug 2026 08:13:33 +0200 Subject: [PATCH 11/11] chore: Update version --- .../spoke-network/meshstack_integration.tf | 52 ++++++++++++++++--- 1 file changed, 44 insertions(+), 8 deletions(-) diff --git a/modules/azure/spoke-network/meshstack_integration.tf b/modules/azure/spoke-network/meshstack_integration.tf index 54e7cfe4..ce302f8c 100644 --- a/modules/azure/spoke-network/meshstack_integration.tf +++ b/modules/azure/spoke-network/meshstack_integration.tf @@ -1,16 +1,32 @@ + + +variable "azure_tenant_id" { + type = string + description = "Azure Entra tenant ID where the spoke network and hub live." + +} + +# variable "azure_subscription_id" { +# type = string +# description = "Azure subscription ID of the spoke landing zone where the vnet is created." +# } + variable "azure_hub_subscription_id" { type = string description = "PROVIDER TARGET: hub subscription the azurerm provider reads the hub vnet from and creates the hub-side peering in. Bare GUID (e.g. '92eae5db-...'), NOT a '/subscriptions/...' path. Same sub as azure_hub_scope in a simple setup, but different format/purpose (that one is the RBAC scope)." + } variable "azure_scope" { type = string description = "RBAC SCOPE: where the spoke deploy role is granted. Full resource path — a management group ('/providers/Microsoft.Management/managementGroups/') or a subscription ('/subscriptions/'). Typically the parent of all landing zones. Not to be confused with azure_subscription_id (the provider target GUID)." + } variable "azure_hub_scope" { type = string description = "RBAC SCOPE: where the hub peering role is granted. Full resource path — a management group ('/providers/Microsoft.Management/managementGroups/') or a subscription ('/subscriptions/') containing the hub vnet. Same sub as azure_hub_subscription_id in a simple setup, but this is the full path (RBAC scope), that one is the bare GUID (provider target)." + } variable "azure_location" { @@ -22,11 +38,14 @@ variable "azure_location" { variable "azure_hub_resource_group_name" { type = string description = "Name of the resource group that contains the hub vnet to peer into." + } variable "azure_hub_vnet_name" { type = string description = "Name of the hub vnet to peer the spoke into." + + } variable "azure_spoke_resource_group_name" { @@ -35,6 +54,12 @@ variable "azure_spoke_resource_group_name" { description = "Name of the resource group created in the spoke subscription to host the spoke vnet." } +variable "azure_backplane_subscription_id" { + type = string + description = "Subscription (bare GUID) where the backplane UAMI + its resource group are created. Typically the hub subscription, so the automation identity lives in a stable, platform-owned place. Deploy once per hub environment (hub-dev, hub-prod) with the respective subscription." + +} + variable "backplane_name" { type = string default = "azure-spoke-network" @@ -53,6 +78,14 @@ variable "meshstack" { tags = optional(map(list(string)), {}) }) description = "Shared meshStack context. Tags are optional and propagated to building block definition metadata." + default = { + owning_workspace_identifier = "flori-land" + # tags = { + # confidentiality = ["Internal"] + # environment = ["dev", "prod"] + # } + } + } variable "hub" { @@ -62,7 +95,7 @@ variable "hub" { }) const = true default = { - git_ref = "main" + git_ref = "feature/update-spoke-backplane" bbd_draft = true } description = <<-EOT @@ -84,10 +117,11 @@ data "meshstack_integrations" "integrations" {} module "backplane" { source = "github.com/meshcloud/meshstack-hub//modules/azure/spoke-network/backplane?ref=${var.hub.git_ref}" - name = var.backplane_name - scope = var.azure_scope - hub_scope = var.azure_hub_scope - location = var.azure_location + name = var.backplane_name + scope = var.azure_scope + hub_scope = var.azure_hub_scope + location = var.azure_location + subscription_id = var.azure_backplane_subscription_id workload_identity_federation = { issuer = data.meshstack_integrations.integrations.workload_identity_federation.replicator.issuer @@ -145,7 +179,7 @@ resource "meshstack_building_block_definition" "this" { implementation = { terraform = { - terraform_version = "1.12.0" + terraform_version = "1.9.0" repository_url = "https://github.com/meshcloud/meshstack-hub.git" repository_path = "modules/azure/spoke-network/buildingblock" ref_name = var.hub.git_ref @@ -168,10 +202,10 @@ resource "meshstack_building_block_definition" "this" { fi echo "Pre-applying spoke resource group + Owner role assignment (target 1/2)..." - tofu apply -input=false -auto-approve -no-color -target=azurerm_role_assignment.spoke_rg + tofu apply -input=false -auto-approve -target=azurerm_role_assignment.spoke_rg echo "Pre-applying spoke vnet (target 2/2)..." - tofu apply -input=false -auto-approve -no-color -target=azurerm_virtual_network.spoke_vnet + tofu apply -input=false -auto-approve -target=azurerm_virtual_network.spoke_vnet echo "Prerequisites applied; the main run will now create the hub peering." SH @@ -258,6 +292,8 @@ resource "meshstack_building_block_definition" "this" { display_name = "Spoke Network Name" description = "Name of the spoke network. Used as the basis for the vnet and peering resource names." assignment_type = "PROJECT_IDENTIFIER" + #value_validation_regex = "^[a-z0-9][-a-z0-9]{1,40}$" + #validation_regex_error_message = "Only lowercase letters, numbers and dashes are allowed (2–41 characters, must start with a letter or number)." } address_space = { type = "STRING"