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.
- 📐 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 withazurerm_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.
If this module saved you time:
- ⭐ Star the repository — it genuinely helps other people find it.
- 💼 Connect on LinkedIn — linkedin.com/in/microsoftexpert
- ☕ Buy me a coffee — buymeacoffee.com/microsoftexpert
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
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.
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
| 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.
| Requirement | Value |
|---|---|
| Terraform | >= 1.12.0 |
hashicorp/azurerm |
~> 4.0 |
| Provider block | None in this module — the caller configures provider "azurerm" { features {} }, including authentication |
- 🔴 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_allocatemust be a POWER OF TWO. A positive integer of at most 128 bits satisfyingn & (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. - 🔴
ExactlyOneOfbinds the two allocation modes — invisible in the binary schema, fired only at plan. - 🟠 The
Updatewrites 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_subnetdraws 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.
| 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.
- The
Microsoft.Networkresource 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.
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
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.
| 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 |
| 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 |
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
}
}🔴
prefixesis empty andhiddenistrue. 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 atterraform plan— that is, only with credentials. This module mirrors it as a validation so the error arrives offline.
ℹ️ The check is carried on
address_prefixesreferencing 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_twois 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 avalidation {}failure also blocksterraform 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_thirdsays 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
Updatewrites 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_subnetcan also draw from an IPAM pool, and treats the same idea differently on two counts.
this resource azurerm_subnetPower-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
truein 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, nevercount. Removing one reservation from the middle of acountlist 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_prefixesis 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_prefixesandcounted_prefixesside by side and the trade is obvious. The first carries["10.10.1.0/24"]; the second is empty, andcounted_hiddenistrue. Both allocations succeeded and both hold real address space.
⚠️ pool_utilisationistrueas well, meaning nothing in this composition can say how much ofpool-corpis now spoken for — not even after two allocations declared in the same file.
💡
resize_replacesis the standing warning: changing the pool'saddress_prefixeswould destroy it and both reservations with it.
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.
}| 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 |
| 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. |
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.
| 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.
terraform init -backend=false
terraform validate
terraform fmt -checkPin the module by tag — ?ref=v1.0.0 — never a branch.
Plan-only: this repository performs no cloud apply. A human applies from CI.
What terraform validate covers, offline and without credentials:
- The name regex, and the anchored
ipam_pool_idcheck at both ends. - The
ExactlyOneOf— both set, and neither set. - Every
address_prefixesentry 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 consolefires root-module variable validations, unliketerraform validateon 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.
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 torequested_address_count = "256"is the whole story. The allocation holds 256 real addresses and Terraform will not tell you which.
| 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 |
azurerm_network_manager_ipam_pool_static_cidr— provider reference- What is Azure Virtual Network Manager? — including centralized IP address management
- Sibling modules:
terraform-azurerm-network-manager-ipam-pool,terraform-azurerm-network-manager,terraform-azurerm-subnet,terraform-azurerm-resource-group - This module's
SCOPE.md— the cross-module contract
💙 "Infrastructure as Code should be standardized, consistent, and secure."