Creates an
azurerm_dev_test_virtual_network— the lab's own wrapper around a network, and the resource whoseidand predicted subnet name every DevTest VM module needs. Targetshashicorp/azurerm ~> 4.0.
- 🌐 Creates the lab's virtual network record — the resource a DevTest VM module's
lab_virtual_network_idactually wants. - 🔮 Emits the subnet name at plan time, reconstructed from the same rule the provider uses, where the resource's own
subnet[0].nameis computed and therefore unknown. - 🚪 Carries the subnet's two permission enums and the ports opened on the lab's shared public IP address.
- 🧭 Names what this resource cannot do: choose the subnet name, attach a network you already own, or manage the
Microsoft.Networknetwork Azure creates behind it.
💡 Why it matters: two facts make this module worth more than a thin wrapper. The lab subnet's name is derived from this network's name and cannot be set — so the VM modules'
lab_subnet_nameis a function of a field on this resource, and the reconstructed value here is usable in a plan where the real one is not. And ARM requires three fields insideshared_public_ip_addressthat the provider marks optional and transmits anyway, so an entry with neither field set sends port0and an empty protocol.
If this module saved you time:
- ⭐ Star the repository — it helps others find it.
- 💼 Connect on LinkedIn — linkedin.com/in/microsoftexpert
- ☕ Buy me a coffee — buymeacoffee.com/microsoftexpert
flowchart TB
RG["terraform-azurerm-resource-group"]
LAB["terraform-azurerm-dev-test-lab"]
VNET["terraform-azurerm-dev-test-virtual-network"]
POLICY["terraform-azurerm-dev-test-policy"]
LINUX["terraform-azurerm-dev-test-linux-virtual-machine"]
WIN["terraform-azurerm-dev-test-windows-virtual-machine"]
SCHED["terraform-azurerm-dev-test-schedule"]
GLOBAL["terraform-azurerm-dev-test-global-vm-shutdown-schedule"]
MSNET["the Microsoft.Network virtual network DevTest Labs creates, which Terraform never sees"]
RG -->|"resource_group_name and location"| LAB
LAB -->|"lab_name, NOT id"| VNET
LAB -->|"lab_name, NOT id"| POLICY
LAB -->|"lab_name, NOT id"| LINUX
LAB -->|"lab_name, NOT id"| WIN
LAB -->|"lab_name, NOT id"| SCHED
VNET -->|"id plus the PREDICTED subnet name, since the real one is computed"| LINUX
VNET -->|"id plus the PREDICTED subnet name"| WIN
VNET -.->|"created implicitly, unmanaged and untagged"| MSNET
POLICY -->|"caps how many machines a user may create, and which sizes and images"| LINUX
POLICY -->|"caps how many machines a user may create"| WIN
SCHED -->|"shuts these down on a timetable, naming none of them"| LINUX
SCHED -->|"shuts these down on a timetable, naming none of them"| WIN
GLOBAL -->|"the alternative when a machine is not in a lab"| WIN
classDef me fill:#0078D4,stroke:#004578,color:#ffffff
classDef keystone fill:#004578,stroke:#002438,color:#ffffff
classDef sibling fill:#F3F6F9,stroke:#8A9BA8,color:#1B1F23
class VNET,POLICY me
class LAB keystone
class RG,LINUX,WIN,SCHED,GLOBAL,MSNET sibling
This diagram is shared with terraform-azurerm-dev-test-policy, authored in the same batch, and it draws the whole dev_test family — which is now one resource short of complete. This README's node is the virtual network. Note the dashed edge: the Microsoft.Network virtual network DevTest Labs creates behind this record is not managed here and has no Terraform state entry at all.
flowchart TB
IN_ID["name plus lab_name plus resource_group_name: which lab, and what to call the network. All three force-new"]
IN_DESC["description: free text, and one of only three fields that update in place"]
IN_PERM["subnet permissions: use_in_virtual_machine_creation and use_public_ip_address, each Allow, Default or Deny"]
IN_PORTS["shared_public_ip_address.allowed_ports: backend ports exposed on the lab's shared address"]
THIS["azurerm_dev_test_virtual_network.this"]
OUT_ID["id: exactly what a DevTest virtual machine module wants for lab_virtual_network_id"]
OUT_PRED["expected_subnet_name_derived_from_the_network_name: the PLAN-TIME subnet name, where the resource's own subnet name is computed"]
OUT_POSTURE["posture flags: virtual_machines_may_take_their_own_public_ip_address, subnet_accepts_no_virtual_machines, and what the shared address exposes"]
OUT_CONST["constants: the subnet name cannot be chosen, an existing network cannot be attached, and the underlying network is unmanaged"]
IN_ID --> THIS
IN_DESC --> THIS
IN_PERM -->|"always sent, because the block is Optional plus Computed"| THIS
IN_PORTS -->|"ARM requires all three fields the provider marks optional"| THIS
THIS --> OUT_ID
THIS --> OUT_PRED
THIS --> OUT_POSTURE
THIS --> OUT_CONST
classDef me fill:#0078D4,stroke:#004578,color:#ffffff
classDef keystone fill:#004578,stroke:#002438,color:#ffffff
classDef sibling fill:#F3F6F9,stroke:#8A9BA8,color:#1B1F23
class THIS keystone
class OUT_PRED,OUT_POSTURE,OUT_CONST me
class IN_ID,IN_DESC,IN_PERM,IN_PORTS,OUT_ID sibling
Resource inventory: one azurerm_dev_test_virtual_network named this. A single-object subnet rendered unconditionally, a nested optional shared_public_ip_address holding a required list of allowed_ports, and the universal tags + timeouts tail. There is no location — the network inherits the lab's region.
| Item | Value |
|---|---|
| Terraform | >= 1.12.0 |
hashicorp/azurerm |
~> 4.0 (pinned; v5.0 deliberately excluded) |
| Provider block | None here — the caller configures provider "azurerm" { features {} }, auth and subscription |
| Module type | standalone (one keystone this, no children) |
Schema notes that bite — each confirmed against the provider's source at the pinned version. Both documentation pages for this resource carry zero ~> / !> / -> note blocks, so none of this came from them:
- Force-new:
name,lab_name,resource_group_name. Updatable in place:description,subnet,tags— that is the complete list. There is nolocationargument at all. - The subnet's name cannot be chosen. The provider's expand always derives
<network name>Subnet— its own comment reads "default found from the Portal" — and the schema marks the fieldComputed. So renaming this network is force-new and changes thelab_subnet_nameevery VM module must pass. - The
subnetblock isOptional + Computed, so leaving it unset takes whatever Azure already holds. Worse, the expand fabricates a subnet override with both permissions set toAllowwhen the block is absent. This module always sends it. - ARM requires three fields the provider marks optional. Inside
shared_public_ip_address, ARM documentsallowedPortsas required and bothbackendPortandtransportProtocolas required per entry. The provider marks all three Optional and its expand sends the two inner fields unconditionally, so an entry with neither set transmits port0and an empty protocol. backend_porthas no validator here at all — the identical field on the DevTest VM resources'inbound_nat_rulegetsvalidate.PortNumber.- A create performs two writes. The create function calls
CreateOrUpdateand then returns the update function, which does its own GET plus a secondCreateOrUpdate. - Update starts from a GET, patching only
description,subnetandtags. Every other property is written back as Azure returned it, so an out-of-band change to anything unmanaged survives every future apply. - ARM's
externalProviderResourceIdhas no argument, so an existing virtual network cannot be attached through this resource. ARM'sallowedSubnetsandvirtualNetworkPoolNameare absent too. resource_group_namecase differences produce no diff (Azure/azure-rest-api-specsissue 3964); the helper supplies Required, ForceNew and the suppression at once.nameandlab_nameuse anchored^[A-Za-z0-9_-]+$regexes with accurate messages, so both are credited rather than restated.
| Principal | Scope | Requirement |
|---|---|---|
| The principal running Terraform | The lab or its resource group | Microsoft.DevTestLab/labs/virtualnetworks/* — DevTest Labs Owner, Contributor or Owner |
| The same principal | The resource group | Rights to create a Microsoft.Network/virtualNetworks resource, because DevTest Labs creates one on your behalf |
⚠️ The second row is the one that surprises people. This resource creates an ordinary virtual network as a side effect, in the same resource group, and Terraform never sees it. A principal withMicrosoft.DevTestLab/*and nothing else can fail here for a permission on a resource type that appears nowhere in the configuration — and a naming-convention policy onMicrosoft.Network/virtualNetworkscan block it for a reason naming neither.
Plan access is not credential access — this module reads and holds no secret, and nothing it emits is sensitive.
Microsoft.DevTestLabregistered in the subscription.- An existing DevTest Lab, in the subscription the caller's provider targets.
- Room in the resource group for a virtual network DevTest Labs creates implicitly — including any address-space or policy constraints that apply to it.
- A decision about the subnet's permissions before the first apply.
subnetis not force-new, so both permissions can be changed later — but the machines created in between will already have been placed.
terraform-azurerm-dev-test-virtual-network/
├── providers.tf # required_version + pinned azurerm; no provider block
├── variables.tf # 7 deeply-typed inputs, 12 validations
├── main.tf # 18 locals + the keystone azurerm_dev_test_virtual_network.this
├── outputs.tf # 35 outputs: 10 passthrough, 16 derived, 9 constant
├── README.md # this file
├── SCOPE.md # the cross-module contract
├── LICENSE # MIT
└── .gitignore
module "lab_net" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-dev-test-virtual-network.git?ref=v1.0.0"
name = "lab-net"
lab_name = "lab-platform"
resource_group_name = "rg-labs"
description = "Platform lab network."
}ℹ️ The caller configures the provider, its authentication and the mandatory
features {}block. The subnet block is sent even when omitted, with the provider's ownAllowdefaults for both permissions.
Consumes
| Input | Type | Source |
|---|---|---|
lab_name |
string |
terraform-azurerm-dev-test-lab output name |
resource_group_name |
string |
terraform-azurerm-resource-group output name |
name |
string |
caller — and it decides the subnet's name |
Emits
| Output | Consumed by |
|---|---|
id |
a DevTest VM module's lab_virtual_network_id |
expected_subnet_name_derived_from_the_network_name |
a DevTest VM module's lab_subnet_name — at plan time |
virtual_machines_may_take_their_own_public_ip_address |
governance check blocks |
subnet_accepts_no_virtual_machines |
governance check blocks |
| the nine constants | documentation, check blocks |
1 · The smallest real call
module "lab_net" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-dev-test-virtual-network.git?ref=v1.0.0"
name = "lab-net"
lab_name = "lab-platform"
resource_group_name = "rg-labs"
}💡 This creates the lab network record, and Azure creates an ordinary
Microsoft.Networkvirtual network behind it. Both subnet permissions take the provider'sAllowdefault, and the lab subnet is namedlab-netSubnet.
2 · The subnet name is a function of this network's name
name = "lab-net" # => the lab subnet is named "lab-netSubnet"
⚠️ You cannot choose the subnet name. The provider derives<name>Subnetin its expand, ignores any configured value, and marks the fieldComputedso there is no argument to set. Two consequences: renaming this network is force-new and silently changes thelab_subnet_nameevery VM module passes, and the two are one decision rather than two.💡
module.lab_net.expected_subnet_name_derived_from_the_network_namegives you that name at plan time. The resource's ownsubnet_nameoutput is computed, so it reads(known after apply)and cannot drive afor_each.
3 · Wiring a virtual machine module — the plan-time way
module "linux_box" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-dev-test-linux-virtual-machine.git?ref=v1.0.0"
name = "vm-dev-01"
lab_name = module.lab.name
resource_group_name = module.labs_rg.name
location = module.labs_rg.location
size = "Standard_DS2_v2"
username = "devuser"
storage_type = "Premium"
ssh_key = file("~/.ssh/id_rsa.pub")
lab_virtual_network_id = module.lab_net.id
lab_subnet_name = module.lab_net.expected_subnet_name_derived_from_the_network_name
gallery_image_reference = {
publisher = "Canonical"
offer = "0001-com-ubuntu-server-jammy"
sku = "22_04-lts"
version = "latest"
}
}💡
expected_subnet_name_derived_from_the_network_name, notsubnet_name. The predicted value is known during the plan; the computed one is not. That difference matters the moment a caller wants tofor_eachseveral machines over subnets, or assert the name in acheckblock on a first run.
4 · Closing public IP creation at the subnet
subnet = {
use_in_virtual_machine_creation = "Allow"
use_public_ip_address = "Deny" # the provider defaults to Allow
}🔒
Denystops machines in this subnet taking their own public IP address. It does not disable the lab's shared public IP, which is configured separately throughshared_public_ip_address— so machines remain reachable through the shared address and its NAT rules.ℹ️ This module keeps the provider's
Allowdefault rather than flipping it. See Design Principles for why: unlike the two flips in the DevTest VM modules, Microsoft publishes no portal default for this setting, so the closed value has no platform authority behind it.
5 · A subnet that refuses virtual machines
subnet = {
use_in_virtual_machine_creation = "Deny"
}
⚠️ Legal, and almost never what a lab network is for. The record applies cleanly and reports healthy; every attempt to place a machine in it then fails, with an error naming the subnet rather than this permission.module.lab_net.subnet_accepts_no_virtual_machinesexists so a reviewer sees it before the VMs do.
6 · The `Default` sentinel defers the decision
subnet = {
use_in_virtual_machine_creation = "Default"
use_public_ip_address = "Default"
}ℹ️
Defaultis the third legal value on both permissions, and it means "use the lab's own setting" rather than stating one. This module cannot see the lab's value, so it cannot report the effective permission — only that the decision was deferred, througha_permission_is_deferred_to_the_lab_default. A reviewer reading the configuration alone cannot tell what is permitted either.
7 · Opening ports on the lab's shared public IP
subnet = {
use_in_virtual_machine_creation = "Allow"
use_public_ip_address = "Deny"
shared_public_ip_address = {
allowed_ports = [
{ backend_port = 22, transport_protocol = "Tcp" },
{ backend_port = 3389, transport_protocol = "Tcp" },
]
}
}🔒 These are the ports on the machines. Azure chooses the external port on the shared address, so a firewall rule naming a fixed public port cannot be written in advance.
⚠️ Both fields in every entry are required by this module, because ARM requires them and the provider does not — see example 8.
8 · What the provider would have let through
# Provider-legal, ARM-invalid, and unrepresentable in this module:
# allowed_ports = [{}]
# The expand sends backend_port and transport_protocol unconditionally, so this
# transmits port 0 with an empty protocol.
⚠️ ARM documentsallowedPortsas required inside the block, andbackendPortandtransportProtocolas required within each entry. The provider marks all threeOptional. This module makes them required in the type, which makes the illegal state unrepresentable rather than rejected after the fact — and it rejects an emptyallowed_portslist for the same reason.ℹ️ It also range-checks
backend_port, which the provider does not: the identical field on the DevTest VM resources getsvalidate.PortNumber, and this one gets nothing.
9 · The underlying network is not yours to manage
# There is no argument for any of these, on this resource:
# address_space, dns_servers, subnet prefixes, network security groups, peering
⚠️ DevTest Labs creates aMicrosoft.Network/virtualNetworksresource behind this record and Terraform never sees it — no state entry, no tags from this module, no plan line. ARM exposesexternalProviderResourceIdto point a lab network at a network you already own; the Terraform resource has no argument for it, so bringing your own network is not expressible here.ℹ️ The provider also fabricates the subnet override's ID from this network's own name and resource group, so it assumes an ordinary virtual network of the same name in the same place.
expected_network_subnet_path_without_the_subscriptionshows the path it builds, minus the subscription a module cannot read.
10 · A create issues two writes
create → CreateOrUpdate → (returns the update function) → GET → CreateOrUpdate
ℹ️ The create path calls
CreateOrUpdateand then returns the update function, which performs its own GET and a secondCreateOrUpdate. So creating this resource consumes the create timeout and then the update one, and nothing in the plan or the schema shows it. Raise both if lab provisioning is slow in your region.
11 · Unmanaged properties survive every apply
# The update path patches only these three where HasChange fires:
description = "..."
subnet = { ... }
tags = { ... }
⚠️ Update starts from a GET of the existing model, so every property this module does not manage is written back exactly as Azure returned it. An out-of-band change to anything else therefore persists indefinitely, with no diff and no error.update_starts_from_a_get_so_unmanaged_fields_persiststates it, because the schedule sibling in this family behaves the opposite way — its update builds a fresh payload.
12 · `for_each` over several lab networks
locals {
lab_networks = {
build = { description = "Build agents." }
test = { description = "Integration test machines." }
}
}
module "lab_nets" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-dev-test-virtual-network.git?ref=v1.0.0"
for_each = local.lab_networks
name = "lab-net-${each.key}"
lab_name = "lab-platform"
resource_group_name = "rg-labs"
description = each.value.description
subnet = {
use_in_virtual_machine_creation = "Allow"
use_public_ip_address = "Deny"
}
tags = { purpose = each.key }
}
output "subnet_names" {
description = "Each network's predicted subnet name -- known at plan time."
value = { for k, m in module.lab_nets : k => m.expected_subnet_name_derived_from_the_network_name }
}💡 The predicted subnet names are usable here; the computed ones are not. A
for_eachkeyed on a computed value fails with "Invalid for_each argument", which is exactly the shape this output exists to avoid.
13 · Guarding the posture with `check` blocks
check "the_subnet_accepts_virtual_machines" {
assert {
condition = module.lab_net.virtual_machines_can_be_created_in_this_subnet
error_message = "use_in_virtual_machine_creation is not Allow, so no machine can be placed in this subnet."
}
}
check "no_dedicated_public_ips_in_the_lab_subnet" {
assert {
condition = module.lab_net.dedicated_public_ip_addresses_are_denied
error_message = "Machines in this subnet may take their own public IP address. Use the lab's shared address unless a dedicated one is required."
}
}
check "the_permissions_are_stated_not_deferred" {
assert {
condition = !module.lab_net.a_permission_is_deferred_to_the_lab_default
error_message = "A subnet permission is set to Default, so the effective value depends on the lab and is not visible in this configuration."
}
}
check "rdp_is_not_exposed_on_the_shared_address" {
assert {
condition = !module.lab_net.shared_public_ip_exposes_rdp
error_message = "Port 3389 is exposed through the lab's shared public IP address, which is internet-facing."
}
}🔒 The second and fourth are the exposure assertions; the third is subtler and worth keeping — a
Defaultpermission means neither a reviewer nor this module can say what is permitted.
14 · 🏗️ End-to-end composition
locals {
platform_tags = {
env = "dev"
owner = "platform-engineering"
}
}
module "labs_rg" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group.git?ref=v1.0.0"
name = "rg-devtest-labs"
location = "eastus2"
tags = local.platform_tags
}
module "lab" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-dev-test-lab.git?ref=v1.0.0"
name = "lab-platform"
resource_group_name = module.labs_rg.name
location = module.labs_rg.location
tags = local.platform_tags
}
module "lab_net" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-dev-test-virtual-network.git?ref=v1.0.0"
name = "lab-net"
lab_name = module.lab.name # the NAME -- this is what creates the dependency edge
resource_group_name = module.labs_rg.name
description = "Platform lab network."
subnet = {
use_in_virtual_machine_creation = "Allow"
use_public_ip_address = "Deny"
shared_public_ip_address = {
allowed_ports = [
{ backend_port = 22, transport_protocol = "Tcp" },
]
}
}
tags = local.platform_tags
}
# Guardrails: how many machines a user may own, and which sizes they may choose.
module "vm_count_policy" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-dev-test-policy.git?ref=v1.0.0"
name = "UserOwnedLabVmCount"
policy_set_name = "default"
lab_name = module.lab.name
resource_group_name = module.labs_rg.name
evaluator_type = "MaxValuePolicy"
threshold = "3"
description = "At most three machines per user."
tags = local.platform_tags
}
module "vm_size_policy" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-dev-test-policy.git?ref=v1.0.0"
name = "LabVmSize"
policy_set_name = "default"
lab_name = module.lab.name
resource_group_name = module.labs_rg.name
evaluator_type = "AllowedValuesPolicy"
threshold = jsonencode(["Standard_DS2_v2", "Standard_DS3_v2"])
description = "Two sizes only."
tags = local.platform_tags
}
module "linux_box" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-dev-test-linux-virtual-machine.git?ref=v1.0.0"
name = "vm-linux-01"
lab_name = module.lab.name
resource_group_name = module.labs_rg.name
location = module.labs_rg.location
size = "Standard_DS2_v2" # must be in the size policy's allowed list
username = "devuser"
storage_type = "Premium"
ssh_key = file("~/.ssh/id_rsa.pub")
lab_virtual_network_id = module.lab_net.id
lab_subnet_name = module.lab_net.expected_subnet_name_derived_from_the_network_name
disallow_public_ip_address = true
inbound_nat_rule = {
ssh = { protocol = "Tcp", backend_port = 22 }
}
gallery_image_reference = {
publisher = "Canonical"
offer = "0001-com-ubuntu-server-jammy"
sku = "22_04-lts"
version = "22.04.202401010"
}
tags = local.platform_tags
}
check "the_machine_size_is_one_the_policy_permits" {
assert {
condition = contains(module.vm_size_policy.allowed_values, module.linux_box.size)
error_message = "The machine's size is not in the lab's allowed-sizes policy, so creation will be refused."
}
}
check "the_subnet_and_the_machine_agree_about_public_ips" {
assert {
condition = module.lab_net.dedicated_public_ip_addresses_are_denied && !module.linux_box.has_a_dedicated_public_ip_address
error_message = "The subnet denies dedicated public IPs while the machine requests one, or vice versa. These are two separate modules and neither can see the other."
}
}💡 The last two
checkblocks are the point of the composition. A size policy and a machine size are set in two different modules and Azure reconciles them only at creation time; the subnet's public-IP permission and the machine's own request are likewise two modules apart.allowed_valuesand the two posture flags exist so a caller can assert both pairings at plan time rather than discovering them at apply.
⚠️ lab_subnet_namecomes from the predicted output. Using the computedsubnet_namewould work but would show as(known after apply)and could not be asserted on.
Identity (3): name, lab_name, resource_group_name — all force-new. No location.
Configuration (2): description, subnet.
Universal tail (2): tags, timeouts.
7 variables, 12 validations, distributed 0/0/2/0/6/0/4 in declaration order.
Full schemas and the deliberate rule gaps
| Variable | Rules | Notes |
|---|---|---|
name |
none, deliberately | The provider's anchored ^[A-Za-z0-9_-]+$ states the whole rule and rejects a Resource ID, whitespace and a blank. |
lab_name |
none, deliberately | The same anchored regex, via validate.DevTestLabName(). |
resource_group_name |
2 | blank; not a Resource ID. |
description |
0 | Free text. |
subnet |
6 | two permission enums; a non-empty allowed_ports when the shared block is set; the port range the provider omits; the protocol set; no duplicate protocol/port pair. |
tags |
0 | Freely updatable. |
timeouts |
4 | Go-duration format on each key. |
The two none, deliberately entries are decisions rather than omissions: re-expressing an anchored provider regex whose message states the whole rule buys wording rather than coverage.
| Output | Description |
|---|---|
id |
The lab network's Resource ID — what a VM module's lab_virtual_network_id wants |
name, lab_name, resource_group_name, description, tags |
As configured |
unique_identifier |
Azure's immutable GUID (computed) |
subnet_name |
Azure's own subnet name — computed, so unknown until apply |
expected_subnet_name_derived_from_the_network_name |
The same name at plan time, reconstructed from the provider's rule |
expected_network_subnet_path_without_the_subscription |
The Microsoft.Network path the provider fabricates, minus the subscription |
use_in_virtual_machine_creation, use_public_ip_address |
The two permissions as configured |
virtual_machines_can_be_created_in_this_subnet, subnet_accepts_no_virtual_machines |
The VM-creation permission, both directions |
virtual_machines_may_take_their_own_public_ip_address, dedicated_public_ip_addresses_are_denied |
The public-IP permission, both directions |
a_permission_is_deferred_to_the_lab_default |
Whether either permission is Default |
has_shared_public_ip_configuration, shared_public_ip_port_count, shared_public_ip_backend_ports, shared_public_ip_protocols |
The shared-address exposure |
shared_public_ip_exposes_ssh, ..._rdp, ..._a_privileged_port |
What is reachable from the internet |
has_description, is_tagged |
Review |
| nine constants | The facts below that produce no error when they bite |
35 outputs: 10 passthrough, 16 derived, 9 constant. No output is sensitive; this module accepts and emits no secret.
The subnet name is the module's most useful output, and it is a prediction. The provider derives <network name>Subnet, ignores any configured value and marks the field computed — so there is no argument to set and the resource's own subnet_name is unknown until apply. Reconstructing it from the same rule gives a plan-time value, which is what makes a for_each over networks and a check on a first run possible. The output is named for its method for that reason: the value comes from a convention the provider's source calls a portal default, not from Azure.
The subnet block is always sent, and that is a decision. The provider declares it Optional + Computed, so omitting it takes whatever Azure already holds rather than anything the caller chose — and the expand additionally fabricates an override with both permissions Allow when the block is absent. Rendering it unconditionally means a plan shows the permissions Azure will receive.
ARM is stricter than the provider inside shared_public_ip_address, which is the opposite of the usual direction. Three fields ARM documents as required are Optional in the schema, and two of them are transmitted unconditionally — so the degenerate entry is not merely accepted, it is sent. Making them required in the module's type removes the state rather than rejecting it, and the port range check is coverage the provider genuinely lacks.
Two writes on create, and a GET-then-patch update. Create calls CreateOrUpdate then returns the update function, which GETs and writes again — so a create consumes two timeouts. And because update starts from the fetched model and patches only three fields, any property this module does not manage is preserved from Azure indefinitely. The schedule sibling in this family builds a fresh payload instead, so the two behave oppositely and a reader who knows one will assume wrong about the other.
There is a whole resource behind this one that Terraform cannot see. DevTest Labs creates the Microsoft.Network/virtualNetworks resource; this record only configures the lab's view of it. Address space, DNS servers, network security groups and peering all live there, unmanaged and untagged — and ARM's externalProviderResourceId, which would let a lab wrap a network you already own, has no Terraform argument at all.
The lab is referenced by name. With a literal lab_name Terraform holds no edge to the lab: it cannot order creation and cannot see a rename. lab_name = module.lab.name is the only thing that fixes it.
| Provider default | Kept or changed | Why |
|---|---|---|
subnet.use_public_ip_address = "Allow" |
Kept, and reported | It is an exposure, and the suite's instinct is to close it — but Microsoft publishes no portal default for "Allow public IP creation", so unlike the two flips in the DevTest VM modules the closed value has no platform authority behind it. Flipping on the suite's enum rule alone would also silently conflict with a caller who legitimately sets disallow_public_ip_address = false on a machine, across a module boundary neither side can see. Reported through two flags instead, with the check block supplied. |
subnet.use_in_virtual_machine_creation = "Allow" |
Kept | Deny makes the subnet unusable for its only purpose. Not a security control in any useful sense. |
subnet present-or-absent |
Always sent | Optional + Computed: omitting it takes Azure's value, and the expand fabricates Allow for both permissions. Sending it explicitly is what makes the configuration authoritative. |
allowed_ports and its two fields, all Optional |
Made required in the type | ARM documents all three as required, and enforcing a published constraint the provider forgot is not inventing one. |
timeouts |
Mirrored | The provider's own 30m/5m/30m/30m. |
Where a rule is added it is because the provider is thin, not because the module disagrees with it: a port range the provider omits on this resource while validating the identical field elsewhere, a required-by-ARM trio the provider marks optional, and a duplicate-pair check on a list the provider does not deduplicate.
terraform init -backend=false
terraform validate
terraform fmt -checkPin the module with ?ref=v1.0.0 — never a branch. Everything above is plan-only static analysis; a human applies from CI.
What validate and fmt cover: every input's type and shape, all 12 validations as declarations, and that the doubly-nested dynamic blocks render.
What only a variable-validation harness covers: whether each rule can be reached. This module's were proven with seven deliberately-bad fixtures — all 12 fired, with zero condition-evaluation errors — plus four good fixtures driving every one of the 18 locals to more than one value, with no constant column at all. The four port rules are mutually exclusive by construction, so each needed its own fixture: a null shared block reaches none of them, and a duplicate pair cannot also be an empty list.
What only plan exercises: the provider's two anchored name regexes.
What nothing offline can exercise: whether the lab exists, whether the resource group has room for the implicit virtual network, and what the lab's own Default permission resolves to.
id = "/subscriptions/00000000-.../resourceGroups/rg-devtest-labs/providers/Microsoft.DevTestLab/labs/lab-platform/virtualnetworks/lab-net"
name = "lab-net"
lab_name = "lab-platform"
subnet_name = "lab-netSubnet"
expected_subnet_name_derived_from_the_network_name = "lab-netSubnet"
use_in_virtual_machine_creation = "Allow"
use_public_ip_address = "Deny"
virtual_machines_can_be_created_in_this_subnet = true
subnet_accepts_no_virtual_machines = false
virtual_machines_may_take_their_own_public_ip_address = false
dedicated_public_ip_addresses_are_denied = true
a_permission_is_deferred_to_the_lab_default = false
has_shared_public_ip_configuration = true
shared_public_ip_port_count = 1
shared_public_ip_backend_ports = ["22"]
shared_public_ip_exposes_ssh = true
shared_public_ip_exposes_rdp = false
the_subnet_name_cannot_be_chosen = true
an_existing_virtual_network_cannot_be_attached_through_this_resource = true
| Symptom | Cause | Fix |
|---|---|---|
| A VM module fails placing a machine in the subnet | use_in_virtual_machine_creation is not Allow |
Set it to Allow; check virtual_machines_can_be_created_in_this_subnet |
lab_subnet_name shows (known after apply) |
The computed subnet_name was wired |
Use expected_subnet_name_derived_from_the_network_name instead |
| The subnet name is not what was expected | It is always <network name>Subnet and cannot be set |
Rename the network; note that is force-new |
Apply fails on a Microsoft.Network permission |
DevTest Labs creates a virtual network implicitly | Grant rights to create one in the resource group; see the RBAC table |
| Cannot point the lab at an existing virtual network | ARM's externalProviderResourceId has no Terraform argument |
Not expressible here — see an_existing_virtual_network_cannot_be_attached_through_this_resource |
allowed_ports must list at least one port |
An empty list, or a shared_public_ip_address with none |
List the ports, or omit the block entirely |
| A port change replaces nothing but does not take effect | subnet updates in place; verify the plan actually shows the change |
The block is always sent, so a diff should appear |
| An out-of-band change never shows a diff | Update starts from a GET and patches only three fields | Expected — see update_starts_from_a_get_so_unmanaged_fields_persist |
| A create takes longer than the create timeout suggests | Create performs two writes, consuming create then update | Raise both timeouts |
| A resource-group casing change shows no diff | This family diff-suppresses it | Expected (azure-rest-api-specs issue 3964) |
| A first apply fails with an import error | A network of that name already exists in the lab | terraform import, or rename. Whether the check runs depends on caller provider configuration |
- Terraform Registry —
azurerm_dev_test_virtual_network - Microsoft Learn — Add a virtual network in Azure DevTest Labs
- Microsoft Learn — Understand shared IP addresses
- ARM template reference —
Microsoft.DevTestLab/labs/virtualnetworks - Sibling module —
terraform-azurerm-dev-test-policy, authored in the same batch and sharing this family DAG. - Sibling modules —
terraform-azurerm-dev-test-linux-virtual-machineandterraform-azurerm-dev-test-windows-virtual-machine, which consume this module'sidand predicted subnet name. - Sibling module —
terraform-azurerm-dev-test-lab, whosenamethis module consumes. - This module's
SCOPE.md.
💙 "Infrastructure as Code should be standardized, consistent, and secure."