Skip to content

Latest commit

 

History

1 Commit

Folders and files

Repository files navigation

☁️ Azure Site Recovery Hyper-V Replication Policy Terraform Module

Defines how often on-premises Hyper-V machines replicate to Azure and how long their recovery points are kept, targeting hashicorp/azurerm ~> 4.0.

Terraform azurerm Module Type Resources Posture


🧩 Overview

  • 🕒 Manages azurerm_site_recovery_hyperv_replication_policy — the retention window, the application-consistent snapshot frequency and the replication interval for Hyper-V-to-Azure replication.
  • 🔴 Three Terraform resource types write this one Azure record type. The Azure-to-Azure and VMware policies use the same client and the same Resource ID constructor, so a shared name in a shared vault is one object.
  • ✏️ This resource can be edited. It declares an Update function, so its three numeric arguments change in place and timeouts carries four keys.
  • 🚫 The cross-field rule its Azure-to-Azure sibling enforces is absent here — no documentation note, no plan-time check and no comparison in either the create or the update body. This module reports the condition instead of inventing the constraint.
  • ⏱️ replication_interval_in_seconds is a two-value set: 30 or 300. Nothing else can be expressed, and a value set outside Terraform is reverted on the next apply.
  • 🔗 Takes the vault by ID where its Azure-to-Azure counterpart takes it by name plus a resource group — and still emits a path output byte-identical to that module's.

💡 Why it matters: the three policy resources in this service look unrelated by name and are interchangeable at the API. The retention argument here is in hours with a ceiling of 24; on the sibling it is in minutes with a ceiling of 525600. A value copied between the two configurations is wrong by a factor of sixty in one direction and by two orders of magnitude in the other, and neither the plan nor the apply says so.


❤️ Support this project

If this module saved you time:


🗺️ Where this fits in the family

This module is terraform-azurerm-site-recovery-hyperv-replication-policy, one of the fourteen blue nodes — all fourteen authored site_recovery_* modules share this diagram. The three dark nodes are not modules: each is a single ARM resource type that several of the blue modules write -- two of the three by three resource types each, the remaining one by two. Any two modules writing one of them 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

The Hyper-V branch reads bottom-up: hosts are registered out of band, a Hyper-V site represents them, this policy states the replication settings, and the association binds the two. Nothing replicates until all four exist.


🧬 What this module builds

flowchart TB
  IN_NAME["name: force-new, and shared with TWO other policy resource types in one Azure namespace"]
  IN_VAULT["recovery_vault_id: a vault Resource ID, split into four values. Anchored, because a Hyper-V site's ID extends it"]
  IN_RET["recovery_point_retention_in_hours: zero to 24. Zero is legal and means no history"]
  IN_FREQ["application_consistent_snapshot_frequency_in_hours: zero to 12. Zero disables app-consistent points"]
  IN_INT["replication_interval_in_seconds: 30 or 300, and nothing else is expressible"]
  THIS["azurerm_site_recovery_hyperv_replication_policy.this"]
  NORULE["NO cross-field rule here. Both sibling policy resources document and enforce one; this resource documents nothing and checks nothing"]
  OUT_PATH["vault and policy paths, comparable with all three policy modules and both associations"]
  OUT_POSTURE["posture: no history, crash-consistent only, frequency above the window, or its exact boundary"]
  OUT_CONST["seven constants: one shared record type, a silent read, an unenforced pairing, an in-place retention cut"]

  IN_NAME --> THIS
  IN_VAULT --> THIS
  IN_RET --> THIS
  IN_FREQ --> THIS
  IN_INT --> THIS
  IN_FREQ -->|"reported, not rejected: Microsoft says should, not must"| NORULE
  IN_RET -->|"carries no rules of its own"| NORULE
  IN_NAME --> OUT_PATH
  IN_VAULT --> OUT_PATH
  IN_RET --> OUT_POSTURE
  IN_FREQ --> OUT_POSTURE
  IN_INT --> OUT_POSTURE
  THIS --> 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 NORULE me
  class IN_NAME,IN_VAULT,IN_RET,IN_FREQ,IN_INT,OUT_PATH,OUT_POSTURE,OUT_CONST sibling
Loading
Resource Cardinality Note
azurerm_site_recovery_hyperv_replication_policy single (this) The keystone. No child or relationship records.

A standalone module: one keystone resource, no for_each children. Five arguments plus timeouts, three of which are editable in place.


✅ Provider / Versions

