Skip to content

Latest commit

Β 

History

1 Commit

Folders and files

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

Repository files navigation

☁️ Azure NetApp Capacity Pool Terraform Module

Provisions one Azure NetApp Files capacity pool (azurerm_netapp_pool) β€” the billing and performance boundary that volumes carve their quota out of. Targets hashicorp/azurerm ~> 4.0.

Terraform Provider Module Type Resources


🧩 Overview

  • πŸ—„οΈ Provisions one capacity pool β€” where NetApp Files capacity is actually bought β€” as a keystone resource named this.
  • πŸ’° size_in_tb is the billed quantity, not the sum of what the volumes inside consume. An oversized pool costs the same empty as full.
  • πŸ”’ Documents which choices are durable: service_level and encryption_type are force-new, and cool_access_enabled is effectively one-way.
  • βœ… Validates the three enums, the 1–2048 size range, and the Flexible + Manual pairing that custom_throughput_mibps requires β€” all at plan.
  • ⚠️ Explains why the schema's minimum size misleads: one volume on Basic network features raises the pool's floor from 2 TiB to 4 TiB.
  • πŸ“€ Emits the cost and posture facts β€” size_in_tb, service_level, encryption_type, cool_access_enabled β€” so a review reads them from state.

πŸ’‘ Why it matters: Almost nothing about a pool can be changed in place. The service level is force-new, double encryption cannot be retrofitted, and cool access cannot be switched back off without rebuilding the pool and migrating every volume in it. So the useful thing a module can do here is not to offer more knobs β€” it is to be clear about which decisions you are making permanently, and which one you are being billed for.

❀️ 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: ALL force-new"]
  sl["service_level: force-new. Standard, Premium, Ultra, Flexible"]
  size["size_in_tb 1-2048: THE BILLED QUANTITY, updates in place"]
  floor["practical minimum is higher: a Basic-network volume raises the floor 2 to 4 TiB"]
  qos["qos_type Auto or Manual"]
  ct["custom_throughput_mibps: only Flexible plus Manual, min 128"]
  enc["encryption_type Single or Double: force-new, so Double cannot be retrofitted"]
  cool["cool_access_enabled: enabling is in place, DISABLING forces a new pool"]
  this["terraform-azurerm-netapp-pool"]
  pool["azurerm_netapp_pool.this"]
  out["outputs: id, name for volumes, size_in_tb, service_level, encryption_type, cool_access_enabled"]
  vols["volumes carve quota from this pool and reference it BY NAME"]

  addr -->|"identity"| this
  sl -->|"durable performance choice"| this
  size -->|"cost"| this
  floor -->|"why the schema minimum misleads"| size
  qos -->|"throughput distribution"| this
  ct -->|"validated pairing"| qos
  enc -->|"encryption at rest"| this
  cool -->|"one-way door"| this
  this -->|"creates"| pool
  pool -->|"emits"| out
  out -->|"pool_name"| vols

  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 pool keystone;
  class addr,sl,size,floor,qos,ct,enc,cool,out,vols sib;
Loading

Resource inventory

Resource Count Role
azurerm_netapp_pool.this 1 The keystone capacity pool, with its optional timeouts block.

βœ… 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, location, service_level, and encryption_type.
  • cool_access_enabled is asymmetric. Enabling it is an in-place update; disabling it forces a new capacity pool β€” so a pool switched on for an experiment cannot be switched back without a rebuild and a volume migration.
  • size_in_tb is the billed quantity. Billing follows provisioned size, not the sum of volume usage, so an oversized pool costs the same empty as full. It updates in place, so start close to the requirement and grow.
  • The schema accepts size_in_tb = 1, but the practical minimum is higher: a 2 TiB pool requires every volume to use Standard network features, and one volume on Basic raises the floor to 4 TiB. A value the schema allows can still be rejected at apply.
  • The maximum is governed by regional quota, which is a support request rather than a configuration change.
  • custom_throughput_mibps is valid only when service_level is Flexible and qos_type is Manual, with a minimum of 128.
  • On a Manual QoS pool, a volume that sets no throughput_in_mibps gets none β€” throughput is not distributed automatically.
  • A pool cannot be deleted while it holds volumes, so a failing delete is usually reporting a volume the configuration does not know about.
  • encryption_type = "Double" cannot be retrofitted β€” it is force-new, so adding it later means rebuilding the pool and migrating every volume.
  • The account is addressed by name, not by Resource ID β€” the pattern across the pool, volume, snapshot, and backup resources in this family.

