Skip to content

Latest commit

 

History

1 Commit

Folders and files

Repository files navigation

☁️ Azure Site Recovery Protection Container Terraform Module

Creates the bucket inside a Site Recovery fabric that replicated machines live in, targeting hashicorp/azurerm ~> 4.0.

Terraform azurerm Module Type Resources Posture


🧩 Overview

  • 📦 Manages azurerm_site_recovery_protection_container — the fabric's direct child, and where replicated machines live.
  • 🔗 Consumes terraform-azurerm-site-recovery-fabric's name, so the fabric arrives as a bare string.
  • 🧮 Reconstructs the container's, fabric's and vault's paths at plan time, in the form the fabric modules already emit.
  • 🕳️ The create payload is completely empty. There is nothing to configure — only where the container is.
  • 🔒 Nothing here can be edited. No Update function, so all four arguments are force-new and timeouts has three keys.
  • ⚠️ Seven constants, including the one that makes this resource unlike three of its four siblings: it does not absorb the API's HTTP 400.

💡 Why it matters: the fabric arrives as a NAME, which means Terraform builds no ordering edge from a literal and a name matching somebody else's fabric is accepted without complaint. This module emits the fabric's path in the same form both fabric-creating modules emit, so a check block can prove the container landed where the composition intended.


❤️ Support this project

If this module saved you time:


🗺️ Where this fits in the family

This module is terraform-azurerm-site-recovery-protection-container, one of the fourteen blue nodes — all fourteen authored site_recovery_* modules share this diagram. The dark node is not a module: it is the single ARM resource type that the fabric and Hyper-V site modules both write, which is why those two can silently collide.

