Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

☁️ Azure DevTest Lab Virtual Network Terraform Module

Creates an azurerm_dev_test_virtual_network — the lab's own wrapper around a network, and the resource whose id and predicted subnet name every DevTest VM module needs. Targets hashicorp/azurerm ~> 4.0.

Terraform azurerm Module Type Resources Posture


🧩 Overview

  • 🌐 Creates the lab's virtual network record — the resource a DevTest VM module's lab_virtual_network_id actually wants.
  • 🔮 Emits the subnet name at plan time, reconstructed from the same rule the provider uses, where the resource's own subnet[0].name is 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.Network network 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_name is 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 inside shared_public_ip_address that the provider marks optional and transmits anyway, so an entry with neither field set sends port 0 and an empty protocol.


❤️ Support this project

If this module saved you time:


🗺️ Where this fits in the family

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
Loading

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.


🧬 What this module builds

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
Loading

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.


✅ Provider / Versions

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 no location argument 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 field Computed. So renaming this network is force-new and changes the lab_subnet_name every VM module must pass.
  • The subnet block is Optional + Computed, so leaving it unset takes whatever Azure already holds. Worse, the expand fabricates a subnet override with both permissions set to Allow when the block is absent. This module always sends it.
  • ARM requires three fields the provider marks optional. Inside shared_public_ip_address, ARM documents allowedPorts as required and both backendPort and transportProtocol as 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 port 0 and an empty protocol.
  • backend_port has no validator here at all — the identical field on the DevTest VM resources' inbound_nat_rule gets validate.PortNumber.
  • A create performs two writes. The create function calls CreateOrUpdate and then returns the update function, which does its own GET plus a second CreateOrUpdate.
  • Update starts from a GET, patching only description, subnet and tags. 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 externalProviderResourceId has no argument, so an existing virtual network cannot be attached through this resource. ARM's allowedSubnets and virtualNetworkPoolName are absent too.
  • resource_group_name case differences produce no diff (Azure/azure-rest-api-specs issue 3964); the helper supplies Required, ForceNew and the suppression at once.
  • name and lab_name use anchored ^[A-Za-z0-9_-]+$ regexes with accurate messages, so both are credited rather than restated.

🔑 Required Azure RBAC Roles / Permissions

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 with Microsoft.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 on Microsoft.Network/virtualNetworks can 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.


Azure Prerequisites

  1. Microsoft.DevTestLab registered in the subscription.
  2. An existing DevTest Lab, in the subscription the caller's provider targets.
  3. Room in the resource group for a virtual network DevTest Labs creates implicitly — including any address-space or policy constraints that apply to it.
  4. A decision about the subnet's permissions before the first apply. subnet is not force-new, so both permissions can be changed later — but the machines created in between will already have been placed.

📁 Module Structure

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

⚙️ Quick Start

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 own Allow defaults for both permissions.


🔌 Cross-Module Contract

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

📚 Example Library

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.Network virtual network behind it. Both subnet permissions take the provider's Allow default, and the lab subnet is named lab-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>Subnet in its expand, ignores any configured value, and marks the field Computed so there is no argument to set. Two consequences: renaming this network is force-new and silently changes the lab_subnet_name every VM module passes, and the two are one decision rather than two.

💡 module.lab_net.expected_subnet_name_derived_from_the_network_name gives you that name at plan time. The resource's own subnet_name output is computed, so it reads (known after apply) and cannot drive a for_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, not subnet_name. The predicted value is known during the plan; the computed one is not. That difference matters the moment a caller wants to for_each several machines over subnets, or assert the name in a check block 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
  }

🔒 Deny stops machines in this subnet taking their own public IP address. It does not disable the lab's shared public IP, which is configured separately through shared_public_ip_address — so machines remain reachable through the shared address and its NAT rules.

ℹ️ This module keeps the provider's Allow default 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_machines exists 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"
  }

ℹ️ Default is 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, through a_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 documents allowedPorts as required inside the block, and backendPort and transportProtocol as required within each entry. The provider marks all three Optional. This module makes them required in the type, which makes the illegal state unrepresentable rather than rejected after the fact — and it rejects an empty allowed_ports list for the same reason.

ℹ️ It also range-checks backend_port, which the provider does not: the identical field on the DevTest VM resources gets validate.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 a Microsoft.Network/virtualNetworks resource behind this record and Terraform never sees it — no state entry, no tags from this module, no plan line. ARM exposes externalProviderResourceId to 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_subscription shows 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 CreateOrUpdate and then returns the update function, which performs its own GET and a second CreateOrUpdate. 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_persist states 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_each keyed 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 Default permission 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 check blocks 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_values and the two posture flags exist so a caller can assert both pairings at plan time rather than discovering them at apply.

⚠️ lab_subnet_name comes from the predicted output. Using the computed subnet_name would work but would show as (known after apply) and could not be asserted on.


📥 Inputs

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.


🧾 Outputs

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.


🧠 Architecture Notes

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.


🧱 Design Principles

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.


🚀 Runbook

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

Pin the module with ?ref=v1.0.0 — never a branch. Everything above is plan-only static analysis; a human applies from CI.


🧪 Testing

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.


💬 Example Output

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

🔍 Troubleshooting

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

🔗 Related Docs


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