Item Value
Terraform >= 1.12.0
Provider hashicorp/azurerm ~> 4.0 (validated against 4.81.0)
Provider block None here. The caller configures provider "azurerm" { features {} }, auth and subscription.
ARM API version Microsoft.RecoveryServices 2024-04-01
tags Not supported — this resource has no tags and no location. Tag the vault instead.

Schema notes that bite

  • 🔴 azurerm_site_recovery_replication_policy and azurerm_site_recovery_vmware_replication_policy write the same Azure record type. Same client, same ID constructor; the three differ only in the provider-specific payload. A name collision in one vault is silent in every direction.
  • 🔴 The read sets the three numeric attributes only inside a type assertion, with no else. A record of either other replication type read through this resource leaves all three at zero, and Terraform proposes to change them on every plan with nothing explaining why.
  • ⚠️ The documented cross-field rule does not exist on this resource. The sibling documents "application_consistent_snapshot_frequency_in_minutes must be less than or equal to recovery_point_retention_in_minutes" and enforces it. Here the documentation page carries zero ~> / !> / -> note blocks, there is no CustomizeDiff, and neither body compares the two.
  • ⚠️ Force-new: name and recovery_vault_id only. The three numeric arguments carry no ForceNew and are updated in place.
  • ⚠️ The retention ceiling is 24 and the unit is hours. IntBetween(0, 24), against the sibling's IntBetween(0, 525600) in minutes.
  • ⚠️ replication_interval_in_seconds is IntInSlice([]int{30, 300}). Two values, both documented.
  • ⚠️ name is validated only by StringIsNotEmpty, which accepts a whitespace-only string. recovery_vault_id gets a real type-specific validator, vaults.ValidateVaultID.
  • ⚠️ The existence check references the API's HTTP 400 — but not for a missing record (Azure/azure-rest-api-specs issue 12759). It guards with the service's own wasBadRequestWithNotExist helper, which returns true only when the response is a 400 and the first service-error detail code is exactly SubscriptionIdNotRegisteredWithSrs — a subscription not registered with Site Recovery, not a missing record. The helper also requires a legacy autorest.DetailedError, and every caller in this service uses a go-azure-sdk client with no go-autorest import, so on the evidence in this service the branch is unreachable. Treat the tolerance as nominal and expect a first apply to fail while looking for the policy.
  • ⚠️ There is no SchemaVersion and no state upgrader.

🔑 Required Azure RBAC Roles / Permissions