flowchart TB
  RG["terraform-azurerm-resource-group"]
  VAULT["terraform-azurerm-recovery-services-vault"]
  FABRIC["terraform-azurerm-site-recovery-fabric"]
  HVS["terraform-azurerm-site-recovery-services-vault-hyperv-site"]
  PC["terraform-azurerm-site-recovery-protection-container"]
  POLICY["terraform-azurerm-site-recovery-replication-policy"]
  HVPOL["terraform-azurerm-site-recovery-hyperv-replication-policy"]
  VMPOL["terraform-azurerm-site-recovery-vmware-replication-policy"]
  PCM["terraform-azurerm-site-recovery-protection-container-mapping"]
  HVASSOC["terraform-azurerm-site-recovery-hyperv-replication-policy-association"]
  VMASSOC["terraform-azurerm-site-recovery-vmware-replication-policy-association"]
  NM["terraform-azurerm-site-recovery-network-mapping"]
  HVNM["terraform-azurerm-site-recovery-hyperv-network-mapping"]
  RVM["terraform-azurerm-site-recovery-replicated-vm"]
  RRP["terraform-azurerm-site-recovery-replication-recovery-plan"]
  ARMF["ONE ARM type, TWO resource types: vaults replicationFabrics. Same name plus same vault means one object, silently"]
  ARMP["ONE ARM type, THREE resource types: vaults replicationPolicies. A2A, Hyper-V and VMware policies are interchangeable at the API"]
  ARMM["ONE ARM type, THREE resource types: replicationProtectionContainerMappings. The hardest group to assert on"]
  VM["terraform-azurerm-linux-virtual-machine or windows-virtual-machine: wire its id, NOT its virtual_machine_id"]
  VNET["terraform-azurerm-virtual-network: the failover network, the test network and the source network"]
  RUNBOOK["an Azure Automation runbook: a plan action names it by id and it runs with the automation account's own permissions. NOT bounded by vault RBAC"]
  VMWVM["terraform-azurerm-site-recovery-vmware-replicated-vm"]
  HOSTS["the Hyper-V hosts themselves, registered with a five-day vault key. NO azurerm resource does this"]
  APPL["the VMware replication appliance, deployed on-premises and registered against the vault. NO azurerm resource does this either"]
  VMM["the System Center VMM server, registered by installing the Site Recovery Provider on it. NO azurerm resource does this either"]
  VCENTER["the vCenter machines and the credentials stored on the appliance. source_vm_name and physical_server_credential_name are FRIENDLY names matched at apply time, and that credential is root or admin ON the source machine"]

  RG -->|"resource_group_name and location"| VAULT
  VAULT -->|"name, NOT id, so there is no dependency edge from a literal"| FABRIC
  VAULT -->|"id, NOT name: this sibling takes the vault ID instead"| HVS
  VAULT -->|"name, and NO fabric: a policy belongs to the vault"| POLICY
  VAULT -->|"id, a second convention on the same parent"| HVPOL
  VAULT -->|"id, and the association takes the SAME vault argument"| VMPOL
  VAULT -->|"id: BOTH the fabric and the container are discovered under it at apply time"| VMASSOC
  VAULT -->|"name plus resource group, a third convention again"| RVM
  VAULT -->|"id: the ONLY Resource ID this module resolves anything from"| HVNM
  VAULT -->|"id, and BOTH fabrics as ids: a fourth convention"| RRP
  FABRIC -->|"hardcoded instance type: an Azure fabric"| ARMF
  HVS -->|"hardcoded instance type: HyperVSite, absent from Swagger"| ARMF
  POLICY -->|"hardcoded A2A payload"| ARMP
  HVPOL -->|"hardcoded Hyper-V to Azure payload"| ARMP
  VMPOL -->|"hardcoded InMageRcm payload: the MODERNIZED VMware provider"| ARMP
  PCM -->|"container taken by name and by id"| ARMM
  HVASSOC -->|"container discovered under the fabric at apply time"| ARMM
  VMASSOC -->|"fabric AND container both discovered under the vault"| ARMM
  FABRIC -->|"name, so a Hyper-V site's name is equally legal here"| PC
  FABRIC -->|"name, for BOTH sides: ARM names, not friendly names"| NM
  FABRIC -->|"id, for BOTH sides, and neither is compared to the vault"| RRP
  PC -->|"name for the source side, id for the target side"| PCM
  POLICY -->|"id, where the container beside it is taken by name"| PCM
  HVPOL -->|"id, the only consumer of a Hyper-V policy"| HVASSOC
  HVS -->|"id, and the protection container is discovered from it"| HVASSOC
  VMPOL -->|"id, the only consumer of a VMware policy"| VMASSOC
  VNET -->|"id, twice. Only the SOURCE id is parsed by the provider"| NM
  VNET -->|"id, the target only. The SOURCE network is a VMM friendly name"| HVNM
  FABRIC -->|"source fabric by NAME, target fabric by ID: one resource, two conventions"| RVM
  PC -->|"source container by NAME, target container by ID"| RVM
  POLICY -->|"id, and a Hyper-V or VMware policy id would also be accepted"| RVM
  VM -->|"the machine being protected"| RVM
  VNET -->|"the failover network and the test network, both Optional AND Computed"| RVM
  PCM -->|"makes a container pair eligible before any item can replicate"| RVM
  NM -->|"decides where an Azure-to-Azure failover lands"| RVM
  HVNM -->|"decides where a VMM Hyper-V failover lands, for every machine on the source network"| VMM
  RVM -->|"ids, into ORDERED boot groups: group 2 starts only after group 1 has finished"| RRP
  VMWVM -->|"ids, into the same ORDERED boot groups as the Azure-to-Azure items"| RRP
  VAULT -->|"id ALONE, a fifth convention: the fabric AND the container are discovered from it, and there must be exactly ONE container"| VMWVM
  VMPOL -->|"id, its second consumer: the policy this VMware item replicates under"| VMWVM
  VNET -->|"the failover network and the test network. test_network_id is read back and NEVER updated"| VMWVM
  VMASSOC -->|"makes the discovered container eligible before any VMware machine can replicate"| VMWVM
  RUNBOOK -->|"id, from a plan pre-action or post-action"| RRP
  APPL -->|"appliance_name is its FRIENDLY name, matched against the fabric process servers. Microsoft: it cannot be changed once set"| VMWVM
  VCENTER -->|"source_vm_name and physical_server_credential_name, both matched by name against records the appliance discovered"| VMWVM
  HOSTS -->|"register into the site out of band, invisible to Terraform"| HVS
  HOSTS -->|"registration is what creates the container this record needs"| HVASSOC
  APPL -->|"required before any VMware machine can be enrolled"| VMASSOC
  VMM -->|"the fabric AND the source network are found under it by FRIENDLY NAME at apply time"| HVNM

  classDef me fill:#0078D4,stroke:#004578,color:#ffffff
  classDef keystone fill:#004578,stroke:#002438,color:#ffffff
  classDef sibling fill:#F3F6F9,stroke:#8A9BA8,color:#1B1F23
  class FABRIC,HVS,PC,POLICY,HVPOL,VMPOL,PCM,HVASSOC,VMASSOC,NM,HVNM,RVM,RRP,VMWVM me
  class ARMF,ARMP,ARMM keystone
  class RG,VAULT,VM,VNET,RUNBOOK,VCENTER,HOSTS,APPL,VMM sibling
Loading

⚠️ Read the arrows into this module. The fabric arrives as a name, and so does the vault — neither is an ID, so Terraform holds no dependency edge from a literal.

ℹ️ Because a Hyper-V site is the same ARM type as a fabric, its name is equally legal as recovery_fabric_name.


🧬 What this module builds

One keystone resource, no children, and an empty payload. The timeouts block has three keys, not four.

