Skip to content

Latest commit

 

History

1 Commit

Folders and files

Repository files navigation

☁️ Azure Network Manager IPAM Pool Static CIDR Terraform Module

An allocation out of an IPAM pool — in one of two modes, only one of which tells you what you got. Targets hashicorp/azurerm ~> 4.0.

Terraform azurerm Module Type Resources Caveat


🧩 Overview

  • 📐 Reserves address space out of a network manager IPAM pool.
  • 🔴 Names what the convenient mode costs: allocate by count and Terraform never tells you which addresses you got.
  • 🧮 Mirrors the rule nobody guesses — the requested count must be a power of two.
  • ⚖️ Mirrors the provider's ExactlyOneOf, which is invisible in the schema and fires only at plan.
  • ⚠️ Contrasts two behaviours with azurerm_subnet, which draws from the same pools under different rules.

💡 Why it matters: the two allocation modes look interchangeable and are not. Naming the prefixes keeps them readable everywhere; asking for a count hands the choice to the service — and the provider then suppresses the diff on the answer, so the addresses are fetched and deliberately not surfaced.


❤️ Support this project

If this module saved you time:


🗺️ Where this fits in the family

flowchart TB
    NM["azurerm_network_manager"]
    POOL["azurerm_network_manager_ipam_pool<br/>address_prefixes is FORCE-NEW"]
    CIDR["azurerm_network_manager_ipam_pool_static_cidr<br/>THIS MODULE"]
    NAMED["address_prefixes<br/>YOU pick, and the result is READABLE"]
    COUNT["number_of_ip_addresses_to_allocate<br/>THE SERVICE picks, and the result is HIDDEN"]
    USED["whatever ends up using the addresses<br/>holds no reference back"]
    SUBNET["azurerm_subnet<br/>same idea, DIFFERENT rules"]

    NM -->|"id to network_manager_id"| POOL
    POOL -->|"id to ipam_pool_id"| CIDR
    NAMED -->|"exactly one of these two"| CIDR
    COUNT -->|"exactly one of these two"| CIDR
    CIDR -.->|"reserves address space for"| USED
    POOL -->|"the same pool can serve"| SUBNET

    style CIDR fill:#0078D4,stroke:#004578,color:#ffffff
    style POOL fill:#004578,stroke:#004578,color:#ffffff
    style COUNT fill:#B71C1C,stroke:#7F0000,color:#ffffff
    style NM fill:#F3F2F1,stroke:#8A8886,color:#201F1E
    style NAMED fill:#F3F2F1,stroke:#8A8886,color:#201F1E
    style USED fill:#F3F2F1,stroke:#8A8886,color:#201F1E
    style SUBNET fill:#F3F2F1,stroke:#8A8886,color:#201F1E
Loading

The red node is the mode to think twice about. Both routes reach this resource; only one of them comes back out in a form anything downstream can use.


🧬 What this module builds