πŸ”‘ Required Azure RBAC Roles / Permissions

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

Azure Prerequisites

  • An existing NetApp account, in the same region as this pool.
  • Sufficient regional capacity quota for size_in_tb. The provider accepts a value the subscription's quota does not allow, and the service rejects it at apply.
  • For a 2 TiB pool: every volume in it must use Standard network features.
  • For encryption_type = "Double": a region and service level that support double encryption at rest.
  • The caller configures the provider "azurerm" { features {} } block, auth, and subscription.

πŸ“ Module Structure

terraform-azurerm-netapp-pool/
β”œβ”€β”€ providers.tf   # required_version >= 1.12.0; azurerm ~> 4.0; no provider block
β”œβ”€β”€ variables.tf   # validated enums, size range, the Flexible+Manual pairing, tags/timeouts tail
β”œβ”€β”€ main.tf        # keystone azurerm_netapp_pool.this
β”œβ”€β”€ outputs.tf     # id, name for volumes, cost and posture values
β”œβ”€β”€ README.md      # this document
β”œβ”€β”€ SCOPE.md       # cross-module contract
β”œβ”€β”€ LICENSE        # MIT
└── .gitignore     # canonical library ignore set

βš™οΈ Quick Start

provider "azurerm" {
  features {}
}

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 # the NAME, not the id
  location            = module.anf_rg.location

  service_level = "Premium" # force-new β€” a durable choice
  size_in_tb    = 4         # THE BILLED QUANTITY

  tags = { workload = "records", cost_owner = "platform-storage" }
}

πŸ’° size_in_tb is what you pay for regardless of volume usage. It updates in place, so starting at the requirement and growing is cheaper than provisioning headroom.

ℹ️ 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 Capacity pool Resource ID (first) diagnostics, RBAC
name Pool name terraform-azurerm-netapp-volume (pool_name) and terraform-azurerm-netapp-snapshot (pool_name) β€” both reference the pool by name, not by ID
account_name / resource_group_name / location Addressing composition wiring; a volume must share the pool's region
service_level The pool's performance tier migration planning β€” force-new here, and a volume changing service level must move to a pool of the target level
size_in_tb Provisioned size cost review β€” the billed quantity
qos_type Auto or Manual volume configuration β€” Manual means each volume sets its own throughput
custom_throughput_mibps Pool throughput. Reads back as 0 when unset, never null β€” use has_custom_throughput to branch capacity review
encryption_type Single or Double governance review β€” force-new, so this is the posture for the pool's lifetime
cool_access_enabled Whether cool-access volumes are allowed review β€” effectively one-way

πŸ“š 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
}
1 Β· The minimal pool
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    = 4
}

ℹ️ There is no meaningful "empty call" here β€” service_level and size_in_tb are both required, and both are decisions nobody else can make for you. What you get by default is qos_type = "Auto", encryption_type = "Single", and cool access off.

2 Β· Why the size minimum is not 1
size_in_tb = 2 # requires EVERY volume in the pool to use Standard network features
size_in_tb = 4 # the floor once any volume uses Basic network features

⚠️ The schema accepts 1, and the validation here allows it, because the real constraint lives in the service and depends on the volumes β€” which this module cannot see. If any volume in the pool uses network_features = "Basic", the minimum is 4 TiB. A pool sized 2 with a Basic volume fails at apply, not at plan.