flowchart TB
  IN_ID["name plus recovery_fabric_name plus recovery_vault_name plus resource_group_name: which fabric, and what to call the container"]
  THIS["azurerm_site_recovery_protection_container.this"]
  EMPTY["the create payload: completely empty. This resource has no configuration at all"]
  OUT_PATHS["three subscription-less paths: the container, its fabric, its vault"]
  OUT_ID["id for a mapping's target side, name for its source side"]
  OUT_CONST["seven constants: no update path, an empty payload, and the one resource here that does not absorb a 400"]

  IN_ID --> THIS
  THIS --> EMPTY
  IN_ID -->|"recombined without the subscription, which a module cannot know"| OUT_PATHS
  THIS -->|"id, known only after apply"| OUT_ID
  THIS --> OUT_CONST
  OUT_PATHS -->|"compare the fabric path against a fabric module's own, since the fabric arrives as a bare NAME"| OUT_CONST

  classDef me fill:#0078D4,stroke:#004578,color:#ffffff
  classDef keystone fill:#004578,stroke:#002438,color:#ffffff
  classDef sibling fill:#F3F6F9,stroke:#8A9BA8,color:#1B1F23
  class THIS keystone
  class OUT_PATHS me
  class IN_ID,EMPTY,OUT_ID,OUT_CONST sibling
Loading

Resource inventory

Address Count Notes
azurerm_site_recovery_protection_container.this 1 The keystone. No child records, no for_each.
timeouts 1 Three operations — there is no update path.

ℹ️ This module does not share a shape diagram with any sibling. The replication policy has two configuration arguments and a rule between them; this one has no configuration at all.


✅ Provider / Versions

Requirement Value
Terraform >= 1.12.0
hashicorp/azurerm ~> 4.0 (pinned; v5.0 deliberately excluded)
Provider block None here — the caller configures provider "azurerm" { features {} }, auth and subscription
Resource azurerm_site_recovery_protection_container
API provider Microsoft.RecoveryServices

Schema notes that bite

  • 🔴 The create payload is completely empty — CreateProtectionContainerInput{Properties: &CreateProtectionContainerInputProperties{}}. The four arguments are not "the important ones", they are all of them.
  • 🔴 All four arguments are force-new and there is no Update function. resource_group_name gets ForceNew invisibly from commonschema.ResourceGroupName().
  • 🔴 This resource does not tolerate the API's HTTP 400 for a missing record. azurerm_site_recovery_fabric, azurerm_site_recovery_replication_policy and azurerm_site_recovery_network_mapping all guard with !WasNotFound(...) && !wasBadRequestWithNotExist(...); this one and azurerm_site_recovery_protection_container_mapping guard on WasNotFound alone.
  • ⚠️ Two of four arguments are effectively unvalidated upstream. name and recovery_fabric_name get only StringIsNotEmpty, which accepts whitespace; recovery_vault_name and resource_group_name get real anchored validators.
  • ⚠️ The fabric is referenced by NAME, so Terraform holds no ordering edge and a name matching a fabric the configuration does not own is accepted.
  • ⚠️ A Hyper-V site's name is a legal recovery_fabric_name, because that resource creates the same ARM type as a fabric.
  • ⚠️ The read sets every attribute from the parsed Resource ID, not from the response body — consistent with an empty payload, and it means drift beyond existence cannot be detected.
  • ℹ️ There is no tags, no location and no SchemaVersion. Tag the vault instead.

🔑 Required Azure RBAC Roles / Permissions