flowchart TB
    IN1["name<br/>REQUIRED, force-new, a real regex"]
    IN2["ipam_pool_id<br/>REQUIRED, force-new"]
    IN3["address_prefixes<br/>you name the CIDRs"]
    IN4["number_of_ip_addresses_to_allocate<br/>a STRING, and a POWER OF TWO"]
    IN5["timeouts<br/>all four keys"]

    GATE["EXACTLY ONE of the two allocation modes<br/>ExactlyOneOf, invisible in the schema"]

    RES["azurerm_network_manager_ipam_pool_static_cidr.this<br/>a real ARM record, ORDINARY delete, no locks"]

    OUT1["id"]
    OUT2["allocates_named_prefixes and allocates_by_count<br/>complements"]
    OUT3["the_allocated_cidr_is_not_readable_from_terraform<br/>TRUE under allocation by count"]
    OUT4["address_prefixes<br/>EMPTY under allocation by count"]
    OUT5["the_requested_count_must_be_a_power_of_two<br/>constant true"]
    OUT6["shrinking_the_requested_count_is_not_refused_here<br/>the subnet resource DOES refuse it"]
    OUT7["destroy_releases_the_addresses_back_to_the_pool<br/>constant true"]

    IN3 --> GATE
    IN4 --> GATE
    GATE --> RES
    IN1 --> RES
    IN2 --> RES
    IN5 --> RES

    RES --> OUT1
    RES --> OUT2
    RES --> OUT3
    RES --> OUT4
    RES --> OUT5
    RES --> OUT6
    RES --> OUT7

    style RES fill:#0078D4,stroke:#004578,color:#ffffff
    style OUT1 fill:#004578,stroke:#004578,color:#ffffff
    style GATE fill:#B71C1C,stroke:#7F0000,color:#ffffff
    style OUT3 fill:#B71C1C,stroke:#7F0000,color:#ffffff
    style IN1 fill:#F3F2F1,stroke:#8A8886,color:#201F1E
    style IN2 fill:#F3F2F1,stroke:#8A8886,color:#201F1E
    style IN3 fill:#F3F2F1,stroke:#8A8886,color:#201F1E
    style IN4 fill:#F3F2F1,stroke:#8A8886,color:#201F1E
    style IN5 fill:#F3F2F1,stroke:#8A8886,color:#201F1E
    style OUT2 fill:#F3F2F1,stroke:#8A8886,color:#201F1E
    style OUT4 fill:#F3F2F1,stroke:#8A8886,color:#201F1E
    style OUT5 fill:#F3F2F1,stroke:#8A8886,color:#201F1E
    style OUT6 fill:#F3F2F1,stroke:#8A8886,color:#201F1E
    style OUT7 fill:#F3F2F1,stroke:#8A8886,color:#201F1E
Loading
Resource Name Cardinality
azurerm_network_manager_ipam_pool_static_cidr this single — a real ARM record under the pool

Only name and ipam_pool_id are force-new. Both allocation fields update in place.


✅ Provider / Versions

Requirement Value
Terraform >= 1.12.0
hashicorp/azurerm ~> 4.0
Provider block None in this module — the caller configures provider "azurerm" { features {} }, including authentication

Schema notes that bite

  • 🔴 Allocating by count hides its result. Attributes() is empty and the provider deliberately suppresses the diff on the allocated range — its own comment explains the API returns a CIDR the pool provisioned. The addresses are fetched and then not surfaced.
  • 🔴 number_of_ip_addresses_to_allocate must be a POWER OF TWO. A positive integer of at most 128 bits satisfying n & (n-1) == 0. "10" is rejected; "8" and "16" pass.
  • 🔴 It is a STRING and is NOT trimmed. "16 " fails, because the provider parses the raw value. But a leading zero is fine — "016" is parsed as 16.
  • 🔴 ExactlyOneOf binds the two allocation modes — invisible in the binary schema, fired only at plan.
  • 🟠 The Update writes an empty value into whichever field is unused, precisely because it cannot rely on a diff that was suppressed. That is why a mode switch works at all.
  • 🟠 The diff suppression reads the raw config so that switching mode stays visible even though ordinary drift does not.
  • ⚠️ azurerm_subnet draws from the same pools under DIFFERENT rules — no power-of-two requirement, and it refuses a shrink where this resource does not.
  • ✅ The delete is NOT forced — no operation options at all — unlike nine other resources on this manager.
  • ✅ A real ARM record. No locks; import guard; all four timeouts.

🔑 Required Azure RBAC Roles / Permissions

Permission Scope Why
Microsoft.Network/networkManagers/ipamPools/staticCidrs/read the pool Refresh, and the create's import check.
Microsoft.Network/networkManagers/ipamPools/staticCidrs/write the pool Create and update.
Microsoft.Network/networkManagers/ipamPools/staticCidrs/delete the record Destroy.

⚠️ Holding write here consumes shared address space. An allocation takes a block out of a pool other teams also draw from — and because the count must be a power of two, the nearest legal value may be double what was wanted.