3 Β· Service level is a durable choice
service_level = "Ultra" # force-new

⚠️ Changing this forces a new capacity pool, which means every volume in it has to be migrated first. And migrating a volume between service levels has its own severe consequence β€” the volume's Resource ID changes and its dependants are silently dropped from state (documented on terraform-azurerm-netapp-volume). Pick against a measured throughput requirement, not a guess.

4 Β· Manual QoS, and what it obliges
qos_type = "Manual"
# Then every volume must set its own throughput:
module "anf_volume" {
  # ...
  throughput_in_mibps = 128
}

⚠️ On a Manual pool, throughput is not distributed automatically β€” a volume that sets nothing gets nothing. Manual is the right choice when one volume needs guaranteed throughput regardless of its size, but it makes the sum of assignments something you have to manage against the pool's total.

5 Β· The Flexible tier and custom throughput
service_level           = "Flexible"
qos_type                = "Manual"
custom_throughput_mibps = 256
service_level           = "Premium" # ❌
qos_type                = "Manual"
custom_throughput_mibps = 256
Error: Invalid value for variable

  custom_throughput_mibps is only valid when service_level is "Flexible" AND qos_type is
  "Manual".

πŸ’‘ Flexible exists precisely to decouple throughput from provisioned capacity, so custom_throughput_mibps only means anything in that combination. Both the pairing and the 128 minimum are checked at plan; the provider would report them at apply.

6 Β· Double encryption is a creation-time decision
encryption_type = "Double" # force-new β€” cannot be added later

πŸ”’ Data at rest is encrypted either way; Double adds a second independent layer. This module does not default to it despite the stronger posture, because it is limited by region and service level β€” a default would make the empty call fail where it is unavailable, with no recovery short of rebuilding the pool and migrating its volumes. Where the data class warrants it, set it at creation.

7 Β· Cool access is a one-way door
cool_access_enabled = true # enabling: in-place. DISABLING: forces a new pool.

⚠️ This asymmetry is the trap. Turning it on to try tiering is a cheap in-place update; turning it back off is a pool rebuild and a volume migration. Enable it only when cool access is actually wanted, and note that cool-access volumes are incompatible with large volumes β€” so a pool intended for both cannot serve both.

8 Β· Enum validation at plan
service_level = "premium" # ❌ lowercase
Error: Invalid value for variable

  service_level must be one of: Standard, Premium, Ultra, Flexible.

ℹ️ Capitalized, and note Flexible is a real member β€” it is easy to assume the set is only the three performance tiers. qos_type (Auto / Manual) and encryption_type (Single / Double) are checked the same way.

9 Β· Size range checked, quota not
size_in_tb = 4096 # ❌
Error: Invalid value for variable

  size_in_tb must be between 1 and 2048. Note the service minimum is higher than 1 in
  practice: a 2 TiB pool requires every volume to use Standard network features, and a
  volume on Basic raises the minimum to 4 TiB.

⚠️ The 1–2048 range is checkable; your subscription's regional quota is not. A value inside the range can still be rejected at apply, and raising the ceiling is a support request rather than a configuration change.

10 Β· Growing a pool
size_in_tb = 8 # was 4 β€” updates in place

πŸ’° Growth is in-place and immediate, which is exactly why provisioning headroom "just in case" is the wrong instinct: you pay for the headroom from day one, and you could have added it in seconds when it was needed. Shrinking is also permitted, but only down to the space the volumes actually occupy.

11 Β· Wiring the pool into a volume
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          # BY NAME, from the output
  location            = module.anf_pool.location
  service_level       = module.anf_pool.service_level # inherit the pool's tier
  storage_quota_in_gb          = 1
  subnet_id                    = var.subnet_id
  volume_path                  = "volume-path"
}

πŸ’‘ Volumes reference the pool by name, and that is a plain string with no Terraform dependency β€” so wire it from the output rather than typing it, and a rename becomes a plan diff instead of a failed apply. Taking service_level from the pool's output keeps the two consistent by construction.

