diff --git a/.github/instructions/azure-backplane.instructions.md b/.github/instructions/azure-backplane.instructions.md new file mode 100644 index 00000000..df32769c --- /dev/null +++ b/.github/instructions/azure-backplane.instructions.md @@ -0,0 +1,150 @@ +--- +applyTo: modules/azure/** +--- + +# Azure Backplane Identity Conventions + +Azure backplanes **must** use **User-Assigned Managed Identities (UAMIs)** as the automation +principal for building block execution. Do **not** create Service Principals (SPNs) via +`azuread_application` + `azuread_service_principal`. + +## Rationale + +- **Self-service**: Platform engineers can deploy UAMIs without invoking a central Entra admin team. + Creating a UAMI requires only `Managed Identity Contributor` on the subscription — no Entra ID + `Application.ReadWrite.All` or `Application Administrator` role needed. +- **WIF-native**: UAMIs support federated identity credentials (`azurerm_federated_identity_credential`) + for meshStack's workload identity federation out of the box. +- **Management Group scope**: UAMIs can hold Azure RBAC role assignments at any scope including + Management Groups. They can also be assigned Entra directory roles (e.g. Directory Readers). +- **CI/CD testability**: E2E smoke tests run under a GHA UAMI with GitHub WIF in a static + subscription. Using UAMIs in backplanes means the same identity model is used end-to-end, + and `tofu test` can deploy and destroy `meshstack_integration.tf` without Entra app registration + privileges. +- **No secrets rotation**: Unlike SPNs with client secrets, UAMIs with WIF produce no secrets to + manage or rotate. + +## Implementation Pattern + +```hcl +# backplane/main.tf — UAMI-based automation principal + +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}" + resource_group_name = azurerm_resource_group.backplane.name + parent_id = azurerm_user_assigned_identity.backplane.id + audience = ["api://AzureADTokenExchange"] + issuer = var.workload_identity_federation.issuer + subject = each.value +} + +resource "azurerm_role_definition" "backplane" { + name = "${var.name}-deploy" + description = "Enables deployment of the ${var.name} building block" + scope = var.scope + permissions { actions = [ /* ... */ ] } +} + +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 +} +``` + +## Backplane Variables (Azure) + +Every Azure backplane must accept these variables: + +```hcl +variable "name" { + type = string + nullable = false + description = "Name for the building block identity and role definition." +} + +variable "scope" { + type = string + nullable = false + description = "Scope for role assignment (management group or subscription ID)." +} + +variable "location" { + type = string + description = "Azure region for the UAMI resource." +} + +variable "workload_identity_federation" { + type = object({ + issuer = string + subjects = list(string) + }) + nullable = false + description = "WIF issuer and subjects for federated authentication." +} +``` + +## Backplane Outputs (Azure) + +```hcl +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 + } +} +``` + +## What to Avoid + +- ❌ `azuread_application` / `azuread_service_principal` — do not create SPNs +- ❌ `azuread_application_password` — no client secrets +- ❌ `existing_principal_ids` / `create_service_principal_name` toggle pattern — unnecessary complexity +- ❌ Conditional WIF-vs-secret logic — always use WIF with UAMIs + +## `meshstack_integration.tf` Wiring (Azure) + +In the integration file, pass the UAMI client ID as the `ARM_CLIENT_ID` environment variable: + +```hcl +module "backplane" { + source = "github.com/meshcloud/meshstack-hub//modules/azure//backplane?ref=${var.hub.git_ref}" + + name = var.backplane_name + scope = var.azure_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}" + ] + } +} +``` + +The `meshstack_integration.tf` must include `azure_location` variable (flat, provider-prefixed) for the UAMI placement. The resource group is derived from and managed by the backplane using `var.name`. + +## Checklist for Azure Backplanes + +- [ ] Uses `azurerm_user_assigned_identity` (not `azuread_application`) +- [ ] Uses `azurerm_federated_identity_credential` (not `azuread_application_federated_identity_credential`) +- [ ] No `azuread_application_password` resources +- [ ] No `create_service_principal_name` / `existing_principal_ids` toggle +- [ ] `workload_identity_federation` variable is non-nullable (always required) +- [ ] Outputs `identity.client_id`, `identity.principal_id`, `identity.tenant_id` +- [ ] `meshstack_integration.tf` includes `azure_location` variable (no separate `azure_resource_group_name` — the backplane manages its own resource group named after `var.name`) diff --git a/.github/copilot-instructions.md b/AGENTS.md similarity index 97% rename from .github/copilot-instructions.md rename to AGENTS.md index 0166f936..33458105 100644 --- a/.github/copilot-instructions.md +++ b/AGENTS.md @@ -1,4 +1,4 @@ -# GitHub Copilot Instructions — meshstack-hub +# meshstack-hub — Agent Instructions ## Purpose of this Repository @@ -167,6 +167,12 @@ resource "meshstack_building_block_definition" "this" { --- +## Azure Backplane Identity Conventions + +See [.github/instructions/azure-backplane.instructions.md](.github/instructions/azure-backplane.instructions.md) for the full Azure backplane identity conventions, including UAMI patterns, WIF wiring, required variables/outputs, and the Azure backplane checklist. + +--- + ## Documentation Requirements **`buildingblock/README.md`** — must include YAML front-matter: @@ -364,3 +370,4 @@ Pass `module..building_block_definition.version_ref` **directly** — do n - [ ] `meshstack` and `hub` variables are at the end of the variable section - [ ] `logo.png` included in `buildingblock/` - [ ] No trailing whitespace +- [ ] **Azure modules**: also follow the [Azure Backplane Checklist](.github/instructions/azure-backplane.instructions.md#checklist-for-azure-backplanes) diff --git a/modules/azure/budget-alert/backplane/README.md b/modules/azure/budget-alert/backplane/README.md index f7a67efe..77957642 100644 --- a/modules/azure/budget-alert/backplane/README.md +++ b/modules/azure/budget-alert/backplane/README.md @@ -23,40 +23,27 @@ No modules. | Name | Type | |------|------| -| [azuread_application.buildingblock_deploy](https://registry.terraform.io/providers/hashicorp/azuread/latest/docs/resources/application) | resource | -| [azuread_application_federated_identity_credential.buildingblock_deploy](https://registry.terraform.io/providers/hashicorp/azuread/latest/docs/resources/application_federated_identity_credential) | resource | -| [azuread_application_password.buildingblock_deploy](https://registry.terraform.io/providers/hashicorp/azuread/latest/docs/resources/application_password) | resource | -| [azuread_directory_role.directory_readers](https://registry.terraform.io/providers/hashicorp/azuread/latest/docs/resources/directory_role) | resource | -| [azuread_directory_role_assignment.directory_readers_created](https://registry.terraform.io/providers/hashicorp/azuread/latest/docs/resources/directory_role_assignment) | resource | -| [azuread_directory_role_assignment.directory_readers_existing](https://registry.terraform.io/providers/hashicorp/azuread/latest/docs/resources/directory_role_assignment) | resource | -| [azuread_service_principal.buildingblock_deploy](https://registry.terraform.io/providers/hashicorp/azuread/latest/docs/resources/service_principal) | resource | -| [azurerm_role_assignment.created_principal](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/role_assignment) | resource | -| [azurerm_role_assignment.existing_principals](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/role_assignment) | resource | -| [azurerm_role_definition.buildingblock_deploy](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/role_definition) | resource | -| [azurerm_subscription.current](https://registry.terraform.io/providers/hashicorp/azurerm/latest/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_definition.backplane](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 | |------|-------------|------|---------|:--------:| -| [create\_service\_principal\_name](#input\_create\_service\_principal\_name) | if set, creates a new service principal with the given name for deploying the building block | `string` | `null` | no | -| [existing\_principal\_ids](#input\_existing\_principal\_ids) | set of existing principal ids that will be granted permissions to deploy the building block | `set(string)` | `[]` | no | -| [name](#input\_name) | name of the building block, used for naming resources | `string` | `"budget-alert"` | no | -| [scope](#input\_scope) | Scope where the building block should be deployable, typically the parent of all Landing Zones. | `string` | n/a | yes | -| [workload\_identity\_federation](#input\_workload\_identity\_federation) | if set, configures workload identity federation for the created service principal |
object({
issuer = string
subject = string
})
| `null` | no | +| [location](#input\_location) | Azure region for the UAMI resource. | `string` | n/a | yes | +| [name](#input\_name) | Name for the building block identity and role definition. | `string` | `"budget-alert"` | no | +| [scope](#input\_scope) | Scope for role assignment (management group or subscription ID). | `string` | n/a | yes | +| [workload\_identity\_federation](#input\_workload\_identity\_federation) | WIF issuer and subjects for federated authentication. |
object({
issuer = string
subjects = list(string)
})
| n/a | yes | ## Outputs | Name | Description | |------|-------------| -| [application\_password](#output\_application\_password) | Information about the created application password (excludes the actual password value for security). | -| [created\_application](#output\_created\_application) | Information about the created Azure AD application. | -| [created\_service\_principal](#output\_created\_service\_principal) | Information about the created service principal. | -| [documentation\_md](#output\_documentation\_md) | Markdown documentation with information about the Budget Alert building block backplane | -| [role\_assignment\_ids](#output\_role\_assignment\_ids) | The IDs of the role assignments for all service principals. | -| [role\_assignment\_principal\_ids](#output\_role\_assignment\_principal\_ids) | The principal IDs of all service principals that have been assigned the role. | +| [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 building block. | | [role\_definition\_name](#output\_role\_definition\_name) | The name of the role definition that enables deployment of the building block. | -| [scope](#output\_scope) | The scope where the role definition and role assignments are applied. | -| [workload\_identity\_federation](#output\_workload\_identity\_federation) | Information about the created workload identity federation credential. | +| [scope](#output\_scope) | The scope where the role definition and role assignment are applied. | diff --git a/modules/azure/budget-alert/backplane/documentation.tf b/modules/azure/budget-alert/backplane/documentation.tf deleted file mode 100644 index 49d32d2c..00000000 --- a/modules/azure/budget-alert/backplane/documentation.tf +++ /dev/null @@ -1,36 +0,0 @@ -output "documentation_md" { - value = < s } - client_id = azuread_application.buildingblock_deploy[0].client_id - app_role_assignment_required = false + name = "subject-${each.key}" + resource_group_name = azurerm_resource_group.backplane.name + parent_id = azurerm_user_assigned_identity.backplane.id + audience = ["api://AzureADTokenExchange"] + issuer = var.workload_identity_federation.issuer + subject = each.value } -resource "azuread_application_federated_identity_credential" "buildingblock_deploy" { - count = var.create_service_principal_name != null && var.workload_identity_federation != null ? 1 : 0 - - application_id = azuread_application.buildingblock_deploy[0].id - display_name = var.create_service_principal_name - audiences = ["api://AzureADTokenExchange"] - issuer = var.workload_identity_federation.issuer - subject = var.workload_identity_federation.subject -} - -resource "azuread_application_password" "buildingblock_deploy" { - count = var.create_service_principal_name != null && var.workload_identity_federation == null ? 1 : 0 - - application_id = azuread_application.buildingblock_deploy[0].id - display_name = "${var.create_service_principal_name}-password" -} - -resource "azurerm_role_definition" "buildingblock_deploy" { +resource "azurerm_role_definition" "backplane" { name = "${var.name}-deploy" description = "Enables deployment of the ${var.name} building block to subscriptions" scope = var.scope @@ -51,34 +40,8 @@ resource "azurerm_role_definition" "buildingblock_deploy" { } } -resource "azurerm_role_assignment" "existing_principals" { - for_each = var.existing_principal_ids - - role_definition_id = azurerm_role_definition.buildingblock_deploy.role_definition_resource_id - principal_id = each.value - scope = var.scope -} - -resource "azurerm_role_assignment" "created_principal" { - count = var.create_service_principal_name != null ? 1 : 0 - - role_definition_id = azurerm_role_definition.buildingblock_deploy.role_definition_resource_id - principal_id = azuread_service_principal.buildingblock_deploy[0].object_id +resource "azurerm_role_assignment" "backplane" { scope = var.scope -} - -resource "azuread_directory_role" "directory_readers" { - display_name = "Directory Readers" -} - -resource "azuread_directory_role_assignment" "directory_readers_existing" { - for_each = var.existing_principal_ids - role_id = azuread_directory_role.directory_readers.template_id - principal_object_id = each.value -} - -resource "azuread_directory_role_assignment" "directory_readers_created" { - count = var.create_service_principal_name != null ? 1 : 0 - role_id = azuread_directory_role.directory_readers.template_id - principal_object_id = azuread_service_principal.buildingblock_deploy[0].object_id + role_definition_id = azurerm_role_definition.backplane.role_definition_resource_id + principal_id = azurerm_user_assigned_identity.backplane.principal_id } diff --git a/modules/azure/budget-alert/backplane/outputs.tf b/modules/azure/budget-alert/backplane/outputs.tf index 2810be8f..22b826f7 100644 --- a/modules/azure/budget-alert/backplane/outputs.tf +++ b/modules/azure/budget-alert/backplane/outputs.tf @@ -1,69 +1,23 @@ +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.id + value = azurerm_role_definition.backplane.id description = "The ID of the role definition that enables deployment of the building block." } output "role_definition_name" { - value = azurerm_role_definition.buildingblock_deploy.name + value = azurerm_role_definition.backplane.name description = "The name of the role definition that enables deployment of the building block." } -output "role_assignment_ids" { - value = concat( - [for id in azurerm_role_assignment.existing_principals : id.id], - var.create_service_principal_name != null ? [azurerm_role_assignment.created_principal[0].id] : [] - ) - description = "The IDs of the role assignments for all service principals." -} - -output "role_assignment_principal_ids" { - value = concat( - [for id in azurerm_role_assignment.existing_principals : id.principal_id], - var.create_service_principal_name != null ? [azurerm_role_assignment.created_principal[0].principal_id] : [] - ) - description = "The principal IDs of all service principals that have been assigned the role." -} - -output "created_service_principal" { - value = var.create_service_principal_name != null ? { - object_id = azuread_service_principal.buildingblock_deploy[0].object_id - client_id = azuread_service_principal.buildingblock_deploy[0].client_id - display_name = azuread_service_principal.buildingblock_deploy[0].display_name - name = var.create_service_principal_name - } : null - description = "Information about the created service principal." -} - -output "created_application" { - value = var.create_service_principal_name != null ? { - object_id = azuread_application.buildingblock_deploy[0].object_id - client_id = azuread_application.buildingblock_deploy[0].client_id - display_name = azuread_application.buildingblock_deploy[0].display_name - } : null - description = "Information about the created Azure AD application." -} - -output "workload_identity_federation" { - value = var.create_service_principal_name != null && var.workload_identity_federation != null ? { - credential_id = azuread_application_federated_identity_credential.buildingblock_deploy[0].credential_id - display_name = azuread_application_federated_identity_credential.buildingblock_deploy[0].display_name - issuer = azuread_application_federated_identity_credential.buildingblock_deploy[0].issuer - subject = azuread_application_federated_identity_credential.buildingblock_deploy[0].subject - audiences = azuread_application_federated_identity_credential.buildingblock_deploy[0].audiences - } : null - description = "Information about the created workload identity federation credential." -} - -output "application_password" { - value = var.create_service_principal_name != null && var.workload_identity_federation == null ? { - key_id = azuread_application_password.buildingblock_deploy[0].key_id - display_name = azuread_application_password.buildingblock_deploy[0].display_name - } : null - description = "Information about the created application password (excludes the actual password value for security)." - sensitive = true -} - output "scope" { value = var.scope - description = "The scope where the role definition and role assignments are applied." + description = "The scope where the role definition and role assignment are applied." } diff --git a/modules/azure/budget-alert/backplane/variables.tf b/modules/azure/budget-alert/backplane/variables.tf index d0254192..f18925e8 100644 --- a/modules/azure/budget-alert/backplane/variables.tf +++ b/modules/azure/budget-alert/backplane/variables.tf @@ -2,7 +2,7 @@ variable "name" { type = string nullable = false default = "budget-alert" - description = "name of the building block, used for naming resources" + description = "Name for the building block identity and role definition." validation { condition = can(regex("^[-a-z0-9]+$", var.name)) error_message = "Only alphanumeric lowercase characters and dashes are allowed" @@ -12,29 +12,20 @@ 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 for role assignment (management group or subscription ID)." } -variable "existing_principal_ids" { - type = set(string) - nullable = false - default = [] - description = "set of existing principal ids that will be granted permissions to deploy the building block" -} - -variable "create_service_principal_name" { +variable "location" { type = string - nullable = true - default = null - description = "if set, creates a new service principal with the given name for deploying the building block" + nullable = false + description = "Azure region for the UAMI resource." } variable "workload_identity_federation" { type = object({ - issuer = string - subject = string + issuer = string + subjects = list(string) }) - nullable = true - default = null - description = "if set, configures workload identity federation for the created service principal" + nullable = false + description = "WIF issuer and subjects for federated authentication." } \ No newline at end of file diff --git a/modules/azure/budget-alert/buildingblock/README.md b/modules/azure/budget-alert/buildingblock/README.md index ddd2a23f..54ed8de2 100644 --- a/modules/azure/budget-alert/buildingblock/README.md +++ b/modules/azure/budget-alert/buildingblock/README.md @@ -22,7 +22,7 @@ Please reference the [backplane implementation](../backplane/) for the required |------|---------| | [terraform](#requirement\_terraform) | >= 1.0 | | [azurerm](#requirement\_azurerm) | ~> 4.64 | -| [time](#requirement\_time) | 0.11.1 | +| [time](#requirement\_time) | ~> 0.11 | ## Modules @@ -33,7 +33,7 @@ No modules. | Name | Type | |------|------| | [azurerm_consumption_budget_subscription.subscription_budget](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/consumption_budget_subscription) | resource | -| [time_static.start_date](https://registry.terraform.io/providers/hashicorp/time/0.11.1/docs/resources/static) | resource | +| [time_static.start_date](https://registry.terraform.io/providers/hashicorp/time/latest/docs/resources/static) | resource | | [azurerm_subscription.subscription](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/data-sources/subscription) | data source | ## Inputs diff --git a/modules/azure/budget-alert/buildingblock/versions.tf b/modules/azure/budget-alert/buildingblock/versions.tf index 664ceec0..7e57bcfa 100644 --- a/modules/azure/budget-alert/buildingblock/versions.tf +++ b/modules/azure/budget-alert/buildingblock/versions.tf @@ -8,7 +8,7 @@ terraform { } time = { source = "hashicorp/time" - version = "0.11.1" + version = "~> 0.11" } } } diff --git a/modules/azure/budget-alert/e2e/provider.tf b/modules/azure/budget-alert/e2e/provider.tf index 50f8fdfa..69325904 100644 --- a/modules/azure/budget-alert/e2e/provider.tf +++ b/modules/azure/budget-alert/e2e/provider.tf @@ -4,7 +4,3 @@ provider "azurerm" { features {} } - -provider "azuread" { - tenant_id = var.test_context.fixtures.azure.entra_tenant_id -} diff --git a/modules/azure/budget-alert/e2e/terraform.tf b/modules/azure/budget-alert/e2e/terraform.tf index 8d29dc32..39e32bf6 100644 --- a/modules/azure/budget-alert/e2e/terraform.tf +++ b/modules/azure/budget-alert/e2e/terraform.tf @@ -8,8 +8,5 @@ terraform { azurerm = { source = "hashicorp/azurerm" } - azuread = { - source = "hashicorp/azuread" - } } } diff --git a/modules/azure/budget-alert/meshstack_integration.tf b/modules/azure/budget-alert/meshstack_integration.tf index b97619ed..42e965d7 100644 --- a/modules/azure/budget-alert/meshstack_integration.tf +++ b/modules/azure/budget-alert/meshstack_integration.tf @@ -13,6 +13,12 @@ variable "azure_scope" { description = "Azure management group or subscription scope for backplane role assignment." } +variable "azure_location" { + type = string + default = "germanywestcentral" + description = "Azure region where the backplane UAMI will be created." +} + variable "backplane_name" { type = string default = "azure-budget-alert" @@ -38,6 +44,8 @@ variable "hub" { git_ref = optional(string, "main") bbd_draft = optional(bool, true) }) + # not supported until we upgrade to tofu 1.12+ + # 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 the meshstack-hub repo. @@ -56,16 +64,17 @@ output "building_block_definition" { data "meshstack_integrations" "integrations" {} module "backplane" { - source = "github.com/meshcloud/meshstack-hub//modules/azure/budget-alert/backplane?ref=226e58cb166002c7e505d6deb9e7bbf7e9c9edd1" + source = "github.com/meshcloud/meshstack-hub//modules/azure/budget-alert/backplane?ref=${var.hub.git_ref}" - name = var.backplane_name - scope = var.azure_scope - - create_service_principal_name = var.backplane_name + name = var.backplane_name + scope = var.azure_scope + location = var.azure_location workload_identity_federation = { - issuer = data.meshstack_integrations.integrations.workload_identity_federation.replicator.issuer - subject = "${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}" + 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}" + ] } } @@ -128,7 +137,7 @@ resource "meshstack_building_block_definition" "this" { description = "Client ID of the service principal used to authenticate with Azure." assignment_type = "STATIC" is_environment = true - argument = jsonencode(module.backplane.created_service_principal.client_id) + argument = jsonencode(module.backplane.identity.client_id) } ARM_TENANT_ID = { type = "STRING" @@ -227,9 +236,5 @@ terraform { source = "hashicorp/azurerm" version = "~> 4.64" } - azuread = { - source = "hashicorp/azuread" - version = "~> 3.8" - } } } diff --git a/modules/azure/storage-account/backplane/README.md b/modules/azure/storage-account/backplane/README.md index 283078cd..bafcb7d5 100644 --- a/modules/azure/storage-account/backplane/README.md +++ b/modules/azure/storage-account/backplane/README.md @@ -108,7 +108,6 @@ module "storage_account_backplane" { | Name | Version | |------|---------| | [terraform](#requirement\_terraform) | >= 1.0 | -| [azuread](#requirement\_azuread) | ~> 3.8 | | [azurerm](#requirement\_azurerm) | ~> 4.64 | ## Modules @@ -119,36 +118,27 @@ No modules. | Name | Type | |------|------| -| [azuread_application.buildingblock_deploy](https://registry.terraform.io/providers/hashicorp/azuread/latest/docs/resources/application) | resource | -| [azuread_application_federated_identity_credential.buildingblock_deploy](https://registry.terraform.io/providers/hashicorp/azuread/latest/docs/resources/application_federated_identity_credential) | resource | -| [azuread_application_password.buildingblock_deploy](https://registry.terraform.io/providers/hashicorp/azuread/latest/docs/resources/application_password) | resource | -| [azuread_service_principal.buildingblock_deploy](https://registry.terraform.io/providers/hashicorp/azuread/latest/docs/resources/service_principal) | resource | -| [azurerm_role_assignment.created_principal](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/role_assignment) | resource | -| [azurerm_role_assignment.existing_principals](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/role_assignment) | resource | -| [azurerm_role_definition.buildingblock_deploy](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/role_definition) | resource | +| [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_definition.backplane](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 | |------|-------------|------|---------|:--------:| -| [create\_service\_principal\_name](#input\_create\_service\_principal\_name) | name of a service principal to create and grant permissions to deploy the building block | `string` | `null` | no | -| [existing\_principal\_ids](#input\_existing\_principal\_ids) | set of existing principal ids that will be granted permissions to deploy the building block | `set(string)` | `[]` | no | -| [name](#input\_name) | name of the building block, used for naming resources | `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 | -| [workload\_identity\_federation](#input\_workload\_identity\_federation) | Configuration for workload identity federation. If not provided, an application password will be created instead. Supports multiple subjects. |
object({
issuer = string
subjects = list(string)
})
| `null` | no | +| [location](#input\_location) | Azure region for the UAMI resource. | `string` | n/a | yes | +| [name](#input\_name) | Name for the building block identity and role definition. | `string` | n/a | yes | +| [scope](#input\_scope) | Scope for role assignment (management group or subscription ID). | `string` | n/a | yes | +| [workload\_identity\_federation](#input\_workload\_identity\_federation) | WIF issuer and subjects for federated authentication. |
object({
issuer = string
subjects = list(string)
})
| n/a | yes | ## Outputs | Name | Description | |------|-------------| -| [application\_password](#output\_application\_password) | Information about the created application password (excludes the actual password value for security). | -| [created\_application](#output\_created\_application) | Information about the created Azure AD application. | -| [created\_service\_principal](#output\_created\_service\_principal) | Information about the created service principal. | -| [documentation\_md](#output\_documentation\_md) | Markdown documentation with information about the Storage Account Building Block building block backplane | -| [role\_assignment\_ids](#output\_role\_assignment\_ids) | The IDs of the role assignments for all service principals. | -| [role\_assignment\_principal\_ids](#output\_role\_assignment\_principal\_ids) | The principal IDs of all service principals that have been assigned the role. | +| [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 building block to subscriptions. | | [role\_definition\_name](#output\_role\_definition\_name) | The name of the role definition that enables deployment of the building block to subscriptions. | -| [scope](#output\_scope) | The scope where the role definition and role assignments are applied. | -| [workload\_identity\_federation](#output\_workload\_identity\_federation) | Information about the created workload identity federation credentials. | +| [scope](#output\_scope) | The scope where the role definition and role assignment are applied. | diff --git a/modules/azure/storage-account/backplane/documentation.tf b/modules/azure/storage-account/backplane/documentation.tf deleted file mode 100644 index e169f0a8..00000000 --- a/modules/azure/storage-account/backplane/documentation.tf +++ /dev/null @@ -1,19 +0,0 @@ -output "documentation_md" { - value = <", formatlist("- `%s`", azurerm_role_definition.buildingblock_deploy.permissions[0].actions))} | - -EOF - description = "Markdown documentation with information about the Storage Account Building Block building block backplane" -} - diff --git a/modules/azure/storage-account/backplane/main.tf b/modules/azure/storage-account/backplane/main.tf index cf3533f8..267ae7fc 100644 --- a/modules/azure/storage-account/backplane/main.tf +++ b/modules/azure/storage-account/backplane/main.tf @@ -1,42 +1,26 @@ -# Service Principal Creation -resource "azuread_application" "buildingblock_deploy" { - count = var.create_service_principal_name != null ? 1 : 0 - - display_name = var.create_service_principal_name +resource "azurerm_resource_group" "backplane" { + name = var.name + location = var.location } -resource "azuread_service_principal" "buildingblock_deploy" { - count = var.create_service_principal_name != null ? 1 : 0 - - client_id = azuread_application.buildingblock_deploy[0].client_id - app_role_assignment_required = false +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 } -# Create federated identity credentials (one per subject) -# Use a map with static numeric string keys so that for_each keys are known at plan time, -# even when subject values contain apply-time unknowns (e.g. building block definition UUIDs). -resource "azuread_application_federated_identity_credential" "buildingblock_deploy" { - for_each = var.create_service_principal_name != null && var.workload_identity_federation != null ? { - for i, s in var.workload_identity_federation.subjects : tostring(i) => s - } : {} - - application_id = azuread_application.buildingblock_deploy[0].id - display_name = "subject-${each.key}" - audiences = ["api://AzureADTokenExchange"] - issuer = var.workload_identity_federation.issuer - subject = each.value -} -# Create application password (when not using workload identity federation) -resource "azuread_application_password" "buildingblock_deploy" { - count = var.create_service_principal_name != null && var.workload_identity_federation == null ? 1 : 0 - - application_id = azuread_application.buildingblock_deploy[0].id - display_name = "${var.create_service_principal_name}-password" + name = "subject-${each.key}" + resource_group_name = azurerm_resource_group.backplane.name + parent_id = azurerm_user_assigned_identity.backplane.id + audience = ["api://AzureADTokenExchange"] + issuer = var.workload_identity_federation.issuer + subject = each.value } -# Role Definition -resource "azurerm_role_definition" "buildingblock_deploy" { +resource "azurerm_role_definition" "backplane" { name = "${var.name}-deploy" description = "Enables deployment of the ${var.name} building block to subscriptions" scope = var.scope @@ -72,20 +56,8 @@ resource "azurerm_role_definition" "buildingblock_deploy" { } } -# Role Assignments for existing principals -resource "azurerm_role_assignment" "existing_principals" { - for_each = var.existing_principal_ids - - role_definition_id = azurerm_role_definition.buildingblock_deploy.role_definition_resource_id - principal_id = each.value - scope = var.scope -} - -# Role Assignment for created service principal -resource "azurerm_role_assignment" "created_principal" { - count = var.create_service_principal_name != null ? 1 : 0 - - role_definition_id = azurerm_role_definition.buildingblock_deploy.role_definition_resource_id - principal_id = azuread_service_principal.buildingblock_deploy[0].object_id +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 } diff --git a/modules/azure/storage-account/backplane/outputs.tf b/modules/azure/storage-account/backplane/outputs.tf index e924ea8b..5d79c804 100644 --- a/modules/azure/storage-account/backplane/outputs.tf +++ b/modules/azure/storage-account/backplane/outputs.tf @@ -1,70 +1,24 @@ +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.id + value = azurerm_role_definition.backplane.id description = "The ID of the role definition that enables deployment of the building block to subscriptions." } output "role_definition_name" { - value = azurerm_role_definition.buildingblock_deploy.name + value = azurerm_role_definition.backplane.name description = "The name of the role definition that enables deployment of the building block to subscriptions." } -output "role_assignment_ids" { - value = concat( - [for id in azurerm_role_assignment.existing_principals : id.id], - var.create_service_principal_name != null ? [azurerm_role_assignment.created_principal[0].id] : [] - ) - description = "The IDs of the role assignments for all service principals." -} - -output "role_assignment_principal_ids" { - value = concat( - [for id in azurerm_role_assignment.existing_principals : id.principal_id], - var.create_service_principal_name != null ? [azurerm_role_assignment.created_principal[0].principal_id] : [] - ) - description = "The principal IDs of all service principals that have been assigned the role." -} - -output "created_service_principal" { - value = var.create_service_principal_name != null ? { - object_id = azuread_service_principal.buildingblock_deploy[0].object_id - client_id = azuread_service_principal.buildingblock_deploy[0].client_id - display_name = azuread_service_principal.buildingblock_deploy[0].display_name - name = var.create_service_principal_name - } : null - description = "Information about the created service principal." -} - -output "created_application" { - value = var.create_service_principal_name != null ? { - object_id = azuread_application.buildingblock_deploy[0].object_id - client_id = azuread_application.buildingblock_deploy[0].client_id - display_name = azuread_application.buildingblock_deploy[0].display_name - } : null - description = "Information about the created Azure AD application." -} -output "workload_identity_federation" { - value = var.create_service_principal_name != null && var.workload_identity_federation != null ? [ - for wif in azuread_application_federated_identity_credential.buildingblock_deploy : { - credential_id = wif.credential_id - display_name = wif.display_name - issuer = wif.issuer - subject = wif.subject - audiences = wif.audiences - }] : null - description = "Information about the created workload identity federation credentials." -} - -output "application_password" { - value = var.create_service_principal_name != null && var.workload_identity_federation == null ? { - key_id = azuread_application_password.buildingblock_deploy[0].key_id - display_name = azuread_application_password.buildingblock_deploy[0].display_name - } : null - description = "Information about the created application password (excludes the actual password value for security)." - sensitive = true -} - output "scope" { value = var.scope - description = "The scope where the role definition and role assignments are applied." + description = "The scope where the role definition and role assignment are applied." } diff --git a/modules/azure/storage-account/backplane/variables.tf b/modules/azure/storage-account/backplane/variables.tf index 488952bf..f33ee46b 100644 --- a/modules/azure/storage-account/backplane/variables.tf +++ b/modules/azure/storage-account/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 and role definition." validation { condition = can(regex("^[-a-z0-9]+$", var.name)) error_message = "Only alphanumeric lowercase characters and dashes are allowed" @@ -11,24 +11,13 @@ 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 for role assignment (management group or subscription ID)." } -variable "existing_principal_ids" { - type = set(string) - default = [] - description = "set of existing principal ids that will be granted permissions to deploy the building block" -} - -variable "create_service_principal_name" { +variable "location" { type = string - default = null - description = "name of a service principal to create and grant permissions to deploy the building block" - - validation { - condition = var.create_service_principal_name == null ? true : can(regex("^[-a-zA-Z0-9_]+$", var.create_service_principal_name)) - error_message = "Service principal name can only contain alphanumeric characters, hyphens, and underscores" - } + nullable = false + description = "Azure region for the UAMI resource." } variable "workload_identity_federation" { @@ -36,6 +25,6 @@ variable "workload_identity_federation" { issuer = string subjects = list(string) }) - default = null - description = "Configuration for workload identity federation. If not provided, an application password will be created instead. Supports multiple subjects." + nullable = false + description = "WIF issuer and subjects for federated authentication." } diff --git a/modules/azure/storage-account/backplane/versions.tf b/modules/azure/storage-account/backplane/versions.tf index 630c0652..67c6c566 100644 --- a/modules/azure/storage-account/backplane/versions.tf +++ b/modules/azure/storage-account/backplane/versions.tf @@ -6,10 +6,6 @@ terraform { source = "hashicorp/azurerm" version = "~> 4.64" } - azuread = { - source = "hashicorp/azuread" - version = "~> 3.8" - } } } diff --git a/modules/azure/storage-account/e2e/provider.tf b/modules/azure/storage-account/e2e/provider.tf index 50f8fdfa..69325904 100644 --- a/modules/azure/storage-account/e2e/provider.tf +++ b/modules/azure/storage-account/e2e/provider.tf @@ -4,7 +4,3 @@ provider "azurerm" { features {} } - -provider "azuread" { - tenant_id = var.test_context.fixtures.azure.entra_tenant_id -} diff --git a/modules/azure/storage-account/e2e/terraform.tf b/modules/azure/storage-account/e2e/terraform.tf index 8d29dc32..39e32bf6 100644 --- a/modules/azure/storage-account/e2e/terraform.tf +++ b/modules/azure/storage-account/e2e/terraform.tf @@ -8,8 +8,5 @@ terraform { azurerm = { source = "hashicorp/azurerm" } - azuread = { - source = "hashicorp/azuread" - } } } diff --git a/modules/azure/storage-account/meshstack_integration.tf b/modules/azure/storage-account/meshstack_integration.tf index 9b2ea684..5b3e6f71 100644 --- a/modules/azure/storage-account/meshstack_integration.tf +++ b/modules/azure/storage-account/meshstack_integration.tf @@ -44,6 +44,7 @@ variable "hub" { 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 the meshstack-hub repo. @@ -62,12 +63,11 @@ output "building_block_definition" { data "meshstack_integrations" "integrations" {} module "backplane" { - source = "github.com/meshcloud/meshstack-hub//modules/azure/storage-account/backplane?ref=0a6d313e509e1c9052712f0d9c41c2d0a96f9a39" + source = "github.com/meshcloud/meshstack-hub//modules/azure/storage-account/backplane?ref=${var.hub.git_ref}" - name = var.backplane_name - scope = var.azure_scope - - create_service_principal_name = var.backplane_name + name = var.backplane_name + scope = var.azure_scope + location = var.azure_location workload_identity_federation = { issuer = data.meshstack_integrations.integrations.workload_identity_federation.replicator.issuer @@ -136,7 +136,7 @@ resource "meshstack_building_block_definition" "this" { description = "Client ID of the service principal used to authenticate with Azure." assignment_type = "STATIC" is_environment = true - argument = jsonencode(module.backplane.created_service_principal.client_id) + argument = jsonencode(module.backplane.identity.client_id) } ARM_TENANT_ID = { type = "STRING" @@ -222,9 +222,5 @@ terraform { source = "hashicorp/azurerm" version = "~> 4.64" } - azuread = { - source = "hashicorp/azuread" - version = "~> 3.8" - } } } diff --git a/tools/scorecard/scorecard.mjs b/tools/scorecard/scorecard.mjs index ca73e5d3..077f5709 100755 --- a/tools/scorecard/scorecard.mjs +++ b/tools/scorecard/scorecard.mjs @@ -4,8 +4,13 @@ * * Scans every building block module and assesses maturity based on * deterministic criteria derived from the repository conventions. + * Checks are organized into categories with conditional applicability: + * - Core Structure: applies to all modules + * - Integration: applies only to modules with meshstack_integration.tf + * - Azure Backplane: applies only to Azure modules with a backplane/ + * - Testing: applies to all modules (aspirational) * - * Usage: node tools/scorecard/scorecard.mjs + * Usage: node tools/scorecard/scorecard.mjs [--category=] [--provider=] */ import { readFileSync, existsSync, readdirSync, statSync } from "fs"; @@ -14,12 +19,44 @@ import { join, relative } from "path"; const ROOT = new URL("../../", import.meta.url).pathname.replace(/\/$/, ""); const MODULES_DIR = join(ROOT, "modules"); +// ─── Category definitions ─────────────────────────────────────────────────── + +const CATEGORIES = { + core: { + id: "core", + name: "Core Structure", + description: "Basic module file structure and documentation", + appliesTo: () => true, + }, + integration: { + id: "integration", + name: "Integration", + description: "meshstack_integration.tf conventions", + appliesTo: (mod) => existsSync(join(mod.path, "meshstack_integration.tf")), + }, + azure_backplane: { + id: "azure_backplane", + name: "Azure Backplane", + description: "Azure UAMI-based automation principal conventions", + appliesTo: (mod) => + mod.provider === "azure" && existsSync(join(mod.path, "backplane")), + }, + testing: { + id: "testing", + name: "Testing", + description: "End-to-end test coverage", + appliesTo: () => true, + }, +}; + // ─── Detector functions ───────────────────────────────────────────────────── // Each detector returns { pass: boolean, detail?: string } const detectors = [ + // ─── Core Structure ───────────────────────────────────────────────────── { id: "buildingblock_dir", + category: "core", name: "buildingblock/ directory exists", emoji: "📦", fn: (mod) => ({ @@ -28,6 +65,7 @@ const detectors = [ }, { id: "meshstack_integration", + category: "core", name: "meshstack_integration.tf present", emoji: "🔗", fn: (mod) => ({ @@ -36,6 +74,7 @@ const detectors = [ }, { id: "readme_frontmatter", + category: "core", name: "buildingblock/README.md with YAML front-matter", emoji: "📝", fn: (mod) => { @@ -54,14 +93,43 @@ const detectors = [ }, { id: "logo", + category: "core", name: "buildingblock/logo.png included", emoji: "🖼️", fn: (mod) => ({ pass: existsSync(join(mod.path, "buildingblock", "logo.png")), }), }, + { + id: "versions_tf", + category: "core", + name: "buildingblock/versions.tf present", + emoji: "📌", + fn: (mod) => ({ + pass: existsSync(join(mod.path, "buildingblock", "versions.tf")), + }), + }, + { + id: "provider_pinned", + category: "core", + name: "Provider versions pinned (~>)", + emoji: "🔒", + fn: (mod) => { + const versionsPath = join(mod.path, "buildingblock", "versions.tf"); + if (!existsSync(versionsPath)) return { pass: false, detail: "no versions.tf" }; + const content = readFileSync(versionsPath, "utf-8"); + const versionLines = content.match(/version\s*=\s*"[^"]+"/g); + if (!versionLines || versionLines.length === 0) + return { pass: true, detail: "no version constraints" }; + const allPinned = versionLines.every((l) => l.includes("~>")); + return { pass: allPinned }; + }, + }, + + // ─── Integration ──────────────────────────────────────────────────────── { id: "variable_hub", + category: "integration", name: 'variable "hub" in integration', emoji: "🏷️", fn: (mod) => { @@ -72,6 +140,7 @@ const detectors = [ }, { id: "variable_meshstack", + category: "integration", name: 'variable "meshstack" in integration', emoji: "🏢", fn: (mod) => { @@ -82,6 +151,7 @@ const detectors = [ }, { id: "output_bbd", + category: "integration", name: "building_block_definition output exposed", emoji: "📤", fn: (mod) => { @@ -94,6 +164,7 @@ const detectors = [ }, { id: "required_providers_meshstack", + category: "integration", name: "meshcloud/meshstack in required_providers", emoji: "🔌", fn: (mod) => { @@ -106,24 +177,24 @@ const detectors = [ }, { id: "variable_hub_const", + category: "integration", name: 'variable "hub" has const = true', emoji: "🔐", fn: (mod) => { const content = readIntegrationTf(mod); if (!content) return { pass: false, detail: "no integration file" }; if (!/variable\s+"hub"/.test(content)) return { pass: false, detail: 'no variable "hub"' }; - // const = true must appear somewhere in the file (it's only valid on a variable) return { pass: /^\s*const\s*=\s*true/m.test(content) }; }, }, { id: "backplane_source_hub_git_ref", + category: "integration", name: "backplane source uses var.hub.git_ref", emoji: "📎", fn: (mod) => { const content = readIntegrationTf(mod); if (!content) return { pass: false, detail: "no integration file" }; - // If no backplane module source exists, treat as passing (backplane is optional) const hasBackplaneSource = /source\s*=\s*"[^"]*\/backplane[^"]*"/.test(content); if (!hasBackplaneSource) return { pass: true, detail: "no backplane module" }; return { @@ -134,12 +205,12 @@ const detectors = [ }, { id: "ref_name_hub_git_ref", + category: "integration", name: "ref_name uses var.hub.git_ref", emoji: "🔀", fn: (mod) => { const content = readIntegrationTf(mod); if (!content) return { pass: false, detail: "no integration file" }; - // Check that ref_name references var.hub.git_ref, not a hardcoded string const hasRefName = /ref_name\s*=/.test(content); if (!hasRefName) return { pass: false, detail: "no ref_name found" }; return { pass: /ref_name\s*=\s*var\.hub\.git_ref/.test(content) }; @@ -147,6 +218,7 @@ const detectors = [ }, { id: "bbd_draft", + category: "integration", name: "version_spec.draft uses var.hub.bbd_draft", emoji: "📋", fn: (mod) => { @@ -157,6 +229,7 @@ const detectors = [ }, { id: "bbd_tags_forwarded", + category: "integration", name: "BBD metadata.tags forwards var.meshstack.tags", emoji: "🏷️", fn: (mod) => { @@ -167,6 +240,7 @@ const detectors = [ }, { id: "bbd_readme", + category: "integration", name: "BBD readme field present", emoji: "📖", fn: (mod) => { @@ -175,8 +249,143 @@ const detectors = [ return { pass: /readme\s*=/.test(content) }; }, }, + + // ─── Azure Backplane ──────────────────────────────────────────────────── + { + id: "azure_uses_uami", + category: "azure_backplane", + name: "Uses azurerm_user_assigned_identity", + emoji: "🪪", + fn: (mod) => { + const mainTf = readBackplaneTf(mod); + if (!mainTf) return { pass: false, detail: "no backplane main.tf" }; + return { + pass: /resource\s+"azurerm_user_assigned_identity"/.test(mainTf), + }; + }, + }, + { + id: "azure_no_azuread_application", + category: "azure_backplane", + name: "No azuread_application resources", + emoji: "🚫", + fn: (mod) => { + const allTf = readAllBackplaneTf(mod); + if (!allTf) return { pass: true, detail: "no backplane tf files" }; + return { + pass: !/resource\s+"azuread_application"/.test(allTf), + detail: "azuread_application found — migrate to azurerm_user_assigned_identity", + }; + }, + }, + { + id: "azure_no_spn", + category: "azure_backplane", + name: "No azuread_service_principal resources", + emoji: "🚫", + fn: (mod) => { + const allTf = readAllBackplaneTf(mod); + if (!allTf) return { pass: true, detail: "no backplane tf files" }; + return { + pass: !/resource\s+"azuread_service_principal"/.test(allTf), + detail: "azuread_service_principal found — migrate to UAMI", + }; + }, + }, + { + id: "azure_no_app_password", + category: "azure_backplane", + name: "No azuread_application_password resources", + emoji: "🔑", + fn: (mod) => { + const allTf = readAllBackplaneTf(mod); + if (!allTf) return { pass: true, detail: "no backplane tf files" }; + return { + pass: !/resource\s+"azuread_application_password"/.test(allTf), + detail: "client secret found — UAMIs with WIF need no secrets", + }; + }, + }, + { + id: "azure_federated_identity_credential", + category: "azure_backplane", + name: "Uses azurerm_federated_identity_credential", + emoji: "🔗", + fn: (mod) => { + const allTf = readAllBackplaneTf(mod); + if (!allTf) return { pass: false, detail: "no backplane tf files" }; + return { + pass: /resource\s+"azurerm_federated_identity_credential"/.test(allTf), + }; + }, + }, + { + id: "azure_wif_nonnullable", + category: "azure_backplane", + name: "workload_identity_federation is non-nullable", + emoji: "⚡", + fn: (mod) => { + const varsTf = readBackplaneFile(mod, "variables.tf"); + if (!varsTf) return { pass: false, detail: "no variables.tf" }; + const hasWifVar = /variable\s+"workload_identity_federation"/.test(varsTf); + if (!hasWifVar) return { pass: false, detail: "variable not found" }; + const hasNullableFalse = /nullable\s*=\s*false/.test(varsTf); + const hasDefaultNull = /variable\s+"workload_identity_federation"[\s\S]*?default\s*=\s*null/.test(varsTf); + return { + pass: hasNullableFalse || !hasDefaultNull, + detail: hasDefaultNull ? "default = null makes WIF optional" : undefined, + }; + }, + }, + { + id: "azure_no_create_spn_toggle", + category: "azure_backplane", + name: "No create_service_principal_name toggle", + emoji: "🧹", + fn: (mod) => { + const varsTf = readBackplaneFile(mod, "variables.tf"); + if (!varsTf) return { pass: true, detail: "no variables.tf" }; + return { + pass: !/variable\s+"create_service_principal_name"/.test(varsTf), + detail: "legacy toggle pattern — remove in favour of UAMI", + }; + }, + }, + { + id: "azure_identity_output", + category: "azure_backplane", + name: 'Outputs identity (client_id, principal_id, tenant_id)', + emoji: "📤", + fn: (mod) => { + const outputsTf = readBackplaneFile(mod, "outputs.tf"); + if (!outputsTf) return { pass: false, detail: "no outputs.tf" }; + return { + pass: /output\s+"identity"/.test(outputsTf), + detail: 'missing output "identity" block', + }; + }, + }, + { + id: "azure_integration_rg_location", + category: "azure_backplane", + name: "Integration has azure_resource_group_name & azure_location", + emoji: "📍", + fn: (mod) => { + const content = readIntegrationTf(mod); + if (!content) return { pass: false, detail: "no integration file" }; + const hasRg = /variable\s+"azure_resource_group_name"/.test(content); + const hasLocation = /variable\s+"azure_location"/.test(content); + return { + pass: hasRg && hasLocation, + detail: !hasRg ? "missing azure_resource_group_name" : "missing azure_location", + }; + }, + }, + + // ─── Testing ──────────────────────────────────────────────────────────── { id: "backplane", + category: "testing", name: "backplane/ directory (optional tier)", emoji: "⚙️", fn: (mod) => ({ @@ -185,6 +394,7 @@ const detectors = [ }, { id: "e2e_tests", + category: "testing", name: "e2e/ test directory exists", emoji: "🧪", fn: (mod) => ({ @@ -193,6 +403,7 @@ const detectors = [ }, { id: "e2e_tftest", + category: "testing", name: "e2e/ contains .tftest.hcl files", emoji: "✅", fn: (mod) => { @@ -202,30 +413,6 @@ const detectors = [ return { pass: files.some((f) => f.endsWith(".tftest.hcl")) }; }, }, - { - id: "versions_tf", - name: "buildingblock/versions.tf present", - emoji: "📌", - fn: (mod) => ({ - pass: existsSync(join(mod.path, "buildingblock", "versions.tf")), - }), - }, - { - id: "provider_pinned", - name: "Provider versions pinned (~>)", - emoji: "🔒", - fn: (mod) => { - const versionsPath = join(mod.path, "buildingblock", "versions.tf"); - if (!existsSync(versionsPath)) return { pass: false, detail: "no versions.tf" }; - const content = readFileSync(versionsPath, "utf-8"); - // Check if there are version constraints and they use ~> - const versionLines = content.match(/version\s*=\s*"[^"]+"/g); - if (!versionLines || versionLines.length === 0) - return { pass: true, detail: "no version constraints" }; - const allPinned = versionLines.every((l) => l.includes("~>")); - return { pass: allPinned }; - }, - }, ]; // ─── Helpers ──────────────────────────────────────────────────────────────── @@ -236,6 +423,26 @@ function readIntegrationTf(mod) { return readFileSync(p, "utf-8"); } +function readBackplaneTf(mod) { + const p = join(mod.path, "backplane", "main.tf"); + if (!existsSync(p)) return null; + return readFileSync(p, "utf-8"); +} + +function readBackplaneFile(mod, filename) { + const p = join(mod.path, "backplane", filename); + if (!existsSync(p)) return null; + return readFileSync(p, "utf-8"); +} + +function readAllBackplaneTf(mod) { + const backplaneDir = join(mod.path, "backplane"); + if (!existsSync(backplaneDir)) return null; + const files = readdirSync(backplaneDir).filter((f) => f.endsWith(".tf")); + if (files.length === 0) return null; + return files.map((f) => readFileSync(join(backplaneDir, f), "utf-8")).join("\n"); +} + function discoverModules() { const modules = []; const providers = readdirSync(MODULES_DIR).filter((d) => @@ -252,8 +459,6 @@ function discoverModules() { for (const service of services) { const modulePath = join(providerDir, service); - // Only consider directories that have a buildingblock/ subdirectory - // or a meshstack_integration.tf (these are actual modules) const hasBB = existsSync(join(modulePath, "buildingblock")); const hasIntegration = existsSync( join(modulePath, "meshstack_integration.tf") @@ -275,18 +480,48 @@ function discoverModules() { // ─── Main ─────────────────────────────────────────────────────────────────── function main() { - const modules = discoverModules(); + const args = process.argv.slice(2); + const filterCategory = args.find((a) => a.startsWith("--category="))?.split("=")[1]; + const filterProvider = args.find((a) => a.startsWith("--provider="))?.split("=")[1]; + + let modules = discoverModules(); + if (filterProvider) { + modules = modules.filter((m) => m.provider === filterProvider); + } + const results = []; for (const mod of modules) { - const checks = detectors.map((d) => ({ - ...d, - result: d.fn(mod), - })); - const passed = checks.filter((c) => c.result.pass).length; - const total = checks.length; - const score = Math.round((passed / total) * 100); - results.push({ mod, checks, passed, total, score }); + const categoryResults = {}; + + // Always compute all categories so the per-module summary is always complete. + // The filterCategory only controls which sections are *rendered*. + for (const [catId, cat] of Object.entries(CATEGORIES)) { + const applicable = cat.appliesTo(mod); + const catDetectors = detectors.filter((d) => d.category === catId); + const checks = catDetectors.map((d) => ({ + ...d, + result: applicable ? d.fn(mod) : { pass: null, detail: "not applicable" }, + })); + + const applicableChecks = checks.filter((c) => c.result.pass !== null); + const passed = applicableChecks.filter((c) => c.result.pass).length; + const total = applicableChecks.length; + const score = total > 0 ? Math.round((passed / total) * 100) : null; + + categoryResults[catId] = { checks, passed, total, score, applicable }; + } + + // For overall score, only include categories matching the active filter (if any). + const allApplicableChecks = Object.entries(categoryResults) + .filter(([catId]) => !filterCategory || catId === filterCategory) + .flatMap(([, cr]) => cr.checks) + .filter((c) => c.result.pass !== null); + const totalPassed = allApplicableChecks.filter((c) => c.result.pass).length; + const totalChecks = allApplicableChecks.length; + const overallScore = totalChecks > 0 ? Math.round((totalPassed / totalChecks) * 100) : null; + + results.push({ mod, categoryResults, passed: totalPassed, total: totalChecks, score: overallScore }); } // ─── Render Report ────────────────────────────────────────────────────── @@ -294,80 +529,108 @@ function main() { lines.push("# 📊 meshstack-hub Module Scorecard"); lines.push(""); lines.push( - `> Generated: ${new Date().toISOString().split("T")[0]} | Modules scanned: **${modules.length}** | Criteria: **${detectors.length}**` + `> Generated: ${new Date().toISOString().split("T")[0]} | Modules scanned: **${modules.length}** | Categories: **${Object.keys(CATEGORIES).length}**` ); lines.push(""); - // Legend - lines.push("## Legend"); - lines.push(""); - lines.push("| Emoji | Criterion |"); - lines.push("|-------|-----------|"); - for (const d of detectors) { - lines.push(`| ${d.emoji} | ${d.name} |`); - } - lines.push(""); + const categoriesToRender = filterCategory + ? { [filterCategory]: CATEGORIES[filterCategory] } + : CATEGORIES; - // Per-module results table - lines.push("## Module Scores"); + // ─── Per-Module Category Summary ──────────────────────────────────────── + lines.push("## 📋 Per-Module Category Summary"); + lines.push(""); + lines.push("Score per category per building block. `n/a` = category does not apply to this module."); lines.push(""); - const headerCols = detectors.map((d) => d.emoji).join(" | "); - lines.push(`| Module | Score | ${headerCols} |`); - lines.push( - `|--------|-------|${detectors.map(() => "---").join("|")}|` - ); + const summaryCategories = Object.entries(CATEGORIES); + const catHeaderCols = summaryCategories.map(([, cat]) => cat.name).join(" | "); + lines.push(`| Module | Overall | ${catHeaderCols} |`); + lines.push(`|--------|---------|${summaryCategories.map(() => "---").join("|")}|`); for (const r of results) { - const checkMarks = r.checks - .map((c) => (c.result.pass ? "✅" : "❌")) - .join(" | "); - const scoreEmoji = r.score >= 80 ? "🟢" : r.score >= 50 ? "🟡" : "🔴"; - lines.push( - `| \`${r.mod.id}\` | ${scoreEmoji} ${r.score}% | ${checkMarks} |` - ); + const overallCell = r.total > 0 + ? `${r.score >= 80 ? "🟢" : r.score >= 50 ? "🟡" : "🔴"} ${r.score}%` + : "—"; + + const catCells = summaryCategories.map(([catId]) => { + const cr = r.categoryResults[catId]; + if (!cr || !cr.applicable) return "n/a"; + if (cr.score === null) return "—"; + const emoji = cr.score >= 80 ? "🟢" : cr.score >= 50 ? "🟡" : "🔴"; + return `${emoji} ${cr.score}%`; + }); + + lines.push(`| \`${r.mod.id}\` | ${overallCell} | ${catCells.join(" | ")} |`); } lines.push(""); - // ─── Summary Statistics ───────────────────────────────────────────────── - lines.push("## 📈 Summary Statistics"); - lines.push(""); + for (const [catId, cat] of Object.entries(categoriesToRender)) { + const catDetectors = detectors.filter((d) => d.category === catId); + const applicableModules = results.filter((r) => r.categoryResults[catId]?.applicable); - const totalModules = results.length; + lines.push(`## ${cat.name}`); + lines.push(""); + lines.push(`*${cat.description}* — applies to **${applicableModules.length}** modules`); + lines.push(""); - for (const d of detectors) { - const passing = results.filter( - (r) => r.checks.find((c) => c.id === d.id).result.pass - ).length; - const pct = Math.round((passing / totalModules) * 100); - const bar = pct >= 80 ? "🟢" : pct >= 50 ? "🟡" : "🔴"; + if (applicableModules.length === 0) { + lines.push("No applicable modules."); + lines.push(""); + continue; + } + + const headerCols = catDetectors.map((d) => d.emoji).join(" | "); + lines.push(`| Module | Score | ${headerCols} |`); lines.push( - `| ${d.emoji} | ${d.name} | **${passing}/${totalModules}** modules | ${bar} ${pct}% |` + `|--------|-------|${catDetectors.map(() => "---").join("|")}|` ); - } - // Table header for summary - const summaryHeader = [ - "| Emoji | Criterion | Coverage | Status |", - "|-------|-----------|----------|--------|", - ]; - // Re-insert header before data rows - const summaryStart = lines.lastIndexOf("## 📈 Summary Statistics"); - lines.splice(summaryStart + 2, 0, ...summaryHeader); + for (const r of applicableModules) { + const cr = r.categoryResults[catId]; + const checkMarks = cr.checks + .map((c) => (c.result.pass === null ? "➖" : c.result.pass ? "✅" : "❌")) + .join(" | "); + const scoreEmoji = cr.score >= 80 ? "🟢" : cr.score >= 50 ? "🟡" : "🔴"; + lines.push( + `| \`${r.mod.id}\` | ${scoreEmoji} ${cr.score}% | ${checkMarks} |` + ); + } + lines.push(""); + lines.push(`### ${cat.name} — Summary`); + lines.push(""); + lines.push("| Emoji | Criterion | Coverage | Status |"); + lines.push("|-------|-----------|----------|--------|"); + for (const d of catDetectors) { + const passing = applicableModules.filter( + (r) => r.categoryResults[catId].checks.find((c) => c.id === d.id)?.result.pass + ).length; + const pct = Math.round((passing / applicableModules.length) * 100); + const bar = pct >= 80 ? "🟢" : pct >= 50 ? "🟡" : "🔴"; + lines.push( + `| ${d.emoji} | ${d.name} | **${passing}/${applicableModules.length}** | ${bar} ${pct}% |` + ); + } + lines.push(""); + } + + // ─── Overall Summary ──────────────────────────────────────────────────── + lines.push("## 📈 Overall Summary"); lines.push(""); - // Overall average - const avgScore = Math.round( - results.reduce((s, r) => s + r.score, 0) / totalModules - ); + // When filtering by category, only count modules where that category applies + const scoredResults = results.filter((r) => r.total > 0); + const totalModules = scoredResults.length; + const avgScore = totalModules > 0 + ? Math.round(scoredResults.reduce((s, r) => s + (r.score || 0), 0) / totalModules) + : 0; lines.push(`### Overall Average Score: **${avgScore}%**`); lines.push(""); - // Score distribution - const high = results.filter((r) => r.score >= 80).length; - const mid = results.filter((r) => r.score >= 50 && r.score < 80).length; - const low = results.filter((r) => r.score < 50).length; + const high = scoredResults.filter((r) => r.score >= 80).length; + const mid = scoredResults.filter((r) => r.score >= 50 && r.score < 80).length; + const low = scoredResults.filter((r) => r.score < 50).length; lines.push("### Score Distribution"); lines.push(""); lines.push(`- 🟢 High maturity (≥80%): **${high}** modules`);