diff --git a/.gitignore b/.gitignore index 1970311..1dc5e7e 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,9 @@ yarn-error.log* *.iml .angular/ -.terraform +*.terraform* +*.tfstate* +*tfvars* + .terraform.lock.hcl -.env \ No newline at end of file +.env diff --git a/modules/AGENTS.md b/modules/AGENTS.md index 7876a75..288e01b 100644 --- a/modules/AGENTS.md +++ b/modules/AGENTS.md @@ -99,6 +99,14 @@ aws/ - Creates custom role definitions with specific permissions - Uses role assignments for principal access - Scoped to subscription or management group level +- **Service Principal Creation (Optional):** + - Can create Azure AD application and service principal + - Supports **Workload Identity Federation (WIF)** for passwordless authentication + - Falls back to application password if WIF not configured + - Automatically assigns created principals to role definitions +- **Two-tier permissions for networking:** + - `buildingblock_deploy` role: Main deployment permissions + - `buildingblock_deploy_hub` role: Hub-specific permissions for VNet peering (e.g., ACR, Key Vault) **GCP Backplane Pattern:** *TBD - To be documented* @@ -232,10 +240,12 @@ category: storage - **Cost Management:** Budget alerts (AWS, Azure, GCP) - **Storage:** S3 buckets, Azure storage accounts, GCS buckets +- **Container Registries:** Azure Container Registry (ACR) with private networking - **Networking:** VPCs, spoke networks, subnets - **Databases:** PostgreSQL, managed database instances - **Security:** Key Vault, IAM roles, secret management - **CI/CD:** GitHub Actions integration, service connections +- **Compute:** Virtual machines (Azure), AKS integration ## Best Practices for New Modules diff --git a/modules/azure/container-registry/backplane/README.md b/modules/azure/container-registry/backplane/README.md new file mode 100644 index 0000000..8221855 --- /dev/null +++ b/modules/azure/container-registry/backplane/README.md @@ -0,0 +1,201 @@ +# Azure Container Registry Building Block - Backplane + +This directory contains the "backplane" configuration for the Azure Container Registry Building Block. The backplane sets up the necessary permissions and service principals required to deploy Azure Container Registries in Azure subscriptions. + +## Overview + +The backplane creates: +- Custom Azure RBAC role definitions with ACR deployment permissions +- Optional service principal for automated deployments (main scope) +- **Optional separate service principal for hub VNet peering (least privilege)** +- Role assignments for the service principals or existing principals +- Support for workload identity federation or application passwords +- Separate scopes: main scope (subscription or management group) and hub scope (subscription or management group) + +## Required Permissions + +The backplane creates two role definitions: + +### Main Deployment Role (`buildingblock_deploy`) + +Grants the following permissions: + +#### Container Registry +- Full CRUD operations on registries +- Manage credentials, webhooks, replications, scope maps, and tokens +- Import images + +#### Networking +- Create and manage private endpoints +- Create and manage private DNS zones and records +- Create and manage virtual networks, subnets, and VNet peerings +- Join subnets and peer VNets + +#### Resource Management +- Create, read, and delete resource groups +- Manage deployments +- Assign roles (for AKS integration) + +### Hub Deployment Role (`buildingblock_deploy_hub`) + +Limited permissions for hub VNet peering: +- Read resource groups and virtual networks +- Manage virtual network peerings + +## Usage + +Choose ONE of the following authentication methods: + +### Option 1: Workload Identity Federation (Recommended) + +Best for GitHub Actions, Azure DevOps, and other OIDC-capable CI/CD systems. No secrets to manage. + +```hcl +module "acr_backplane" { + source = "./backplane" + + name = "container-registry" + scope = "/providers/Microsoft.Management/managementGroups/landing-zones" + hub_scope = "/subscriptions/00000000-0000-0000-0000-000000000000" + + # Create main service principal with WIF + create_service_principal_name = "acr-deployer" + workload_identity_federation = { + issuer = "https://token.actions.githubusercontent.com" + subject = "repo:your-org/your-repo:ref:refs/heads/main" + } + + # Create separate hub service principal with WIF (least privilege) + create_hub_service_principal_name = "acr-hub-peering" + hub_workload_identity_federation = { + issuer = "https://token.actions.githubusercontent.com" + subject = "repo:your-org/your-repo:ref:refs/heads/main" + } +} +``` + +### Option 2: Using Existing Service Principals + +Use this when you already have service principals created and managed externally. + +```hcl +module "acr_backplane" { + source = "./backplane" + + name = "container-registry" + scope = "/providers/Microsoft.Management/managementGroups/landing-zones" + hub_scope = "/subscriptions/00000000-0000-0000-0000-000000000000" + + # Provide object IDs of existing service principals + existing_principal_ids = ["00000000-0000-0000-0000-000000000001"] + existing_hub_principal_ids = ["00000000-0000-0000-0000-000000000002"] +} +``` + +### Option 3: Password Authentication (Not Recommended) + +Only use when WIF is not available. Requires secure password management. + +```hcl +module "acr_backplane" { + source = "./backplane" + + name = "container-registry" + scope = "/providers/Microsoft.Management/managementGroups/landing-zones" + hub_scope = "/subscriptions/00000000-0000-0000-0000-000000000000" + + # Create service principals with password authentication + create_service_principal_name = "acr-deployer" + create_hub_service_principal_name = "acr-hub-peering" + + # Do NOT set workload_identity_federation - passwords will be auto-generated + # Retrieve passwords from Terraform state or Azure portal +} +``` + +## Security Considerations + +- The role definitions follow the principle of least privilege +- Service principals should use workload identity federation when possible +- Passwords are marked as sensitive and not exposed in outputs +- Review the permissions before deploying to production environments +- The hub role has minimal permissions for VNet peering only + + +## Requirements + +| Name | Version | +|------|---------| +| [terraform](#requirement\_terraform) | >= 1.3.0 | +| [azuread](#requirement\_azuread) | ~> 3.6.0 | +| [azurerm](#requirement\_azurerm) | ~> 4.36.0 | + +## Modules + +No modules. + +## Resources + +| Name | Type | +|------|------| +| [azuread_application.buildingblock_deploy](https://registry.terraform.io/providers/hashicorp/azuread/latest/docs/resources/application) | resource | +| [azuread_application.buildingblock_deploy_hub](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_federated_identity_credential.buildingblock_deploy_hub](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_application_password.buildingblock_deploy_hub](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 | +| [azuread_service_principal.buildingblock_deploy_hub](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.created_principal_hub](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/role_assignment) | resource | +| [azurerm_role_assignment.created_principal_hub_to_landingzone](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/role_assignment) | resource | +| [azurerm_role_assignment.created_principal_landingzone_to_hub](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_assignment.existing_principals_hub](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/role_assignment) | resource | +| [azurerm_role_assignment.existing_principals_hub_to_landingzone](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/role_assignment) | resource | +| [azurerm_role_assignment.existing_principals_landingzone_to_hub](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_role_definition.buildingblock_deploy_hub](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/role_definition) | resource | +| [azurerm_role_definition.buildingblock_hub_to_landingzone](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/role_definition) | resource | +| [azurerm_role_definition.buildingblock_landingzone_to_hub](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 | + +## Inputs + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|:--------:| +| [create\_hub\_service\_principal\_name](#input\_create\_hub\_service\_principal\_name) | name of a separate service principal to create for hub VNet peering (least privilege) | `string` | `null` | no | +| [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\_hub\_principal\_ids](#input\_existing\_hub\_principal\_ids) | set of existing principal ids that will be granted permissions to peer with the hub VNet | `set(string)` | `[]` | 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 | +| [hub\_scope](#input\_hub\_scope) | Scope for hub VNet peering permissions (management group or subscription). Typically a hub subscription, but can be a management group containing hub resources. | `string` | n/a | yes | +| [hub\_workload\_identity\_federation](#input\_hub\_workload\_identity\_federation) | Configuration for workload identity federation for hub service principal. If not provided, an application password will be created instead. |
object({
issuer = string
subject = string
})
| `null` | no | +| [name](#input\_name) | name of the building block, used for naming resources | `string` | `"container-registry"` | no | +| [scope](#input\_scope) | Scope where the building block should be deployable (management group or subscription), 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. |
object({
issuer = string
subject = string
})
| `null` | no | + +## 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\_hub\_application](#output\_created\_hub\_application) | Information about the created hub Azure AD application. | +| [created\_hub\_service\_principal](#output\_created\_hub\_service\_principal) | Information about the created hub service principal. | +| [created\_service\_principal](#output\_created\_service\_principal) | Information about the created service principal. | +| [documentation\_md](#output\_documentation\_md) | Markdown documentation with information about the Container Registry Building Block backplane | +| [hub\_application\_password](#output\_hub\_application\_password) | Information about the created hub application password (excludes the actual password value for security). | +| [hub\_role\_assignment\_ids](#output\_hub\_role\_assignment\_ids) | The IDs of the hub role assignments for all service principals. | +| [hub\_role\_assignment\_principal\_ids](#output\_hub\_role\_assignment\_principal\_ids) | The principal IDs of all service principals that have been assigned the hub role. | +| [hub\_role\_definition\_id](#output\_hub\_role\_definition\_id) | The ID of the role definition that enables deployment of the building block to the hub. | +| [hub\_role\_definition\_name](#output\_hub\_role\_definition\_name) | The name of the role definition that enables deployment of the building block to the hub. | +| [hub\_scope](#output\_hub\_scope) | The scope (management group or subscription) where VNet peering role is applied. | +| [hub\_workload\_identity\_federation](#output\_hub\_workload\_identity\_federation) | Information about the created hub workload identity federation credential. | +| [provider\_tf](#output\_provider\_tf) | Ready-to-use provider.tf configuration for buildingblock deployment | +| [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. | +| [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. | + diff --git a/modules/azure/container-registry/backplane/documentation.tf b/modules/azure/container-registry/backplane/documentation.tf new file mode 100644 index 0000000..256cf3c --- /dev/null +++ b/modules/azure/container-registry/backplane/documentation.tf @@ -0,0 +1,19 @@ +output "documentation_md" { + value = <", formatlist("- `%s`", azurerm_role_definition.buildingblock_deploy.permissions[0].actions))} | +| `${azurerm_role_definition.buildingblock_deploy_hub.name}` | ${azurerm_role_definition.buildingblock_deploy_hub.description} | ${join("
", formatlist("- `%s`", azurerm_role_definition.buildingblock_deploy_hub.permissions[0].actions))} | + +EOF + description = "Markdown documentation with information about the Container Registry Building Block backplane" +} diff --git a/modules/azure/container-registry/backplane/main.tf b/modules/azure/container-registry/backplane/main.tf new file mode 100644 index 0000000..f3b6954 --- /dev/null +++ b/modules/azure/container-registry/backplane/main.tf @@ -0,0 +1,254 @@ +data "azurerm_subscription" "current" {} + +resource "azuread_application" "buildingblock_deploy" { + count = var.create_service_principal_name != null ? 1 : 0 + + display_name = "${var.name}-${var.create_service_principal_name}" +} + +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 "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 "azuread_application" "buildingblock_deploy_hub" { + count = var.create_hub_service_principal_name != null ? 1 : 0 + + display_name = "${var.name}-${var.create_hub_service_principal_name}" +} + +resource "azuread_service_principal" "buildingblock_deploy_hub" { + count = var.create_hub_service_principal_name != null ? 1 : 0 + + client_id = azuread_application.buildingblock_deploy_hub[0].client_id + app_role_assignment_required = false +} + +resource "azuread_application_federated_identity_credential" "buildingblock_deploy_hub" { + count = var.create_hub_service_principal_name != null && var.hub_workload_identity_federation != null ? 1 : 0 + + application_id = azuread_application.buildingblock_deploy_hub[0].id + display_name = var.create_hub_service_principal_name + audiences = ["api://AzureADTokenExchange"] + issuer = var.hub_workload_identity_federation.issuer + subject = var.hub_workload_identity_federation.subject +} + +resource "azuread_application_password" "buildingblock_deploy_hub" { + count = var.create_hub_service_principal_name != null && var.hub_workload_identity_federation == null ? 1 : 0 + + application_id = azuread_application.buildingblock_deploy_hub[0].id + display_name = "${var.create_hub_service_principal_name}-password" +} + +resource "azurerm_role_definition" "buildingblock_deploy" { + name = "${var.name}-deploy" + scope = var.scope + description = "Enables deployment of the ${var.name} building block to subscriptions" + + permissions { + actions = [ + # Container Registry + "Microsoft.ContainerRegistry/registries/read", + "Microsoft.ContainerRegistry/registries/write", + "Microsoft.ContainerRegistry/registries/delete", + "Microsoft.ContainerRegistry/registries/listCredentials/action", + "Microsoft.ContainerRegistry/registries/regenerateCredential/action", + "Microsoft.ContainerRegistry/registries/listUsages/read", + "Microsoft.ContainerRegistry/registries/importImage/action", + "Microsoft.ContainerRegistry/registries/operationStatuses/read", + "Microsoft.ContainerRegistry/registries/PrivateEndpointConnectionsApproval/action", + "Microsoft.ContainerRegistry/registries/webhooks/read", + "Microsoft.ContainerRegistry/registries/webhooks/write", + "Microsoft.ContainerRegistry/registries/webhooks/delete", + "Microsoft.ContainerRegistry/registries/replications/read", + "Microsoft.ContainerRegistry/registries/replications/write", + "Microsoft.ContainerRegistry/registries/replications/delete", + "Microsoft.ContainerRegistry/registries/scopeMaps/read", + "Microsoft.ContainerRegistry/registries/scopeMaps/write", + "Microsoft.ContainerRegistry/registries/scopeMaps/delete", + "Microsoft.ContainerRegistry/registries/tokens/read", + "Microsoft.ContainerRegistry/registries/tokens/write", + "Microsoft.ContainerRegistry/registries/tokens/delete", + + # Private Endpoints + "Microsoft.Network/privateEndpoints/read", + "Microsoft.Network/privateEndpoints/write", + "Microsoft.Network/privateEndpoints/delete", + "Microsoft.Network/privateEndpoints/privateDnsZoneGroups/read", + "Microsoft.Network/privateEndpoints/privateDnsZoneGroups/write", + "Microsoft.Network/privateEndpoints/privateDnsZoneGroups/delete", + + # Private DNS Zones + "Microsoft.Network/privateDnsZones/read", + "Microsoft.Network/privateDnsZones/write", + "Microsoft.Network/privateDnsZones/delete", + "Microsoft.Network/privateDnsZones/join/action", + "Microsoft.Network/privateDnsZones/virtualNetworkLinks/read", + "Microsoft.Network/privateDnsZones/virtualNetworkLinks/write", + "Microsoft.Network/privateDnsZones/virtualNetworkLinks/delete", + "Microsoft.Network/privateDnsZones/A/read", + "Microsoft.Network/privateDnsZones/A/write", + "Microsoft.Network/privateDnsZones/A/delete", + "Microsoft.Network/privateDnsZones/SOA/read", + + # Virtual Networks + "Microsoft.Network/virtualNetworks/read", + "Microsoft.Network/virtualNetworks/write", + "Microsoft.Network/virtualNetworks/delete", + "Microsoft.Network/virtualNetworks/subnets/read", + "Microsoft.Network/virtualNetworks/subnets/write", + "Microsoft.Network/virtualNetworks/subnets/delete", + "Microsoft.Network/virtualNetworks/subnets/join/action", + "Microsoft.Network/virtualNetworks/join/action", + "Microsoft.Network/virtualNetworks/virtualNetworkPeerings/read", + "Microsoft.Network/virtualNetworks/virtualNetworkPeerings/write", + "Microsoft.Network/virtualNetworks/virtualNetworkPeerings/delete", + "Microsoft.Network/virtualNetworks/peer/action", + + # Resource Groups + "Microsoft.Resources/subscriptions/resourceGroups/read", + "Microsoft.Resources/subscriptions/resourceGroups/write", + "Microsoft.Resources/subscriptions/resourceGroups/delete", + + # Deployments + "Microsoft.Resources/deployments/read", + "Microsoft.Resources/deployments/write", + "Microsoft.Resources/deployments/delete", + + # Role Assignments (for AKS integration) + "Microsoft.Authorization/roleAssignments/read", + "Microsoft.Authorization/roleAssignments/write", + "Microsoft.Authorization/roleAssignments/delete", + ] + } +} + +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 + scope = var.scope +} + +resource "azurerm_role_definition" "buildingblock_deploy_hub" { + name = "${var.name}-deploy-hub" + description = "Enables deployment of the ${var.name} building block to the hub (for private endpoint peering)" + 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_definition" "buildingblock_hub_to_landingzone" { + name = "${var.name}-hub-to-landingzone" + description = "Allows hub service principal to peer back to landing zone vnets" + scope = var.scope + + permissions { + actions = [ + "Microsoft.Network/virtualNetworks/read", + "Microsoft.Network/virtualNetworks/peer/action", + ] + } +} + +resource "azurerm_role_definition" "buildingblock_landingzone_to_hub" { + name = "${var.name}-landingzone-to-hub" + description = "Allows landing zone service principal to peer to hub vnets" + scope = var.hub_scope + + permissions { + actions = [ + "Microsoft.Resources/subscriptions/resourceGroups/read", + "Microsoft.Network/virtualNetworks/read", + "Microsoft.Network/virtualNetworks/peer/action", + ] + } +} + +resource "azurerm_role_assignment" "existing_principals_hub" { + for_each = var.existing_hub_principal_ids + + role_definition_id = azurerm_role_definition.buildingblock_deploy_hub.role_definition_resource_id + description = azurerm_role_definition.buildingblock_deploy_hub.description + principal_id = each.value + scope = var.hub_scope +} + +resource "azurerm_role_assignment" "created_principal_hub" { + count = var.create_hub_service_principal_name != null ? 1 : 0 + + role_definition_id = azurerm_role_definition.buildingblock_deploy_hub.role_definition_resource_id + description = azurerm_role_definition.buildingblock_deploy_hub.description + principal_id = azuread_service_principal.buildingblock_deploy_hub[0].object_id + scope = var.hub_scope +} + +resource "azurerm_role_assignment" "existing_principals_hub_to_landingzone" { + for_each = var.existing_hub_principal_ids + + role_definition_id = azurerm_role_definition.buildingblock_hub_to_landingzone.role_definition_resource_id + principal_id = each.value + scope = var.scope +} + +resource "azurerm_role_assignment" "created_principal_hub_to_landingzone" { + count = var.create_hub_service_principal_name != null ? 1 : 0 + + role_definition_id = azurerm_role_definition.buildingblock_hub_to_landingzone.role_definition_resource_id + principal_id = azuread_service_principal.buildingblock_deploy_hub[0].object_id + scope = var.scope +} + +resource "azurerm_role_assignment" "existing_principals_landingzone_to_hub" { + for_each = var.existing_principal_ids + + role_definition_id = azurerm_role_definition.buildingblock_landingzone_to_hub.role_definition_resource_id + principal_id = each.value + scope = var.hub_scope +} + +resource "azurerm_role_assignment" "created_principal_landingzone_to_hub" { + count = var.create_service_principal_name != null ? 1 : 0 + + role_definition_id = azurerm_role_definition.buildingblock_landingzone_to_hub.role_definition_resource_id + principal_id = azuread_service_principal.buildingblock_deploy[0].object_id + scope = var.hub_scope +} diff --git a/modules/azure/container-registry/backplane/outputs.tf b/modules/azure/container-registry/backplane/outputs.tf new file mode 100644 index 0000000..6c0692f --- /dev/null +++ b/modules/azure/container-registry/backplane/outputs.tf @@ -0,0 +1,197 @@ +output "role_definition_id" { + value = azurerm_role_definition.buildingblock_deploy.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 + 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." +} + +output "hub_scope" { + value = var.hub_scope + description = "The scope (management group or subscription) where VNet peering role is applied." +} + +output "hub_role_definition_id" { + value = azurerm_role_definition.buildingblock_deploy_hub.id + description = "The ID of the role definition that enables deployment of the building block to the hub." +} + +output "hub_role_definition_name" { + value = azurerm_role_definition.buildingblock_deploy_hub.name + description = "The name of the role definition that enables deployment of the building block to the hub." +} + +output "hub_role_assignment_ids" { + value = concat( + [for id in azurerm_role_assignment.existing_principals_hub : id.id], + var.create_hub_service_principal_name != null ? [azurerm_role_assignment.created_principal_hub[0].id] : [] + ) + description = "The IDs of the hub role assignments for all service principals." +} + +output "hub_role_assignment_principal_ids" { + value = concat( + [for id in azurerm_role_assignment.existing_principals_hub : id.principal_id], + var.create_hub_service_principal_name != null ? [azurerm_role_assignment.created_principal_hub[0].principal_id] : [] + ) + description = "The principal IDs of all service principals that have been assigned the hub role." +} + +output "created_hub_service_principal" { + value = var.create_hub_service_principal_name != null ? { + object_id = azuread_service_principal.buildingblock_deploy_hub[0].object_id + client_id = azuread_service_principal.buildingblock_deploy_hub[0].client_id + display_name = azuread_service_principal.buildingblock_deploy_hub[0].display_name + name = var.create_hub_service_principal_name + } : null + description = "Information about the created hub service principal." +} + +output "created_hub_application" { + value = var.create_hub_service_principal_name != null ? { + object_id = azuread_application.buildingblock_deploy_hub[0].object_id + client_id = azuread_application.buildingblock_deploy_hub[0].client_id + display_name = azuread_application.buildingblock_deploy_hub[0].display_name + } : null + description = "Information about the created hub Azure AD application." +} + +output "hub_workload_identity_federation" { + value = var.create_hub_service_principal_name != null && var.hub_workload_identity_federation != null ? { + credential_id = azuread_application_federated_identity_credential.buildingblock_deploy_hub[0].credential_id + display_name = azuread_application_federated_identity_credential.buildingblock_deploy_hub[0].display_name + issuer = azuread_application_federated_identity_credential.buildingblock_deploy_hub[0].issuer + subject = azuread_application_federated_identity_credential.buildingblock_deploy_hub[0].subject + audiences = azuread_application_federated_identity_credential.buildingblock_deploy_hub[0].audiences + } : null + description = "Information about the created hub workload identity federation credential." +} + +output "hub_application_password" { + value = var.create_hub_service_principal_name != null && var.hub_workload_identity_federation == null ? { + key_id = azuread_application_password.buildingblock_deploy_hub[0].key_id + display_name = azuread_application_password.buildingblock_deploy_hub[0].display_name + } : null + description = "Information about the created hub application password (excludes the actual password value for security)." + sensitive = true +} + +output "provider_tf" { + value = var.create_service_principal_name != null && var.create_hub_service_principal_name != null ? ( + var.workload_identity_federation == null && var.hub_workload_identity_federation == null ? <<-EOT + provider "azurerm" { + features {} + + client_id = "${azuread_service_principal.buildingblock_deploy[0].client_id}" + client_secret = "${azuread_application_password.buildingblock_deploy[0].value}" + subscription_id = "" + tenant_id = "${data.azurerm_subscription.current.tenant_id}" + } + + provider "azurerm" { + alias = "hub" + features {} + + client_id = "${azuread_service_principal.buildingblock_deploy_hub[0].client_id}" + client_secret = "${azuread_application_password.buildingblock_deploy_hub[0].value}" + subscription_id = "" + tenant_id = "${data.azurerm_subscription.current.tenant_id}" + } + EOT + : <<-EOT + terraform { + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "~> 4.36.0" + } + } + } + + provider "azurerm" { + features {} + + client_id = "${azuread_service_principal.buildingblock_deploy[0].client_id}" + use_oidc = true + subscription_id = "" + tenant_id = "${data.azurerm_subscription.current.tenant_id}" + } + + provider "azurerm" { + alias = "hub" + features {} + + client_id = "${azuread_service_principal.buildingblock_deploy_hub[0].client_id}" + use_oidc = true + subscription_id = "" + tenant_id = "${data.azurerm_subscription.current.tenant_id}" + } + EOT + ) : null + description = "Ready-to-use provider.tf configuration for buildingblock deployment" + sensitive = true +} + + diff --git a/modules/azure/container-registry/backplane/provider.tf b/modules/azure/container-registry/backplane/provider.tf new file mode 100644 index 0000000..ab91b24 --- /dev/null +++ b/modules/azure/container-registry/backplane/provider.tf @@ -0,0 +1,3 @@ +provider "azurerm" { + features {} +} diff --git a/modules/azure/container-registry/backplane/terraform.tfvars b/modules/azure/container-registry/backplane/terraform.tfvars new file mode 100644 index 0000000..40c330b --- /dev/null +++ b/modules/azure/container-registry/backplane/terraform.tfvars @@ -0,0 +1,8 @@ +scope = "/providers/Microsoft.Management/managementGroups/a9129d40-1943-4224-9ff7-9949ac7e2fa1" +hub_scope = "/subscriptions/5066eff7-4173-4fea-8c67-268456b4a4f7" + +# Create separate service principals with workload identity federation +create_service_principal_name = "acr-deployer" +create_hub_service_principal_name = "acr-hub-peering" + + diff --git a/modules/azure/container-registry/backplane/variables.tf b/modules/azure/container-registry/backplane/variables.tf new file mode 100644 index 0000000..2961746 --- /dev/null +++ b/modules/azure/container-registry/backplane/variables.tf @@ -0,0 +1,74 @@ +variable "name" { + type = string + nullable = false + description = "name of the building block, used for naming resources" + default = "container-registry" + validation { + condition = can(regex("^[-a-z0-9]+$", var.name)) + error_message = "Only alphanumeric lowercase characters and dashes are allowed" + } +} + +variable "scope" { + type = string + nullable = false + description = "Scope where the building block should be deployable (management group or subscription), typically the parent of all Landing Zones." +} + +variable "hub_scope" { + type = string + nullable = false + description = "Scope for hub VNet peering permissions (management group or subscription). Typically a hub subscription, but can be a management group containing hub resources." +} + +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 "existing_hub_principal_ids" { + type = set(string) + default = [] + description = "set of existing principal ids that will be granted permissions to peer with the hub VNet" +} + +variable "create_service_principal_name" { + 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" + } +} + +variable "create_hub_service_principal_name" { + type = string + default = null + description = "name of a separate service principal to create for hub VNet peering (least privilege)" + + validation { + condition = var.create_hub_service_principal_name == null ? true : can(regex("^[-a-zA-Z0-9_]+$", var.create_hub_service_principal_name)) + error_message = "Service principal name can only contain alphanumeric characters, hyphens, and underscores" + } +} + +variable "workload_identity_federation" { + type = object({ + issuer = string + subject = string + }) + default = null + description = "Configuration for workload identity federation. If not provided, an application password will be created instead." +} + +variable "hub_workload_identity_federation" { + type = object({ + issuer = string + subject = string + }) + default = null + description = "Configuration for workload identity federation for hub service principal. If not provided, an application password will be created instead." +} diff --git a/modules/azure/container-registry/backplane/versions.tf b/modules/azure/container-registry/backplane/versions.tf new file mode 100644 index 0000000..7e487f6 --- /dev/null +++ b/modules/azure/container-registry/backplane/versions.tf @@ -0,0 +1,14 @@ +terraform { + required_version = ">= 1.3.0" + + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "~> 4.36.0" + } + azuread = { + source = "hashicorp/azuread" + version = "~> 3.6.0" + } + } +} diff --git a/modules/azure/container-registry/buildingblock/APP_TEAM_README.md b/modules/azure/container-registry/buildingblock/APP_TEAM_README.md new file mode 100644 index 0000000..b3eb80d --- /dev/null +++ b/modules/azure/container-registry/buildingblock/APP_TEAM_README.md @@ -0,0 +1,285 @@ +# Azure Container Registry (ACR) + +## Description +This building block provides a production-grade Azure Container Registry (ACR) for storing and managing Docker container images and OCI artifacts. It delivers a fully managed and secure container registry with support for both public and private deployments, optional hub connectivity, and seamless integration with Azure Kubernetes Service (AKS). + +## Usage Motivation +This building block is for application teams that need a secure and reliable container registry to store Docker images, Helm charts, and other OCI artifacts. The ACR comes pre-configured with enterprise-grade security features including private endpoints, network access controls, and Azure AD authentication, eliminating the complexity of managing container registries while ensuring compliance with security policies. + +## 🚀 Usage Examples +- A development team stores and versions Docker images for microservices applications +- A CI/CD pipeline pushes built container images to ACR and deploys them to AKS clusters +- A security team implements content trust and vulnerability scanning for container images +- A data science team stores and manages ML model containers and training environments + +## 🔄 Shared Responsibility + +| Responsibility | Platform Team | Application Team | +|----------------|--------------|------------------| +| Provisioning and configuring the ACR | ✅ | ❌ | +| Managing network configuration and private endpoints | ✅ | ❌ | +| Setting up hub network peering (for private ACR) | ✅ | ❌ | +| Managing IAM roles and permissions | ✅ | ❌ | +| Pushing and managing container images | ❌ | ✅ | +| Implementing image tagging strategies | ❌ | ✅ | +| Scanning images for vulnerabilities | ❌ | ✅ | +| Managing image retention and cleanup | ❌ | ✅ | +| Integrating ACR with CI/CD pipelines | ❌ | ✅ | + +## 💡 Best Practices for Secure and Efficient ACR Usage + +### Security +- **Use Azure AD authentication**: Always use Azure AD (via `az acr login`) instead of admin credentials +- **Disable admin user**: Set `admin_enabled = false` in production to enforce Azure AD authentication +- **Enable content trust**: For Premium SKU, enable content trust to sign and verify images +- **Scan for vulnerabilities**: Integrate with Microsoft Defender for Containers or third-party scanners +- **Use private endpoints**: Deploy private ACR for production workloads to prevent internet exposure +- **Implement network rules**: Restrict access using IP allowlists when public access is required +- **Rotate credentials**: If using service principals, rotate credentials regularly + +### Image Management +- **Use semantic versioning**: Tag images with semantic versions (e.g., `v1.2.3`) for better tracking +- **Avoid latest tag**: Always use specific version tags in production deployments +- **Implement retention policies**: Configure retention policies to automatically remove untagged manifests +- **Use multi-stage builds**: Optimize Dockerfile with multi-stage builds to reduce image size +- **Minimize layers**: Combine RUN commands to reduce the number of layers +- **Scan before push**: Scan images locally before pushing to ACR + +### Performance & Reliability +- **Use dedicated data endpoints**: Enable data endpoints in Premium SKU for improved performance +- **Enable zone redundancy**: For high availability, enable zone redundancy in supported regions +- **Monitor registry metrics**: Track storage usage, pull/push operations, and throttling in Azure Monitor +- **Use caching**: Implement image pull caching in AKS for faster pod startup times + +### Cost Optimization +- **Choose appropriate SKU**: Start with Standard for development, use Premium only when needed +- **Implement cleanup policies**: Remove old and unused images automatically +- **Monitor storage usage**: Regularly review storage consumption and clean up unnecessary images +- **Use retention policies**: Configure retention days to auto-delete untagged manifests +- **Optimize image sizes**: Smaller images reduce storage costs and improve pull performance + +### CI/CD Integration +- **Use service principals or managed identities**: Authenticate CI/CD pipelines using Azure AD +- **Implement automated scanning**: Scan images as part of CI/CD pipeline +- **Tag with build metadata**: Include git commit SHA, build number in image tags +- **Use webhooks**: Configure ACR webhooks to trigger deployments or notifications +- **Implement approval gates**: Require security scan approval before production deployment + +## Registry Features + +### SKU Comparison + +| Feature | Basic | Standard | Premium | +|---------|-------|----------|---------| +| Storage (GB) | 10 | 100 | 500 | +| Webhooks | 2 | 10 | 500 | +| Private endpoints | ❌ | ❌ | ✅ | +| Content trust | ❌ | ❌ | ✅ | +| Customer-managed keys | ❌ | ❌ | ✅ | +| Zone redundancy | ❌ | ❌ | ✅ | +| Retention policies | ❌ | ❌ | ✅ | + +### Security Features +- **Azure AD Authentication**: Native integration with Azure Active Directory +- **RBAC**: Fine-grained role-based access control (AcrPull, AcrPush, AcrDelete) +- **Network Isolation**: Private endpoints with Private DNS integration +- **Content Trust**: Sign and verify container images (Premium SKU) +- **Vulnerability Scanning**: Integration with Microsoft Defender for Containers +- **Firewall Rules**: IP-based access control and VNet service endpoints + +### Network Options +- **Public Access**: Internet-accessible registry with optional IP filtering +- **Private Endpoint**: Private IP address within your VNet (Premium SKU) +- **Hub Connectivity**: VNet peering to central hub for on-premises access +- **Service Endpoints**: VNet service endpoints for controlled access + +### High Availability +- **Zone Redundancy**: Deploy across availability zones (Premium SKU, select regions) +- **SLA**: 99.9% uptime SLA for Standard and Premium SKUs + +## Deployment Scenarios + +### Scenario Matrix + +This building block supports 4 deployment scenarios based on your networking and security requirements: + +| # | Scenario | Private Endpoint | VNet Type | Hub Peering | Use Case | +|---|----------|-----------------|-----------|-------------|----------| +| **1** | **New VNet + Hub Peering** | ✅ | New (created) | ✅ Created | Isolated workload needing hub/on-prem access | +| **2** | **Existing Shared VNet** | ✅ | Existing (shared) | ❌ Skipped | Multi-tenant with shared connectivity | +| **3** | **Private Isolated** | ✅ | New or Existing | ❌ None | Secure workload, same-VNet access only | +| **4** | **Completely Public** | ❌ | Not applicable | ❌ None | Dev/test, public CI/CD access | + +**Configuration Quick Reference:** + +| Scenario | `private_endpoint_enabled` | `vnet_name` | `hub_vnet_name` | +|----------|---------------------------|-------------|-----------------| +| **1 - New VNet + Hub** | `true` | `null` | Set (creates peering) | +| **2 - Existing Shared VNet** | `true` | Set (existing) | Omit/null (no peering) | +| **3 - Private Isolated** | `true` | `null` or Set | `null` | +| **4 - Public** | `false` | Any | Any | + +--- + +## Getting Started + +### Authenticating with ACR + +#### Using Azure CLI (Recommended) +```bash +# Login to ACR using Azure AD +az acr login --name mycompanyacr + +# Verify login +docker images +``` + +#### Using Docker with Admin Credentials (Not Recommended for Production) +```bash +# Get admin credentials (only if admin_enabled = true) +az acr credential show --name mycompanyacr + +# Docker login with username/password +docker login mycompanyacr.azurecr.io -u -p +``` + +#### Using Managed Identity (AKS) +```bash +# AKS automatically authenticates using its managed identity +# No manual login required when AcrPull role is assigned +kubectl create deployment nginx --image=mycompanyacr.azurecr.io/nginx:latest +``` + +### Pushing Images + +```bash +# Tag your image +docker tag myapp:latest mycompanyacr.azurecr.io/myapp:v1.0.0 + +# Push to ACR +docker push mycompanyacr.azurecr.io/myapp:v1.0.0 + +# List images in ACR +az acr repository list --name mycompanyacr +``` + +### Pulling Images + +```bash +# Pull image from ACR +docker pull mycompanyacr.azurecr.io/myapp:v1.0.0 + +# From AKS +kubectl run myapp --image=mycompanyacr.azurecr.io/myapp:v1.0.0 +``` + +### Working with Private ACR + +For private ACR, ensure you have network connectivity: + +1. **From Azure Resources**: Must be in peered VNet or same VNet as private endpoint +2. **From On-Premises**: Connect via hub VNet using ExpressRoute or VPN Gateway +3. **From Developer Workstation**: Use VPN or Azure Bastion to access private network + +```bash +# Connect via VPN or Bastion, then login +az acr login --name mycompanyacr + +# Verify private endpoint resolution +nslookup mycompanyacr.azurecr.io +# Should resolve to private IP (10.x.x.x) +``` + +### Managing Images + +```bash +# List repositories +az acr repository list --name mycompanyacr + +# List tags for a repository +az acr repository show-tags --name mycompanyacr --repository myapp + +# Delete an image +az acr repository delete --name mycompanyacr --image myapp:v1.0.0 + +# Purge old images (older than 30 days) +az acr run --cmd "acr purge --filter 'myapp:.*' --ago 30d" --registry mycompanyacr /dev/null +``` + +### Monitoring and Troubleshooting + +```bash +# View ACR metrics +az monitor metrics list --resource --metric StorageUsed + +# Check ACR health +az acr check-health --name mycompanyacr + +# View webhook events +az acr webhook list-events --name mywebhook --registry mycompanyacr + +# Enable diagnostic logs +az monitor diagnostic-settings create \ + --resource \ + --name acr-diagnostics \ + --workspace \ + --logs '[{"category": "ContainerRegistryRepositoryEvents", "enabled": true}]' +``` + +## Security Checklist + +- [ ] Disable admin user (`admin_enabled = false`) +- [ ] Use Azure AD authentication exclusively +- [ ] Enable private endpoint for production workloads +- [ ] Configure network access rules (IP allowlist or private endpoint) +- [ ] Enable content trust for Premium SKU +- [ ] Implement vulnerability scanning in CI/CD pipeline +- [ ] Configure retention policies to clean up old images +- [ ] Enable diagnostic logging to Log Analytics +- [ ] Use managed identities for AKS integration +- [ ] Implement RBAC with least-privilege access +- [ ] Enable Microsoft Defender for Containers +- [ ] Regularly rotate service principal credentials (if used) + +## Common Integration Patterns + +### CI/CD Pipeline (Azure DevOps) +```yaml +- task: Docker@2 + inputs: + command: buildAndPush + repository: myapp + containerRegistry: mycompanyacr + tags: | + $(Build.BuildId) + latest +``` + +### GitHub Actions +```yaml +- name: Login to ACR + uses: azure/docker-login@v1 + with: + login-server: mycompanyacr.azurecr.io + username: ${{ secrets.ACR_USERNAME }} + password: ${{ secrets.ACR_PASSWORD }} + +- name: Build and push + run: | + docker build -t mycompanyacr.azurecr.io/myapp:${{ github.sha }} . + docker push mycompanyacr.azurecr.io/myapp:${{ github.sha }} +``` + +### Kubernetes Deployment +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: myapp +spec: + template: + spec: + containers: + - name: myapp + image: mycompanyacr.azurecr.io/myapp:v1.0.0 +``` diff --git a/modules/azure/container-registry/buildingblock/README.md b/modules/azure/container-registry/buildingblock/README.md new file mode 100644 index 0000000..897a29b --- /dev/null +++ b/modules/azure/container-registry/buildingblock/README.md @@ -0,0 +1,226 @@ +--- +name: Azure Container Registry +supportedPlatforms: + - azure +description: | + Provides a production-grade Azure Container Registry for storing and managing Docker container images and OCI artifacts with private networking support. +category: container-registry +--- + +# Azure Container Registry Building Block + +This Terraform module provisions an Azure Container Registry with optional private endpoint networking, VNet peering to hub networks, and AKS integration. + +## Requirements +- Terraform >= 1.3.0 +- Azure RM Provider ~> 4.36.0 + +## Architecture + +The module supports multiple deployment scenarios: + +1. **New VNet + Hub Peering** - Creates a new VNet and establishes peering to hub network +2. **Existing Shared VNet** - Uses an existing VNet (assumes already peered to hub) +3. **Private Isolated** - Private endpoint without hub connectivity +4. **Public ACR** - Internet-accessible registry + +### Peering Logic + +VNet peering to hub is **automatically determined**: +- **Created** when `vnet_name == null` (creating new VNet) AND `hub_vnet_name` is set +- **Skipped** when `vnet_name` is set (using existing VNet, assumes already connected to hub) + +This automatic approach simplifies configuration by inferring intent from VNet creation vs. usage. + +## Permissions + +Please reference the [backplane implementation](../backplane/) for the required permissions to deploy this building block. + +Key permissions include: +- `Microsoft.ContainerRegistry/*` - ACR management +- `Microsoft.Network/virtualNetworks/*` - VNet and subnet operations +- `Microsoft.Network/privateEndpoints/*` - Private endpoint creation +- `Microsoft.Network/privateDnsZones/*` - Private DNS zone integration +- `Microsoft.Network/privateDnsZones/join/action` - DNS zone association +- Hub subscription permissions for VNet peering (when applicable) + +## Provider Configuration + +This module requires two provider configurations when using hub peering: + +```hcl +provider "azurerm" { + alias = "hub" + subscription_id = "hub-subscription-id" + features {} +} + +module "acr" { + source = "./buildingblock" + + providers = { + azurerm = azurerm + azurerm.hub = azurerm.hub + } + + # ... configuration +} +``` + +When not using hub peering, only the default provider is needed. + +## Advanced Configuration + +### Cross-Resource-Group VNet Support + +The module supports using existing VNets from different resource groups: + +```hcl +vnet_name = "shared-connectivity-vnet" +existing_vnet_resource_group_name = "connectivity-rg" +subnet_name = "acr-subnet" +``` + +If `existing_vnet_resource_group_name` is not specified, it defaults to the ACR's resource group. + +### Private Endpoint Network Policies + +The module configures subnets with `private_endpoint_network_policies = "NetworkSecurityGroupEnabled"` to allow NSG rules to secure private endpoint traffic. + +### AKS Integration + +When `aks_managed_identity_principal_id` is provided, the module automatically assigns the `AcrPull` role to the AKS managed identity. + +## Resource Dependencies + +When creating a new VNet: +- VNet must be created before private endpoint +- Subnet must be created before private endpoint +- Hub peering requires both VNets to exist + +When using an existing VNet: +- VNet and subnet must already exist +- Data sources are used to reference existing resources + +## Troubleshooting + +### Private Endpoint DNS Resolution Issues + +**Symptom:** ACR FQDN resolves to public IP instead of private IP + +**Solutions:** +- Verify private DNS zone is linked to the VNet +- Check `private_dns_zone_id` is set to "System" or valid zone ID +- Confirm VNet peering allows DNS forwarding +- Test: `nslookup .azurecr.io` should return 10.x.x.x + +### Hub Peering Not Created + +**Symptom:** Expected hub peering but it wasn't created + +**Cause:** Using existing VNet (`vnet_name` is set) - peering is automatically skipped + +**Solution:** Either: +- Set `vnet_name = null` to create new VNet with peering +- Manually peer the existing VNet to hub outside this module + +### AKS Cannot Pull Images + +**Symptom:** `ImagePullBackOff` errors in AKS + +**Solutions:** +- Verify `aks_managed_identity_principal_id` is correctly set +- Check AcrPull role assignment exists: `az role assignment list --assignee ` +- Confirm AKS and ACR are in same/peered VNets (for private ACR) +- Verify private DNS zone is linked to AKS VNet + +### Permission Denied Errors During Deployment + +**Symptom:** Terraform fails with authorization errors + +**Solutions:** +- Verify backplane roles are assigned +- Check hub subscription provider is configured (for peering scenarios) +- Confirm `Microsoft.Network/privateDnsZones/join/action` permission exists + + +## Requirements + +| Name | Version | +|------|---------| +| [terraform](#requirement\_terraform) | >= 1.3.0 | +| [azurerm](#requirement\_azurerm) | ~> 4.36.0 | +| [random](#requirement\_random) | ~> 3.6.0 | + +## Modules + +No modules. + +## Resources + +| Name | Type | +|------|------| +| [azurerm_container_registry.acr](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/container_registry) | resource | +| [azurerm_private_dns_zone.acr_dns](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/private_dns_zone) | resource | +| [azurerm_private_dns_zone_virtual_network_link.acr_dns_link](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/private_dns_zone_virtual_network_link) | resource | +| [azurerm_private_endpoint.acr_pe](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/private_endpoint) | resource | +| [azurerm_resource_group.acr](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/resource_group) | resource | +| [azurerm_role_assignment.acr_pull](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/role_assignment) | resource | +| [azurerm_subnet.pe_subnet](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/subnet) | resource | +| [azurerm_virtual_network.vnet](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/virtual_network) | resource | +| [azurerm_virtual_network_peering.acr_to_hub](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/virtual_network_peering) | resource | +| [azurerm_virtual_network_peering.hub_to_acr](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/virtual_network_peering) | resource | +| [random_string.suffix](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/string) | resource | +| [azurerm_resource_group.hub_rg](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/data-sources/resource_group) | data source | +| [azurerm_subnet.existing_subnet](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/data-sources/subnet) | data source | +| [azurerm_subscription.current](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/data-sources/subscription) | data source | +| [azurerm_virtual_network.existing_vnet](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/data-sources/virtual_network) | data source | +| [azurerm_virtual_network.hub_vnet](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/data-sources/virtual_network) | data source | + +## Inputs + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|:--------:| +| [acr\_name](#input\_acr\_name) | Name of the Azure Container Registry (must be globally unique, alphanumeric only) | `string` | n/a | yes | +| [admin\_enabled](#input\_admin\_enabled) | Enable admin user for basic authentication (not recommended for production) | `bool` | `false` | no | +| [aks\_managed\_identity\_principal\_id](#input\_aks\_managed\_identity\_principal\_id) | Principal ID of the AKS managed identity to grant AcrPull access. If provided, AcrPull role will be assigned automatically. | `string` | `null` | no | +| [allow\_gateway\_transit\_from\_hub](#input\_allow\_gateway\_transit\_from\_hub) | Allow gateway transit from hub to spoke. Set to true if hub has a gateway and you want spoke to use it. | `bool` | `false` | no | +| [allowed\_ip\_ranges](#input\_allowed\_ip\_ranges) | List of IP ranges (CIDR) allowed to access the ACR | `list(string)` | `[]` | no | +| [anonymous\_pull\_enabled](#input\_anonymous\_pull\_enabled) | Enable anonymous pull access (allows unauthenticated pulls) | `bool` | `false` | no | +| [data\_endpoint\_enabled](#input\_data\_endpoint\_enabled) | Enable dedicated data endpoints (Premium SKU only) | `bool` | `false` | no | +| [existing\_vnet\_resource\_group\_name](#input\_existing\_vnet\_resource\_group\_name) | Resource group name of the existing VNet. Only used when vnet\_name is provided. Defaults to the ACR resource group if not specified. | `string` | `null` | no | +| [hub\_resource\_group\_name](#input\_hub\_resource\_group\_name) | Resource group name of the hub virtual network. Required when private\_endpoint\_enabled is true and connecting to a hub. | `string` | `null` | no | +| [hub\_subscription\_id](#input\_hub\_subscription\_id) | Subscription ID of the hub network. Required when private\_endpoint\_enabled is true and connecting to a hub. | `string` | `null` | no | +| [hub\_vnet\_name](#input\_hub\_vnet\_name) | Name of the hub virtual network to peer with. Required when private\_endpoint\_enabled is true and connecting to a hub. | `string` | `null` | no | +| [location](#input\_location) | Azure region where resources will be deployed | `string` | `"Germany West Central"` | no | +| [network\_rule\_bypass\_option](#input\_network\_rule\_bypass\_option) | Whether to allow trusted Azure services to bypass network rules (AzureServices or None) | `string` | `"AzureServices"` | no | +| [private\_dns\_zone\_id](#input\_private\_dns\_zone\_id) | Private DNS Zone ID for private endpoint. Use 'System' for Azure-managed zone, or provide custom zone ID. Only used when private\_endpoint\_enabled is true. | `string` | `"System"` | no | +| [private\_endpoint\_enabled](#input\_private\_endpoint\_enabled) | Enable private endpoint for ACR (Premium SKU required) | `bool` | `false` | no | +| [public\_network\_access\_enabled](#input\_public\_network\_access\_enabled) | Enable public network access to the ACR | `bool` | `true` | no | +| [resource\_group\_name](#input\_resource\_group\_name) | Name of the resource group to create for the ACR | `string` | `"acr-rg"` | no | +| [retention\_days](#input\_retention\_days) | Number of days to retain untagged manifests (Premium SKU only, 0 to disable) | `number` | `7` | no | +| [sku](#input\_sku) | SKU tier for the ACR (Basic, Standard, Premium). Premium required for private endpoints. | `string` | `"Premium"` | no | +| [subnet\_address\_prefix](#input\_subnet\_address\_prefix) | Address prefix for the private endpoint subnet (only used if subnet\_name is not provided) | `string` | `"10.250.1.0/24"` | no | +| [subnet\_name](#input\_subnet\_name) | Name of the subnet for private endpoint. If not provided, a new subnet will be created. | `string` | `null` | no | +| [tags](#input\_tags) | Tags to apply to all resources | `map(string)` | `{}` | no | +| [trust\_policy\_enabled](#input\_trust\_policy\_enabled) | Enable content trust policy (Premium SKU only) | `bool` | `false` | no | +| [use\_remote\_gateways](#input\_use\_remote\_gateways) | Use remote gateways from hub VNet. Set to true only if hub has a VPN/ExpressRoute gateway configured. | `bool` | `false` | no | +| [vnet\_address\_space](#input\_vnet\_address\_space) | Address space for the VNet (only used if vnet\_name is not provided) | `string` | `"10.250.0.0/16"` | no | +| [vnet\_name](#input\_vnet\_name) | Name of the virtual network for private endpoint. If not provided, a new VNet will be created. | `string` | `null` | no | +| [zone\_redundancy\_enabled](#input\_zone\_redundancy\_enabled) | Enable zone redundancy for the ACR (Premium SKU only, available in select regions) | `bool` | `false` | no | + +## Outputs + +| Name | Description | +|------|-------------| +| [acr\_id](#output\_acr\_id) | The ID of the Azure Container Registry | +| [acr\_login\_server](#output\_acr\_login\_server) | The login server URL for the Azure Container Registry | +| [acr\_name](#output\_acr\_name) | The name of the Azure Container Registry | +| [admin\_password](#output\_admin\_password) | Admin password for the Azure Container Registry (only available when admin\_enabled is true) | +| [admin\_username](#output\_admin\_username) | Admin username for the Azure Container Registry (only available when admin\_enabled is true) | +| [private\_dns\_zone\_id](#output\_private\_dns\_zone\_id) | ID of the private DNS zone (when System-managed) | +| [private\_endpoint\_ip](#output\_private\_endpoint\_ip) | Private IP address of the ACR private endpoint | +| [resource\_group\_name](#output\_resource\_group\_name) | Name of the resource group containing the ACR | +| [subnet\_id](#output\_subnet\_id) | ID of the subnet used for private endpoint | +| [vnet\_id](#output\_vnet\_id) | ID of the virtual network used for private endpoint | + diff --git a/modules/azure/container-registry/buildingblock/acr.tftest.hcl b/modules/azure/container-registry/buildingblock/acr.tftest.hcl new file mode 100644 index 0000000..39cd080 --- /dev/null +++ b/modules/azure/container-registry/buildingblock/acr.tftest.hcl @@ -0,0 +1,403 @@ +run "scenario_1_new_vnet_with_hub_peering" { + variables { + acr_name = "testacr01" + resource_group_name = "acr-test-rg01" + location = "Germany West Central" + sku = "Premium" + admin_enabled = false + public_network_access_enabled = false + + private_endpoint_enabled = true + private_dns_zone_id = "System" + vnet_address_space = "10.250.0.0/16" + subnet_address_prefix = "10.250.1.0/24" + + hub_subscription_id = "5066eff7-4173-4fea-8c67-268456b4a4f7" + hub_resource_group_name = "likvid-hub-vnet-rg" + hub_vnet_name = "hub-vnet" + } + + assert { + condition = azurerm_container_registry.acr.sku == "Premium" + error_message = "ACR SKU should be Premium for private endpoint" + } + + assert { + condition = azurerm_container_registry.acr.public_network_access_enabled == false + error_message = "Public network access should be disabled" + } + + assert { + condition = length(azurerm_virtual_network.vnet) == 1 + error_message = "VNet should be created when vnet_name is null" + } + + assert { + condition = contains(one(azurerm_virtual_network.vnet[*].address_space), "10.250.0.0/16") + error_message = "VNet should have correct address space" + } + + assert { + condition = length(azurerm_private_endpoint.acr_pe) == 1 + error_message = "Private endpoint should be created" + } + + assert { + condition = length(azurerm_virtual_network_peering.acr_to_hub) == 1 + error_message = "Peering to hub should be created when creating new VNet" + } + + assert { + condition = length(azurerm_virtual_network_peering.hub_to_acr) == 1 + error_message = "Peering from hub should be created when creating new VNet" + } + + assert { + condition = length(azurerm_private_dns_zone.acr_dns) == 1 + error_message = "Private DNS zone should be created with System option" + } +} + +run "scenario_2_existing_shared_vnet" { + variables { + acr_name = "testacr02" + resource_group_name = "acr-test-rg02" + location = "Germany West Central" + sku = "Premium" + admin_enabled = false + public_network_access_enabled = false + + private_endpoint_enabled = true + private_dns_zone_id = "System" + vnet_name = "lz102-on-prem-nwk-vnet" + existing_vnet_resource_group_name = "connectivity" + subnet_name = "default" + } + + assert { + condition = azurerm_container_registry.acr.sku == "Premium" + error_message = "ACR SKU should be Premium for private endpoint" + } + + assert { + condition = length(azurerm_virtual_network.vnet) == 0 + error_message = "VNet should NOT be created when vnet_name is provided" + } + + assert { + condition = length(azurerm_virtual_network_peering.acr_to_hub) == 0 + error_message = "Peering to hub should NOT be created when using existing VNet" + } + + assert { + condition = length(azurerm_virtual_network_peering.hub_to_acr) == 0 + error_message = "Peering from hub should NOT be created when using existing VNet" + } + + assert { + condition = length(azurerm_private_endpoint.acr_pe) == 1 + error_message = "Private endpoint should be created in existing VNet" + } + + assert { + condition = var.existing_vnet_resource_group_name == "connectivity" + error_message = "Should use VNet from different resource group" + } +} + +run "scenario_3_private_isolated_no_hub" { + variables { + acr_name = "testacr03" + resource_group_name = "acr-test-rg03" + location = "Germany West Central" + sku = "Premium" + admin_enabled = false + public_network_access_enabled = false + + private_endpoint_enabled = true + private_dns_zone_id = "System" + vnet_address_space = "10.250.0.0/16" + subnet_address_prefix = "10.250.1.0/24" + + hub_vnet_name = null + hub_resource_group_name = null + hub_subscription_id = null + } + + assert { + condition = azurerm_container_registry.acr.sku == "Premium" + error_message = "ACR SKU should be Premium for private endpoint" + } + + assert { + condition = azurerm_container_registry.acr.public_network_access_enabled == false + error_message = "Public network access should be disabled" + } + + assert { + condition = length(azurerm_virtual_network.vnet) == 1 + error_message = "VNet should be created" + } + + assert { + condition = length(azurerm_private_endpoint.acr_pe) == 1 + error_message = "Private endpoint should be created" + } + + assert { + condition = length(azurerm_virtual_network_peering.acr_to_hub) == 0 + error_message = "Peering to hub should NOT be created when hub_vnet_name is null" + } + + assert { + condition = length(azurerm_virtual_network_peering.hub_to_acr) == 0 + error_message = "Peering from hub should NOT be created when hub_vnet_name is null" + } +} + +run "scenario_4_completely_public" { + variables { + acr_name = "testacr04" + resource_group_name = "acr-test-rg04" + location = "Germany West Central" + sku = "Standard" + admin_enabled = false + public_network_access_enabled = true + + private_endpoint_enabled = false + } + + assert { + condition = azurerm_container_registry.acr.sku == "Standard" + error_message = "Can use cheaper SKU for public ACR" + } + + assert { + condition = azurerm_container_registry.acr.public_network_access_enabled == true + error_message = "Public network access should be enabled" + } + + assert { + condition = length(azurerm_private_endpoint.acr_pe) == 0 + error_message = "Private endpoint should NOT be created" + } + + assert { + condition = length(azurerm_virtual_network.vnet) == 0 + error_message = "VNet should NOT be created" + } + + assert { + condition = output.acr_login_server != "" + error_message = "ACR login server should be accessible" + } +} + +run "public_acr_with_ip_filtering" { + variables { + acr_name = "testacr05" + resource_group_name = "acr-test-rg05" + location = "Germany West Central" + sku = "Premium" + public_network_access_enabled = true + allowed_ip_ranges = ["203.0.113.0/24", "198.51.100.5/32"] + } + + assert { + condition = azurerm_container_registry.acr.public_network_access_enabled == true + error_message = "Public network access should be enabled" + } + + assert { + condition = length(var.allowed_ip_ranges) == 2 + error_message = "Should have 2 allowed IP ranges" + } + + assert { + condition = output.acr_login_server != "" + error_message = "ACR login server output should not be empty" + } + + assert { + condition = length(azurerm_container_registry.acr.network_rule_set) > 0 + error_message = "Network rule set should be configured for IP filtering" + } +} + +run "premium_features_on_basic_sku" { + variables { + acr_name = "testacr06" + resource_group_name = "acr-test-rg06" + location = "Germany West Central" + sku = "Basic" + retention_days = 14 + trust_policy_enabled = true + zone_redundancy_enabled = true + data_endpoint_enabled = true + } + + assert { + condition = azurerm_container_registry.acr.zone_redundancy_enabled == false + error_message = "Zone redundancy should be disabled for Basic SKU" + } + + assert { + condition = azurerm_container_registry.acr.data_endpoint_enabled == false + error_message = "Data endpoints should be disabled for Basic SKU" + } + + assert { + condition = azurerm_container_registry.acr.trust_policy_enabled == false + error_message = "Trust policy should be disabled for Basic SKU" + } + + assert { + condition = azurerm_container_registry.acr.retention_policy_in_days == 0 + error_message = "Retention policy should be 0 for Basic SKU" + } +} + +run "private_endpoint_subnet_network_policies" { + variables { + acr_name = "testacr07" + resource_group_name = "acr-test-rg07" + location = "Germany West Central" + sku = "Premium" + private_endpoint_enabled = true + public_network_access_enabled = false + private_dns_zone_id = "System" + vnet_address_space = "10.250.0.0/16" + subnet_address_prefix = "10.250.1.0/24" + } + + assert { + condition = length(azurerm_subnet.pe_subnet) == 1 + error_message = "Subnet should be created" + } + + assert { + condition = one(azurerm_subnet.pe_subnet[*].private_endpoint_network_policies) == "NetworkSecurityGroupEnabled" + error_message = "Subnet should have NSG network policies enabled for private endpoints" + } + + assert { + condition = length(azurerm_private_endpoint.acr_pe) == 1 + error_message = "Private endpoint should be created" + } +} + +run "admin_enabled" { + variables { + acr_name = "testacr08" + resource_group_name = "acr-test-rg08" + location = "Germany West Central" + sku = "Basic" + admin_enabled = true + } + + assert { + condition = azurerm_container_registry.acr.admin_enabled == true + error_message = "Admin should be enabled" + } + + assert { + condition = output.admin_username != null + error_message = "Admin username should be available" + } + + assert { + condition = output.admin_password != null + error_message = "Admin password should be available" + } +} + +run "tags_applied" { + variables { + acr_name = "testacr09" + resource_group_name = "acr-test-rg09" + location = "Germany West Central" + sku = "Premium" + tags = { + Environment = "test" + CostCenter = "engineering" + } + } + + assert { + condition = azurerm_container_registry.acr.tags["Environment"] == "test" + error_message = "Environment tag should be set to test" + } + + assert { + condition = azurerm_container_registry.acr.tags["CostCenter"] == "engineering" + error_message = "CostCenter tag should be set to engineering" + } + + assert { + condition = azurerm_container_registry.acr.tags["ManagedBy"] == "Terraform" + error_message = "ManagedBy tag should be automatically set to Terraform" + } +} + +run "network_rule_bypass" { + variables { + acr_name = "testacr10" + resource_group_name = "acr-test-rg10" + location = "Germany West Central" + sku = "Premium" + network_rule_bypass_option = "None" + } + + assert { + condition = azurerm_container_registry.acr.network_rule_bypass_option == "None" + error_message = "Network rule bypass should be set to None" + } +} + +run "anonymous_pull_enabled" { + variables { + acr_name = "testacr11" + resource_group_name = "acr-test-rg11" + location = "Germany West Central" + sku = "Standard" + anonymous_pull_enabled = true + } + + assert { + condition = azurerm_container_registry.acr.anonymous_pull_enabled == true + error_message = "Anonymous pull should be enabled" + } +} + +run "existing_vnet_with_custom_dns_zone" { + command = plan + variables { + acr_name = "testacr12" + resource_group_name = "acr-test-rg12" + location = "Germany West Central" + sku = "Premium" + admin_enabled = false + public_network_access_enabled = false + + private_endpoint_enabled = true + private_dns_zone_id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/dns-rg/providers/Microsoft.Network/privateDnsZones/privatelink.azurecr.io" + vnet_name = "lz102-on-prem-nwk-vnet" + existing_vnet_resource_group_name = "connectivity" + subnet_name = "default" + } + + assert { + condition = var.private_dns_zone_id != "System" + error_message = "Should use custom DNS zone ID" + } + + assert { + condition = length(azurerm_private_dns_zone.acr_dns) == 0 + error_message = "Private DNS zone should NOT be created when custom ID is provided" + } + + assert { + condition = length(azurerm_private_endpoint.acr_pe) == 1 + error_message = "Private endpoint should be created with custom DNS zone" + } +} diff --git a/modules/azure/container-registry/buildingblock/logo.png b/modules/azure/container-registry/buildingblock/logo.png new file mode 100644 index 0000000..fe0ef8b Binary files /dev/null and b/modules/azure/container-registry/buildingblock/logo.png differ diff --git a/modules/azure/container-registry/buildingblock/main.tf b/modules/azure/container-registry/buildingblock/main.tf new file mode 100644 index 0000000..1dfa4ab --- /dev/null +++ b/modules/azure/container-registry/buildingblock/main.tf @@ -0,0 +1,194 @@ +data "azurerm_subscription" "current" {} + +resource "random_string" "suffix" { + length = 4 + special = false + upper = false +} + +resource "azurerm_resource_group" "acr" { + name = var.resource_group_name + location = var.location + tags = var.tags +} + +resource "azurerm_virtual_network" "vnet" { + count = var.private_endpoint_enabled && var.sku == "Premium" && var.vnet_name == null ? 1 : 0 + name = "${var.acr_name}-vnet" + address_space = [var.vnet_address_space] + location = var.location + resource_group_name = azurerm_resource_group.acr.name + tags = var.tags + + depends_on = [azurerm_resource_group.acr] +} + +data "azurerm_virtual_network" "existing_vnet" { + count = var.private_endpoint_enabled && var.sku == "Premium" && var.vnet_name != null ? 1 : 0 + name = var.vnet_name + resource_group_name = var.existing_vnet_resource_group_name != null ? var.existing_vnet_resource_group_name : var.resource_group_name +} + +locals { + vnet_id = var.private_endpoint_enabled && var.sku == "Premium" ? (var.vnet_name != null ? data.azurerm_virtual_network.existing_vnet[0].id : azurerm_virtual_network.vnet[0].id) : null + vnet_name = var.private_endpoint_enabled && var.sku == "Premium" ? (var.vnet_name != null ? var.vnet_name : azurerm_virtual_network.vnet[0].name) : null +} + +resource "azurerm_subnet" "pe_subnet" { + count = var.private_endpoint_enabled && var.sku == "Premium" && var.subnet_name == null ? 1 : 0 + name = "${var.acr_name}-pe-subnet" + resource_group_name = azurerm_resource_group.acr.name + virtual_network_name = local.vnet_name + address_prefixes = [var.subnet_address_prefix] + + private_endpoint_network_policies = "NetworkSecurityGroupEnabled" + + depends_on = [azurerm_virtual_network.vnet] +} + +data "azurerm_subnet" "existing_subnet" { + count = var.private_endpoint_enabled && var.sku == "Premium" && var.subnet_name != null ? 1 : 0 + name = var.subnet_name + virtual_network_name = var.vnet_name + resource_group_name = var.existing_vnet_resource_group_name != null ? var.existing_vnet_resource_group_name : var.resource_group_name +} + +locals { + subnet_id = var.private_endpoint_enabled && var.sku == "Premium" ? (var.subnet_name != null ? data.azurerm_subnet.existing_subnet[0].id : azurerm_subnet.pe_subnet[0].id) : null +} + +resource "azurerm_container_registry" "acr" { + name = "${var.acr_name}${random_string.suffix.result}" + resource_group_name = azurerm_resource_group.acr.name + location = var.location + sku = var.sku + admin_enabled = var.admin_enabled + public_network_access_enabled = var.sku == "Premium" ? var.public_network_access_enabled : true + zone_redundancy_enabled = var.zone_redundancy_enabled && var.sku == "Premium" ? true : false + anonymous_pull_enabled = var.anonymous_pull_enabled + data_endpoint_enabled = var.data_endpoint_enabled && var.sku == "Premium" ? true : false + network_rule_bypass_option = var.network_rule_bypass_option + retention_policy_in_days = var.sku == "Premium" && var.retention_days > 0 ? var.retention_days : 0 + trust_policy_enabled = var.sku == "Premium" ? var.trust_policy_enabled : false + + dynamic "network_rule_set" { + for_each = length(var.allowed_ip_ranges) > 0 ? [1] : [] + content { + default_action = "Deny" + + dynamic "ip_rule" { + for_each = var.allowed_ip_ranges + content { + action = "Allow" + ip_range = ip_rule.value + } + } + } + } + + tags = merge( + var.tags, + { + ManagedBy = "Terraform" + } + ) + + depends_on = [azurerm_resource_group.acr] +} + +resource "azurerm_private_endpoint" "acr_pe" { + count = var.private_endpoint_enabled && var.sku == "Premium" ? 1 : 0 + name = "${var.acr_name}-pe" + location = var.location + resource_group_name = azurerm_resource_group.acr.name + subnet_id = local.subnet_id + + private_service_connection { + name = "${var.acr_name}-psc" + private_connection_resource_id = azurerm_container_registry.acr.id + is_manual_connection = false + subresource_names = ["registry"] + } + + dynamic "private_dns_zone_group" { + for_each = var.private_dns_zone_id != null ? [1] : [] + content { + name = "default" + private_dns_zone_ids = var.private_dns_zone_id == "System" ? [azurerm_private_dns_zone.acr_dns[0].id] : [var.private_dns_zone_id] + } + } + + tags = var.tags + + depends_on = [azurerm_container_registry.acr] +} + +resource "azurerm_private_dns_zone" "acr_dns" { + count = var.private_endpoint_enabled && var.sku == "Premium" && var.private_dns_zone_id == "System" ? 1 : 0 + name = "privatelink.azurecr.io" + resource_group_name = azurerm_resource_group.acr.name + tags = var.tags + + depends_on = [azurerm_resource_group.acr] +} + +resource "azurerm_private_dns_zone_virtual_network_link" "acr_dns_link" { + count = var.private_endpoint_enabled && var.sku == "Premium" && var.private_dns_zone_id == "System" ? 1 : 0 + name = "${var.acr_name}-dns-link" + resource_group_name = azurerm_resource_group.acr.name + private_dns_zone_name = azurerm_private_dns_zone.acr_dns[0].name + virtual_network_id = local.vnet_id + tags = var.tags + + depends_on = [ + azurerm_private_dns_zone.acr_dns, + azurerm_private_endpoint.acr_pe + ] +} + +data "azurerm_resource_group" "hub_rg" { + count = var.private_endpoint_enabled && var.sku == "Premium" && var.hub_resource_group_name != null ? 1 : 0 + provider = azurerm.hub + name = var.hub_resource_group_name +} + +data "azurerm_virtual_network" "hub_vnet" { + count = var.private_endpoint_enabled && var.sku == "Premium" && var.hub_vnet_name != null ? 1 : 0 + provider = azurerm.hub + name = var.hub_vnet_name + resource_group_name = data.azurerm_resource_group.hub_rg[0].name +} + +resource "azurerm_virtual_network_peering" "acr_to_hub" { + count = var.private_endpoint_enabled && var.sku == "Premium" && var.vnet_name == null && var.hub_vnet_name != null ? 1 : 0 + name = "${var.acr_name}-to-hub" + resource_group_name = azurerm_resource_group.acr.name + virtual_network_name = local.vnet_name + remote_virtual_network_id = data.azurerm_virtual_network.hub_vnet[0].id + + allow_virtual_network_access = true + allow_forwarded_traffic = true + allow_gateway_transit = false + use_remote_gateways = var.use_remote_gateways +} + +resource "azurerm_virtual_network_peering" "hub_to_acr" { + count = var.private_endpoint_enabled && var.sku == "Premium" && var.vnet_name == null && var.hub_vnet_name != null ? 1 : 0 + provider = azurerm.hub + name = "hub-to-${var.acr_name}" + resource_group_name = data.azurerm_resource_group.hub_rg[0].name + virtual_network_name = data.azurerm_virtual_network.hub_vnet[0].name + remote_virtual_network_id = local.vnet_id + + allow_virtual_network_access = true + allow_forwarded_traffic = true + allow_gateway_transit = var.allow_gateway_transit_from_hub + use_remote_gateways = false +} + +resource "azurerm_role_assignment" "acr_pull" { + count = var.aks_managed_identity_principal_id != null ? 1 : 0 + scope = azurerm_container_registry.acr.id + role_definition_name = "AcrPull" + principal_id = var.aks_managed_identity_principal_id +} diff --git a/modules/azure/container-registry/buildingblock/outputs.tf b/modules/azure/container-registry/buildingblock/outputs.tf new file mode 100644 index 0000000..21276ec --- /dev/null +++ b/modules/azure/container-registry/buildingblock/outputs.tf @@ -0,0 +1,51 @@ +output "acr_id" { + description = "The ID of the Azure Container Registry" + value = azurerm_container_registry.acr.id +} + +output "acr_name" { + description = "The name of the Azure Container Registry" + value = azurerm_container_registry.acr.name +} + +output "acr_login_server" { + description = "The login server URL for the Azure Container Registry" + value = azurerm_container_registry.acr.login_server +} + +output "admin_username" { + description = "Admin username for the Azure Container Registry (only available when admin_enabled is true)" + value = var.admin_enabled ? azurerm_container_registry.acr.admin_username : null + sensitive = true +} + +output "admin_password" { + description = "Admin password for the Azure Container Registry (only available when admin_enabled is true)" + value = var.admin_enabled ? azurerm_container_registry.acr.admin_password : null + sensitive = true +} + +output "resource_group_name" { + description = "Name of the resource group containing the ACR" + value = azurerm_resource_group.acr.name +} + +output "private_endpoint_ip" { + description = "Private IP address of the ACR private endpoint" + value = var.private_endpoint_enabled && var.sku == "Premium" ? azurerm_private_endpoint.acr_pe[0].private_service_connection[0].private_ip_address : null +} + +output "vnet_id" { + description = "ID of the virtual network used for private endpoint" + value = local.vnet_id +} + +output "subnet_id" { + description = "ID of the subnet used for private endpoint" + value = local.subnet_id +} + +output "private_dns_zone_id" { + description = "ID of the private DNS zone (when System-managed)" + value = var.private_endpoint_enabled && var.sku == "Premium" && var.private_dns_zone_id == "System" ? azurerm_private_dns_zone.acr_dns[0].id : null +} diff --git a/modules/azure/container-registry/buildingblock/provider.tf b/modules/azure/container-registry/buildingblock/provider.tf new file mode 100644 index 0000000..ab91b24 --- /dev/null +++ b/modules/azure/container-registry/buildingblock/provider.tf @@ -0,0 +1,3 @@ +provider "azurerm" { + features {} +} diff --git a/modules/azure/container-registry/buildingblock/variables.tf b/modules/azure/container-registry/buildingblock/variables.tf new file mode 100644 index 0000000..21969ac --- /dev/null +++ b/modules/azure/container-registry/buildingblock/variables.tf @@ -0,0 +1,190 @@ +variable "resource_group_name" { + type = string + description = "Name of the resource group to create for the ACR" + default = "acr-rg" +} + +variable "location" { + type = string + description = "Azure region where resources will be deployed" + default = "Germany West Central" +} + +variable "acr_name" { + type = string + description = "Name of the Azure Container Registry (must be globally unique, alphanumeric only)" + + validation { + condition = can(regex("^[a-zA-Z0-9]{5,50}$", var.acr_name)) + error_message = "ACR name must be 5-50 characters, alphanumeric only (no hyphens or special characters)." + } +} + +variable "sku" { + type = string + description = "SKU tier for the ACR (Basic, Standard, Premium). Premium required for private endpoints." + default = "Premium" + + validation { + condition = contains(["Basic", "Standard", "Premium"], var.sku) + error_message = "SKU must be Basic, Standard, or Premium." + } +} + +variable "admin_enabled" { + type = bool + description = "Enable admin user for basic authentication (not recommended for production)" + default = false +} + +variable "public_network_access_enabled" { + type = bool + description = "Enable public network access to the ACR" + default = true +} + +variable "zone_redundancy_enabled" { + type = bool + description = "Enable zone redundancy for the ACR (Premium SKU only, available in select regions)" + default = false +} + +variable "anonymous_pull_enabled" { + type = bool + description = "Enable anonymous pull access (allows unauthenticated pulls)" + default = false +} + +variable "data_endpoint_enabled" { + type = bool + description = "Enable dedicated data endpoints (Premium SKU only)" + default = false +} + +variable "network_rule_bypass_option" { + type = string + description = "Whether to allow trusted Azure services to bypass network rules (AzureServices or None)" + default = "AzureServices" + + validation { + condition = contains(["AzureServices", "None"], var.network_rule_bypass_option) + error_message = "Network rule bypass must be AzureServices or None." + } +} + +variable "allowed_ip_ranges" { + type = list(string) + description = "List of IP ranges (CIDR) allowed to access the ACR" + default = [] +} + +variable "retention_days" { + type = number + description = "Number of days to retain untagged manifests (Premium SKU only, 0 to disable)" + default = 7 + + validation { + condition = var.retention_days >= 0 && var.retention_days <= 365 + error_message = "Retention days must be between 0 and 365." + } +} + +variable "trust_policy_enabled" { + type = bool + description = "Enable content trust policy (Premium SKU only)" + default = false +} + +variable "private_endpoint_enabled" { + type = bool + description = "Enable private endpoint for ACR (Premium SKU required)" + default = false +} + +variable "private_dns_zone_id" { + type = string + description = "Private DNS Zone ID for private endpoint. Use 'System' for Azure-managed zone, or provide custom zone ID. Only used when private_endpoint_enabled is true." + default = "System" +} + +variable "vnet_name" { + type = string + description = "Name of the virtual network for private endpoint. If not provided, a new VNet will be created." + default = null +} + +variable "existing_vnet_resource_group_name" { + type = string + description = "Resource group name of the existing VNet. Only used when vnet_name is provided. Defaults to the ACR resource group if not specified." + default = null +} + +variable "vnet_address_space" { + type = string + description = "Address space for the VNet (only used if vnet_name is not provided)" + default = "10.250.0.0/16" + + validation { + condition = can(cidrhost(var.vnet_address_space, 0)) + error_message = "VNet address space must be a valid CIDR block." + } +} + +variable "subnet_name" { + type = string + description = "Name of the subnet for private endpoint. If not provided, a new subnet will be created." + default = null +} + +variable "subnet_address_prefix" { + type = string + description = "Address prefix for the private endpoint subnet (only used if subnet_name is not provided)" + default = "10.250.1.0/24" + + validation { + condition = can(cidrhost(var.subnet_address_prefix, 0)) + error_message = "Subnet address prefix must be a valid CIDR block." + } +} + +variable "hub_subscription_id" { + type = string + description = "Subscription ID of the hub network. Required when private_endpoint_enabled is true and connecting to a hub." + default = null +} + +variable "hub_resource_group_name" { + type = string + description = "Resource group name of the hub virtual network. Required when private_endpoint_enabled is true and connecting to a hub." + default = null +} + +variable "hub_vnet_name" { + type = string + description = "Name of the hub virtual network to peer with. Required when private_endpoint_enabled is true and connecting to a hub." + default = null +} + +variable "use_remote_gateways" { + type = bool + description = "Use remote gateways from hub VNet. Set to true only if hub has a VPN/ExpressRoute gateway configured." + default = false +} + +variable "allow_gateway_transit_from_hub" { + type = bool + description = "Allow gateway transit from hub to spoke. Set to true if hub has a gateway and you want spoke to use it." + default = false +} + +variable "aks_managed_identity_principal_id" { + type = string + description = "Principal ID of the AKS managed identity to grant AcrPull access. If provided, AcrPull role will be assigned automatically." + default = null +} + +variable "tags" { + type = map(string) + description = "Tags to apply to all resources" + default = {} +} diff --git a/modules/azure/container-registry/buildingblock/versions.tf b/modules/azure/container-registry/buildingblock/versions.tf new file mode 100644 index 0000000..3874cdd --- /dev/null +++ b/modules/azure/container-registry/buildingblock/versions.tf @@ -0,0 +1,16 @@ +terraform { + required_version = ">= 1.3.0" + + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "~> 4.36.0" + configuration_aliases = [azurerm, azurerm.hub] + } + + random = { + source = "hashicorp/random" + version = "~> 3.6.0" + } + } +}