12 Β· Several pools from a keyed map
locals {
  pools = {
    "pool-prod-premium"  = { level = "Premium", size = 8 }
    "pool-prod-standard" = { level = "Standard", size = 4 }
  }
}

module "pools" {
  source   = "git::https://github.com/microsoftexpert/terraform-azurerm-netapp-pool.git?ref=v1.0.0"
  for_each = local.pools

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

  service_level = each.value.level
  size_in_tb    = each.value.size
}

πŸ’‘ One pool per service level is the usual arrangement, precisely because service_level is force-new on the pool β€” it is how a volume gets to change tier at all, by moving between pools that already exist.

13 Β· The cost review this module is designed for
output "anf_capacity_cost_basis" {
  description = "Provisioned TiB per pool β€” the billed quantity, regardless of volume usage."
  value = {
    for k, m in module.pools : k => {
      provisioned_tb = m.size_in_tb
      tier           = m.service_level
      encryption     = m.encryption_type
      cool_access    = m.cool_access_enabled
    }
  }
}

πŸ’° size_in_tb and service_level together are the cost basis; encryption_type and cool_access_enabled are the two irreversible choices. All four are emitted so this table can be built from state rather than by reading configuration.

14 Β· Deleting a pool
Error: deleting NetApp Pool: pool contains volumes

⚠️ A pool cannot be deleted while it holds volumes. In practice a failing delete is reporting a volume this configuration does not know about β€” often one created by a volume group, or one left behind by a migration that re-addressed it out of state (see the volume module). Enumerate the pool's volumes before assuming the error is spurious.

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 β€” the regional parent everything else is named against.
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 delegated subnet the volumes will land in.
module "anf_vnet" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-network.git?ref=v1.0.0"

  name                = "vnet-anf-eastus2"
  resource_group_name = module.anf_rg.name
  location            = module.anf_rg.location
  address_space       = ["10.40.0.0/16"]

  subnets = {
    anf = {
      address_prefixes = ["10.40.1.0/24"]
      delegations = [{
        name = "netapp"
        service_delegation = {
          name    = "Microsoft.NetApp/volumes"
          actions = ["Microsoft.Network/networkinterfaces/*", "Microsoft.Network/virtualNetworks/subnets/join/action"]
        }
      }]
    }
  }
}

# 4 Β· The capacity pool β€” this module. Sized at the requirement, not padded.
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" # force-new
  size_in_tb    = 4         # billed; grow in place when needed

  # Left at defaults deliberately: qos_type Auto, encryption_type Single, cool access off.
  # encryption_type and cool_access_enabled are both effectively permanent β€” see examples 6 and 7.

  tags = { workload = "records", cost_owner = "platform-storage" }
}

# 5 Β· A volume drawing quota from the pool, referencing it BY NAME.
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 = 1024
  volume_path         = "records"
  subnet_id           = module.anf_vnet.subnet_ids["anf"]

  # The volume exports to nobody until a rule says otherwise.
  export_policy_rule = {
    app_tier = {
      rule_index      = 1
      allowed_clients = ["10.40.2.0/24"]
      protocol        = ["NFSv3"]
      unix_read_write = true
      # root_access_enabled defaults to false.
    }
  }
}

output "pool_cost_basis" {
  description = "What is actually billed, and the two choices that cannot be undone."
  value = {
    provisioned_tb = module.anf_pool.size_in_tb
    tier           = module.anf_pool.service_level
    encryption     = module.anf_pool.encryption_type
    cool_access    = module.anf_pool.cool_access_enabled
  }
}

πŸ’‘ The ordering in this composition is the family's real dependency chain: the account is named by the pool, the pool is named by the volume, and the delegated subnet in step 3 is what the volume joins. Note step 4 leaves encryption_type and cool_access_enabled at their defaults deliberately β€” both are effectively permanent, so they deserve a decision rather than a drift. Output names on sibling modules are illustrative; match them to the versions you pin.