Principal Scope Requirement
The principal running Terraform The vault or its resource group Site Recovery Contributor — carries Microsoft.RecoveryServices/vaults/replicationFabrics/*, which covers the containers nested beneath a fabric
Alternatively The same scope Contributor or Owner
  • 🔒 Site Recovery Contributor cannot create or delete the vault, per Microsoft's own description of the role.
  • 🔒 Site Recovery Operator and Site Recovery Reader are not sufficient. The Operator role explicitly cannot "register new infrastructure"; the Reader role is read-only.
  • ⚠️ Backup Contributor is the wrong role, and it is an easy mistake — a Recovery Services vault serves both Azure Backup and Site Recovery, and Backup Contributor grants nothing over replicationFabrics.
  • ✅ Plan access is not credential access. No secret is read or emitted, and no output is sensitive.

Azure Prerequisites

  1. Microsoft.RecoveryServices registered in the subscription.
  2. An existing Recovery Services vault.
  3. An existing Site Recovery fabric, referenced here by name — a Hyper-V site counts.
  4. A name unique within that fabric. Nothing enforces it across configurations.
  5. An understanding that this container is empty and stays empty until mappings and replicated items fill it.

📁 Module Structure

terraform-azurerm-site-recovery-protection-container/
├── providers.tf     # required_version + the pinned azurerm; no provider block
├── variables.tf     # 5 variables, 7 validations
├── main.tf          # 3 locals + the keystone azurerm_site_recovery_protection_container.this
├── outputs.tf       # 15 outputs: 5 passthrough, 3 derived, 7 constant
├── README.md        # this file
├── SCOPE.md         # the cross-module contract
├── LICENSE          # MIT
└── .gitignore

⚙️ Quick Start

module "dr_container_primary" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-site-recovery-protection-container.git?ref=v1.0.0"

  name                 = "container-eastus"
  recovery_fabric_name = module.dr_fabric_primary.name
  recovery_vault_name  = module.dr_vault.name
  resource_group_name  = module.dr_rg.name
}

⚠️ Every argument is force-new. There is no in-place edit on this resource, and replacing a container affects the mappings that reference it and the replicated items inside it.

ℹ️ The caller configures the provider, its authentication and its features {} block. This module declares none of them.


🔌 Cross-Module Contract

Consumes

Input Type Source
recovery_fabric_name string terraform-azurerm-site-recovery-fabric output name
recovery_vault_name string terraform-azurerm-recovery-services-vault output name
resource_group_name string terraform-azurerm-resource-group output name
name string caller

Emits

Output Description
id The container's Resource ID (first) — a mapping's target side takes this
name A mapping's source side takes this instead
container_path_within_the_subscription The container's path at plan time
fabric_path_within_the_subscription Comparable with either fabric module's own output
vault_path_within_the_subscription The parent vault's path
the seven constants Documentation and check blocks

📚 Example Library

1 · The smallest legal call
module "dr_container_primary" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-site-recovery-protection-container.git?ref=v1.0.0"

  name                 = "container-eastus"
  recovery_fabric_name = module.dr_fabric_primary.name
  recovery_vault_name  = module.dr_vault.name
  resource_group_name  = module.dr_rg.name
}

ℹ️ Four arguments and no options. There is no emptier call and nothing optional to add except timeouts.

2 · 🕳️ The create payload is empty, and that is the whole resource
# There is no size, no tier, no policy, no network, no encryption setting.
# The provider sends: CreateProtectionContainerInputProperties{}

🔴 A protection container is a named bucket and nothing else. So the only thing a caller can get wrong is where it is — which is why this module's derived outputs are all about location and its constants are all about the lifecycle.

ℹ️ It also explains the read path: every attribute is set from the parsed Resource ID rather than from the response body, so drift beyond "does it exist" cannot be detected. Read module.dr_container_primary.the_create_payload_is_completely_empty.

3 · The fabric arrives as a NAME, so wire the module output
  # CORRECT -- creates a dependency edge, so Terraform orders the fabric first
  recovery_fabric_name = module.dr_fabric_primary.name

  # WRONG -- a literal gives Terraform nothing to order against
  # recovery_fabric_name = "fabric-eastus"

⚠️ A container created before its fabric exists fails at apply, not at plan. Wiring module.<fabric>.name is what creates the edge.

⚠️ The quiet direction is worse: a literal that matches a fabric somebody else created is accepted without complaint. Example 4 closes that.

ℹ️ module.dr_fabric_primary.id is the plausible wrong value here — the fabric module emits both. This module rejects an ID with a message naming the name output.

4 · Proving the container landed in the intended fabric
check "the_container_is_in_our_own_fabric" {
  assert {
    condition = (
      module.dr_container_primary.fabric_path_within_the_subscription
      == module.dr_fabric_primary.fabric_path_within_the_subscription
    )
    error_message = "The protection container's fabric is not the fabric this configuration declares."
  }
}

✅ Both sides emit the value under the same name and in the same subscription-less form — this module derives it from a name plus a vault plus a resource group, and the fabric module derives it from its own arguments. That is what makes the comparison meaningful rather than circular.

ℹ️ It works against terraform-azurerm-site-recovery-services-vault-hyperv-site too, since that module emits the same output and creates the same ARM type.

5 · A Hyper-V site is a legal fabric here
# The Hyper-V site. A DIFFERENT Terraform resource that writes the SAME ARM type as a fabric.
module "hyperv_site" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-site-recovery-services-vault-hyperv-site.git?ref=v1.0.0"

  name              = "contoso-hyperv-site"
  recovery_vault_id = module.dr_vault.id # note: the ID here, where the fabric module takes a name
}

module "hyperv_container" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-site-recovery-protection-container.git?ref=v1.0.0"

  name                 = "container-hyperv"
  recovery_fabric_name = module.hyperv_site.name # a Hyper-V site, not an "azurerm_site_recovery_fabric"
  recovery_vault_name  = module.hyperv_site.recovery_vault_name
  resource_group_name  = module.hyperv_site.resource_group_name
}

ℹ️ azurerm_site_recovery_services_vault_hyperv_site creates the same ARM type as azurerm_site_recovery_fabric — a Microsoft.RecoveryServices/vaults/replicationFabrics record — so its name is equally valid as recovery_fabric_name. That is usually what a Hyper-V topology wants.

⚠️ Nothing here distinguishes the two. Read module.hyperv_container.a_hyperv_site_is_also_a_valid_fabric_name_here, and note the same fact is why the two fabric-creating modules can collide with each other.

6 · Both identity outputs have a consumer, in different arguments
module "dr_mapping" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-site-recovery-protection-container-mapping.git?ref=v1.0.0"

  name                 = "mapping-primary-to-secondary"
  recovery_fabric_name = module.dr_container_primary.recovery_fabric_name
  recovery_vault_name  = module.dr_container_primary.recovery_vault_name
  resource_group_name  = module.dr_container_primary.resource_group_name

  # The SOURCE container by name, the TARGET container by id. Both from this module.
  recovery_source_protection_container_name = module.dr_container_primary.name
  recovery_target_protection_container_id   = module.dr_container_secondary.id
  recovery_replication_policy_id            = module.dr_policy.id
}

ℹ️ One downstream resource takes the source container by name and the target container by ID. So both of this module's identity outputs are load-bearing, and which one you need depends on which side of the mapping the container is on.

⚠️ The two container references are not interchangeable. Passing the target's id where the source's name belongs fails the mapping module's own anchored rule at plan; passing the source's name where the target's id belongs fails it too, which is the point of carrying both rules there.

7 · There is no update path
  # Changing ANY of these four destroys and recreates the container:
  name                 = "container-eastus"
  recovery_fabric_name = module.dr_fabric_primary.name
  recovery_vault_name  = module.dr_vault.name
  resource_group_name  = module.dr_rg.name

🔴 The provider declares Create, Read and Delete and no Update function. Three arguments carry explicit ForceNew; resource_group_name gets it invisibly from commonschema.ResourceGroupName(), so the schema shows three of four.

⚠️ Replacement is not local: mappings reference the container and replicated items live inside it. Read module.dr_container_primary.there_is_no_update_path_so_every_argument_is_force_new.

8 · The `timeouts` block has three keys, and a fourth is discarded silently
  timeouts = {
    create = "45m"
    read   = "10m"
    delete = "45m"
  }
  # WRONG, and it produces no error, no warning and no plan diff:
  # timeouts = { create = "45m", update = "45m" }

🔴 This module's timeouts type declares exactly the three keys the resource has. Terraform's object-type conversion silently discards an attribute the type does not declare — the one class of wrong input in this suite that produces no signal at all.

ℹ️ The replication policy sibling has four keys, because it is the one resource in this family with an update path. The shape has to match the resource rather than be copied across.

9 · 🔴 The one sibling difference that changes a first apply
azurerm_site_recovery_fabric              !WasNotFound(...) && !wasBadRequestWithNotExist(...)
azurerm_site_recovery_replication_policy  !WasNotFound(...) && !wasBadRequestWithNotExist(...)
azurerm_site_recovery_network_mapping     !WasNotFound(...) && !wasBadRequestWithNotExist(...)
azurerm_site_recovery_protection_container   !WasNotFound(...)                        <-- this one
azurerm_site_recovery_protection_container_mapping  !WasNotFound(...)             <-- and this one

🔴 The Site Recovery API can answer HTTP 400 rather than 404 for a record that does not exist (Azure/azure-rest-api-specs issue 12759). Across all fourteen resources in this service, twelve perform an existence check and six of those reference a 400 at all. This one does not — nor do the protection container mapping, the Hyper-V network mapping, either replicated-VM resource, or the VMware policy association.

⚠️ So a first apply can fail during the pre-create existence check with "checking for presence of existing site recovery protection container ..." wrapping a 400 — an error about looking for the container, not about creating it, which sends a reader to the wrong place.

ℹ️ If it happens, the caller's own provider features block can skip the check entirely. Read module.dr_container_primary.this_resource_does_not_tolerate_the_apis_bad_request_for_a_missing_record.

10 · Both unvalidated names get module rules; the other two do not
  name                 = "container-eastus"          # StringIsNotEmpty upstream -> 2 module rules
  recovery_fabric_name = module.dr_fabric_primary.name # StringIsNotEmpty upstream -> 2 module rules
  recovery_vault_name  = module.dr_vault.name        # anchored regex upstream    -> none, deliberately
  resource_group_name  = module.dr_rg.name           # three checks upstream      -> none, deliberately

ℹ️ StringIsNotEmpty accepts a whitespace-only string, so a blank container name or fabric name reaches Azure. Those two arguments get a blank check and a no-slash check each — genuine coverage.

ℹ️ validate.RecoveryServicesVaultName (^[a-zA-Z][-a-zA-Z0-9]{1,49}$) and resourcegroups.ValidateName are both anchored, both exclude the forward slash, and both state the whole rule in their messages — so a Resource ID and a blank are already rejected. Restating them would buy wording rather than coverage.

11 · Asserting two containers are distinct
check "no_two_containers_collide" {
  assert {
    condition = length(distinct([
      module.dr_container_primary.container_path_within_the_subscription,
      module.dr_container_secondary.container_path_within_the_subscription,
    ])) == 2
    error_message = "Two protection containers resolve to the same path, so they are one Azure object."
  }
}

ℹ️ Useful at plan time, which the resource's own id is not — id is unknown until apply. Two containers with the same name in the same fabric collide; the same name in different fabrics does not, and the path captures that distinction.

ℹ️ The subscription is omitted for the same reason every site-recovery module in this suite omits it: a module cannot read the caller's provider configuration, and two modules under one provider share a subscription anyway.

12 · `for_each` over the containers a fabric holds
locals {
  containers = {
    web = "container-web"
    db  = "container-db"
    app = "container-app"
  }
}

module "dr_containers" {
  source   = "git::https://github.com/microsoftexpert/terraform-azurerm-site-recovery-protection-container.git?ref=v1.0.0"
  for_each = local.containers

  name                 = each.value
  recovery_fabric_name = module.dr_fabric_primary.name
  recovery_vault_name  = module.dr_vault.name
  resource_group_name  = module.dr_rg.name
}

output "container_names" {
  value = { for k, m in module.dr_containers : k => m.name }
}

ℹ️ Key on your own identifier rather than the container name, so renaming a container does not re-index the map. The name output is what a mapping's source side consumes, so emitting the map of names is usually what a composition wants.

13 · Tagging, and why there is nothing to tag
# There is no `tags` argument on this resource. Tag the vault instead:
module "dr_vault" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-recovery-services-vault.git?ref=v1.0.0"

  name                = "vault-dr-prod"
  location            = module.dr_rg.location
  resource_group_name = module.dr_rg.name
  sku                 = "Standard"

  tags = { environment = "prod", owner = "platform" }
}

ℹ️ Confirmed against both the schema and the provider source: no tags and no location. The universal tail on this module is timeouts alone, and a cost or ownership report has to reach the container through its vault.

14 · 🏗️ End-to-end composition

A resource group, a vault, both ends of a replication pair, a container in each fabric, a policy — and the checks that hold the topology together.

module "dr_rg" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group.git?ref=v1.0.0"

  name     = "rg-dr-prod"
  location = "eastus"

  tags = local.platform_tags
}

module "dr_vault" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-recovery-services-vault.git?ref=v1.0.0"

  name                = "vault-dr-prod"
  location            = module.dr_rg.location
  resource_group_name = module.dr_rg.name
  sku                 = "Standard"

  tags = local.platform_tags
}

# Both ends of the pair. These take the vault by NAME.
module "dr_fabric_primary" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-site-recovery-fabric.git?ref=v1.0.0"

  name                = "fabric-eastus"
  location            = "eastus"
  recovery_vault_name = module.dr_vault.name
  resource_group_name = module.dr_rg.name
}

module "dr_fabric_secondary" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-site-recovery-fabric.git?ref=v1.0.0"

  name                = "fabric-westus2"
  location            = "westus2"
  recovery_vault_name = module.dr_vault.name
  resource_group_name = module.dr_rg.name
}

# A container in each fabric. The source side of a mapping takes the name, the target side the id.
module "dr_container_primary" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-site-recovery-protection-container.git?ref=v1.0.0"

  name                 = "container-eastus"
  recovery_fabric_name = module.dr_fabric_primary.name
  recovery_vault_name  = module.dr_vault.name
  resource_group_name  = module.dr_rg.name
}

module "dr_container_secondary" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-site-recovery-protection-container.git?ref=v1.0.0"

  name                 = "container-westus2"
  recovery_fabric_name = module.dr_fabric_secondary.name
  recovery_vault_name  = module.dr_vault.name
  resource_group_name  = module.dr_rg.name
}

# The policy takes NO fabric -- it belongs to the vault, so it can be created alongside the fabrics.
module "dr_policy" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-site-recovery-replication-policy.git?ref=v1.0.0"

  name                = "policy-24h"
  recovery_vault_name = module.dr_vault.name
  resource_group_name = module.dr_rg.name

  recovery_point_retention_in_minutes                  = 1440
  application_consistent_snapshot_frequency_in_minutes = 240
}

check "both_containers_are_in_our_own_fabrics" {
  assert {
    condition = (
      module.dr_container_primary.fabric_path_within_the_subscription == module.dr_fabric_primary.fabric_path_within_the_subscription
      && module.dr_container_secondary.fabric_path_within_the_subscription == module.dr_fabric_secondary.fabric_path_within_the_subscription
    )
    error_message = "A protection container is attached to a fabric this configuration does not declare."
  }
}

check "the_whole_topology_is_in_one_vault" {
  assert {
    condition = length(distinct([
      module.dr_fabric_primary.vault_path_within_the_subscription,
      module.dr_fabric_secondary.vault_path_within_the_subscription,
      module.dr_container_primary.vault_path_within_the_subscription,
      module.dr_container_secondary.vault_path_within_the_subscription,
      module.dr_policy.vault_path_within_the_subscription,
    ])) == 1
    error_message = "The replication topology spans more than one Recovery Services vault, which cannot work."
  }
}

⚠️ The composition is incomplete on purpose. What is missing is the record that ties it together: azurerm_site_recovery_protection_container_mapping joins the source container (by name), the target container (by id) and the policy (by id), and is owned by terraform-azurerm-site-recovery-protection-container-mapping — every input it needs is emitted above. The ordering of a multi-machine failover across those items is owned by terraform-azurerm-site-recovery-replication-recovery-plan, and the replicated items themselves by terraform-azurerm-site-recovery-replicated-vm.

✅ The second check is worth copying. Thirteen modules across thirteen resource types all emit vault_path_within_the_subscription in the same form, so one assertion covers the whole topology.


📥 Inputs

Identity — name, recovery_fabric_name, recovery_vault_name, resource_group_name, all force-new. Universal tail — timeouts only. There is no tags and no location on this resource.

Full schemas
variable "name" { type = string }                 # REQUIRED, force-new
variable "recovery_fabric_name" { type = string } # REQUIRED, force-new -- a NAME, not an id
variable "recovery_vault_name" { type = string }  # REQUIRED, force-new
variable "resource_group_name" { type = string }  # REQUIRED, force-new via commonschema.ResourceGroupName()

variable "timeouts" {
  type = object({
    create = optional(string, "30m")
    read   = optional(string, "5m")
    delete = optional(string, "30m")
  })
  default = {}
} # THREE keys -- there is no update path

7 validations. Two on name and two on recovery_fabric_name (blank, and no forward slash — both are validated upstream only by StringIsNotEmpty, which accepts whitespace); three on timeouts. recovery_vault_name and resource_group_name carry none, deliberately: both provider validators are anchored, exclude the forward slash, reject a blank and state the whole rule.


🧾 Outputs

Output Description
id The container's Resource ID (first) — a mapping's recovery_target_protection_container_id
name A mapping's recovery_source_protection_container_name
recovery_fabric_name, recovery_vault_name, resource_group_name As configured
container_path_within_the_subscription This container's path, at plan time
fabric_path_within_the_subscription The fabric's path, in the form both fabric modules emit
vault_path_within_the_subscription The parent vault's path
the_create_payload_is_completely_empty constant
there_is_no_update_path_so_every_argument_is_force_new constant
the_timeouts_block_has_no_update_key_and_a_supplied_one_is_silently_discarded constant
the_fabric_is_referenced_by_name_so_terraform_holds_no_dependency_edge constant
a_hyperv_site_is_also_a_valid_fabric_name_here constant
this_resource_does_not_tolerate_the_apis_bad_request_for_a_missing_record constant
whether_a_first_apply_is_refused_depends_on_caller_provider_configuration constant

15 outputs: 5 passthrough, 3 derived, 7 constant. No output is sensitive.

ℹ️ The count is low on purpose. All four arguments are identity, the create payload is empty, and there is no update path — so there is no posture to derive. Padding the list would mean emitting values no input can change.


🧠 Architecture Notes

The empty payload is the organising fact. CreateProtectionContainerInputProperties{} carries nothing, so the only thing a caller can get wrong about a protection container is where it is. That shapes everything else in the module: the derived outputs are all locations, the constants are all lifecycle, and the read path sets every attribute from the parsed Resource ID rather than from the response body — which means drift beyond "does it exist" is undetectable by design rather than by omission.

The fabric arrives as a bare name, and that fails in two directions. Loudly, when a literal means Terraform builds no ordering edge and the apply hits a fabric that does not exist yet. Quietly, when a literal matches a fabric somebody else created and the container attaches to it without complaint. Only the first is a plan problem; the second is why this module reconstructs fabric_path_within_the_subscription in exactly the form both fabric-creating modules emit under the same name, so a one-line check can close it.

Two of four arguments are unvalidated upstream and two are well covered, which is why the module's rule list looks lopsided. StringIsNotEmpty on name and recovery_fabric_name accepts a whitespace-only string; validate.RecoveryServicesVaultName and resourcegroups.ValidateName are anchored, exclude the forward slash and state their whole rule. Adding rules to the latter two would buy wording, so they carry none — recorded as examined rather than left to look inconsistent.

A Hyper-V site is a fabric here, because it is a fabric everywhere. azurerm_site_recovery_services_vault_hyperv_site writes the same Microsoft.RecoveryServices/vaults/replicationFabrics type as azurerm_site_recovery_fabric, so its name is a legal recovery_fabric_name and nothing in this resource distinguishes the two. That is usually convenient; it is the same fact that lets those two modules silently collide with each other.

The 400-tolerance asymmetry is the sharpest sibling difference — and it is narrower than it looks. Six of the twelve resources in this service that perform an existence check reference the API's Bad Request at all, and this one does not. Five of those six call the service's wasBadRequestWithNotExist helper, which fires only for the SubscriptionIdNotRegisteredWithSrs detail code and only for a legacy autorest.DetailedError these clients do not return, so what they actually gain over this resource is close to nothing. The consequence is a first-apply failure whose message is about looking for the container, which is a different debugging path from a failure to create one. Nothing in the schema or the documentation shows it, and the workaround being present on four of the seven that check at all is what makes it look like an omission rather than a decision.

Nothing can be edited, and the replacement is not local. No Update function means all four arguments are force-new — three explicitly, one invisibly through a commonschema helper. Mappings reference this container and replicated items live inside it, so a one-character rename is the start of a much larger rebuild. The visible trace is the three-key timeouts block, which in turn is why this module's timeouts variable declares three keys: an object type silently discards an undeclared attribute, so a caller who supplies update loses it without being told.


🧱 Design Principles

There is nothing to harden, and that is the honest starting point. Four identity arguments and an empty create payload; the resource has no boolean, no enum, no network surface and no encryption setting. A secure-defaults table would be empty, so what follows records what was examined.

Concern Provider's value This module Why
Exposure controls none exist — No public-access, TLS, identity or encryption surface of any kind.
name validation StringIsNotEmpty two rules added It accepts a whitespace-only string. Genuine coverage.
recovery_fabric_name validation StringIsNotEmpty two rules added Same gap, and the message names the fabric module's name output because its id is the plausible wrong value.
recovery_vault_name anchored regex, accurate message kept, no rule Restating it would buy wording, not coverage.
resource_group_name anchored class, three checks kept, no rule Same. The class excludes /, so a Resource ID is already rejected.
timeouts shape three keys mirrored exactly Declaring a fourth would be a validate error; omitting it is why the silent-discard trap is documented.
Fabric attachment unchecked emitted as a comparable path The provider cannot see whether the fabric was declared by this configuration; a check block can.

🚀 Runbook

terraform init -backend=false
terraform validate
terraform fmt -check

Pin the module at a tag, never a branch:

source = "git::https://github.com/microsoftexpert/terraform-azurerm-site-recovery-protection-container.git?ref=v1.0.0"

Everything above is plan-only static analysis. A human applies from CI.


🧪 Testing

What validate and fmt -check cover. Every type, the optional() defaults, all 7 validations as expressions, and the locals block's total-ness. The three derived paths were each evaluated offline and printed rather than read, across fixtures that vary the vault, the resource group, the fabric and the container name independently — so no value was confirmed by coincidence.

What only plan against a real subscription exercises. That the fabric exists and its name was spelled correctly; that Microsoft.RecoveryServices is registered; whether an existing container causes the create to fail or be overwritten; and whether the API answers 400 for a missing container in your region.

What no plan can show. That the fabric named here is one this configuration owns, and that replacing this container will cascade through every mapping and replicated item referencing it. Both are why the path outputs and the constants exist.


💬 Example Output

id = "/subscriptions/00000000-.../resourceGroups/rg-dr-prod/providers/Microsoft.RecoveryServices/vaults/vault-dr-prod/replicationFabrics/fabric-eastus/replicationProtectionContainers/container-eastus"
name = "container-eastus"
recovery_fabric_name = "fabric-eastus"
recovery_vault_name = "vault-dr-prod"
resource_group_name = "rg-dr-prod"
container_path_within_the_subscription = "/resourceGroups/rg-dr-prod/providers/Microsoft.RecoveryServices/vaults/vault-dr-prod/replicationFabrics/fabric-eastus/replicationProtectionContainers/container-eastus"
fabric_path_within_the_subscription = "/resourceGroups/rg-dr-prod/providers/Microsoft.RecoveryServices/vaults/vault-dr-prod/replicationFabrics/fabric-eastus"
vault_path_within_the_subscription = "/resourceGroups/rg-dr-prod/providers/Microsoft.RecoveryServices/vaults/vault-dr-prod"

🔍 Troubleshooting

Symptom Cause Fix
name must not be blank or whitespace-only An empty or space-only name Supply a real name. The provider's StringIsNotEmpty would have accepted the whitespace
recovery_fabric_name is the fabric's own short name, not a Resource ID module.<fabric>.id was wired instead of .name Pass module.<fabric>.name
recovery_fabric_name must not be blank An unset variable reached it Wire the fabric module's name output
An apply fails saying the fabric does not exist, although the plan succeeded The fabric is referenced by NAME, so Terraform built no ordering edge Wire module.<fabric>.name rather than a literal
The container was created in a fabric this configuration does not own A literal fabric name matched an existing fabric Add the check block from example 4
checking for presence of existing site recovery protection container ... wrapping a 400 This resource alone does not absorb the API's Bad Request for a missing record Set the provider's overwrite feature knowingly, or retry — see example 9
already exists on a first apply A container exists at this name in this fabric Import it, or set the provider's overwrite feature knowingly
A plan proposes replacing the container after a one-character edit Every argument is force-new and there is no update path Expected — and the replacement cascades to mappings and replicated items
A timeouts value seems to be ignored An update key was supplied; the object type discarded it silently Use only create, read and delete
A mapping rejects this module's id The mapping's source side takes a name; only its target side takes an id Pass name for the source, id for the target — see example 6
The container exists but nothing is replicating Expected — the container is empty Add a protection container mapping and replicated-item resources

🔗 Related Docs


💙 "Infrastructure as Code should be standardized, consistent, and secure."