Principal Scope Requirement
The principal running Terraform The vault or its resource group Site Recovery Contributor — it carries Microsoft.RecoveryServices/vaults/replicationPolicies/*
Alternatively The same scope Contributor or Owner

Whoever can write this policy can shorten a recovery window in place. Narrowing recovery_point_retention_in_hours is an update, not a replacement: no resource is destroyed, nothing in the plan flags data loss, and recovery points outside the new window are not recoverable afterwards. That belongs here rather than only in Architecture Notes, because this table is what a reviewer reads before granting write access.

Site Recovery Contributor cannot create or delete the vault itself, per Microsoft's description of the role. Site Recovery Operator cannot register new infrastructure and Site Recovery Reader is read-only.

Backup Contributor is the wrong role and it is an easy mistake, because a Recovery Services vault serves both Azure Backup and Site Recovery. It grants nothing over replicationPolicies.

Plan access is not credential access. This module reads and holds no secret, and no output is sensitive.


Azure Prerequisites

  1. Microsoft.RecoveryServices registered in the subscription.
  2. An existing Recovery Services vault, referenced here by Resource ID.
  3. A name unique within that vault across ALL THREE policy resource types — nothing enforces it across configurations.
  4. An understanding that this policy protects nothing on its own. A Hyper-V topology also needs a Hyper-V site, an association binding the two, and hosts registered out of band.

📁 Module Structure

terraform-azurerm-site-recovery-hyperv-replication-policy/
├── providers.tf   # required_version and the pinned azurerm; no provider block
├── variables.tf   # 6 variables, 9 validations
├── main.tf        # the keystone `this`, plus the vault parse and the reported posture
├── outputs.tf     # 27 outputs: 6 passthrough, 14 derived, 7 constant
├── README.md      # this file
├── SCOPE.md       # the cross-module contract
├── LICENSE        # MIT
└── .gitignore

⚙️ Quick Start

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

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

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

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

module "hyperv_policy" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-site-recovery-hyperv-replication-policy.git?ref=v1.0.0"

  name              = "hyperv-policy-24h"
  recovery_vault_id = module.dr_vault.id

  recovery_point_retention_in_hours                  = 24
  application_consistent_snapshot_frequency_in_hours = 1
  replication_interval_in_seconds                    = 300
}

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


🔌 Cross-Module Contract

Consumes

Input Type Source module
recovery_vault_id string terraform-azurerm-recovery-services-vault output id
name string caller
recovery_point_retention_in_hours number caller
application_consistent_snapshot_frequency_in_hours number caller
replication_interval_in_seconds number caller

Emits

Output Description Consumed by
id The policy's Resource ID (first) terraform-azurerm-site-recovery-hyperv-replication-policy-association as policy_id
policy_path_within_the_subscription The plan-time path check blocks across all three policy modules
vault_path_within_the_subscription The parent vault's path check blocks asserting one topology, one vault
vault_name The vault's name, parsed out of the ID the siblings in this family that take a vault by name
recovery_point_retention_in_minutes The window converted comparison against the Azure-to-Azure policy module
seven constants The facts that produce no error when they bite documentation, check blocks

📚 Example Library

1 · The smallest real call
module "hyperv_policy" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-site-recovery-hyperv-replication-policy.git?ref=v1.0.0"

  name              = "hyperv-policy-24h"
  recovery_vault_id = module.dr_vault.id

  recovery_point_retention_in_hours                  = 24
  application_consistent_snapshot_frequency_in_hours = 1
  replication_interval_in_seconds                    = 300
}

ℹ️ Every argument is required. There is no empty call to make safe, and no argument here is an exposure control — this policy governs retention and frequency, not access.

2 · Microsoft's own tutorial values

Microsoft's Hyper-V-to-Azure tutorial selects a five-minute copy frequency, two hours of retention and an hourly application-consistent snapshot.

module "hyperv_policy_tutorial" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-site-recovery-hyperv-replication-policy.git?ref=v1.0.0"

  name              = "hyperv-policy-tutorial"
  recovery_vault_id = module.dr_vault.id

  recovery_point_retention_in_hours                  = 2
  application_consistent_snapshot_frequency_in_hours = 1
  replication_interval_in_seconds                    = 300
}

💡 Two hours of retention with hourly application-consistent points means exactly two such points exist at any time. application_consistent_points_within_the_retention_window reports that number.

3 · The 30-second replication interval
module "hyperv_policy_tight" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-site-recovery-hyperv-replication-policy.git?ref=v1.0.0"

  name              = "hyperv-policy-30s"
  recovery_vault_id = module.dr_vault.id

  recovery_point_retention_in_hours                  = 24
  application_consistent_snapshot_frequency_in_hours = 1
  replication_interval_in_seconds                    = 30
}

⚠️ This buys a tighter recovery point objective, not more recovery points. Microsoft documents a crash-consistent recovery point every five minutes on a schedule that cannot be configured, so a 30-second replication interval moves data more often while the number of points to choose from is unchanged. replication_interval_is_shorter_than_the_crash_consistent_point_interval reports the distinction.

4 · Crash-consistent points only
module "hyperv_policy_no_vss" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-site-recovery-hyperv-replication-policy.git?ref=v1.0.0"

  name              = "hyperv-policy-crash-only"
  recovery_vault_id = module.dr_vault.id

  recovery_point_retention_in_hours                  = 12
  application_consistent_snapshot_frequency_in_hours = 0
  replication_interval_in_seconds                    = 300
}

💡 Zero disables application-consistent snapshots, and Microsoft's documented default for a new policy is exactly that. It is the right choice where no VSS-aware application needs quiescing — application-consistent snapshots are the most involved and, per Microsoft, affect the performance of applications running on a replicated machine.

5 · No recovery point history at all
module "hyperv_policy_latest_only" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-site-recovery-hyperv-replication-policy.git?ref=v1.0.0"

  name              = "hyperv-policy-latest-only"
  recovery_vault_id = module.dr_vault.id

  recovery_point_retention_in_hours                  = 0
  application_consistent_snapshot_frequency_in_hours = 0
  replication_interval_in_seconds                    = 30
}

⚠️ Zero retention is inside the provider's range and means a failover can use only the latest available point. There is no error at any stage, and a 0 in a plan reads as a default rather than as a decision — which is why retains_no_recovery_point_history exists.

6 · The combination Microsoft recommends against, which nothing rejects
module "hyperv_policy_inverted" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-site-recovery-hyperv-replication-policy.git?ref=v1.0.0"

  name              = "hyperv-policy-inverted"
  recovery_vault_id = module.dr_vault.id

  recovery_point_retention_in_hours                  = 2
  application_consistent_snapshot_frequency_in_hours = 12
  replication_interval_in_seconds                    = 30
}

⚠️ Both values are inside their published ranges, so this applies cleanly. Microsoft's guidance is that the frequency "should always be less than you set for retaining recovery points" — a recommendation, not a limit, which is why this module reports it rather than rejecting it.

Assert on it where the recommendation matters:

check "application_consistent_frequency_fits_the_window" {
  assert {
    condition     = !module.hyperv_policy_inverted.application_consistent_frequency_exceeds_the_retention_window
    error_message = "The application-consistent snapshot frequency is longer than the retention window, so no application-consistent point survives to be used."
  }
}
7 · The rule this resource does NOT have, and the sibling that does

The Azure-to-Azure policy resource enforces the pairing; this one does not. Both configurations are legal, and only one of them fails.

# REJECTED at plan by terraform-azurerm-site-recovery-replication-policy, which carries the documented rule.
module "a2a_policy_rejected" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-site-recovery-replication-policy.git?ref=v1.0.0"

  name                = "a2a-policy"
  recovery_vault_name = module.dr_vault.name
  resource_group_name = module.dr_rg.name

  recovery_point_retention_in_minutes                  = 60
  application_consistent_snapshot_frequency_in_minutes = 120
}

# ACCEPTED here, because this resource publishes no such rule.
module "hyperv_policy_accepted" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-site-recovery-hyperv-replication-policy.git?ref=v1.0.0"

  name              = "hyperv-policy"
  recovery_vault_id = module.dr_vault.id

  recovery_point_retention_in_hours                  = 1
  application_consistent_snapshot_frequency_in_hours = 2
  replication_interval_in_seconds                    = 300
}

💡 The asymmetry is deliberate on both sides. Each module mirrors what its own resource actually enforces, rather than making the two look consistent by inventing a constraint on one of them.

8 · Asserting the three policy resource types do not collide

This is the hazard with no validation available: two Terraform resource types, one Azure record type, no visibility across configurations.

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

  name                = "a2a-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 = 60
}

module "hyperv_policy" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-site-recovery-hyperv-replication-policy.git?ref=v1.0.0"

  name              = "hyperv-policy-24h"
  recovery_vault_id = module.dr_vault.id

  recovery_point_retention_in_hours                  = 24
  application_consistent_snapshot_frequency_in_hours = 1
  replication_interval_in_seconds                    = 300
}

check "the_two_policies_are_distinct_records" {
  assert {
    condition = (
      module.a2a_policy.policy_path_within_the_subscription
      != module.hyperv_policy.policy_path_within_the_subscription
    )
    error_message = "Both policy modules resolve to the same Azure record. Both will plan, the second apply wins, and neither reports a conflict."
  }
}

🔒 The two modules reach that value from different arguments — one from a vault name plus a resource group, the other from a vault Resource ID — and produce byte-identical strings. That is what makes the comparison sound rather than coincidental.

9 · Editing the retention window in place
module "hyperv_policy" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-site-recovery-hyperv-replication-policy.git?ref=v1.0.0"

  name              = "hyperv-policy-24h"
  recovery_vault_id = module.dr_vault.id

  # Was 24. Reducing it is an in-place update: no replacement appears in the plan.
  recovery_point_retention_in_hours                  = 1
  application_consistent_snapshot_frequency_in_hours = 1
  replication_interval_in_seconds                    = 300
}

⚠️ Recovery points outside the new window are not recoverable afterwards, and the plan shows a one-line numeric diff. lifecycle is not valid inside a module block, so a caller cannot add ignore_changes here — the controls are review of the plan and a CanNotDelete management lock on the vault, which prevents deletion rather than editing.

10 · Rejecting the vault-versus-site transposition
# WRONG: this is the Hyper-V site's ID, which the association wants -- not the vault's.
module "hyperv_policy_bad" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-site-recovery-hyperv-replication-policy.git?ref=v1.0.0"

  name              = "hyperv-policy"
  recovery_vault_id = module.hyperv_site.id

  recovery_point_retention_in_hours                  = 24
  application_consistent_snapshot_frequency_in_hours = 1
  replication_interval_in_seconds                    = 300
}

🔒 Caught at plan by two rules. The site's ID extends a valid vault path, so an unanchored check would accept it and this module would then derive a vault name with four unread trailing segments instead of raising an error. The second rule names the transposition and says which module's output belongs where.

11 · Wiring the sibling that wants a vault NAME

This module receives an ID and re-emits the parts, so a composition does not need both forms from the vault module.

module "hyperv_policy" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-site-recovery-hyperv-replication-policy.git?ref=v1.0.0"

  name              = "hyperv-policy-24h"
  recovery_vault_id = module.dr_vault.id

  recovery_point_retention_in_hours                  = 24
  application_consistent_snapshot_frequency_in_hours = 1
  replication_interval_in_seconds                    = 300
}

# This sibling takes the vault BY NAME. Its value comes out of the ID above.
module "hyperv_container" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-site-recovery-protection-container.git?ref=v1.0.0"

  name                 = "hyperv-container"
  recovery_fabric_name = module.hyperv_site.name
  recovery_vault_name  = module.hyperv_policy.vault_name
  resource_group_name  = module.hyperv_policy.vault_resource_group_name
}

⚠️ Read the constant the_protection_container_is_discovered_at_apply_time_and_exactly_one_must_exist on the association module before adding a container to a Hyper-V site. A second container in one site breaks the association permanently.

12 · Several policies at scale, keyed by tier
locals {
  hyperv_tiers = {
    database = { retention = 24, frequency = 1, interval = 30 }
    app      = { retention = 12, frequency = 4, interval = 300 }
    file     = { retention = 4, frequency = 0, interval = 300 }
  }
}

module "hyperv_policy_per_tier" {
  source   = "git::https://github.com/microsoftexpert/terraform-azurerm-site-recovery-hyperv-replication-policy.git?ref=v1.0.0"
  for_each = local.hyperv_tiers

  name              = "hyperv-policy-${each.key}"
  recovery_vault_id = module.dr_vault.id

  recovery_point_retention_in_hours                  = each.value.retention
  application_consistent_snapshot_frequency_in_hours = each.value.frequency
  replication_interval_in_seconds                    = each.value.interval
}

output "hyperv_policy_paths" {
  value = { for k, m in module.hyperv_policy_per_tier : k => m.policy_path_within_the_subscription }
}

💡 The file tier disables application-consistent snapshots, which Microsoft recommends for non-database workloads. The database tier pairs the 30-second interval with hourly application-consistent points, which is the heaviest combination this resource can express.

13 · Reporting rather than asserting, for a plan review
output "hyperv_policy_posture" {
  value = {
    retention_hours            = module.hyperv_policy.recovery_point_retention_in_hours
    retention_minutes          = module.hyperv_policy.recovery_point_retention_in_minutes
    app_consistent_points      = module.hyperv_policy.application_consistent_points_within_the_retention_window
    crash_consistent_only      = module.hyperv_policy.only_crash_consistent_recovery_points_are_taken
    no_history                 = module.hyperv_policy.retains_no_recovery_point_history
    frequency_exceeds_window   = module.hyperv_policy.application_consistent_frequency_exceeds_the_retention_window
    frequency_equals_window    = module.hyperv_policy.frequency_equals_the_whole_retention_window
    at_the_thirty_second_floor = module.hyperv_policy.replicates_at_the_thirty_second_floor
  }
}

💡 retention_minutes is in the list for one reason: it is the only value directly comparable against the Azure-to-Azure policy module, whose argument is in minutes. Reading two policies in one vault without it invites a factor-of-sixty mistake.

14 · Timeouts, and the key this resource does have
module "hyperv_policy" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-site-recovery-hyperv-replication-policy.git?ref=v1.0.0"

  name              = "hyperv-policy-24h"
  recovery_vault_id = module.dr_vault.id

  recovery_point_retention_in_hours                  = 24
  application_consistent_snapshot_frequency_in_hours = 1
  replication_interval_in_seconds                    = 300

  timeouts = {
    create = "45m"
    update = "45m"
  }
}

ℹ️ All four keys exist here, so update is accepted. On the three-key modules in this family — the fabric, the Hyper-V site, the protection container, both mappings and the association — the same update key is discarded silently, with no error, no warning and no plan diff. A four-key timeouts block is the reliable way to tell from the schema alone that a resource has an update path.

15 · 🏗️ End-to-end composition — a Hyper-V topology
locals {
  platform_tags = {
    workload = "disaster-recovery"
    managed  = "terraform"
  }
}

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                = "rsv-dr-prod"
  resource_group_name = module.dr_rg.name
  location            = module.dr_rg.location
  sku                 = "Standard"

  tags = local.platform_tags
}

# The site that represents the on-premises Hyper-V hosts. The hosts themselves are registered out of band.
module "hyperv_site" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-site-recovery-services-vault-hyperv-site.git?ref=v1.0.0"

  name              = "hyperv-site-hq"
  recovery_vault_id = module.dr_vault.id
}

module "hyperv_policy" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-site-recovery-hyperv-replication-policy.git?ref=v1.0.0"

  name              = "hyperv-policy-24h"
  recovery_vault_id = module.dr_vault.id

  recovery_point_retention_in_hours                  = 24
  application_consistent_snapshot_frequency_in_hours = 1
  replication_interval_in_seconds                    = 300
}

# The record that makes the policy applicable to the site.
module "hyperv_association" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-site-recovery-hyperv-replication-policy-association.git?ref=v1.0.0"

  name           = "hyperv-association"
  hyperv_site_id = module.hyperv_site.id
  policy_id      = module.hyperv_policy.id
}

check "one_vault_holds_the_whole_topology" {
  assert {
    condition = (
      module.hyperv_policy.vault_path_within_the_subscription
      == module.hyperv_site.vault_path_within_the_subscription
    )
    error_message = "The policy and the Hyper-V site are in different vaults. Nothing in the provider checks this and the association's apply will fail against the API."
  }
}

check "the_association_binds_the_policy_this_composition_declared" {
  assert {
    condition = (
      module.hyperv_association.policy_path_within_the_subscription
      == module.hyperv_policy.policy_path_within_the_subscription
    )
    error_message = "The association is bound to a policy other than the one declared here."
  }
}

output "the_topology_is_still_incomplete" {
  description = "Deliberately named for what is missing. All four records exist and nothing replicates yet."
  value       = module.hyperv_policy.a_policy_with_no_association_protects_nothing
}

🔒 This composition is deliberately incomplete, and the closing output says so. Four Azure records exist and no machine is protected: the Hyper-V hosts are registered by installing an agent and using a vault registration key, which no azurerm resource performs — and this provider has no Hyper-V replicated-item resource at all. azurerm_site_recovery_replicated_vm, owned by terraform-azurerm-site-recovery-replicated-vm, models the AZURE-TO-AZURE scenario only.


📥 Inputs

Group Variables
Identity name, recovery_vault_id
Recovery points recovery_point_retention_in_hours, application_consistent_snapshot_frequency_in_hours
Replication replication_interval_in_seconds
Universal tail timeouts (no tags — the resource has none)
Full input schemas
Variable Type Default Notes
name string — Force-new. Two rules: blank, and not a Resource ID. Shares a namespace with the A2A policy resource.
recovery_vault_id string — Force-new. Two rules: an anchored vault-ID shape, and a rejection of a /replicationFabrics/ path.
recovery_point_retention_in_hours number — IntBetween(0, 24). No module rule. Zero is legal. Updatable in place.
application_consistent_snapshot_frequency_in_hours number — IntBetween(0, 12). No module rule. Zero disables application-consistent snapshots. Updatable in place.
replication_interval_in_seconds number — 30 or 300. One module rule, for wording: it names the unit and the likely mistake. Updatable in place.
timeouts object({ create, read, update, delete }) {} → 30m / 5m / 30m / 30m Four keys, because an Update function exists. Four format rules.

Nine validations. Two on name, two on recovery_vault_id, one on replication_interval_in_seconds, four on timeouts. The two numeric arguments carry none, deliberately — their provider ranges are exact and state both bounds in their own messages, and the relationship between them is reported rather than enforced.


🧾 Outputs

27 outputs: 6 passthrough, 14 derived, 7 constant. None is sensitive; this module accepts and emits no secret.

Output Description
id The policy's Resource ID. Consumed by the association as policy_id.
name The policy's name.
recovery_vault_id The vault, as configured.
recovery_point_retention_in_hours The retention window in the resource's own unit.
application_consistent_snapshot_frequency_in_hours The frequency in the resource's own unit.
replication_interval_in_seconds 30 or 300, as configured.
vault_subscription_id Parsed from the vault ID. The subscription the record is created in.
vault_resource_group_name Parsed from the vault ID.
vault_name Parsed from the vault ID, for wiring the siblings that take a vault by name.
vault_path_within_the_subscription Comparable across every authored site-recovery module.
policy_path_within_the_subscription Comparable across all three policy modules, both associations and the replicated-VM module.
recovery_point_retention_in_minutes The window converted, for comparison with the A2A policy module.
retains_no_recovery_point_history True at zero retention.
application_consistent_snapshots_are_disabled True at zero frequency.
only_crash_consistent_recovery_points_are_taken The same condition, named for what still happens.
application_consistent_frequency_exceeds_the_retention_window The state Microsoft recommends against and nothing rejects.
application_consistent_points_within_the_retention_window How many fit, floored. Zero when disabled.
frequency_equals_the_whole_retention_window The boundary of the recommendation.
replicates_at_the_thirty_second_floor True at 30 rather than 300.
replication_interval_is_shorter_than_the_crash_consistent_point_interval Separates replication frequency from recovery point frequency.
two_terraform_resource_types_write_this_one_azure_record_type Constant. The collision with the A2A policy resource.
the_read_adopts_a_policy_of_the_other_replication_type_without_reporting_it Constant. The type assertion with no else.
the_provider_enforces_no_relationship_between_the_two_recovery_point_settings Constant. The rule the sibling has and this one does not.
narrowing_the_retention_window_is_an_in_place_update_that_discards_recovery_points Constant.
the_crash_consistent_recovery_point_interval_is_not_configurable Constant. Five minutes, per Microsoft.
a_policy_with_no_association_protects_nothing Constant. What a green apply does and does not mean.
whether_a_first_apply_is_refused_depends_on_caller_provider_configuration Constant. Plus the 400 tolerance tally across the service.

🧠 Architecture Notes

The record type is shared, and that is the fact to design around. azurerm_site_recovery_replication_policy and this resource both write a Microsoft.RecoveryServices/vaults/replicationPolicies record. The provider resolves both through the same ReplicationPolicies client and constructs both Resource IDs with the same constructor; the only difference is the provider-specific payload inside the create call. Two modules cannot see each other's arguments, so no validation reaches this — policy_path_within_the_subscription and a check block do.

And the read makes the collision quiet in the other direction. The read sets the three numeric attributes only inside a type assertion for the Hyper-V-to-Azure policy detail, with no else branch. A record of the other type read through this resource leaves all three at their Go zero values, so Terraform proposes changing them on every plan with nothing explaining why. azurerm_site_recovery_fabric, facing the equivalent situation, errors instead and names the Go type it expected. Within one service, one resource tells you and another does not.

The cross-field rule is absent, and the absence is the finding. On the A2A resource the provider documents that the application-consistent frequency must not exceed the retention, enforces it in both bodies, and catches the zero corner at plan time. Here the documentation page carries no note blocks at all, there is no CustomizeDiff, and neither body compares the two numbers. Microsoft's own guidance is a recommendation — "should always be less" — and this suite does not enforce what the provider has not published, so the state is reported through application_consistent_frequency_exceeds_the_retention_window and frequency_equals_the_whole_retention_window. The two policy modules in this library therefore differ on purpose.

Mutability is the security-relevant property here, not immutability. Most of this family cannot be edited at all. This resource can: the three numeric arguments update in place, so narrowing a retention window is a one-line diff with no replacement in the plan and no signal that recovery points are being discarded.

Units are the quiet trap. Retention here is hours with a ceiling of 24; on the sibling it is minutes with a ceiling of 525600. A 24 means a day here and 24 minutes there. recovery_point_retention_in_minutes exists so the two can be compared without arithmetic.

Identity is derived from one argument. The vault arrives as a single Resource ID and the module splits it for the subscription, the resource group and the vault name. The anchored rule on that variable is what makes the fixed indices safe: split() with index access throws from inside a locals block, which is a worse error than a validation failure and arrives from a place the caller was not looking. Anchoring at both ends matters specifically because a Hyper-V site's ID extends a vault's path.

timeouts has four keys and its siblings have three. Declaring a key the resource does not have is a validate error; omitting one it does have means a caller's value is discarded silently. The shape has to match the resource rather than be copied across the family.


🧱 Design Principles

There is no secure-defaults table on this module, and the reason is worth stating. All five arguments are required, none is a boolean, none is an enum with a permissive member, and none gates network exposure. There is no empty call to make safe and nothing to close. What this resource governs is retention and replication frequency — availability and cost, which this suite's secure-by-default rule deliberately does not cover.

The choices that are made, and why:

Decision Value Reasoning
Cross-field rule between the two recovery point settings Not enforced The provider documents none and checks none; Microsoft's guidance says should, not must. Reported through two flags instead.
Range rules on the two numeric arguments None IntBetween(0, 24) and IntBetween(0, 12) are exact and state both bounds in their own messages.
Rule on replication_interval_in_seconds One, for wording The provider's value set is correct; its message does not say the numbers are seconds, and the probable mistake is writing 5 for five minutes.
Rules on recovery_vault_id Two The anchored shape makes the module's own derivation total; the second names the likeliest transposition in this family.
Rules on name Two StringIsNotEmpty accepts a whitespace-only string, so both are genuine coverage.
Emitting the subscription in path outputs Omitted Matching the weaker form used by the modules that take a vault by name is what keeps every path output in this family comparable.

🚀 Runbook

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

Pin the module with ?ref=v1.0.0 and never a branch. This library is plan-only: a human applies from CI.


🧪 Testing

terraform validate proves the configuration parses, the resource's arguments exist and the types line up. It does not fire a variable validation when this module is called from another configuration — root-module validations do fire, and terraform console with a -var-file is the offline harness for them.

What was proven offline for this module:

  • All 9 validations fired, each by a fixture built to fail it, with zero condition-evaluation errors. A rule whose condition throws looks exactly like a rule that never fired.
  • The fabric-in-the-vault-argument fixture fires both rules on recovery_vault_id, which is the case the anchoring exists for.
  • All 15 locals were driven to more than one value across four good fixtures, chosen to reach each posture flag: the ordinary case, zero retention with zero frequency, frequency above retention, and frequency equal to retention.
  • policy_path_within_the_subscription was evaluated in three modules — this one, the A2A policy module and the association module — from three different argument conventions, and came out byte-identical. That is what makes the check block in example 8 trustworthy.

What only an apply exercises: whether Azure accepts a given combination of the two recovery point settings, and the read path's behaviour against a record written by the other policy resource type.


💬 Example Output

id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-dr-prod/providers/Microsoft.RecoveryServices/vaults/rsv-dr-prod/replicationPolicies/hyperv-policy-24h"
name = "hyperv-policy-24h"
recovery_point_retention_in_hours = 24
recovery_point_retention_in_minutes = 1440
application_consistent_snapshot_frequency_in_hours = 1
replication_interval_in_seconds = 300
vault_name = "rsv-dr-prod"
vault_resource_group_name = "rg-dr-prod"
policy_path_within_the_subscription = "/resourceGroups/rg-dr-prod/providers/Microsoft.RecoveryServices/vaults/rsv-dr-prod/replicationPolicies/hyperv-policy-24h"
application_consistent_points_within_the_retention_window = 24
retains_no_recovery_point_history = false
only_crash_consistent_recovery_points_are_taken = false
application_consistent_frequency_exceeds_the_retention_window = false
replicates_at_the_thirty_second_floor = false
two_terraform_resource_types_write_this_one_azure_record_type = true
the_provider_enforces_no_relationship_between_the_two_recovery_point_settings = true

🔍 Troubleshooting

Symptom Cause Fix
expected replication_interval_in_seconds to be one of [30 300] A value other than 30 or 300 — most often 5, meaning "five minutes" Pass 300 for five minutes. No other interval is expressible through this resource.
expected recovery_point_retention_in_hours to be in the range (0 - 24) A value copied from an A2A policy, where the argument is in minutes Convert: 1440 minutes is 24 hours, which is this resource's ceiling.
recovery_vault_id must be a Recovery Services vault Resource ID and nothing deeper The Hyper-V site's id was wired instead of the vault's Pass the vault module's id; the site's id belongs to the association's hyperv_site_id.
A policy created here appears with different settings than configured Another configuration wrote the same record through azurerm_site_recovery_replication_policy Compare policy_path_within_the_subscription across every policy module; see example 8.
Every plan proposes changing all three numeric values, with no explanation The record in Azure is of one of the other two replication types, so the read's type assertion sets nothing Import or recreate the record through the resource type that matches it.
A resource with the ID ... already exists on a first apply A policy of either resource type already exists at this name in this vault Import it, rename, or set the provider's overwrite feature knowingly.
A timeouts key was ignored Only on the three-key siblings; not on this resource Confirm which module the block was written on — object conversion discards an undeclared key silently.
Recovery points vanished after a routine change recovery_point_retention_in_hours was narrowed, which is an in-place update Nothing recovers them. Review numeric diffs on this resource as data changes, not tuning.

🔗 Related Docs


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