πŸ“₯ Inputs

Required: name, resource_group_name, account_name, location, service_level, size_in_tb.

Performance: qos_type (defaults Auto), custom_throughput_mibps.

Durable choices: encryption_type (defaults Single), cool_access_enabled (defaults false).

Universal tail: tags, timeouts.

Full object() schemas
variable "name" {
  # force-new. Volumes and snapshots reference the pool BY THIS NAME as a plain string with
  # no Terraform dependency β€” wire callers from the `name` output.
  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

variable "service_level" {
  # Standard | Premium | Ultra | Flexible (validated).
  # ⚠️ FORCE-NEW β€” changing it means migrating every volume in the pool first.
  # Flexible is the only tier where custom_throughput_mibps applies.
  type = string
}

variable "size_in_tb" {
  # 1-2048 (validated). πŸ’° THE BILLED QUANTITY β€” billing follows provisioned size, not volume
  # usage, so an oversized pool costs the same empty as full. Updates in place, so grow rather
  # than pad.
  # ⚠️ The practical minimum is higher than 1: a 2 TiB pool needs EVERY volume on Standard
  #    network features; one volume on Basic raises the floor to 4 TiB. The ceiling is regional
  #    quota, which is a support request.
  type = number
}

variable "qos_type" {
  # Auto (default) distributes throughput by quota | Manual requires each volume to set its own
  # throughput_in_mibps β€” and a volume that sets none gets none. Validated.
  type    = string
  default = "Auto"
}

variable "custom_throughput_mibps" {
  # Min 128. Valid ONLY when service_level = "Flexible" AND qos_type = "Manual" β€” both checks
  # live on this variable and read the other two one-directionally, because Terraform rejects
  # validations that reference each other.
  type    = number
  default = null
}

variable "encryption_type" {
  # Single (default) | Double (validated). ⚠️ FORCE-NEW.
  # NOT defaulted to Double despite the stronger posture: it is region- and service-level
  # limited, so a default would fail the empty call where unavailable β€” with no recovery short
  # of rebuilding the pool and migrating its volumes. Set it at CREATION where warranted.
  type    = string
  default = "Single"
}

variable "cool_access_enabled" {
  # ⚠️ ASYMMETRIC: enabling is in-place, DISABLING forces a new pool. Effectively one-way.
  # Also incompatible with large volumes.
  type    = bool
  default = false
}

variable "tags"     { type = map(string), default = {} }
variable "timeouts" { type = object({ create = optional(string), read = optional(string), update = optional(string), delete = optional(string) }), default = null }

🧾 Outputs

Output Description Kind
id The Azure Resource ID of the capacity pool, of the form /subscriptions/.../resourceGroups/.../providers/Microsoft.NetApp/netAppAccounts//capacityPools/ Passthrough
name The capacity pool name Passthrough
account_name The NetApp account this pool belongs to Passthrough
resource_group_name The resource group holding the NetApp account Passthrough
location The region the pool resides in, 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 capacityPools segment from this pool's ID Passthrough
arm_resource_type The ARM resource type this module creates Derived
tags The tags applied to the capacity pool Passthrough
tag_count The number of tag pairs on the pool, against an Azure ceiling of 50 per resource Passthrough
size_in_tb Provisioned size in TiB Passthrough
size_in_bytes The provisioned size in bytes, the unit the Azure API actually receives - the provider multiplies size_in_tb by 1024 four times before sending it Passthrough
billed_on_provisioned_size_not_consumption Always true, and it is the single most expensive fact about this resource Constant
pools_and_volumes_never_auto_grow Always true Constant
can_be_shrunk_down_to_allocated_volume_capacity Always true, and it is the reason over-provisioning is recoverable rather than permanent Constant
minimum_size_tib_all_volumes_standard_network_features The smallest pool Azure will accept when EVERY volume in it uses Standard network features Passthrough
minimum_size_tib_if_any_volume_uses_basic_network_features The smallest pool Azure will accept once ANY volume in it uses Basic network features - four times the Standard floor Passthrough
max_size_tib The largest single capacity pool Azure NetApp Files supports Passthrough
regional_capacity_quota_tib The default regional capacity quota per subscription, in TiB, from the Azure NetApp Files resource-limits table Passthrough
max_volumes_per_pool The Azure default ceiling on volumes inside one capacity pool, adjustable by support request Passthrough
max_pools_per_account The Azure default ceiling on capacity pools under one NetApp account, adjustable by support request Passthrough
service_level The pool's performance tier Passthrough
qos_type How throughput is assigned to volumes: Auto distributes it in proportion to each volume's quota, Manual requires each volume to set its own throughput_in_mibps and leaves an unassigned volume with none Passthrough
custom_throughput_mibps The pool's custom throughput in MiB/s Passthrough
has_custom_throughput Whether a custom throughput value is actually in force on this pool Derived
max_custom_throughput_mibps The largest custom throughput this pool could carry at its current size - Microsoft's documented maximum of 5 x 128 MiB/s/TiB x the pool size in TiB Passthrough
throughput_ceiling_mibps The total throughput available to be shared across this pool's volumes, computed from the documented per-TiB allocation of the chosen service level: 16 MiB/s per TiB on Standard, 64 on Premium, 128 on Ultra Passthrough
flexible_throughput_decrease_requires_cooldown_hours How long a Flexible pool must wait before its throughput can be REDUCED, in hours Passthrough
encryption_type Encryption at rest: Single or Double Passthrough
double_encryption_requested_on_flexible_pool True when encryption_type is Double and service_level is Flexible - a combination Microsoft documents as unsupported, only single encryption being available on the Flexible tier Passthrough
cool_access_enabled Whether the pool may hold cool-access volumes Passthrough
cool_access_cannot_be_disabled Always true, and the asymmetry is invisible in the schema, where cool_access_enabled looks like an ordinary updatable boolean Constant
force_new_arguments The arguments that destroy and recreate this pool rather than updating it Derived
service_level_change_requires_moving_the_volumes Always true Constant
flexible_service_level_cannot_be_converted Always true, and it is stricter than the general force-new rule Constant
destroy_fails_while_volumes_remain Always true Constant
volumes_reference_this_pool_by_name Always true, and it is why the name output above matters more than it looks Constant
cannot_be_moved_between_accounts Always true Constant
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

  • The pool is where capacity is bought, and that frames everything else. size_in_tb is billed on provisioned size rather than volume consumption, so headroom is a standing cost rather than prudence β€” and since it grows in place in seconds, there is no operational reason to pad it. size_in_tb and service_level are emitted together because they are the cost basis.
  • Three choices here are effectively permanent, and the module's job is to say which. service_level and encryption_type are force-new; cool_access_enabled is force-new in one direction only. That asymmetry is the subtlest of the three: enabling cool access looks like an ordinary toggle and reads as reversible, but reversing it costs a pool rebuild and a volume migration.
  • encryption_type defaults to Single, and that is a deliberate departure from defaulting to the hardened value. Double encryption is limited by region and service level, so defaulting it would make the empty call fail where it is unavailable β€” and because it is force-new, the wrong choice cannot be corrected without rebuilding the pool and migrating its volumes. A default that sometimes fails irreversibly is worse than a documented decision.
  • The schema's minimum size misleads, and the reason is that the constraint lives elsewhere. The real floor depends on the network features of the volumes in the pool, which this module cannot see: 2 TiB requires every volume on Standard, and one volume on Basic raises it to 4 TiB. The validation allows what the schema allows and the documentation carries the rest, because a stricter check would reject legitimate configurations.
  • The account and this pool are addressed by name; volumes address this pool by name too. Those are plain strings with no Terraform dependency, which is why name is emitted with an explicit note β€” wiring from the output is what makes a rename a plan diff instead of a failed apply.
  • Manual QoS shifts an obligation onto every volume. Throughput stops being distributed automatically, so a volume that sets no throughput_in_mibps gets none. qos_type is emitted so a composition can tell whether that obligation is in force.
  • One pool per service level is the normal arrangement, and it follows from service_level being force-new: moving a volume between tiers means moving it to a pool that already exists at the target tier.
  • A pool cannot be deleted while it holds volumes, and the most confusing case is a volume the configuration has lost track of β€” including one dropped from state by a volume migration (see terraform-azurerm-netapp-volume).
  • 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)