⚠️ Destroying a reservation releases the addresses back to the pool, where a later allocation may take them. Anything still configured to use them is not updated and does not object.

ℹ️ The provider takes no lock on the pool, so two concurrent allocations by count can both succeed against a pool that had room for one. The failure surfaces at apply.


Azure Prerequisites

  • The Microsoft.Network resource provider registered in the target subscription.
  • An IPAM pool with room. Exhaustion surfaces at apply — neither this module nor the pool can report remaining capacity.
  • For the named-prefix mode: blocks inside the pool's own prefixes, which this module cannot verify because it does not read the pool.

📁 Module Structure

terraform-azurerm-network-manager-ipam-pool-static-cidr/
├── providers.tf    # required_version >= 1.12.0, azurerm ~> 4.0, no provider block
├── variables.tf    # 5 inputs, 9 validations -- ExactlyOneOf and the power-of-two rule
├── main.tf         # one keystone `this`; ID parsing from the END; the two modes
├── outputs.tf      # 40 outputs -- id first, then the modes, then what is hidden
├── README.md       # this file
├── SCOPE.md        # the cross-module contract
├── LICENSE         # MIT
└── .gitignore

⚙️ Quick Start

provider "azurerm" {
  features {}
}

module "platform_reservation" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-network-manager-ipam-pool-static-cidr.git?ref=v1.0.0"

  name         = "cidr-platform"
  ipam_pool_id = var.ipam_pool_id

  address_prefixes = ["10.10.1.0/24"]
}

💡 This is the readable mode. See example 2 before reaching for the other one.


🔌 Cross-Module Contract

Consumes

Input Type Source
name string the caller — a real regex applies
ipam_pool_id string terraform-azurerm-network-manager-ipam-pool → id
address_prefixes list(string) the caller — exactly one of this or the next
number_of_ip_addresses_to_allocate string the caller — a power of two
timeouts object(...) the caller

Emits

Output Description Consumed by
id A real ARM Resource ID reporting
address_prefixes The blocks — empty under allocation by count downstream wiring
the_allocated_cidr_is_not_readable_from_terraform True under allocation by count design review
allocates_named_prefixes / allocates_by_count Which mode — complements review

📚 Example Library

1 · Naming the addresses — the readable mode
module "platform_reservation" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-network-manager-ipam-pool-static-cidr.git?ref=v1.0.0"

  name         = "cidr-platform"
  ipam_pool_id = var.ipam_pool_id

  address_prefixes = ["10.10.1.0/24"]
}

output "reserved" {
  value = module.platform_reservation.address_prefixes
}

✅ In this mode the allocation is fully readable — the prefixes appear in the plan, in state and in this module's outputs. Prefer it whenever anything downstream needs the addresses: a firewall rule, a peering plan, a DNS record, an on-premises route.

ℹ️ Each entry is validated as CIDR, mirroring the provider, and duplicates are refused by this module.

2 · 🔴 Allocating by count hides its result
module "workload_allocation" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-network-manager-ipam-pool-static-cidr.git?ref=v1.0.0"

  name         = "cidr-workload"
  ipam_pool_id = var.ipam_pool_id

  number_of_ip_addresses_to_allocate = "256"
}

output "what_did_we_get" {
  value = {
    prefixes = module.workload_allocation.address_prefixes                             # EMPTY
    hidden   = module.workload_allocation.the_allocated_cidr_is_not_readable_from_terraform
  }
}

🔴 prefixes is empty and hidden is true. The resource exposes no computed attributes at all, and the provider deliberately suppresses the diff on the allocated range — its own comment explains that the API returns a CIDR the pool provisioned, which would otherwise show as a perpetual difference.

⚠️ So Terraform fetches the answer and declines to surface it. There is no output, no attribute and no plan line that tells you which addresses this allocation received. It is not an oversight in this module; it is the shape of the resource.

