Skip to content

Latest commit

Β 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

☁️ Azure NetApp Snapshot Policy Terraform Module

Provisions one Azure NetApp Files snapshot policy (azurerm_netapp_snapshot_policy) β€” the schedule that takes snapshots and ages them out. Targets hashicorp/azurerm ~> 4.0.

Terraform Provider Module Type Resources


🧩 Overview

  • πŸ—“οΈ Provisions one snapshot policy β€” hourly, daily, weekly and monthly schedules β€” as a keystone resource named this.
  • βœ… Rejects a policy with no schedule at all. All four schedule blocks are optional to the provider, so one that takes nothing is otherwise accepted and leaves its volumes unprotected while looking configured.
  • ⚠️ There is no per-volume pause here. Unlike a backup policy, enabled = false stops snapshots for every volume referencing this policy, and the only way to exempt one is to detach it.
  • πŸ• States the trap plainly: schedules run in UTC, because there is no time-zone field on this resource.
  • πŸ’° Emits total_snapshots_retained, because snapshots pin capacity in the parent pool as the volume diverges β€” the counts are a capacity commitment, not just a recovery window.
  • 🧭 This is the resource for a rolling series; terraform-azurerm-netapp-snapshot takes one on-demand snapshot and is a poor substitute.

πŸ’‘ Why it matters: Two things about this resource behave differently from the backup policy it superficially resembles. It has no per-volume off switch, so the one boolean here reaches every volume that uses it. And its retention counts are not free β€” snapshots live in the volume's own capacity pool and grow as data diverges, so an hourly schedule with a high count is a standing capacity commitment against a resource this module cannot see.

❀️ Support this project

If this module saves you time, please consider supporting its continued development:


πŸ—ΊοΈ Where this fits in the family

flowchart LR
  rg["terraform-azurerm-resource-group"]
  kv["terraform-azurerm-key-vault"]
  vnet["terraform-azurerm-virtual-network: subnet delegated to Microsoft.NetApp/volumes"]
  acct["terraform-azurerm-netapp-account"]
  enc["terraform-azurerm-netapp-account-encryption: who holds the key"]
  pool["terraform-azurerm-netapp-pool: the BILLED capacity"]
  vol["terraform-azurerm-netapp-volume: what clients mount"]
  snap["terraform-azurerm-netapp-snapshot: one on-demand, in the pool"]
  spol["terraform-azurerm-netapp-snapshot-policy: the schedule"]
  vault["terraform-azurerm-netapp-backup-vault: the destination"]
  bpol["terraform-azurerm-netapp-backup-policy: the retention"]
  vghana["terraform-azurerm-netapp-volume-group-sap-hana"]
  vgora["terraform-azurerm-netapp-volume-group-oracle"]

  rg -->|"resource_group_name, location"| acct
  kv -->|"encryption_key URI"| enc
  acct -->|"id, BY ID"| enc
  acct -->|"account_name, BY NAME"| pool
  acct -->|"account_name, BY NAME"| vault
  acct -->|"account_name, BY NAME"| bpol
  acct -->|"account_name, BY NAME"| spol
  acct -->|"account_name, BY NAME"| vghana
  acct -->|"account_name, BY NAME"| vgora
  pool -->|"pool_name, BY NAME"| vol
  pool -->|"capacity_pool_id, BY ID"| vghana
  pool -->|"capacity_pool_id, BY ID"| vgora
  vnet -->|"subnet_id, must be delegated"| vol
  vol -->|"name plus pool_name, BY NAME"| snap
  spol -->|"snapshot_policy_id, BY ID"| vol
  vault -->|"backup_vault_id, BY ID"| vol
  bpol -->|"backup_policy_id, BY ID"| vol

  classDef me fill:#0078D4,stroke:#004578,color:#fff;
  classDef keystone fill:#004578,stroke:#001f3f,color:#fff;
  classDef sib fill:#eef2f7,stroke:#b8c4d0,color:#1b1b1b;
  class acct keystone;
  class enc,pool,vol,snap,spol,vault,bpol,vghana,vgora me;
  class rg,kv,vnet sib;
Loading

🧬 What this module builds