Encryption at rest always on; encryption_type = "Single" "Double" β€” at creation only, region/tier permitting
Cool access cool_access_enabled = false set true (effectively one-way)
Throughput model qos_type = "Auto" β€” no per-volume obligation "Manual" (each volume must then set its own)
Cost visibility size_in_tb and service_level emitted β€” (no opt-out)
Permanence visibility encryption_type and cool_access_enabled emitted β€” (no opt-out)
Enum and range correctness three enums, the size range, and the Flexible+Manual pairing validated at plan β€” (no opt-out)
Caller wiring name emitted so a rename is a plan diff type the pool name into volumes as a string

πŸš€ 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.
  • Before applying: confirm regional quota covers size_in_tb, and decide encryption_type and cool_access_enabled now β€” both are effectively permanent.
  • Treat a service_level change as a planned migration of every volume in the pool, not an edit.
  • Before destroying: enumerate the pool's volumes. A pool holding volumes cannot be deleted.

πŸ§ͺ Testing

  • terraform validate proves the configuration is internally consistent and type-correct against the pinned provider schema, and exercises every validation {} block: the service_level, qos_type, and encryption_type enums; the 1–2048 size_in_tb range; the 128 minimum on custom_throughput_mibps; and its Flexible + Manual pairing.
  • 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 regional quota covers size_in_tb, whether the region and tier support encryption_type = "Double", and whether the account exists in the named region.
  • What nothing exercises: whether size_in_tb clears the real minimum, since that depends on the network features of volumes this module cannot see β€” a 2 TiB pool with one Basic volume fails at apply.