💡 If anything downstream needs the addresses, use address_prefixes. That is the whole trade: convenience against visibility.

3 · Exactly one mode, and both ways to get it wrong
# REFUSED -- both set.
address_prefixes                   = ["10.10.1.0/24"]
number_of_ip_addresses_to_allocate = "256"
# ALSO REFUSED -- neither set. The allocation would have no size.
# (omit both)

💡 The provider binds these with ExactlyOneOf, which is invisible in the binary schema and fires only at terraform plan — that is, only with credentials. This module mirrors it as a validation so the error arrives offline.

ℹ️ The check is carried on address_prefixes referencing the other field one-directionally, because a validation condition must reference its own variable. The message names both fields and both failure directions.

4 · 🧮 The count must be a power of two
# ACCEPTED
number_of_ip_addresses_to_allocate = "16"
number_of_ip_addresses_to_allocate = "256"
# REFUSED -- not a power of two.
number_of_ip_addresses_to_allocate = "10"
number_of_ip_addresses_to_allocate = "1000"

🔴 The provider requires n & (n-1) == 0 — a positive integer of at most 128 bits that is a power of two. Its own message is "expected ... to be a power of 2". That rule lives in the provider's validation code and not in the schema, so it is mirrored here.

⚠️ The practical consequence is over-allocation. If you want 200 addresses, the nearest legal value is 256 — you take a quarter more of a shared pool than you intended, and nothing says so.

💡 the_requested_count_must_be_a_power_of_two is emitted as a constant so the rule is discoverable from the module rather than only from a failed apply.

5 · Why it is a string, and what that changes
# ACCEPTED -- a leading zero is fine; the provider parses it as an integer.
number_of_ip_addresses_to_allocate = "016"
# REFUSED -- the provider does NOT trim, so whitespace fails.
number_of_ip_addresses_to_allocate = "16 "

💡 It is a string because an IPv6 allocation can request more addresses than a 64-bit integer holds — the provider parses it with arbitrary precision and accepts up to 128 bits.

🔴 This module validates the RAW value, deliberately not trimming it, because trimming first would accept "16 " and let it fail at apply. And it permits leading zeros, because refusing them would reject input the provider accepts — and a validation {} failure also blocks terraform destroy.

⚠️ The power-of-two check has a stated limit. Terraform has no bitwise operators and no arbitrary-precision integers, so the check is exact only up to 2^53. Above that this module enforces the digit and positivity rules and defers to the provider, which is authoritative. the_power_of_two_check_is_exact_only_below_two_to_the_fifty_third says so rather than implying a ceiling that does not exist.

6 · Switching modes
# Before
number_of_ip_addresses_to_allocate = "256"

# After -- an in-place update, and it IS visible in the plan
address_prefixes = ["10.10.1.0/24"]

💡 Neither allocation field is force-new, so a change of mode updates in place.

ℹ️ The provider went to trouble to keep this visible. Its diff suppression reads the raw configuration specifically so that a change of mode still shows, even though ordinary drift on a service-allocated range does not. And its Update writes an empty value into whichever field is now unused — because it cannot rely on a diff it deliberately suppressed.

⚠️ Switching from a count to named prefixes means you are choosing addresses the service previously chose. Since the old ones were never readable, you cannot pick the same block by inspection.

7 · ⚠️ The subnet resource plays by different rules
output "do_not_carry_assumptions_across" {
  value = {
    shrink_ok   = module.workload_allocation.shrinking_the_requested_count_is_not_refused_on_this_resource
    rules_differ = module.workload_allocation.the_subnet_resource_validates_the_same_idea_with_a_different_rule
  }
}

⚠️ azurerm_subnet can also draw from an IPAM pool, and treats the same idea differently on two counts.

this resource azurerm_subnet
Power-of-two required yes no — any positive number
Shrinking the count permitted refused inside the update

🔴 The subnet's shrink refusal produces a clean plan and then an error. This resource has no such check — its update simply re-sends the value. Do not carry either behaviour across.