flowchart TB
  addr["name, resource_group_name, account_name, location: force-new"]
  rolling["use THIS for a rolling series; netapp-snapshot takes ONE on-demand snapshot"]
  hourly["hourly_schedule: minute plus snapshots_to_keep"]
  daily["daily_schedule: hour, minute, snapshots_to_keep"]
  weekly["weekly_schedule: days_of_week full English names"]
  monthly["monthly_schedule: days_of_month 1-31; a day of 29 to 31 does not fire every month"]
  optional["ALL FOUR blocks are optional, so a policy with NO schedule is accepted and takes nothing"]
  guard["at-least-one validated at plan, on the monthly variable reading the other three"]
  utc["schedules run in UTC: there is NO time-zone field on this resource"]
  enabled["enabled is provider-REQUIRED with no default; this module supplies true"]
  nopause["there is NO per-volume pause, unlike a backup policy: false stops EVERY volume using it"]
  capacity["snapshots pin POOL capacity as the volume diverges"]
  this["terraform-azurerm-netapp-snapshot-policy"]
  pol["azurerm_netapp_snapshot_policy.this"]
  out["outputs: id BY ID, enabled, schedule_summary, total_snapshots_retained"]

  addr -->|"identity"| this
  rolling -->|"when to reach for it"| this
  hourly -->|"one schedule"| optional
  daily -->|"one schedule"| optional
  weekly -->|"one schedule"| optional
  monthly -->|"one schedule"| optional
  optional -->|"the gap to close"| guard
  guard -->|"enforced"| this
  utc -->|"schedule correctness"| this
  enabled -->|"protective default"| this
  nopause -->|"blast radius"| enabled
  capacity -->|"why the counts matter"| this
  this -->|"creates"| pol
  pol -->|"emits"| out

  classDef me fill:#0078D4,stroke:#004578,color:#fff;
  classDef keystone fill:#004578,stroke:#001f3f,color:#fff;
  classDef sib fill:#eef2f7,stroke:#b8c4d0,color:#1b1b1b;
  class this me;
  class pol keystone;
  class addr,rolling,hourly,daily,weekly,monthly,optional,guard,utc,enabled,nopause,capacity,out sib;
Loading

Resource inventory

Resource Count Role
azurerm_netapp_snapshot_policy.this 1 The keystone policy, with its hourly_schedule, daily_schedule, weekly_schedule, monthly_schedule, and timeouts blocks.

βœ… Provider / Versions

Requirement Value
Terraform >= 1.12.0
hashicorp/azurerm ~> 4.0
Provider block None in this module β€” the caller configures provider "azurerm" { features {} }, auth, and subscription.

Schema notes that bite (verified against the live provider schema):

  • Force-new: name, resource_group_name, account_name, and location. The schedules and enabled update in place.
  • All four schedule blocks are optional, so the provider accepts a policy with no schedule whatsoever β€” it takes no snapshots and nothing reports the gap. This module rejects it at plan.
  • Within each schedule block every field is required; there are no partial schedules.
  • enabled is marked required by the provider with no default of its own. This module supplies true.
  • enabled = false stops snapshots for every volume referencing the policy, and there is no per-volume pause β€” unlike a backup policy, whose volumes each carry a policy_enabled switch. The only way to exempt one volume is to detach the policy from it.
  • Schedules run in UTC. There is no time-zone field, so a schedule written in local business hours drifts relative to it twice a year.
  • A monthly_schedule day of 29, 30, or 31 does not fire in every month.
  • Snapshots consume capacity in the parent pool, growing as the live volume diverges.
  • A volume references the policy by Resource ID, so renaming replaces it and any volume still pointing at the old ID stops having snapshots taken.
  • πŸ”΄ Destroying this policy DETACHES it from every volume in the account, and succeeds. The provider catches the service's "SnapshotPolicy is used" error, enumerates every volume referencing the policy, clears each one's SnapshotPolicyId, and retries the delete. Volumes this configuration does not manage lose their scheduled snapshots silently β€” nothing fails and nothing warns.
  • The account is addressed by name, but volumes consume this policy by Resource ID β€” the family mixes both conventions.

