Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
39 changes: 39 additions & 0 deletions alz/azuredevops/agent_identity_permissions.tf
Original file line number Diff line number Diff line change
@@ -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
]
}
29 changes: 21 additions & 8 deletions alz/azuredevops/locals.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -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"]
}
}

Expand Down Expand Up @@ -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 {
Expand Down
70 changes: 64 additions & 6 deletions alz/azuredevops/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -16,18 +16,35 @@ 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
create_storage_account = var.iac_type == local.iac_terraform
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
Expand All @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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
}
}

# ========================================
# 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"]
}
48 changes: 46 additions & 2 deletions alz/azuredevops/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -363,22 +374,36 @@ 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"
}

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.
Expand Down Expand Up @@ -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}}")
Expand All @@ -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 = {}
}
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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"
}
Loading
Loading