💡 Where a subnet allocates from a pool, the allocated range is exposed by that resource. This one exposes nothing. Same pool, same concept, opposite visibility.

8 · What this allocation cannot tell you
output "honest_limits" {
  value = {
    remaining = module.workload_allocation.the_module_cannot_see_how_much_of_the_pool_remains
    users     = module.workload_allocation.the_module_cannot_see_what_uses_these_addresses
    family    = module.workload_allocation.the_address_family_is_unknown_when_allocating_by_count
  }
}

🔴 All three are constant true in the count mode. The pool exposes no computed attributes either, so neither resource can report remaining capacity — exhaustion fails at apply, not at plan.

⚠️ A reservation attaches to nothing. Whatever eventually uses these addresses holds no reference back, so this module cannot say whether the reservation is in use or idle.

💡 On a dual-stack pool, even the address family is unknowable under allocation by count: the service chooses which family to carve from, and the result is never returned.

9 · Several allocations from one pool
locals {
  reservations = {
    platform = "10.10.1.0/24"
    data     = "10.10.2.0/24"
    shared   = "10.10.3.0/24"
  }
}

module "reservations" {
  for_each = local.reservations

  source = "git::https://github.com/microsoftexpert/terraform-azurerm-network-manager-ipam-pool-static-cidr.git?ref=v1.0.0"

  name         = "cidr-${each.key}"
  ipam_pool_id = var.ipam_pool_id

  address_prefixes = [each.value]
}

💡 for_each, never count. Removing one reservation from the middle of a count list re-indexes every one after it — and each re-index releases and re-takes address space that something may be using.

⚠️ The provider takes no lock on the pool, so these are written concurrently. With named prefixes that is safe; with allocation by count two concurrent requests can both succeed against a pool that had room for one, and the failure appears at apply.

10 · What NOT to do
# WRONG -- the network manager where the POOL belongs.
ipam_pool_id = var.network_manager_id

ℹ️ Refused. The pattern is anchored at both ends, so the manager above and a static CIDR below are both rejected.

# WRONG -- an address with no mask.
address_prefixes = ["10.10.1.0"]

ℹ️ Refused, mirroring the provider's per-element CIDR check.

# WRONG -- a number, not a string.
number_of_ip_addresses_to_allocate = 256

ℹ️ A type error. The field is a string because the value can exceed a 64-bit integer.

# WRONG -- an empty list is not "no prefixes".
address_prefixes = []

ℹ️ Refused, with the alternative named: omit the argument entirely and set the count instead.

11 · Destroy releases the addresses
output "destroy_behaviour" {
  value = {
    not_forced = module.platform_reservation.destroy_is_not_forced_unlike_the_configuration_resources
    releases   = module.platform_reservation.destroy_releases_the_addresses_back_to_the_pool
    via_pool   = module.platform_reservation.destroying_the_pool_destroys_this_record_too
  }
}

✅ The delete is not forced — the provider passes no operation options at all — unlike nine other resources on this manager, which hard-code the service's force flag.

⚠️ Destroying releases the addresses back to the pool, where a later allocation may take them. Anything still configured to use them is not updated and does not object, so the reservation and the reality can part company silently.

🔴 And the pool's own address_prefixes is force-new, so a resize of the pool destroys and recreates it — taking this record with it.

12 · 🏗️ End-to-end composition
provider "azurerm" {
  features {}
}

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

  name     = "rg-network-eastus"
  location = "eastus"

  tags = {
    environment = "production"
    workload    = "network-governance"
  }
}

module "network_manager" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-network-manager.git?ref=v1.0.0"

  name                = "nm-corp"
  resource_group_name = module.rg.name
  location            = module.rg.location

  scope = {
    subscription_ids = [var.managed_subscription_id]
  }

  scope_accesses = ["Connectivity"]

  tags = module.rg.tags
}

