diff --git a/README.md b/README.md index 53268fe..e0d2513 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,14 @@ This bootstrap framework supports multiple Infrastructure as Code approaches: | **bicep** | Bicep-based Azure Landing Zones (New Framework using Azure Verified Modules) | [alz-bicep-accelerator](https://github.com/Azure/alz-bicep-accelerator) | | **bicep-classic** | Bicep-based Azure Landing Zones (Classic Framework) | [ALZ-Bicep](https://github.com/Azure/ALZ-Bicep) | +## Features + +- Azure DevOps and GitHub bootstrap orchestrations +- User-assigned managed identities with federated credentials +- Self-hosted agents with Container Instances or Container App Jobs (Azure DevOps only) +- Private networking support with NAT Gateway +- Container Registry with ACR Tasks for agent images + ## Configuration The supported frameworks and their configuration are defined in [`.config/ALZ-Powershell.config.json`](.config/ALZ-Powershell.config.json). diff --git a/alz/azuredevops/agent_identity_permissions.tf b/alz/azuredevops/agent_identity_permissions.tf new file mode 100644 index 0000000..0044ec2 --- /dev/null +++ b/alz/azuredevops/agent_identity_permissions.tf @@ -0,0 +1,39 @@ +# Azure DevOps Service Principal Entitlement for Agent Identity +# Required for UAMI authentication to work with Azure DevOps +# Pattern from: https://github.com/Azure/terraform-azurerm-avm-ptn-cicd-agents-and-runners/blob/main/examples/azure_devops_container_app_uami/main.tf + +data "azuredevops_group" "project_collection_service_accounts" { + count = var.use_self_hosted_agents && var.use_container_app_jobs ? 1 : 0 + name = "Project Collection Service Accounts" +} + +# Wait for UAMI propagation in Azure AD +resource "time_sleep" "agent_identity_propagation" { + count = var.use_self_hosted_agents && var.use_container_app_jobs ? 1 : 0 + create_duration = "30s" + + depends_on = [module.identities] +} + +# Create service principal entitlement for the agent UAMI +resource "azuredevops_service_principal_entitlement" "agent_identity" { + count = var.use_self_hosted_agents && var.use_container_app_jobs ? 1 : 0 + account_license_type = "express" # Basic license for service principals + origin = "aad" # Azure Active Directory + origin_id = module.identities.managed_identity_principal_ids["agent"] + + depends_on = [time_sleep.agent_identity_propagation] +} + +# Add agent identity service principal to Project Collection Service Accounts group +resource "azuredevops_group_membership" "agent_identity" { + count = var.use_self_hosted_agents && var.use_container_app_jobs ? 1 : 0 + group = data.azuredevops_group.project_collection_service_accounts[0].descriptor + members = [azuredevops_service_principal_entitlement.agent_identity[0].descriptor] + mode = "add" + + depends_on = [ + azuredevops_service_principal_entitlement.agent_identity, + data.azuredevops_group.project_collection_service_accounts + ] +} diff --git a/alz/azuredevops/locals.tf b/alz/azuredevops/locals.tf index 2fa0704..9c00b3d 100644 --- a/alz/azuredevops/locals.tf +++ b/alz/azuredevops/locals.tf @@ -12,7 +12,8 @@ locals { } locals { - use_private_networking = var.use_self_hosted_agents && var.use_private_networking + # Auto-enable private networking when using Container App Jobs (Container App Environment requires VNET) + use_private_networking = var.use_self_hosted_agents && (var.use_private_networking || var.use_container_app_jobs) allow_storage_access_from_my_ip = local.use_private_networking && var.allow_storage_access_from_my_ip } @@ -39,23 +40,31 @@ locals { } locals { - managed_identities = { - (local.plan_key) = local.resource_names.user_assigned_managed_identity_plan - (local.apply_key) = local.resource_names.user_assigned_managed_identity_apply - } - - federated_credentials = { + managed_identities = merge( + { + (local.plan_key) = local.resource_names.user_assigned_managed_identity_plan + (local.apply_key) = local.resource_names.user_assigned_managed_identity_apply + }, + var.use_self_hosted_agents && var.use_container_app_jobs ? { + agent = local.resource_names.user_assigned_managed_identity_agent + } : {} + ) + + # Federated credentials for the identities module (includes audience) + federated_credentials_for_identities = { (local.plan_key) = { user_assigned_managed_identity_key = local.plan_key federated_credential_subject = module.azure_devops.subjects[local.plan_key] federated_credential_issuer = module.azure_devops.issuers[local.plan_key] federated_credential_name = local.resource_names.user_assigned_managed_identity_federated_credentials_plan + audience = ["api://AzureADTokenExchange"] } (local.apply_key) = { user_assigned_managed_identity_key = local.apply_key federated_credential_subject = module.azure_devops.subjects[local.apply_key] federated_credential_issuer = module.azure_devops.issuers[local.apply_key] federated_credential_name = local.resource_names.user_assigned_managed_identity_federated_credentials_apply + audience = ["api://AzureADTokenExchange"] } } @@ -106,7 +115,11 @@ locals { } locals { - agent_container_instance_dockerfile_url = "${var.agent_container_image_repository}#${var.agent_container_image_tag}:${var.agent_container_image_folder}" + # Use different image folder and tag for Container App Jobs vs Container Instances + agent_image_folder = var.use_container_app_jobs ? var.agent_container_app_image_folder : var.agent_container_image_folder + agent_image_tag = var.use_container_app_jobs ? var.agent_container_app_image_tag : var.agent_container_image_tag + + agent_container_instance_dockerfile_url = "${var.agent_container_image_repository}#${local.agent_image_tag}:${local.agent_image_folder}" } locals { diff --git a/alz/azuredevops/main.tf b/alz/azuredevops/main.tf index a7b9ab5..e922cf8 100644 --- a/alz/azuredevops/main.tf +++ b/alz/azuredevops/main.tf @@ -16,9 +16,25 @@ module "files" { additional_folders_path = var.additional_folders_path } +# ======================================== +# IDENTITY MANAGEMENT +# ======================================== +# Create all bootstrap identities (plan, apply) in a dedicated module +# This must happen before azure_devops module to get federated credentials + +module "identities" { + source = "../../modules/identities" + resource_group_name = local.resource_names.resource_group_identity + location = var.bootstrap_location + managed_identities = local.managed_identities + # Federated credentials created after azure_devops module provides subjects/issuers + federated_credentials = local.federated_credentials_for_identities + tags = var.tags +} + module "azure" { source = "../../modules/azure" - resource_group_identity_name = local.resource_names.resource_group_identity + resource_group_identity_name = module.identities.resource_group_name resource_group_agents_name = local.resource_names.resource_group_agents resource_group_network_name = local.resource_names.resource_group_network resource_group_state_name = local.resource_names.resource_group_state @@ -26,8 +42,9 @@ module "azure" { storage_account_name = local.resource_names.storage_account storage_container_name = local.resource_names.storage_container azure_location = var.bootstrap_location - user_assigned_managed_identities = local.managed_identities - federated_credentials = local.federated_credentials + managed_identity_ids = module.identities.managed_identity_ids + managed_identity_client_ids = module.identities.managed_identity_client_ids + managed_identity_principal_ids = module.identities.managed_identity_principal_ids agent_container_instances = local.agent_container_instances agent_container_instance_managed_identity_name = local.resource_names.container_instance_managed_identity agent_organization_url = module.azure_devops.organization_url @@ -42,20 +59,31 @@ module "azure" { virtual_network_name = local.resource_names.virtual_network virtual_network_subnet_name_container_instances = local.resource_names.subnet_container_instances virtual_network_subnet_name_private_endpoints = local.resource_names.subnet_private_endpoints + virtual_network_subnet_name_container_apps = var.use_container_app_jobs ? local.resource_names.subnet_container_apps : "" storage_account_private_endpoint_name = local.resource_names.storage_account_private_endpoint use_private_networking = local.use_private_networking allow_storage_access_from_my_ip = local.allow_storage_access_from_my_ip virtual_network_address_space = var.virtual_network_address_space virtual_network_subnet_address_prefix_container_instances = var.virtual_network_subnet_address_prefix_container_instances virtual_network_subnet_address_prefix_private_endpoints = var.virtual_network_subnet_address_prefix_private_endpoints + virtual_network_subnet_address_prefix_container_apps = var.virtual_network_subnet_address_prefix_container_apps storage_account_replication_type = var.storage_account_replication_type public_ip_name = local.resource_names.public_ip nat_gateway_name = local.resource_names.nat_gateway use_self_hosted_agents = var.use_self_hosted_agents + use_container_app_jobs = var.use_container_app_jobs + agent_container_cpu = var.agent_container_cpu + agent_container_memory = var.agent_container_memory + service_name = var.service_name + environment_name = var.environment_name + container_app_environment_name = local.resource_names.container_app_environment + container_app_job_name = local.resource_names.container_app_job + container_app_job_placeholder_name = local.resource_names.container_app_job_placeholder + container_app_infrastructure_resource_group_name = local.resource_names.container_app_infrastructure_resource_group container_registry_name = local.resource_names.container_registry container_registry_private_endpoint_name = local.resource_names.container_registry_private_endpoint container_registry_image_name = local.resource_names.container_image_name - container_registry_image_tag = var.agent_container_image_tag + container_registry_image_tag = local.agent_image_tag container_registry_dockerfile_name = var.agent_container_image_dockerfile container_registry_dockerfile_repository_folder_url = local.agent_container_instance_dockerfile_url custom_role_definitions = var.iac_type == "terraform" ? local.custom_role_definitions_terraform : (var.iac_type == "bicep" ? local.custom_role_definitions_bicep : local.custom_role_definitions_bicep_classic) @@ -76,7 +104,7 @@ module "azure_devops" { create_project = var.azure_devops_create_project project_name = var.azure_devops_project_name environments = local.environments - managed_identity_client_ids = module.azure.user_assigned_managed_identity_client_ids + managed_identity_client_ids = module.identities.managed_identity_client_ids repository_name = local.resource_names.version_control_system_repository repository_files = module.file_manipulation.repository_files template_repository_files = module.file_manipulation.template_repository_files @@ -121,4 +149,34 @@ module "file_manipulation" { agent_pool_or_runner_configuration = local.agent_pool_or_runner_configuration pipeline_files_directory_path = local.pipeline_files_directory_path pipeline_template_files_directory_path = local.pipeline_template_files_directory_path -} \ No newline at end of file +} + +# ======================================== +# STATE MIGRATION (Backwards Compatibility) +# ======================================== +# These moved blocks ensure existing deployments migrate smoothly to the new identity module structure + +moved { + from = module.azure.azurerm_resource_group.identity + to = module.identities.azurerm_resource_group.identity +} + +moved { + from = module.azure.azurerm_user_assigned_identity.alz["plan"] + to = module.identities.azurerm_user_assigned_identity.identities["plan"] +} + +moved { + from = module.azure.azurerm_user_assigned_identity.alz["apply"] + to = module.identities.azurerm_user_assigned_identity.identities["apply"] +} + +moved { + from = module.azure.azurerm_federated_identity_credential.alz["plan"] + to = module.identities.azurerm_federated_identity_credential.credentials["plan"] +} + +moved { + from = module.azure.azurerm_federated_identity_credential.alz["apply"] + to = module.identities.azurerm_federated_identity_credential.credentials["apply"] +} diff --git a/alz/azuredevops/variables.tf b/alz/azuredevops/variables.tf index f678c00..f18b07b 100644 --- a/alz/azuredevops/variables.tf +++ b/alz/azuredevops/variables.tf @@ -276,6 +276,17 @@ variable "use_self_hosted_agents" { default = true } +variable "use_container_app_jobs" { + description = "Whether to use Container App Jobs for self-hosted agents (Azure DevOps only). Mutually exclusive with Container Instances." + type = bool + default = false + + validation { + condition = !var.use_container_app_jobs || var.use_self_hosted_agents + error_message = "use_container_app_jobs requires use_self_hosted_agents to be true." + } +} + variable "azure_devops_agents_personal_access_token" { description = <<-EOT **(Optional, default: `""`)** Personal access token for Azure DevOps self-hosted agents. @@ -363,9 +374,10 @@ variable "agent_container_image_repository" { default = "https://github.com/Azure/avm-container-images-cicd-agents-and-runners" } +# Container Instances (ACI) configuration variable "agent_container_image_tag" { description = <<-EOT - **(Optional, default: `"39b9059"`)** The container image tag/commit hash for Azure DevOps agents. + **(Optional, default: `"39b9059"`)** The container image tag/commit hash for Azure DevOps agents with Container Instances. EOT type = string default = "57a937f" @@ -373,12 +385,25 @@ variable "agent_container_image_tag" { variable "agent_container_image_folder" { description = <<-EOT - **(Optional, default: `"azure-devops-agent-aci"`)** The folder containing the Dockerfile for the container image. + **(Optional, default: `"azure-devops-agent-aci"`)** The folder containing the Dockerfile for Container Instances. EOT type = string default = "azure-devops-agent-aci" } +# Container App Jobs (ACA) configuration +variable "agent_container_app_image_tag" { + description = "The container image tag to use for Azure DevOps Agents with Container App Jobs" + type = string + default = "221742d" +} + +variable "agent_container_app_image_folder" { + description = "The folder containing the Dockerfile for Container App Jobs" + type = string + default = "azure-devops-agent-aca" +} + variable "agent_container_image_dockerfile" { description = <<-EOT **(Optional, default: `"Dockerfile"`)** The Dockerfile name to use for the container image. @@ -491,6 +516,7 @@ variable "resource_names" { resource_group_network = optional(string, "rg-{{service_name}}-{{environment_name}}-network-{{azure_location}}-{{postfix_number}}") user_assigned_managed_identity_plan = optional(string, "id-{{service_name}}-{{environment_name}}-{{azure_location}}-plan-{{postfix_number}}") user_assigned_managed_identity_apply = optional(string, "id-{{service_name}}-{{environment_name}}-{{azure_location}}-apply-{{postfix_number}}") + user_assigned_managed_identity_agent = optional(string, "id-{{service_name}}-{{environment_name}}-{{azure_location}}-agent-{{postfix_number}}") user_assigned_managed_identity_federated_credentials_plan = optional(string, "id-{{service_name}}-{{environment_name}}-{{azure_location}}-{{postfix_number}}-plan") user_assigned_managed_identity_federated_credentials_apply = optional(string, "id-{{service_name}}-{{environment_name}}-{{azure_location}}-{{postfix_number}}-apply") storage_account = optional(string, "sto{{service_name_short}}{{environment_name_short}}{{azure_location_short}}{{postfix_number}}{{random_string}}") @@ -516,10 +542,16 @@ variable "resource_names" { nat_gateway = optional(string, "nat-{{service_name}}-{{environment_name}}-{{azure_location}}-{{postfix_number}}") subnet_container_instances = optional(string, "subnet-{{service_name}}-{{environment_name}}-{{azure_location}}-{{postfix_number}}-aci") subnet_private_endpoints = optional(string, "subnet-{{service_name}}-{{environment_name}}-{{azure_location}}-{{postfix_number}}-pe") + subnet_container_apps = optional(string, "subnet-{{service_name}}-{{environment_name}}-{{azure_location}}-{{postfix_number}}-ca") storage_account_private_endpoint = optional(string, "pe-{{service_name}}-{{environment_name}}-{{azure_location}}-sto-{{postfix_number}}") container_registry = optional(string, "acr{{service_name}}{{environment_name}}{{azure_location_short}}{{postfix_number}}{{random_string}}") container_registry_private_endpoint = optional(string, "pe-{{service_name}}-{{environment_name}}-{{azure_location}}-acr-{{postfix_number}}") container_image_name = optional(string, "azure-devops-agent") + # Container App Jobs naming (max 32 chars) + container_app_environment = optional(string, "cae-{{service_name}}-{{environment_name}}-{{azure_location_short}}-{{postfix_number}}") + container_app_job = optional(string, "caj-{{service_name}}-{{environment_name}}-{{azure_location_short}}-{{postfix_number}}") + container_app_job_placeholder = optional(string, "caj-{{service_name}}-{{environment_name}}-{{azure_location_short}}-{{postfix_number}}-ph") + container_app_infrastructure_resource_group = optional(string, "rg-{{service_name}}-{{environment_name}}-{{azure_location}}-{{postfix_number}}-ca-infra") }) default = {} } @@ -580,6 +612,12 @@ variable "virtual_network_subnet_address_prefix_private_endpoints" { default = "10.0.0.64/26" } +variable "virtual_network_subnet_address_prefix_container_apps" { + type = string + description = "Address prefix for the Container Apps subnet" + default = "10.0.0.128/26" +} + variable "storage_account_replication_type" { description = <<-EOT **(Optional, default: `"ZRS"`)** The replication strategy for the Azure storage account storing state files. @@ -1116,3 +1154,9 @@ variable "bicep_tenant_role_assignment_role_definition_name" { type = string default = "Landing Zone Management Owner" } + +variable "tags" { + type = map(string) + default = {} + description = "Tags to apply to Azure resources" +} diff --git a/alz/github/locals.tf b/alz/github/locals.tf index 845f9e7..b802a85 100644 --- a/alz/github/locals.tf +++ b/alz/github/locals.tf @@ -58,12 +58,14 @@ locals { (local.apply_key) = local.resource_names.user_assigned_managed_identity_apply } - federated_credentials = { for key, value in module.github.subjects : + # Federated credentials for the identities module (includes audience) + federated_credentials_for_identities = { for key, value in module.github.subjects : key => { user_assigned_managed_identity_key = value.user_assigned_managed_identity_key federated_credential_subject = value.subject federated_credential_issuer = module.github.issuer federated_credential_name = "${local.resource_names.user_assigned_managed_identity_federated_credentials_prefix}-${key}" + audience = ["api://AzureADTokenExchange"] } } diff --git a/alz/github/main.tf b/alz/github/main.tf index 4797274..a2edd6f 100644 --- a/alz/github/main.tf +++ b/alz/github/main.tf @@ -1,3 +1,8 @@ +# Validation: Container App Jobs are not supported for GitHub +locals { + validation_container_app_jobs_not_supported = var.use_self_hosted_agents && var.use_container_app_jobs ? tobool("ERROR: Container App Jobs (use_container_app_jobs=true) are only supported with Azure DevOps, not GitHub. Please use Container Instances instead (use_container_app_jobs=false).") : true +} + module "resource_names" { source = "../../modules/resource_names" azure_location = var.bootstrap_location @@ -16,11 +21,25 @@ module "files" { additional_folders_path = var.additional_folders_path } +# ======================================== +# IDENTITY MANAGEMENT +# ======================================== +# Create all bootstrap identities (plan, apply) in a dedicated module +# This must happen before github module to get federated credentials + +module "identities" { + source = "../../modules/identities" + resource_group_name = local.resource_names.resource_group_identity + location = var.bootstrap_location + managed_identities = local.managed_identities + # Federated credentials created after github module provides subjects/issuers + federated_credentials = local.federated_credentials_for_identities + tags = var.tags +} + module "azure" { source = "../../modules/azure" - user_assigned_managed_identities = local.managed_identities - federated_credentials = local.federated_credentials - resource_group_identity_name = local.resource_names.resource_group_identity + resource_group_identity_name = module.identities.resource_group_name resource_group_state_name = local.resource_names.resource_group_state resource_group_agents_name = local.resource_names.resource_group_agents resource_group_network_name = local.resource_names.resource_group_network @@ -28,6 +47,9 @@ module "azure" { storage_account_name = local.resource_names.storage_account storage_container_name = local.resource_names.storage_container azure_location = var.bootstrap_location + managed_identity_ids = module.identities.managed_identity_ids + managed_identity_client_ids = module.identities.managed_identity_client_ids + managed_identity_principal_ids = module.identities.managed_identity_principal_ids target_subscriptions = local.target_subscriptions root_parent_management_group_id = local.root_parent_management_group_id agent_container_instances = local.runner_container_instances @@ -81,7 +103,7 @@ module "github" { repository_files = module.file_manipulation.repository_files template_repository_files = module.file_manipulation.template_repository_files workflows = local.workflows - managed_identity_client_ids = module.azure.user_assigned_managed_identity_client_ids + managed_identity_client_ids = module.identities.managed_identity_client_ids azure_tenant_id = data.azurerm_client_config.current.tenant_id azure_subscription_id = var.subscription_ids["management"] backend_azure_resource_group_name = local.resource_names.resource_group_state @@ -124,3 +146,27 @@ module "file_manipulation" { pipeline_template_files_directory_path = local.pipeline_template_files_directory_path concurrency_value = local.resource_names.storage_container } + +# ======================================== +# STATE MIGRATION (Backwards Compatibility) +# ======================================== +# These moved blocks ensure existing deployments migrate smoothly to the new identity module structure + +moved { + from = module.azure.azurerm_resource_group.identity + to = module.identities.azurerm_resource_group.identity +} + +moved { + from = module.azure.azurerm_user_assigned_identity.alz["plan"] + to = module.identities.azurerm_user_assigned_identity.identities["plan"] +} + +moved { + from = module.azure.azurerm_user_assigned_identity.alz["apply"] + to = module.identities.azurerm_user_assigned_identity.identities["apply"] +} + +# Note: Federated credentials in github are dynamically created based on subjects +# The moved blocks need to account for the dynamic keys from module.github.subjects +# Users may need to manually move these or use terraform state mv commands diff --git a/alz/github/variables.tf b/alz/github/variables.tf index bf6bfa3..8fac1e8 100644 --- a/alz/github/variables.tf +++ b/alz/github/variables.tf @@ -294,6 +294,12 @@ variable "use_self_hosted_runners" { default = true } +variable "use_container_app_jobs" { + description = "NOT SUPPORTED FOR GITHUB. Container App Jobs are only supported with Azure DevOps. This variable exists for validation purposes only." + type = bool + default = false +} + variable "github_runners_personal_access_token" { description = <<-EOT **(Optional, default: `""`)** Personal access token for GitHub self-hosted runners. @@ -1168,3 +1174,10 @@ variable "bicep_tenant_role_assignment_role_definition_name" { type = string default = "Landing Zone Management Owner" } + + +variable "tags" { + type = map(string) + default = {} + description = "Tags to apply to Azure resources" +} diff --git a/alz/local/main.tf b/alz/local/main.tf index 0ef2eea..f15f499 100644 --- a/alz/local/main.tf +++ b/alz/local/main.tf @@ -16,12 +16,28 @@ module "files" { additional_folders_path = var.additional_folders_path } +# ======================================== +# IDENTITY MANAGEMENT (Optional for local deployment) +# ======================================== +# Create bootstrap identities only if deploying Azure resources + +module "identities" { + count = var.create_bootstrap_resources_in_azure ? 1 : 0 + source = "../../modules/identities" + resource_group_name = local.resource_names.resource_group_identity + location = var.bootstrap_location + managed_identities = local.managed_identities + federated_credentials = var.federated_credentials + tags = var.tags +} + module "azure" { source = "../../modules/azure" count = var.create_bootstrap_resources_in_azure ? 1 : 0 - user_assigned_managed_identities = local.managed_identities - federated_credentials = local.federated_credentials - resource_group_identity_name = local.resource_names.resource_group_identity + managed_identity_ids = module.identities[0].managed_identity_ids + managed_identity_client_ids = module.identities[0].managed_identity_client_ids + managed_identity_principal_ids = module.identities[0].managed_identity_principal_ids + resource_group_identity_name = module.identities[0].resource_group_name resource_group_state_name = local.resource_names.resource_group_state create_storage_account = var.iac_type == local.iac_terraform storage_account_name = local.resource_names.storage_account @@ -74,3 +90,18 @@ resource "local_file" "command" { content = local.command_final filename = "${local.target_directory}/scripts/deploy-local.ps1" } + +moved { + from = module.azure[0].azurerm_resource_group.identity + to = module.identities[0].azurerm_resource_group.identity +} + +moved { + from = module.azure[0].azurerm_user_assigned_identity.alz + to = module.identities[0].azurerm_user_assigned_identity.identities +} + +moved { + from = module.azure[0].azurerm_federated_identity_credential.alz + to = module.identities[0].azurerm_federated_identity_credential.credentials +} diff --git a/alz/local/variables.tf b/alz/local/variables.tf index 243e6e6..5aa74d7 100644 --- a/alz/local/variables.tf +++ b/alz/local/variables.tf @@ -867,3 +867,9 @@ variable "bicep_tenant_role_assignment_role_definition_name" { description = "The name of the Azure role definition to assign at the tenant level for Bicep deployments. This role grants the managed identity permissions to manage Azure Landing Zones resources across the tenant. Common values: 'Landing Zone Management Owner', 'Owner', or a custom role name." default = "Landing Zone Management Owner" } + +variable "tags" { + type = map(string) + description = "(Optional) Tags to apply to resources." + default = {} +} diff --git a/modules/azure/container_app_jobs.tf b/modules/azure/container_app_jobs.tf new file mode 100644 index 0000000..75a383c --- /dev/null +++ b/modules/azure/container_app_jobs.tf @@ -0,0 +1,77 @@ +module "container_app_jobs" { + source = "Azure/avm-ptn-cicd-agents-and-runners/azurerm" + version = "~> 0.5" + + count = var.use_self_hosted_agents && var.use_container_app_jobs ? 1 : 0 + + # Required inputs + location = var.azure_location + postfix = "${var.service_name}-${var.environment_name}" + version_control_system_organization = var.agent_organization_url + version_control_system_type = "azuredevops" + + # Override default naming to follow bootstrap naming convention + container_app_environment_name = var.container_app_environment_name + container_app_job_name = var.container_app_job_name + container_app_placeholder_job_name = var.container_app_job_placeholder_name + container_app_infrastructure_resource_group_name = var.container_app_infrastructure_resource_group_name + + # Compute type + compute_types = ["azure_container_app"] + + # Resource group (BYO mode) + resource_group_creation_enabled = false + resource_group_name = azurerm_resource_group.agents[0].name + + # Virtual network (BYO mode) + virtual_network_creation_enabled = false + virtual_network_name = azurerm_virtual_network.alz[0].name + virtual_network_address_space = var.virtual_network_address_space + virtual_network_id = azurerm_virtual_network.alz[0].id + container_app_subnet_id = azurerm_subnet.container_apps[0].id + + # Container registry (BYO mode) + container_registry_creation_enabled = false + custom_container_registry_login_server = azurerm_container_registry.alz[0].login_server + container_registry_private_endpoint_subnet_id = azurerm_subnet.private_endpoints[0].id + container_registry_dns_zone_id = azurerm_private_dns_zone.alz["container_registry"].id + container_registry_private_dns_zone_creation_enabled = false + + # Custom container image (use our pre-built image) + use_default_container_image = false + custom_container_registry_images = { + container_app = { + task_name = "image-build-task" + dockerfile_path = var.container_registry_dockerfile_name + context_path = var.container_registry_dockerfile_repository_folder_url + context_access_token = "a" + image_names = ["${var.container_registry_image_name}:${var.container_registry_image_tag}"] + } + } + + # User-assigned managed identity (BYO mode) + user_assigned_managed_identity_creation_enabled = false + user_assigned_managed_identity_id = var.managed_identity_ids["agent"] + user_assigned_managed_identity_client_id = var.managed_identity_client_ids["agent"] + user_assigned_managed_identity_principal_id = var.managed_identity_principal_ids["agent"] + + # Container App Job configuration + container_app_container_cpu = var.agent_container_cpu + container_app_container_memory = "${var.agent_container_memory}Gi" + container_app_max_execution_count = 10 + container_app_min_execution_count = 0 + container_app_polling_interval_seconds = 30 + + # Version control system configuration + version_control_system_authentication_method = "uami" + version_control_system_personal_access_token = null + version_control_system_pool_name = var.agent_pool_name + + # Private networking + use_private_networking = true + + depends_on = [ + azurerm_role_assignment.container_registry_pull_for_agent, + azurerm_role_assignment.container_registry_push_for_agent + ] +} diff --git a/modules/azure/container_instances.tf b/modules/azure/container_instances.tf index 0c2b073..59dd919 100644 --- a/modules/azure/container_instances.tf +++ b/modules/azure/container_instances.tf @@ -1,5 +1,5 @@ resource "azurerm_container_group" "alz" { - for_each = var.use_self_hosted_agents ? var.agent_container_instances : {} + for_each = var.use_self_hosted_agents && !var.use_container_app_jobs ? var.agent_container_instances : {} name = each.value.container_instance_name location = var.azure_location resource_group_name = azurerm_resource_group.agents[0].name @@ -49,7 +49,7 @@ resource "azurerm_container_group" "alz" { } resource "azurerm_user_assigned_identity" "container_instances" { - count = var.use_self_hosted_agents ? 1 : 0 + count = var.use_self_hosted_agents && !var.use_container_app_jobs ? 1 : 0 location = var.azure_location name = var.agent_container_instance_managed_identity_name resource_group_name = azurerm_resource_group.agents[0].name diff --git a/modules/azure/container_registry.tf b/modules/azure/container_registry.tf index 5b1c40c..b7e9408 100644 --- a/modules/azure/container_registry.tf +++ b/modules/azure/container_registry.tf @@ -57,7 +57,7 @@ resource "azurerm_container_registry_task_schedule_run_now" "alz" { } resource "azurerm_role_assignment" "container_registry_pull_for_container_instance" { - count = var.use_self_hosted_agents ? 1 : 0 + count = var.use_self_hosted_agents && !var.use_container_app_jobs ? 1 : 0 scope = azurerm_container_registry.alz[0].id role_definition_name = "AcrPull" principal_id = azurerm_user_assigned_identity.container_instances[0].principal_id @@ -69,3 +69,17 @@ resource "azurerm_role_assignment" "container_registry_push_for_task" { role_definition_name = "AcrPush" principal_id = azurerm_container_registry_task.alz[0].identity[0].principal_id } + +resource "azurerm_role_assignment" "container_registry_pull_for_agent" { + count = var.use_self_hosted_agents && var.use_container_app_jobs ? 1 : 0 + scope = azurerm_container_registry.alz[0].id + role_definition_name = "AcrPull" + principal_id = var.managed_identity_principal_ids["agent"] +} + +resource "azurerm_role_assignment" "container_registry_push_for_agent" { + count = var.use_self_hosted_agents && var.use_container_app_jobs ? 1 : 0 + scope = azurerm_container_registry.alz[0].id + role_definition_name = "AcrPush" + principal_id = var.managed_identity_principal_ids["agent"] +} diff --git a/modules/azure/managed_identity.tf b/modules/azure/managed_identity.tf deleted file mode 100644 index a0b1195..0000000 --- a/modules/azure/managed_identity.tf +++ /dev/null @@ -1,16 +0,0 @@ -resource "azurerm_user_assigned_identity" "alz" { - for_each = var.user_assigned_managed_identities - location = var.azure_location - name = each.value - resource_group_name = azurerm_resource_group.identity.name -} - -resource "azurerm_federated_identity_credential" "alz" { - for_each = var.federated_credentials - name = each.value.federated_credential_name - resource_group_name = azurerm_resource_group.identity.name - audience = [local.audience] - issuer = each.value.federated_credential_issuer - parent_id = azurerm_user_assigned_identity.alz[each.value.user_assigned_managed_identity_key].id - subject = each.value.federated_credential_subject -} diff --git a/modules/azure/networking.tf b/modules/azure/networking.tf index d2b5d10..3c130f9 100644 --- a/modules/azure/networking.tf +++ b/modules/azure/networking.tf @@ -59,3 +59,24 @@ resource "azurerm_subnet" "private_endpoints" { address_prefixes = [var.virtual_network_subnet_address_prefix_private_endpoints] private_endpoint_network_policies = "Enabled" } + +resource "azurerm_subnet" "container_apps" { + count = var.use_private_networking && var.use_self_hosted_agents && var.use_container_app_jobs ? 1 : 0 + name = var.virtual_network_subnet_name_container_apps + resource_group_name = azurerm_resource_group.network[0].name + virtual_network_name = azurerm_virtual_network.alz[0].name + address_prefixes = [var.virtual_network_subnet_address_prefix_container_apps] + delegation { + name = "container-app-delegation" + service_delegation { + name = "Microsoft.App/environments" + actions = ["Microsoft.Network/virtualNetworks/subnets/join/action"] + } + } +} + +resource "azurerm_subnet_nat_gateway_association" "container_apps" { + count = var.use_private_networking && var.use_self_hosted_agents && var.use_container_app_jobs ? 1 : 0 + subnet_id = azurerm_subnet.container_apps[0].id + nat_gateway_id = azurerm_nat_gateway.alz[0].id +} diff --git a/modules/azure/outputs.tf b/modules/azure/outputs.tf index 05266cc..2cbd812 100644 --- a/modules/azure/outputs.tf +++ b/modules/azure/outputs.tf @@ -1,6 +1,6 @@ output "user_assigned_managed_identity_client_ids" { - description = "Map of user-assigned managed identity keys to their client IDs. Client IDs are used for OIDC authentication in CI/CD pipelines and for configuring service connections." - value = { for key, value in var.user_assigned_managed_identities : key => azurerm_user_assigned_identity.alz[key].client_id } + value = var.managed_identity_client_ids + description = "Map of managed identity client IDs (passed through from identities module)" } output "role_assignments" { diff --git a/modules/azure/resource_groups.tf b/modules/azure/resource_groups.tf index 6b2877c..af98de5 100644 --- a/modules/azure/resource_groups.tf +++ b/modules/azure/resource_groups.tf @@ -4,10 +4,8 @@ resource "azurerm_resource_group" "state" { location = var.azure_location } -resource "azurerm_resource_group" "identity" { - name = var.resource_group_identity_name - location = var.azure_location -} +# Identity resource group is now created by the identities module +# Variable kept for backwards compatibility but resource removed resource "azurerm_resource_group" "agents" { count = var.use_self_hosted_agents ? 1 : 0 diff --git a/modules/azure/role_assignments.tf b/modules/azure/role_assignments.tf index 3f1c3e0..8f4a9a9 100644 --- a/modules/azure/role_assignments.tf +++ b/modules/azure/role_assignments.tf @@ -3,7 +3,7 @@ locals { user_assigned_managed_identity_key = value.user_assigned_managed_identity_key custom_role_definition_key = value.custom_role_definition_key scope = value.scope - principal_id = azurerm_user_assigned_identity.alz[value.user_assigned_managed_identity_key].principal_id + principal_id = var.managed_identity_principal_ids[value.user_assigned_managed_identity_key] } } additional_role_assignments = { for assignment in flatten([ diff --git a/modules/azure/storage.tf b/modules/azure/storage.tf index e606750..8c8503c 100644 --- a/modules/azure/storage.tf +++ b/modules/azure/storage.tf @@ -59,10 +59,10 @@ resource "azapi_resource" "storage_account_container" { } resource "azurerm_role_assignment" "alz_storage_container" { - for_each = var.create_storage_account ? var.user_assigned_managed_identities : {} + for_each = var.create_storage_account ? var.managed_identity_principal_ids : {} scope = azapi_resource.storage_account_container[0].id role_definition_name = "Storage Blob Data Owner" - principal_id = azurerm_user_assigned_identity.alz[each.key].principal_id + principal_id = each.value } resource "azurerm_role_assignment" "alz_storage_container_additional" { @@ -75,10 +75,10 @@ resource "azurerm_role_assignment" "alz_storage_container_additional" { # These role assignments are a temporary addition to handle this issue in the Terraform CLI: https://github.com/hashicorp/terraform/issues/36595 # They will be removed once the issue has been resolved resource "azurerm_role_assignment" "alz_storage_reader" { - for_each = var.create_storage_account ? var.user_assigned_managed_identities : {} + for_each = var.create_storage_account ? var.managed_identity_principal_ids : {} scope = azurerm_storage_account.alz[0].id role_definition_name = "Reader" - principal_id = azurerm_user_assigned_identity.alz[each.key].principal_id + principal_id = each.value } resource "azurerm_role_assignment" "alz_storage_reader_additional" { diff --git a/modules/azure/variables.tf b/modules/azure/variables.tf index 80d7087..dbc90ac 100644 --- a/modules/azure/variables.tf +++ b/modules/azure/variables.tf @@ -8,38 +8,20 @@ variable "azure_location" { type = string } -variable "user_assigned_managed_identities" { - description = <<-EOT - **(Required)** Map of user-assigned managed identity names to create for Azure Landing Zones automation. - - Typically includes 'plan' and 'apply' identities used for Terraform/Bicep plan and apply operations - with appropriate RBAC permissions. - EOT +# Managed identities are now created externally and passed in +variable "managed_identity_ids" { type = map(string) + description = "Map of managed identity resource IDs (key = logical name like 'plan', 'apply')" } -variable "federated_credentials" { - description = <<-EOT - **(Optional, default: `{}`)** Configuration for OIDC federated identity credentials. - - Links user-assigned managed identities with external identity providers (GitHub Actions, Azure DevOps, etc.). - Enables keyless authentication by establishing trust relationships between Azure and external CI/CD systems. +variable "managed_identity_client_ids" { + type = map(string) + description = "Map of managed identity client IDs for outputs" +} - Map structure: - - **Key**: Unique identifier for the credential - - **Value**: Object containing: - - `user_assigned_managed_identity_key` (string) - Key of the managed identity to federate - - `federated_credential_subject` (string) - Subject claim from external IdP - - `federated_credential_issuer` (string) - Issuer URL of external IdP - - `federated_credential_name` (string) - Display name for the credential - EOT - type = map(object({ - user_assigned_managed_identity_key = string - federated_credential_subject = string - federated_credential_issuer = string - federated_credential_name = string - })) - default = {} +variable "managed_identity_principal_ids" { + type = map(string) + description = "Map of managed identity principal IDs for role assignments" } variable "resource_group_identity_name" { @@ -399,6 +381,18 @@ variable "virtual_network_subnet_address_prefix_private_endpoints" { default = "10.0.0.64/26" } +variable "virtual_network_subnet_name_container_apps" { + type = string + description = "Name of the virtual network subnet for Container Apps" + default = "" +} + +variable "virtual_network_subnet_address_prefix_container_apps" { + type = string + description = "Address prefix for the Container Apps subnet" + default = "10.0.0.128/26" +} + variable "storage_account_private_endpoint_name" { description = <<-EOT **(Optional, default: `""`)** Name of the private endpoint for the storage account. @@ -508,6 +502,59 @@ variable "use_self_hosted_agents" { default = true } +variable "use_container_app_jobs" { + type = bool + default = false + description = "Whether to use Container App Jobs for self-hosted agents (Azure DevOps only). Mutually exclusive with Container Instances." +} + +variable "agent_container_cpu" { + type = number + default = 2 + description = "CPU allocation for agent containers" +} + +variable "agent_container_memory" { + type = number + default = 4 + description = "Memory allocation for agent containers in Gibibytes" +} + +# Container App Jobs naming variables +variable "container_app_environment_name" { + type = string + default = "" + description = "Name for the Container App Environment" +} + +variable "container_app_job_name" { + type = string + default = "" + description = "Name for the Container App Job" +} + +variable "container_app_job_placeholder_name" { + type = string + default = "" + description = "Name for the Container App Job placeholder" +} + +variable "container_app_infrastructure_resource_group_name" { + type = string + default = "" + description = "Name for the Container Apps infrastructure resource group" +} + +variable "service_name" { + type = string + description = "Service name for resource naming" +} + +variable "environment_name" { + type = string + description = "Environment name for resource naming" +} + variable "agent_container_instance_managed_identity_name" { description = <<-EOT **(Optional, default: `""`)** Name of the user-assigned managed identity attached to agent container instances. diff --git a/modules/azure_devops/repository_module.tf b/modules/azure_devops/repository_module.tf index a1f960d..01eb3e6 100644 --- a/modules/azure_devops/repository_module.tf +++ b/modules/azure_devops/repository_module.tf @@ -1,11 +1,36 @@ +# When creating a new project, Azure DevOps automatically creates a default repository +# with the same name as the project. We need to rename this default repo to avoid conflicts +# with our Terraform-managed repository. +resource "azuredevops_git_repository" "default_rename" { + count = var.create_project ? 1 : 0 + project_id = azuredevops_project.alz[0].id + name = "${var.project_name}-default" + default_branch = "refs/heads/main" + + initialization { + init_type = "Clean" + } + + lifecycle { + ignore_changes = [initialization, default_branch] + } + + depends_on = [azuredevops_project.alz] +} + resource "azuredevops_git_repository" "alz" { - depends_on = [azuredevops_environment.alz] + depends_on = [azuredevops_environment.alz, azuredevops_git_repository.default_rename] project_id = local.project_id name = var.repository_name default_branch = local.default_branch + initialization { init_type = "Clean" } + + lifecycle { + ignore_changes = [initialization] + } } resource "azuredevops_git_repository_file" "alz" { diff --git a/modules/identities/README.md b/modules/identities/README.md new file mode 100644 index 0000000..8ee7986 --- /dev/null +++ b/modules/identities/README.md @@ -0,0 +1,48 @@ +# Bootstrap Identities Module + +This module creates User Assigned Managed Identities and their federated credentials for use in the Azure Landing Zones (ALZ) bootstrap process. + +## Purpose + +This module manages all identity resources needed for the ALZ accelerator bootstrap: +- Resource group for identities +- Plan and Apply user-assigned managed identities (for Terraform state management) +- Federated identity credentials (for workload identity federation with Azure DevOps/GitHub) +- Optional agent identity (for Container App Jobs with UAMI authentication) + +## Usage + +```hcl +module "identities" { + source = "../../modules/identities" + + resource_group_name = "rg-identity-prod" + location = "eastus" + + managed_identities = { + plan = "uami-plan-prod" + apply = "uami-apply-prod" + } + + federated_credentials = { + plan = { + user_assigned_managed_identity_key = "plan" + federated_credential_subject = "sc://org/project/environment/plan" + federated_credential_issuer = "https://vstoken.dev.azure.com/..." + federated_credential_name = "fc-plan" + audience = ["api://AzureADTokenExchange"] + } + } + + tags = { + environment = "production" + } +} +``` + +## Outputs + +The module provides maps keyed by logical names for easy reference: +- `managed_identity_ids` - For passing to other modules +- `managed_identity_client_ids` - For authentication +- `managed_identity_principal_ids` - For role assignments diff --git a/modules/identities/main.tf b/modules/identities/main.tf new file mode 100644 index 0000000..06cbe8b --- /dev/null +++ b/modules/identities/main.tf @@ -0,0 +1,26 @@ +# Identity Resource Group +resource "azurerm_resource_group" "identity" { + name = var.resource_group_name + location = var.location + tags = var.tags +} + +# User Assigned Managed Identities +resource "azurerm_user_assigned_identity" "identities" { + for_each = var.managed_identities + name = each.value + location = var.location + resource_group_name = azurerm_resource_group.identity.name + tags = var.tags +} + +# Federated Identity Credentials (for workload identity federation) +resource "azurerm_federated_identity_credential" "credentials" { + for_each = var.federated_credentials + name = each.value.federated_credential_name + resource_group_name = azurerm_resource_group.identity.name + audience = each.value.audience + issuer = each.value.federated_credential_issuer + parent_id = azurerm_user_assigned_identity.identities[each.value.user_assigned_managed_identity_key].id + subject = each.value.federated_credential_subject +} diff --git a/modules/identities/outputs.tf b/modules/identities/outputs.tf new file mode 100644 index 0000000..397b089 --- /dev/null +++ b/modules/identities/outputs.tf @@ -0,0 +1,29 @@ +output "resource_group_name" { + value = azurerm_resource_group.identity.name + description = "Name of the identity resource group" +} + +output "resource_group_id" { + value = azurerm_resource_group.identity.id + description = "Resource ID of the identity resource group" +} + +output "managed_identity_ids" { + value = { for k, v in azurerm_user_assigned_identity.identities : k => v.id } + description = "Map of managed identity resource IDs (key = logical name)" +} + +output "managed_identity_client_ids" { + value = { for k, v in azurerm_user_assigned_identity.identities : k => v.client_id } + description = "Map of managed identity client IDs for authentication (key = logical name)" +} + +output "managed_identity_principal_ids" { + value = { for k, v in azurerm_user_assigned_identity.identities : k => v.principal_id } + description = "Map of managed identity principal IDs for role assignments (key = logical name)" +} + +output "federated_credential_ids" { + value = { for k, v in azurerm_federated_identity_credential.credentials : k => v.id } + description = "Map of federated credential IDs" +} diff --git a/modules/identities/terraform.tf b/modules/identities/terraform.tf new file mode 100644 index 0000000..5026d7c --- /dev/null +++ b/modules/identities/terraform.tf @@ -0,0 +1,9 @@ +terraform { + required_version = ">= 1.9" + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "~> 4.0" + } + } +} diff --git a/modules/identities/variables.tf b/modules/identities/variables.tf new file mode 100644 index 0000000..c8cf043 --- /dev/null +++ b/modules/identities/variables.tf @@ -0,0 +1,32 @@ +variable "resource_group_name" { + type = string + description = "Name of the resource group for bootstrap identities" +} + +variable "location" { + type = string + description = "Azure location for resources" +} + +variable "managed_identities" { + type = map(string) + description = "Map of managed identities to create. Key is the logical name (e.g., 'plan', 'apply'), value is the resource name." +} + +variable "federated_credentials" { + type = map(object({ + user_assigned_managed_identity_key = string + federated_credential_subject = string + federated_credential_issuer = string + federated_credential_name = string + audience = list(string) + })) + default = {} + description = "Federated identity credentials for workload identity federation. Typically used with Azure DevOps or GitHub Actions." +} + +variable "tags" { + type = map(string) + default = {} + description = "Tags to apply to all resources" +}