πŸ”‘ Required Azure RBAC Roles / Permissions

  • Contributor on the resource group holding the NetApp account, or a custom role covering Microsoft.NetApp/netAppAccounts/snapshotPolicies/*.
  • Read access on the NetApp account, so the policy can be created within it.
  • No data-plane or Key Vault permission is needed here.

⚠️ Note the operational consequence: enabled = false stops snapshots for every volume that references this policy, and there is no per-volume pause. Write access here reaches every workload using it. Scope the role accordingly.

Azure Prerequisites

  • An existing NetApp account, in the same region as this policy and as the volumes that will use it.
  • Headroom in the capacity pools holding those volumes β€” total_snapshots_retained is the upper bound on what this policy can pin per volume.
  • A stated recovery objective. The schedules are deliberately left at null rather than invented, because a requirement should supply them.
  • The caller configures the provider "azurerm" { features {} } block, auth, and subscription.

πŸ“ Module Structure

terraform-azurerm-netapp-snapshot-policy/
β”œβ”€β”€ providers.tf   # required_version >= 1.12.0; azurerm ~> 4.0; no provider block
β”œβ”€β”€ variables.tf   # four schedule blocks with an at-least-one check, enabled, tags/timeouts tail
β”œβ”€β”€ main.tf        # keystone azurerm_netapp_snapshot_policy.this; four dynamic schedule blocks
β”œβ”€β”€ outputs.tf     # id (consumed by ID), enabled, schedule_summary, total_snapshots_retained
β”œβ”€β”€ README.md      # this document
β”œβ”€β”€ SCOPE.md       # cross-module contract
β”œβ”€β”€ LICENSE        # MIT
└── .gitignore     # canonical library ignore set

βš™οΈ Quick Start

provider "azurerm" {
  features {}
}

module "anf_snapshot_policy" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-netapp-snapshot-policy.git?ref=v1.0.0"

  name                = "sp-prod-01"
  resource_group_name = module.anf_rg.name
  account_name        = module.anf_account.name # the NAME, not the id
  location            = module.anf_rg.location

  # At least one schedule is required. All times are UTC.
  daily_schedule = {
    hour              = 2 # 02:00 UTC β€” convert deliberately
    minute            = 0
    snapshots_to_keep = 7
  }

  weekly_schedule = {
    days_of_week      = ["Sunday"]
    hour              = 3
    minute            = 0
    snapshots_to_keep = 4
  }

  # enabled defaults to true.
  tags = { recovery_objective = "7-daily-4-weekly" }
}

πŸ’° That keeps up to 11 snapshots per volume, each pinning pool capacity as the volume diverges. See example 6.

ℹ️ The caller owns the provider, its authentication, and the mandatory features {} block. This module never declares them.


πŸ”Œ Cross-Module Contract

Consumes

Input Type Source module
resource_group_name string terraform-azurerm-resource-group (name)
account_name string terraform-azurerm-netapp-account (name)
location string caller / terraform-azurerm-netapp-account (location)

Emits

Output Description Consumed by
id Snapshot policy Resource ID (first) terraform-azurerm-netapp-volume (data_protection_snapshot_policy.snapshot_policy_id) and both volume-group modules β€” by ID, not by name
name Policy name operational review
account_name / resource_group_name Addressing composition wiring
location The policy's region composition check β€” a volume and its policy must share a region
enabled Whether the policy is taking snapshots operational review β€” a disabled policy is indistinguishable from a working one from the volume's side, and there is no per-volume pause
schedule_summary Which schedules are set and how many each keeps recovery-objective review
total_snapshots_retained Sum of all snapshots_to_keep capacity review β€” the upper bound on pool capacity this policy can pin per volume

πŸ“š Example Library

Values these examples reference but do not create are declared inputs:

variable "subnet_id" {
  description = "subnet id of an existing resource these examples reference."
  type        = string
}

The examples below reference existing resources by ID or name rather than creating them; this module owns only its own resource. Those references are declared inputs:

variable "anf_vnet_subnet_ids" {
  description = "subnet ids of an existing anf vnet that these examples reference but do not create."
  type        = map(string)
}
1 Β· A layered schedule
module "anf_snapshot_policy" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-netapp-snapshot-policy.git?ref=v1.0.0"

  name                = "sp-prod-01"
  resource_group_name = module.anf_rg.name
  account_name        = module.anf_account.name
  location            = module.anf_rg.location

  daily_schedule   = { hour = 2, minute = 0, snapshots_to_keep = 7 }
  weekly_schedule  = { days_of_week = ["Sunday"], hour = 3, minute = 0, snapshots_to_keep = 4 }
  monthly_schedule = { days_of_month = [1], hour = 4, minute = 0, snapshots_to_keep = 6 }
}

πŸ’‘ Layering is the point of having four blocks: a week of dailies for recent mistakes, a month of weeklies, half a year of monthlies β€” 17 snapshots for six months of coverage, rather than 180 dailies.

2 Β· The empty policy the provider accepts
module "bad_policy" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-netapp-snapshot-policy.git?ref=v1.0.0"

  name                = "sp-prod-01"
  resource_group_name = module.anf_rg.name
  account_name        = module.anf_account.name
  location            = module.anf_rg.location
  # ❌ no schedule at all
}
Error: Invalid value for variable

  Set at least one of hourly_schedule, daily_schedule, weekly_schedule, or
  monthly_schedule β€” every schedule block is optional to the provider, so a policy with none
  is accepted, takes no snapshots, and leaves the volumes that reference it unprotected
  while appearing configured.

⚠️ This module's most valuable check. Without it the policy applies, volumes attach it, protection looks configured, and no snapshot is ever taken. Nothing downstream reports the gap.

3 Β· Schedules are UTC β€” there is no time-zone field
daily_schedule = {
  hour              = 2 # 02:00 UTC, NOT local
  minute            = 0
  snapshots_to_keep = 7
}

⚠️ Unlike the batch-configuration resource elsewhere in this library, there is no time_zone argument here at all. A "2 a.m. quiet period" written as hour = 2 runs at 21:00 or 22:00 US Eastern depending on the season β€” and the offset shifts twice a year. Convert to UTC deliberately and record the intent in tags.

4 Β· The blast radius of `enabled = false`
enabled = false # ⚠️ stops snapshots for EVERY volume referencing this policy

⚠️ And there is no per-volume alternative. A backup policy lets a volume opt out with policy_enabled = false in its own block; a snapshot policy has no equivalent. To exempt one volume you must detach the policy from that volume or point it at a different one β€” see example 5.

5 Β· Exempting a single volume
# Volume A keeps the policy:
module "vol_a" {
  data_protection_snapshot_policy = { snapshot_policy_id = module.anf_snapshot_policy.id }
}
# Volume B is exempted by simply not attaching one:
module "vol_b" {
  # data_protection_snapshot_policy omitted
}

πŸ’‘ Detachment is the only per-volume lever. If several volumes need different cadences, create several policies (example 10) rather than toggling one policy's enabled.

6 Β· Retention counts are pool capacity
output "capacity_commitment" {
  value = module.anf_snapshot_policy.total_snapshots_retained
}
# On the pool: leave headroom accordingly.
size_in_tb = 8 # the volumes need 4

πŸ’° A snapshot starts nearly free and grows as the live volume diverges from it, pinning blocks that cannot be reclaimed. total_snapshots_retained is the upper bound on how many this policy holds per volume β€” and the pool, which this module cannot see, is what pays for them.

7 Β· Hourly is the expensive one
hourly_schedule = { minute = 0, snapshots_to_keep = 24 }

⚠️ Twenty-four hourly snapshots on a busy volume is a substantial standing capacity commitment β€” each one pins the blocks that changed since the last. Legitimate for a workload where an hour of loss is unacceptable, and worth pricing against the pool before adopting it as a default.

8 Β· Weekday names are full English words
weekly_schedule = { days_of_week = ["Monday", "Thursday"], hour = 3, minute = 0, snapshots_to_keep = 8 }
days_of_week = ["Mon"] # ❌
Error: weekly_schedule.days_of_week values must be full English weekday names: Monday,
Tuesday, Wednesday, Thursday, Friday, Saturday, Sunday.

ℹ️ No abbreviations and no locale variants. An empty set is rejected too β€” it never fires.

9 Β· A monthly day that does not exist every month
monthly_schedule = { days_of_month = [30], hour = 4, minute = 0, snapshots_to_keep = 12 } # ⚠️
monthly_schedule = { days_of_month = [28], hour = 4, minute = 0, snapshots_to_keep = 12 } # βœ… always fires

⚠️ Day 31 fires in seven months of the year; day 29 or 30 skips February most years. If a monthly snapshot must always be taken, use a day of 28 or lower. The provider accepts only 1-30, so day 31 cannot be scheduled at all; whether the day exists in a given month is not something a check can fix.

10 Β· One policy per cadence
locals {
  policies = {
    "sp-standard" = { daily = 7, weekly = 4 }
    "sp-critical" = { daily = 14, weekly = 8 }
  }
}

module "snapshot_policies" {
  source   = "git::https://github.com/microsoftexpert/terraform-azurerm-netapp-snapshot-policy.git?ref=v1.0.0"
  for_each = local.policies

  name                = each.key
  resource_group_name = module.anf_rg.name
  account_name        = module.anf_account.name
  location            = module.anf_rg.location

  daily_schedule  = { hour = 2, minute = 0, snapshots_to_keep = each.value.daily }
  weekly_schedule = { days_of_week = ["Sunday"], hour = 3, minute = 0, snapshots_to_keep = each.value.weekly }
}

πŸ’‘ Because there is no per-volume pause and no per-volume override, several policies is how you get several cadences. Naming them after the objective rather than the workload keeps that legible, since one policy serves many volumes and cannot tell you which.

11 Β· Attaching a policy to a volume
module "anf_volume" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-netapp-volume.git?ref=v1.0.0"
  account_name                 = "account-example"
  location                     = "eastus2"
  name                         = "anf-volume-example"
  pool_name                    = "pool-example"
  resource_group_name          = "rg-example"
  service_level                = "Premium"
  storage_quota_in_gb          = 100
  subnet_id                    = var.subnet_id
  volume_path                  = "volume-path"

  data_protection_snapshot_policy = {
    snapshot_policy_id = module.anf_snapshot_policy.id # BY ID
  }
}

⚠️ Valid on a primary volume only β€” attaching a snapshot policy to a replication destination raises an error. ℹ️ Note the ID form creates a real Terraform dependency, so ordering is implicit and a rename shows as a plan diff on the volume.

12 Β· Volume groups take the same policy
module "anf_vg_hana" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-netapp-volume-group-sap-hana.git?ref=v1.0.0"
  account_name                 = "account-example"
  application_identifier       = "application-identifier"
  group_description            = "group-description"
  location                     = "eastus2"
  name                         = "anf-vg-hana-example"
  resource_group_name          = "rg-example"
  volume = [
    {
      volume_spec_name = "data"
      # ...
      data_protection_snapshot_policy = { snapshot_policy_id = module.anf_snapshot_policy.id }
    },
    # ...
  ]
}

πŸ’‘ Each volume inside a volume group can attach a snapshot policy individually, so one policy can serve both standalone volumes and volume-group members β€” which is another reason the policy is account-anchored rather than volume-owned.

13 Β· Reviewing the estate
output "snapshot_posture" {
  description = "A false `enabled` means no snapshots at all; a large total is a pool commitment."
  value = {
    for k, m in module.snapshot_policies : k => {
      enabled   = m.enabled
      schedules = m.schedule_summary
      pinned    = m.total_snapshots_retained
    }
  }
}

πŸ’‘ Three failure modes visible in one table: a disabled policy, a thin schedule, and an oversized retention total. All three are emitted deliberately, because none of them is readable from the resource's own attributes.

14 Β· Deleting a policy in use
Error: deleting Snapshot Policy: policy is in use by one or more volumes

⚠️ A policy cannot be deleted while a volume references it β€” which is a helpful guard, and also means a teardown must detach it from every volume first. Note the guard does not extend to renaming: a rename replaces the policy, and volumes pointing at the old ID quietly stop having snapshots taken.

15 Β· πŸ—οΈ End-to-end composition
provider "azurerm" {
  features {}
}

# 1 Β· The resource group.
module "anf_rg" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group.git?ref=v1.0.0"

  name     = "rg-anf-eastus2"
  location = "eastus2"
}

# 2 Β· The NetApp account.
module "anf_account" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-netapp-account.git?ref=v1.0.0"

  name                = "anf-prod-eastus2"
  resource_group_name = module.anf_rg.name
  location            = module.anf_rg.location
}

# 3 Β· The pool β€” sized with headroom BECAUSE the snapshot policy in step 4 pins capacity.
module "anf_pool" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-netapp-pool.git?ref=v1.0.0"

  name                = "pool-prod-01"
  resource_group_name = module.anf_rg.name
  account_name        = module.anf_account.name
  location            = module.anf_rg.location

  service_level = "Premium"
  size_in_tb    = 8 # the volume needs 4; the rest is snapshot divergence
}

# 4 Β· The snapshot policy β€” this module. Layered, UTC, enabled by default.
module "anf_snapshot_policy" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-netapp-snapshot-policy.git?ref=v1.0.0"

  name                = "sp-prod-01"
  resource_group_name = module.anf_rg.name
  account_name        = module.anf_account.name
  location            = module.anf_rg.location

  # All times UTC β€” there is no time-zone field on this resource.
  daily_schedule   = { hour = 2, minute = 0, snapshots_to_keep = 7 }
  weekly_schedule  = { days_of_week = ["Sunday"], hour = 3, minute = 0, snapshots_to_keep = 4 }
  monthly_schedule = { days_of_month = [28], hour = 4, minute = 0, snapshots_to_keep = 6 } # 28, so it always fires

  # enabled defaults to true. Do NOT use it to pause one volume β€” detach instead.

  tags = {
    recovery_objective = "7-daily-4-weekly-6-monthly"
    schedule_timezone  = "UTC (02:00 UTC = 21:00/22:00 US Eastern)"
  }
}

# 5 Β· The volume, attaching the policy BY ID.
module "anf_volume" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-netapp-volume.git?ref=v1.0.0"

  name                = "vol-records"
  resource_group_name = module.anf_rg.name
  account_name        = module.anf_account.name
  pool_name           = module.anf_pool.name
  location            = module.anf_pool.location

  service_level       = module.anf_pool.service_level
  storage_quota_in_gb = 4096
  volume_path         = "records"
  subnet_id           = var.anf_vnet_subnet_ids["anf"]

  export_policy_rule = {
    app_tier = {
      rule_index      = 1
      allowed_clients = ["10.40.2.0/24"]
      protocol        = ["NFSv3"]
      unix_read_write = true
    }
  }

  # Primary volumes only β€” attaching this to a replication destination is an error.
  data_protection_snapshot_policy = {
    snapshot_policy_id = module.anf_snapshot_policy.id
  }
}

# 6 Β· Off-pool retention, because snapshots share the pool's fate.
module "anf_vault" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-netapp-backup-vault.git?ref=v1.0.0"

  name                = "bv-prod-01"
  resource_group_name = module.anf_rg.name
  account_name        = module.anf_account.name
  location            = module.anf_rg.location
}

output "snapshot_posture" {
  description = "Whether snapshots are running, and what they commit the pool to."
  value = {
    enabled = module.anf_snapshot_policy.enabled
    pinned  = module.anf_snapshot_policy.total_snapshots_retained
  }
}

πŸ’‘ Two deliberate choices here. The pool in step 3 is oversized because step 4 pins capacity β€” snapshots are not free and the pool is what pays. And step 4's monthly day is 28 rather than 1 or 31, so it fires in every month; a day of 31 would silently skip five months a year. Step 6 exists because a snapshot shares its pool's fate and is not a backup. Output names on sibling modules are illustrative; match them to the versions you pin.


πŸ“₯ Inputs

Required addressing (all four force-new): name, resource_group_name, account_name, location.

Schedules β€” at least one required: hourly_schedule, daily_schedule, weekly_schedule, monthly_schedule.

Enablement: enabled (defaults true; provider-required with no default of its own).

Universal tail: tags, timeouts.

Full object() schemas
variable "name" {
  # force-new. Volumes reference the policy by RESOURCE ID, so a rename replaces it and any
  # volume still pointing at the old ID stops having snapshots taken.
  type = string
}

variable "resource_group_name" { type = string } # force-new
variable "account_name"        { type = string } # force-new; the NAME, not the Resource ID
variable "location"            { type = string } # force-new; match the account's region

# --- Schedules. ALL FOUR are optional to the provider, so a policy with NONE is accepted and
#     takes nothing. The at-least-one check lives on monthly_schedule, reading the other three
#     one-directionally, because Terraform rejects validations that reference each other.
#     ⚠️ ALL TIMES ARE UTC β€” there is no time-zone field on this resource.
#     Within each block EVERY field is required; there are no partial schedules.

variable "hourly_schedule" {
  # πŸ’° The shortest interval and the most expensive: snapshots pin POOL capacity as the volume
  #    diverges. Validated: minute 0-59, snapshots_to_keep > 0.
  type    = object({ minute = number, snapshots_to_keep = number })
  default = null
}

variable "daily_schedule" {
  # Validated: hour 0-23, minute 0-59, snapshots_to_keep > 0.
  type    = object({ hour = number, minute = number, snapshots_to_keep = number })
  default = null
}

variable "weekly_schedule" {
  # days_of_week takes FULL ENGLISH weekday names ("Monday", not "Mon") and must be non-empty.
  type    = object({ days_of_week = set(string), hour = number, minute = number, snapshots_to_keep = number })
  default = null
}

variable "monthly_schedule" {
  # days_of_month 1-31, non-empty. ⚠️ A day of 29-31 does not fire in every month β€” use 28 or
  # lower if a monthly snapshot must always be taken.
  # THE AT-LEAST-ONE CHECK LIVES HERE.
  type    = object({ days_of_month = set(number), hour = number, minute = number, snapshots_to_keep = number })
  default = null
}

variable "enabled" {
  # Provider-REQUIRED with no default of its own; this module supplies true so the protective
  # value is what you get.
  # ⚠️ false stops snapshots for EVERY volume referencing this policy, and there is NO per-volume
  #    pause (unlike a backup policy's policy_enabled). Detach the policy from a volume instead.
  type    = bool
  default = true
}

variable "tags" {
  # A good place for the recovery objective the schedules implement β€” and for the local-time
  # intent behind the UTC hours, since the resource records neither.
  type    = map(string)
  default = {}
}

variable "timeouts" {
  # Destroying this policy detaches it from EVERY volume in the account and succeeds --
  # including volumes this configuration does not manage.
  type    = object({ create = optional(string), read = optional(string), update = optional(string), delete = optional(string) })
  default = null
}

🧾 Outputs

Output Description Kind
id Passthrough
name The snapshot policy name Passthrough
account_name The NetApp account this policy belongs to Passthrough
resource_group_name The resource group holding the NetApp account Passthrough
location The policy's region, normalized by the provider (an input of "East US" is emitted as "eastus") Passthrough
account_id The Azure Resource ID of the parent NetApp account, derived by trimming the snapshotPolicies segment from this policy's ID Passthrough
arm_resource_type The ARM resource type this module creates Derived
tags The tags applied to the snapshot policy Passthrough
tag_count The number of tag pairs on the policy, against an Azure ceiling of 50 per resource Passthrough
enabled Whether the policy takes snapshots Passthrough
configured_schedules Which of the four schedule cadences are configured, as a sorted list drawn from hourly, daily, weekly and monthly Passthrough
schedule_count How many of the four schedule cadences are configured, 0 to 4 Passthrough
takes_no_snapshots True when this policy is enabled but has no schedule at all, or has schedules that all retain zero snapshots Derived
schedules_keeping_nothing Names of any configured schedules whose snapshots_to_keep is zero Passthrough
total_snapshots_retained The sum of snapshots_to_keep across every configured schedule - the number of snapshots this policy alone holds on each volume that attaches it Passthrough
max_snapshots_per_volume Microsoft's hard ceiling on snapshots held by one volume, counting scheduled and on-demand alike Passthrough
retention_exceeds_volume_snapshot_limit True when this policy's schedules together demand more than the 255 snapshots a single volume can hold Passthrough
snapshots_per_day How many snapshots this policy creates per day on each attached volume, averaging the weekly and monthly cadences over a 7-day week and a 30-day month Passthrough
schedule_summary Derived
monthly_days_that_skip_february Days of the month in the monthly schedule that do not exist in every month - 29 and 30 Derived
schedules_run_in_utc Always true Constant
destroy_detaches_this_policy_from_every_volume Always true, and it is the most consequential fact about this resource Constant
removing_a_schedule_block_forces_a_new_policy Always true, and it is asymmetric in a way the schema does not show Constant
volumes_are_not_re_attached_after_replacement Always true, and it is the reason the two facts above matter together Constant
disabling_stops_snapshots_for_every_attached_volume Always true Constant
cannot_be_attached_to_a_replication_destination Always true Constant
snapshots_consume_parent_pool_capacity Always true Constant
policy_carries_no_recovery_objective Always true, and it is why the tags on this resource are worth using Constant
force_new_arguments The arguments that destroy and recreate this policy rather than updating it Derived
timeout_defaults The provider's built-in timeouts for this resource, which appear nowhere in the schema Derived
effective_timeouts The timeouts actually in force: the caller's values where supplied, the provider's defaults otherwise Derived

No secret is emitted; this resource carries none.

🧠 Architecture Notes

  • This resource looks like the backup policy and differs in the way that matters. A backup policy lets a volume opt out with policy_enabled = false in its own block; a snapshot policy has no per-volume pause at all. So the single enabled boolean here reaches every volume referencing the policy, and the only way to exempt one is to detach it. The module documents that asymmetry explicitly, because a reader who knows the backup policy will reasonably assume symmetry.
  • The at-least-one-schedule check closes a silent gap. All four blocks are optional to the provider, so a policy with none applies cleanly, gets attached by volumes, and takes nothing β€” protection appears configured and does not exist. The check lives on monthly_schedule and reads the other three one-directionally, because Terraform rejects validations that reference each other.
  • enabled is provider-required with no default, which is unusual; this module supplies true so the protective value is what an empty call produces. It is also emitted, since a disabled policy is indistinguishable from a working one when viewed from the volume.
  • Retention counts are capacity, not just recovery. Snapshots live in the volume's parent capacity pool and grow as data diverges, pinning blocks that cannot be reclaimed. total_snapshots_retained is emitted as the upper bound on that commitment per volume β€” a fact no single field expresses, and one the module cannot check because the pool is not an input.
  • There is no time-zone field, so everything is UTC. That is worth stating flatly rather than glossing, because a sibling resource in this library (netapp batch scheduling aside, the Logic Apps batch configuration) does have one, and the inconsistency invites a wrong assumption. The module suggests recording local-time intent in tags, since the resource cannot.
  • A monthly day of 29–31 does not fire every month. The provider accepts only 1-30, so day 31 cannot be scheduled at all; calendar reality is not something a check can repair, so it is documented with the practical advice to use 28 or lower.
  • The policy is account-anchored, and deliberately so. Because each volume β€” including each volume inside a volume group β€” attaches a policy individually, one policy can serve standalone volumes and volume-group members alike. Owning it from a volume would make that impossible.
  • Several cadences means several policies. With no per-volume pause and no per-volume override, that is the only mechanism, which is why the module suggests naming policies after the objective rather than the workload.
  • Deletion is guarded but renaming is not. A policy in use cannot be deleted; a rename replaces it and leaves volumes pointing at an ID that no longer exists, with no error.
  • features {} dependence. The module carries no provider {} block. If it appears not to initialize in isolation, the cause is a missing caller-side provider "azurerm" { features {} }.

🧱 Design Principles

Concern Secure default (empty call) Opt-out (caller must type it)
Policy that takes nothing at-least-one schedule enforced at plan β€” (no opt-out)
Zero-count schedule every snapshots_to_keep validated > 0 β€” (no opt-out)
Snapshots running enabled = true set false (stops every volume using it)
Disabled-policy visibility enabled emitted β€” (no opt-out)
Capacity commitment total_snapshots_retained emitted β€” (no opt-out)
Invented cadence schedules left null β€” the requirement supplies them β€” (structural)
Per-volume exemption documented: detach the policy from the volume use enabled (far wider)
Schedule correctness ranges and weekday names validated at plan β€” (no opt-out)
Time-zone intent documented: UTC only; record local intent in tags leave it unrecorded

πŸš€ Runbook

terraform init -backend=false
terraform validate
terraform fmt -check
  • Pin the module with ?ref=v1.0.0 β€” never a branch.
  • This library is plan-only during authoring; a human runs terraform plan / apply from CI against real credentials.
  • Convert schedule hours to UTC deliberately, and record the local-time intent in tags.
  • Check the parent pools have headroom for total_snapshots_retained per volume before applying.
  • Treat a plan that flips enabled to false as stopping snapshots across every volume using the policy β€” there is no per-volume pause.
  • To retire a policy, detach it from every volume first; deletion is blocked while it is in use.

πŸ§ͺ Testing

  • terraform validate proves the configuration is internally consistent and type-correct against the pinned provider schema, and exercises every validation {} block: the at-least-one-schedule check, the hour and minute ranges on all four schedules, the positive retention counts, full English weekday names, non-empty day sets, and the 1–31 day-of-month range.
  • terraform fmt -check enforces canonical formatting.
  • Neither command calls Azure. Only terraform plan (run by a human, from CI) exercises the ARM API β€” the module ships without any cloud apply.
  • What only apply exercises: whether the named account exists in the stated region.
  • What nothing exercises: whether any volume attaches this policy, whether the parent pools have capacity for the retention totals, and whether the UTC hours correspond to the quiet period someone intended. The first two are why total_snapshots_retained is emitted; the third is why the module asks for the intent in tags.

πŸ’¬ Example Output

Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

Outputs:

id                       = "/subscriptions/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx/resourceGroups/rg-anf-eastus2/providers/Microsoft.NetApp/netAppAccounts/anf-prod-eastus2/snapshotPolicies/sp-prod-01"
name                     = "sp-prod-01"
account_name             = "anf-prod-eastus2"
resource_group_name      = "rg-anf-eastus2"
location                 = "eastus2"
enabled                  = true
schedule_summary         = {
  "daily"   = 7
  "hourly"  = null
  "monthly" = 6
  "weekly"  = 4
}
total_snapshots_retained = 17

πŸ” Troubleshooting

Symptom Cause Fix
Provider configuration not present / features error No caller-side provider "azurerm" { features {} }. Add the provider block with features {} in the root module.
Plan error: Set at least one of hourly_/daily_/weekly_/monthly_ No schedule configured. The provider would have accepted it. Add a schedule against a stated recovery objective.
No snapshots anywhere, configuration looks complete enabled = false. Check the enabled output; re-enable, and detach the policy if only one volume should be exempt.
Snapshots stopped for more volumes than intended The policy's enabled was used to pause one volume. There is no per-volume pause. Re-enable, and detach the policy from the specific volume instead.
Snapshots fire at the wrong local time Schedules are UTC; there is no time-zone field. Convert to UTC; record the local intent in tags.
A monthly snapshot is sometimes missing days_of_month uses 29, 30, or 31. Use 28 or lower.
Plan error: days_of_week values must be full English weekday names An abbreviation such as Mon. Use Monday.
Plan error: must be greater than 0 A snapshots_to_keep of zero. Supply a positive count.
The pool ran out of space Snapshot divergence pinned capacity. Grow the pool, or reduce snapshots_to_keep; check total_snapshots_retained.
A volume stopped getting snapshots after a rename The policy was replaced, so its Resource ID changed. Wire snapshot_policy_id from this module's id output.
Attaching to a volume raises an error The volume is a replication destination; snapshot policies attach to primaries only. Attach it to the primary volume instead.
Delete fails: policy in use A volume still references it. Detach it from every volume first.

πŸ”— Related Docs


πŸ’™ "Infrastructure as Code should be standardized, consistent, and secure."