module "corp_pool" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-network-manager-ipam-pool.git?ref=v1.0.0"

  name               = "pool-corp"
  network_manager_id = module.network_manager.id
  location           = module.rg.location

  address_prefixes = ["10.0.0.0/8"]

  description = "Corporate RFC1918 address space"
  tags        = module.rg.tags
}

# Named prefixes -- readable everywhere.
module "platform_reservation" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-network-manager-ipam-pool-static-cidr.git?ref=v1.0.0"

  name         = "cidr-platform"
  ipam_pool_id = module.corp_pool.id

  address_prefixes = ["10.10.1.0/24"]
}

# By count -- convenient, and the result is never surfaced.
module "workload_allocation" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-network-manager-ipam-pool-static-cidr.git?ref=v1.0.0"

  name         = "cidr-workload"
  ipam_pool_id = module.corp_pool.id

  number_of_ip_addresses_to_allocate = "256"
}

output "addressing" {
  value = {
    pool             = module.corp_pool.id
    named_prefixes   = module.platform_reservation.address_prefixes
    named_is_visible = module.platform_reservation.allocates_named_prefixes

    counted_prefixes = module.workload_allocation.address_prefixes
    counted_hidden   = module.workload_allocation.the_allocated_cidr_is_not_readable_from_terraform

    pool_utilisation = module.corp_pool.the_module_cannot_see_what_has_been_allocated_from_this_pool
    resize_replaces  = module.corp_pool.resizing_the_pool_destroys_and_recreates_it
  }
}

🔴 Put named_prefixes and counted_prefixes side by side and the trade is obvious. The first carries ["10.10.1.0/24"]; the second is empty, and counted_hidden is true. Both allocations succeeded and both hold real address space.

⚠️ pool_utilisation is true as well, meaning nothing in this composition can say how much of pool-corp is now spoken for — not even after two allocations declared in the same file.

💡 resize_replaces is the standing warning: changing the pool's address_prefixes would destroy it and both reservations with it.


📥 Inputs

Required: name, ipam_pool_id, and exactly one of address_prefixes / number_of_ip_addresses_to_allocate Optional: timeouts

There is no tags variable — this resource exposes none. Note the parent pool does, unusually for this family, so neither should be inferred from the other.

Full input schemas
variable "name" {
  type = string
  # REQUIRED, FORCE-NEW. ^[a-zA-Z0-9_.-]{1,64}$
}

variable "ipam_pool_id" {
  type = string
  # REQUIRED, FORCE-NEW. Anchored to .../ipamPools/<pool> at BOTH ends.
}

variable "address_prefixes" {
  type    = list(string)
  default = null
  # EXACTLY ONE of this or number_of_ip_addresses_to_allocate.
  # THE READABLE MODE. Each entry validated as CIDR; duplicates refused.
}

variable "number_of_ip_addresses_to_allocate" {
  type    = string
  default = null
  # EXACTLY ONE of this or address_prefixes.
  # A STRING (IPv6 counts exceed int64), a POSITIVE integer, and A POWER OF TWO.
  # NOT trimmed -- "16 " fails. Leading zeros ARE fine -- "016" is 16.
  # THE SERVICE PICKS THE CIDR AND TERRAFORM NEVER SURFACES IT.
}

🧾 Outputs