πŸ’¬ 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/capacityPools/pool-prod-01"
name                    = "pool-prod-01"
account_name            = "anf-prod-eastus2"
resource_group_name     = "rg-anf-eastus2"
location                = "eastus2"
service_level           = "Premium"
size_in_tb              = 4
qos_type                = "Auto"
custom_throughput_mibps = 0
encryption_type         = "Single"
cool_access_enabled     = false

πŸ” 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: service_level must be one of Wrong casing, or a value outside the set. Use Standard, Premium, Ultra, or Flexible.
Plan error: custom_throughput_mibps is only valid when... Set without Flexible + Manual. Use that pairing, or drop the field.
Apply fails: insufficient quota Regional capacity quota is below size_in_tb. Request more quota; it is not a configuration change.
Apply fails: pool size below minimum A volume uses Basic network features, raising the floor to 4 TiB. Raise size_in_tb, or move the volumes to Standard.
Apply fails: double encryption unsupported The region or service level does not offer it. Use Single, or choose a supporting region β€” and note it cannot be changed later.
Volumes get no throughput qos_type = "Manual" and the volumes set no throughput_in_mibps. Assign throughput per volume, or switch the pool to Auto.
Cool access cannot be turned off Disabling cool_access_enabled is force-new. Expected β€” reversal is a pool rebuild plus a volume migration.
Plan wants to replace the pool after a small edit service_level, encryption_type, and the addressing fields are all force-new. Expected; migrate volumes first.
Delete fails: pool contains volumes A volume exists that this configuration may not know about. Enumerate and remove the volumes β€” including any dropped from state by a migration.
A volume cannot find its pool The pool name was typed rather than wired, and has since changed. Wire pool_name from this module's name output.

πŸ”— Related Docs


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