Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
27 changes: 27 additions & 0 deletions .github/workflows/plan-storage-contract.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
name: Plan Storage Contract Tests

on:
pull_request:
paths:
- 'pipelines/terraform/templates/ci-template.yaml'
- 'pipelines/terraform/templates/cd-template.yaml'
- 'pipelines/terraform/templates/helpers/terraform-plan*.yaml'
- 'tests/contract/Test-PipelinePlanStorage.ps1'
- '.github/workflows/plan-storage-contract.yml'
workflow_dispatch:

jobs:
contract:
name: Verify plan storage pipeline contract
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout Code
uses: actions/checkout@v4

- name: Run contract tests
shell: pwsh
run: |
./tests/contract/Test-PipelinePlanStorage.ps1
40 changes: 39 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,25 @@ This Azure Verified Module (AVM) pattern module bootstraps a complete, opinionat

### Azure resources

- **Terraform state** — when `deployment_mode = "terraform"`, provisions a hardened storage account (private endpoint, no public access) for Terraform remote state per environment.
- **Terraform state & plan storage** — when `deployment_mode = "terraform"`, provisions a hardened storage account (private endpoint, no public access) for Terraform remote state per environment, and, when `use_storage_account_for_plan = true` (the default), a dedicated `<env>-tfplan` container per environment for secure plan hand-off between the CD `plan` and `apply` stages.
- **Networking** — provisions a virtual network with dedicated subnets for agents and private endpoints, or accepts a pre-existing VNet / subnets in BYO mode.
- **Private DNS** — manages private DNS zones for private endpoints, with an opt-out (`azure_alz_platform_landing_zone_mode_enabled`) for ALZ platforms that manage DNS centrally via Azure Policy.
- **Identity** — creates the per-environment UAMIs used by the service connections, plus (when `agent_authentication_method = "uami"`) the UAMI used by the agent pool.
- **Resource groups** — creates dedicated resource groups for identity, state, agents, and networking (or reuses an existing VNet's resource group in BYO mode).

### Secure Terraform plan hand-off

- **Default-secure** — `use_storage_account_for_plan = true` by default. The CD `plan` stage uploads the binary plan to a per-environment Blob container (`<env>-tfplan`) instead of bundling it into the Azure Pipelines artifact; the `apply` stage downloads the exact same blob by build ID and deletes it after a successful apply.
- **Legacy fallback** — set `use_storage_account_for_plan = false` to revert to shipping the plan inside the Azure Pipelines build artifact. This is less secure (plan contents can include sensitive values and are retained per your organization's pipeline artifact retention policy) and is provided only for compatibility with self-managed/BYO template repos that haven't adopted the new templates.
- **Custom template repositories are not auto-secured** — if you set `azuredevops_existing_template_repository_name` or a custom pipeline template path, the module does not modify your pipeline YAML. You must adopt the upload/download/delete steps yourself for storage-backed hand-off to apply.
- **`show_plan_in_pipeline_logs`** — defaults to `false`. Enabling it prints the full plan to the pipeline log, visible to anyone with read access to the project/pipeline runs. Only enable if your organization has explicitly accepted that exposure.
- **`plan_storage_retention_days`** (default `7`) — a storage lifecycle policy rule deletes abandoned plan blobs, snapshots, and previous versions after this many days. This is a backstop only; successful applies delete their own plan blob immediately. Choose a value longer than the longest expected plan-to-apply approval wait; if a plan expires before approval, rerun the pipeline to generate a fresh plan.
- **Recoverability** — the plan container inherits the storage account's blob versioning/soft-delete settings, so an accidentally-deleted plan blob may still be recoverable within your soft-delete window.
- **Trusted-admin threat boundary** — this feature keeps plan contents out of Azure Pipelines artifact storage. It does not protect against an Azure user with Storage Blob Data Contributor/Owner-equivalent access to the storage account — the same trust boundary as Terraform remote state.
- **Stale/concurrent plans** — the `apply` stage always downloads the blob written by its own `plan` stage run (keyed by `$(Build.BuildId)`), never "the latest" blob, so a concurrent or superseded run cannot apply a stranger's plan.
- **Non-retroactive upgrade** — enabling this on an existing deployment only takes effect for the next `plan`/`apply` cycle; it does not migrate plans already in flight.
- **Upgrading an existing storage account** — `storage_management_policy_rule` replaces the account's *entire* lifecycle policy, not just this module's rules. If you already manage that storage account's lifecycle policy outside this module, applying this upgrade will silently overwrite those rules. Before upgrading, check existing rules with `az storage account management-policy show` and fold them into your Terraform config first.

## Authentication required to use the module

The module talks to two control planes: **Azure Resource Manager** and **Azure DevOps**. Configure provider authentication via environment variables — the module itself does not accept any provider credentials as input variables.
Expand Down Expand Up @@ -107,6 +120,7 @@ The following resources are used by this module:
- [modtm_telemetry.telemetry](https://registry.terraform.io/providers/azure/modtm/latest/docs/resources/telemetry) (resource)
- [random_string.unique_name](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/string) (resource)
- [random_uuid.telemetry](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/uuid) (resource)
- [terraform_data.plan_storage_container_validation](https://registry.terraform.io/providers/hashicorp/terraform/latest/docs/resources/data) (resource)
- [time_sleep.agents_user_assigned_managed_identity_propagation](https://registry.terraform.io/providers/hashicorp/time/latest/docs/resources/sleep) (resource)
- [time_sleep.service_connection_teardown](https://registry.terraform.io/providers/hashicorp/time/latest/docs/resources/sleep) (resource)
- [azapi_client_config.current](https://registry.terraform.io/providers/Azure/azapi/latest/docs/data-sources/client_config) (data source)
Expand Down Expand Up @@ -454,6 +468,14 @@ Type: `string`

Default: `null`

### <a name="input_plan_storage_retention_days"></a> [plan\_storage\_retention\_days](#input\_plan\_storage\_retention\_days)

Description: The number of days after which abandoned Terraform plan base blobs, snapshots, and previous versions are eligible for lifecycle deletion. Choose a value longer than the longest expected plan-to-apply approval wait; expired plans must be regenerated.

Type: `number`

Default: `7`

### <a name="input_resource_name_environment"></a> [resource\_name\_environment](#input\_resource\_name\_environment)

Description: The name segment for the management environment (used for naming Azure infrastructure resources, not deployment environments).
Expand Down Expand Up @@ -522,6 +544,14 @@ Type: `string`

Default: `"dema"`

### <a name="input_show_plan_in_pipeline_logs"></a> [show\_plan\_in\_pipeline\_logs](#input\_show\_plan\_in\_pipeline\_logs)

Description: Whether to print the full Terraform plan in pipeline logs. Enabling this can expose sensitive values.

Type: `bool`

Default: `false`

### <a name="input_tags"></a> [tags](#input\_tags)

Description: (Optional) Tags of the resource.
Expand All @@ -530,6 +560,14 @@ Type: `map(string)`

Default: `null`

### <a name="input_use_storage_account_for_plan"></a> [use\_storage\_account\_for\_plan](#input\_use\_storage\_account\_for\_plan)

Description: Whether to use the Terraform state Storage Account for secure plan hand-off. Set to false to use the legacy CI/CD artifact hand-off.

Type: `bool`

Default: `true`

## Outputs

The following outputs are exported:
Expand Down
15 changes: 14 additions & 1 deletion _header.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,25 @@ This Azure Verified Module (AVM) pattern module bootstraps a complete, opinionat

### Azure resources

- **Terraform state** — when `deployment_mode = "terraform"`, provisions a hardened storage account (private endpoint, no public access) for Terraform remote state per environment.
- **Terraform state & plan storage** — when `deployment_mode = "terraform"`, provisions a hardened storage account (private endpoint, no public access) for Terraform remote state per environment, and, when `use_storage_account_for_plan = true` (the default), a dedicated `<env>-tfplan` container per environment for secure plan hand-off between the CD `plan` and `apply` stages.
- **Networking** — provisions a virtual network with dedicated subnets for agents and private endpoints, or accepts a pre-existing VNet / subnets in BYO mode.
- **Private DNS** — manages private DNS zones for private endpoints, with an opt-out (`azure_alz_platform_landing_zone_mode_enabled`) for ALZ platforms that manage DNS centrally via Azure Policy.
- **Identity** — creates the per-environment UAMIs used by the service connections, plus (when `agent_authentication_method = "uami"`) the UAMI used by the agent pool.
- **Resource groups** — creates dedicated resource groups for identity, state, agents, and networking (or reuses an existing VNet's resource group in BYO mode).

### Secure Terraform plan hand-off

- **Default-secure** — `use_storage_account_for_plan = true` by default. The CD `plan` stage uploads the binary plan to a per-environment Blob container (`<env>-tfplan`) instead of bundling it into the Azure Pipelines artifact; the `apply` stage downloads the exact same blob by build ID and deletes it after a successful apply.
- **Legacy fallback** — set `use_storage_account_for_plan = false` to revert to shipping the plan inside the Azure Pipelines build artifact. This is less secure (plan contents can include sensitive values and are retained per your organization's pipeline artifact retention policy) and is provided only for compatibility with self-managed/BYO template repos that haven't adopted the new templates.
- **Custom template repositories are not auto-secured** — if you set `azuredevops_existing_template_repository_name` or a custom pipeline template path, the module does not modify your pipeline YAML. You must adopt the upload/download/delete steps yourself for storage-backed hand-off to apply.
- **`show_plan_in_pipeline_logs`** — defaults to `false`. Enabling it prints the full plan to the pipeline log, visible to anyone with read access to the project/pipeline runs. Only enable if your organization has explicitly accepted that exposure.
- **`plan_storage_retention_days`** (default `7`) — a storage lifecycle policy rule deletes abandoned plan blobs, snapshots, and previous versions after this many days. This is a backstop only; successful applies delete their own plan blob immediately. Choose a value longer than the longest expected plan-to-apply approval wait; if a plan expires before approval, rerun the pipeline to generate a fresh plan.
- **Recoverability** — the plan container inherits the storage account's blob versioning/soft-delete settings, so an accidentally-deleted plan blob may still be recoverable within your soft-delete window.
- **Trusted-admin threat boundary** — this feature keeps plan contents out of Azure Pipelines artifact storage. It does not protect against an Azure user with Storage Blob Data Contributor/Owner-equivalent access to the storage account — the same trust boundary as Terraform remote state.
- **Stale/concurrent plans** — the `apply` stage always downloads the blob written by its own `plan` stage run (keyed by `$(Build.BuildId)`), never "the latest" blob, so a concurrent or superseded run cannot apply a stranger's plan.
- **Non-retroactive upgrade** — enabling this on an existing deployment only takes effect for the next `plan`/`apply` cycle; it does not migrate plans already in flight.
- **Upgrading an existing storage account** — `storage_management_policy_rule` replaces the account's *entire* lifecycle policy, not just this module's rules. If you already manage that storage account's lifecycle policy outside this module, applying this upgrade will silently overwrite those rules. Before upgrading, check existing rules with `az storage account management-policy show` and fold them into your Terraform config first.

## Authentication required to use the module

The module talks to two control planes: **Azure Resource Manager** and **Azure DevOps**. Configure provider authentication via environment variables — the module itself does not accept any provider credentials as input variables.
Expand Down
12 changes: 12 additions & 0 deletions locals.cicd.tf
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,16 @@ locals {
)
has_approvers = var.azuredevops_existing_approvers_group_origin_id != null || length(var.approvers) > 0
has_template_repo = var.azuredevops_existing_template_repository_name != null || local.create_template_repository

plan_storage_container_backend_collisions = [
for container_name in values(local.plan_storage_container_names) : container_name
if contains(keys(local.environments), container_name)
]
plan_storage_container_duplicate_names = [
for name in distinct(values(local.plan_storage_container_names)) : name
if length([for v in values(local.plan_storage_container_names) : v if v == name]) > 1
]
plan_storage_container_names = var.deployment_mode == "terraform" && var.use_storage_account_for_plan ? { for env_key, env_value in local.environments : env_key => (
length(env_key) <= 56 ? "${env_key}-tfplan" : "tfplan-${substr(sha256(env_key), 0, 32)}"
) } : {}
}
61 changes: 53 additions & 8 deletions main.azure.storage.tf
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,21 @@ module "private_dns_zone_storage_account" {
}
}

resource "terraform_data" "plan_storage_container_validation" {
count = var.deployment_mode == "terraform" && var.use_storage_account_for_plan ? 1 : 0

lifecycle {
precondition {
condition = length(local.plan_storage_container_backend_collisions) == 0
error_message = "Computed plan storage container name(s) collide with an existing backend state container: ${join(", ", local.plan_storage_container_backend_collisions)}."
}
precondition {
condition = length(local.plan_storage_container_duplicate_names) == 0
error_message = "Computed plan storage container name(s) collide across environments: ${join(", ", local.plan_storage_container_duplicate_names)}."
}
}
}

module "storage_account" {
source = "Azure/avm-res-storage-storageaccount/azurerm"
version = "0.6.8"
Expand All @@ -23,15 +38,26 @@ module "storage_account" {
resource_group_name = module.resource_group["state"].name
account_replication_type = "ZRS"
account_tier = "Standard"
containers = { for env_key, env_value in local.environments : env_key => {
name = env_key
public_access = "None"
role_assignments = { for identity_key, identity_value in env_value.identities : "uami-${identity_key}" => {
role_definition_id_or_name = "Storage Blob Data Contributor"
principal_id = module.user_assigned_managed_identity["${env_key}-${identity_key}"].principal_id
} }
containers = merge(
{ for env_key, env_value in local.environments : env_key => {
name = env_key
public_access = "None"
role_assignments = { for identity_key, identity_value in env_value.identities : "uami-${identity_key}" => {
role_definition_id_or_name = "Storage Blob Data Contributor"
principal_id = module.user_assigned_managed_identity["${env_key}-${identity_key}"].principal_id
} if identity_value.enabled }
}
},
{ for env_key, container_name in local.plan_storage_container_names : container_name => {
name = container_name
public_access = "None"
role_assignments = { for identity_key, identity_value in local.environments[env_key].identities : "uami-${identity_key}" => {
role_definition_id_or_name = "Storage Blob Data Contributor"
principal_id = module.user_assigned_managed_identity["${env_key}-${identity_key}"].principal_id
} if identity_value.enabled }
}
}
}
)
network_rules = local.use_private_networking ? {} : null
private_endpoints = local.use_private_networking ? { blob = {
name = local.resource_names.storage_account_private_endpoint_name
Expand All @@ -42,4 +68,23 @@ module "storage_account" {
} : {}
private_endpoints_manage_dns_zone_group = !var.azure_alz_platform_landing_zone_mode_enabled
public_network_access_enabled = !local.use_private_networking
storage_management_policy_rule = { for env_key, container_name in local.plan_storage_container_names : env_key => {
name = "plan${replace(env_key, "/[^a-zA-Z0-9]/", "")}"
enabled = true
filters = {
blob_types = ["blockBlob"]
prefix_match = ["${container_name}/runs/"]
}
actions = {
base_blob = {
delete_after_days_since_modification_greater_than = var.plan_storage_retention_days
}
snapshot = {
delete_after_days_since_creation_greater_than = var.plan_storage_retention_days
}
version = {
delete_after_days_since_creation = var.plan_storage_retention_days
}
}
} }
}
16 changes: 16 additions & 0 deletions main.azuredevops.variable.groups.tf
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,22 @@ resource "azuredevops_variable_group" "this" {
name = "BACKEND_AZURE_STORAGE_ACCOUNT_CONTAINER_NAME"
value = each.key
}
variable {
name = "USE_STORAGE_ACCOUNT_FOR_PLAN"
value = var.use_storage_account_for_plan ? "true" : "false"
}
variable {
name = "SHOW_PLAN_IN_PIPELINE_LOGS"
value = var.show_plan_in_pipeline_logs ? "true" : "false"
}
dynamic "variable" {
for_each = var.use_storage_account_for_plan && contains(keys(local.plan_storage_container_names), each.key) ? [1] : []

content {
name = "PLAN_STORAGE_CONTAINER_NAME"
value = local.plan_storage_container_names[each.key]
}
}
}

resource "azuredevops_variable_group" "bicep" {
Expand Down
Loading
Loading