Output Type Notes
id string A real ARM Resource ID
name / ipam_pool_id / ipam_pool_name string Identity
network_manager_id / network_manager_name string Derived from the pool's ID
resource_group_name / subscription_id string Parsed
allocates_named_prefixes / allocates_by_count bool Complements
the_allocated_cidr_is_not_readable_from_terraform bool True under allocation by count
exactly_one_allocation_mode_must_be_set bool Constant true
switching_allocation_mode_is_deliberately_not_suppressed bool Constant true
the_update_clears_the_other_allocation_field_explicitly bool Constant true
shrinking_the_requested_count_is_not_refused_on_this_resource bool Constant true
the_subnet_resource_validates_the_same_idea_with_a_different_rule bool Constant true
address_prefixes / address_prefix_count list / number Empty / zero under count
requested_address_count string Null when prefixes were named
ipv4_prefixes / ipv6_prefixes / is_dual_stack list / list / bool Named mode only
the_address_family_is_unknown_when_allocating_by_count bool True under count
the_requested_count_must_be_a_power_of_two bool Constant true
the_power_of_two_check_is_exact_only_below_two_to_the_fifty_third bool Constant true
the_module_cannot_see_how_much_of_the_pool_remains bool Constant true
the_module_cannot_see_what_uses_these_addresses bool Constant true
this_allocation_is_live_on_create_rather_than_on_deployment bool Constant true
destroy_is_not_forced_unlike_the_configuration_resources bool Constant true
destroy_releases_the_addresses_back_to_the_pool bool Constant true
destroying_the_pool_destroys_this_record_too bool Constant true
lifecycle_prevent_destroy_is_not_available_to_a_module_caller bool Constant true
this_is_a_real_azure_resource_not_a_composite bool Constant true
the_provider_takes_no_lock_on_the_pool bool Constant true
an_import_guard_exists_on_this_resource bool Constant true
this_resource_supports_no_azure_resource_tags bool Constant true
no_secret_is_accepted_or_emitted_by_this_module bool Constant true
force_new_fields list(string) Two — not the allocation fields
fields_that_can_change_after_creation list(string) Both allocation fields
fields_azure_returns_on_read list(string) Fetched, then suppressed under count

🔴 Service facts the provider carries nothing about

Output Why it matters
a_static_cidr_reserves_space_azure_itself_does_not_consume Its purpose in Microsoft's own framing: on-premises ranges, a Virtual WAN hub, an AVS private cloud. An unused-looking reservation is doing its job.
pools_nest_up_to_seven_layers_and_the_depth_is_invisible_here This resource knows only the pool it draws from, never that pool's ancestry.
the_allocation_figures_exist_in_the_portal_even_though_not_here An allocation that exhausts the pool fails at apply; the remaining space is readable on the pool's Allocations page.
the_service_recycles_a_released_cidr_back_into_the_pool Destroying this frees the space — but does not reserve the same addresses for a re-create unless they were named explicitly.

🧠 Architecture Notes

The two allocation modes look interchangeable and are not. Naming the prefixes keeps them readable in the plan, in state and in this module's outputs. Asking for a count hands the choice to the service — and the resource exposes no computed attributes at all, while the provider deliberately suppresses the diff on the allocated range to avoid a perpetual difference. So the answer is fetched from Azure and then not surfaced. That is the shape of the resource, not a gap in this module, and it is emitted as a flag rather than left for someone to discover.

The provider bound the modes with ExactlyOneOf, which is invisible in the binary schema and fires only at plan. Mirroring it moves the error to terraform validate, offline. The check is carried on address_prefixes referencing the other field one-directionally, because a condition must reference its own variable — and the message names both failure directions, since setting neither is as wrong as setting both.

The count must be a power of two, from n & (n-1) == 0 in the provider's validator. The practical consequence is over-allocation: wanting 200 addresses means taking 256 out of a shared pool, and nothing says so. It is a string because an IPv6 request can exceed a 64-bit integer, and the provider parses it with arbitrary precision up to 128 bits.

That string is validated raw, deliberately. The provider does not trim, so "16 " fails — trimming first would accept it and defer the failure to apply. Leading zeros are permitted, because the value is parsed as an integer and refusing "016" would reject input the provider accepts, which a validation {} failure would also make undestroyable. And the power-of-two mirror is exact only to 2^53, because Terraform has no bitwise operators or big integers — the limit is stated in the module rather than dressed up as a ceiling.

Two behaviours must not be carried over from azurerm_subnet, which draws from the same pools: it applies no power-of-two rule, and it refuses a shrink inside its update. This resource does neither. Same pool, same concept, different rules — read the validator per resource.

A reservation attaches to nothing. Whatever uses the addresses holds no reference back, so this module cannot say whether the reservation is live or idle — and destroying it releases the addresses to be taken by someone else, silently.


🧱 Design Principles

Concern This module's position What the caller must type to change it
The hidden result Emitted as a flag on the count mode use address_prefixes
ExactlyOneOf Mirrored, both failure directions named —
The power-of-two rule Mirrored, with its own limit stated —
Whitespace Validated raw, matching the provider exactly —
Leading zeros Permitted, because the provider accepts them —
The subnet's differing rules Two constants, so nobody carries them across —
Pool utilisation Stated as unknowable —
tags Absent — but the parent pool has them tag the pool

🔒 There is no secure-by-default option here. The real choice is visibility: one mode returns the addresses and one does not, and this module makes that choice legible rather than making it for you.


🚀 Runbook

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

Pin the module by tag — ?ref=v1.0.0 — never a branch.

Plan-only: this repository performs no cloud apply. A human applies from CI.


🧪 Testing

What terraform validate covers, offline and without credentials:

  • The name regex, and the anchored ipam_pool_id check at both ends.
  • The ExactlyOneOf — both set, and neither set.
  • Every address_prefixes entry as CIDR, a non-empty list, and no duplicates.
  • The power-of-two rule, digits-only on the raw value, and positivity.

What only terraform plan exercises (credentials required):

  • That the pool exists, and the import guard.

What only terraform apply reveals:

  • Whether the pool has room.
  • Whether named prefixes fall inside the pool's own space.
  • Whether a value above 2^53 is genuinely a power of two.

What nothing reveals at all:

  • Which addresses an allocation by count received.

ℹ️ terraform console fires root-module variable validations, unlike terraform validate on a calling configuration. It is the honest offline harness for the first list and for every derived flag — including both modes proved as complements, "10" refused as not a power of two, "16 " refused for whitespace, and "016" proved to be ACCEPTED, since refusing it would reject legal input.


💬 Example Output

Outputs:

id                       = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-network-eastus/providers/Microsoft.Network/networkManagers/nm-corp/ipamPools/pool-corp/staticCidrs/cidr-workload"
ipam_pool_name           = "pool-corp"
network_manager_name     = "nm-corp"
allocates_named_prefixes = false
allocates_by_count       = true
address_prefixes         = []
requested_address_count  = "256"
the_allocated_cidr_is_not_readable_from_terraform = true
the_address_family_is_unknown_when_allocating_by_count = true
the_requested_count_must_be_a_power_of_two = true

ℹ️ address_prefixes = [] next to requested_address_count = "256" is the whole story. The allocation holds 256 real addresses and Terraform will not tell you which.


🔍 Troubleshooting

Symptom Cause Fix
must be between 1 and 64 characters Spaces or slashes in the name The pattern is the routing family's
must be an IPAM pool Resource ID The manager above, or a static CIDR below Anchored at both ends
Exactly one of address_prefixes or number_of... Both set, or neither Choose a mode
must not be an empty list address_prefixes = [] Omit it and set the count instead
must be valid CIDR notation An address with no mask 10.10.1.0/24
must not repeat the same block A duplicate prefix Copy-paste error
must be a POWER OF TWO e.g. "10" or "1000" Use 8, 16, 256 …
must contain ONLY digits "16 ", " 16", "-16", "16.0" The provider does not trim
must be a POSITIVE integer "0" Zero is rejected
"016" was accepted — is that right? Yes — the provider parses it as 16 Refusing it would reject legal input
Apply failed for no visible reason The pool is exhausted Utilisation is not readable
Two allocations both succeeded then one failed No lock is taken on the pool Expected under concurrency
I cannot find the allocated addresses Allocation by count never surfaces them Use address_prefixes
A shrink was accepted here but refused elsewhere azurerm_subnet refuses it; this does not Different resources, different rules
The reservation disappeared The pool was resized — its prefixes are force-new Expected

🔗 Related Docs


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