Skip to content

microsoftexpert/tf-mod-azure-dev-test

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

🚀 Azure Dev Test Labs Terraform Module

The single deeply-typed boundary for the whole Azure Dev Test Labs family — labs, lab virtual networks, Linux & Windows lab VMs, cost-control policies, lab start/stop schedules, and standalone ARM-VM auto-shutdown — every resource a for_each map collection, secure by default. Built for AzureRM v4.x.

Terraform AzureRM Module Type Resources


🧩 Overview

This composite module provisions the entire Azure Dev Test Labs surface behind one module call:

  • 🧪 Dev Test Labs — the lab boundary that owns cost policies, images, schedules and an auto-provisioned Key Vault.
  • 🌐 Lab virtual networks — VNets registered to a lab, with subnet public-IP and VM-creation permissions.
  • 🐧 Linux lab VMs — gallery-image VMs with SSH-key or password auth, claimable by lab users.
  • 🪟 Windows lab VMs — gallery-image VMs with mandatory password auth.
  • 📊 Lab policiesMaxValuePolicy / AllowedValuesPolicy fact rules that cap VM count, size, cost, etc.
  • Lab schedules — automated start/stop (LabVmsShutdownTask / LabVmsStartupTask) for lab VMs.
  • 🛑 Global VM shutdown schedules (primary this) — auto-shutdown for standalone ARM VMs that are NOT in a lab.
  • 🔎 Read-only lookups — existing labs and lab VNets resolved by name for downstream wiring.

💡 Why it matters: one typed contract covers the full Dev Test Labs lifecycle, and because every resource type is an independent for_each map, adding a VM never re-plans your labs, and rotating a schedule never touches your VNets — small, predictable blast radius.


❤️ Support this project

If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:

Whether it's a star, a professional connection, or a coffee, every gesture helps keep these modules actively maintained and continually improving. Thank you for being part of the community!


🗺️ Where this fits in the family

flowchart LR
 rg[tf-mod-azure-resource-group]
 vmmod[tf-mod-azure-linux-virtual-machine / tf-mod-azure-windows-virtual-machine]
 devtest[tf-mod-azure-dev-test]
 ra[tf-mod-azure-role-assignment]

 rg -->|resource_group_name, location| devtest
 vmmod -->|virtual_machine_id| devtest
 devtest -->|lab_ids / vm ids / schedule ids| ra

 style devtest fill:#8957E5,color:#fff
 style rg fill:#0078D4,color:#fff
Loading

Consumes resource_group_name + location (from tf-mod-azure-resource-group) and, for the primary global shutdown schedule, a virtual_machine_id (from the standalone VM modules). Emits ID/name maps for labs, VNets, VMs, policies and schedules — see the Cross-Module Contract.


🧬 What this module builds

flowchart TD
 subgraph create["Created by this module"]
 lab["azurerm_dev_test_lab • lab"]
 vnet["azurerm_dev_test_virtual_network • virtual_network"]
 linux["azurerm_dev_test_linux_virtual_machine • linux_virtual_machine"]
 windows["azurerm_dev_test_windows_virtual_machine • windows_virtual_machine"]
 policy["azurerm_dev_test_policy • policy"]
 sched["azurerm_dev_test_schedule • schedule"]
 this["azurerm_dev_test_global_vm_shutdown_schedule • this"]
 end

 subgraph read["Read-only lookups (data sources)"]
 labget["data.azurerm_dev_test_lab • lab_lookup"]
 vnetget["data.azurerm_dev_test_virtual_network • virtual_network_lookup"]
 end

 lab -->|lab_key resolves lab_name| vnet
 lab -->|lab_key| policy
 lab -->|lab_key| sched
 lab -->|lab_key| linux
 lab -->|lab_key| windows
 vnet -->|virtual_network_key resolves lab_virtual_network_id| linux
 vnet -->|virtual_network_key resolves lab_virtual_network_id| windows
 extvm["external ARM VM (by id)"] -->|virtual_machine_id| this

 style this fill:#8957E5,color:#fff
Loading

Resource inventory (one per azurerm_* type):

  • 🛑 azurerm_dev_test_global_vm_shutdown_schedule.thisprimary; auto-shutdown for standalone ARM VMs (lab-independent).
  • 🧪 azurerm_dev_test_lab.lab — the lab boundary; the for_each key is the in-call wiring handle (lab_key).
  • 🌐 azurerm_dev_test_virtual_network.virtual_network — lab VNet; the key is the virtual_network_key handle.
  • 🐧 azurerm_dev_test_linux_virtual_machine.linux_virtual_machine — Linux lab VM.
  • 🪟 azurerm_dev_test_windows_virtual_machine.windows_virtual_machine — Windows lab VM.
  • 📊 azurerm_dev_test_policy.policy — lab policy-set fact rule.
  • azurerm_dev_test_schedule.schedule — lab VM start/stop schedule.
  • 🔎 data.azurerm_dev_test_lab.lab_lookup — read an existing lab.
  • 🔎 data.azurerm_dev_test_virtual_network.virtual_network_lookup — read an existing lab VNet.

✅ Provider / Versions

Requirement Value
Terraform >= 1.12.0
AzureRM provider >= 4.0, < 5.0
API Microsoft.DevTestLab 2018-09-15

⚠️ AzureRM 4.x note: the v4 provider requires a subscription_id on the azurerm provider block (configured by the root, never by this module). This module declares the provider requirement only — it has no provider {} block, so it composes cleanly. ℹ️ Schema quirks baked into the types: VNet allowed_ports.transport_protocol is UPPERCASE TCP/UDP, while VM inbound_nat_rule.protocol is mixed-case Tcp/Udp — see Troubleshooting.


📁 Module Structure

tf-mod-azure-dev-test/
├── providers.tf # Terraform + AzureRM version pins (no provider config)
├── variables.tf # Deeply-typed inputs, heredoc schemas, 20+ validation blocks
├── main.tf # 7 for_each resource collections + 2 data-source collections
├── outputs.tf # Composition contract: ID/name/fqdn maps per collection
├── SCOPE.md # Composite scope contract (allow-list, consumes/emits, gotchas)
└── README.md # You are here

⚙️ Quick Start

module "dev_test" {
  source = "git::https://github.com/microsoftexpert/tf-mod-azure-dev-test?ref=v1.0.0"

  resource_group_name = module.resource_group.name
  location            = module.resource_group.location

  labs = {
    primary = {
      name = "dtl-casey-dev-001"
    }
  }
}

💡 The map key primary is the stable wiring handle — other collections reference this lab with lab_key = "primary".


🔌 Cross-Module Contract

Sourced directly from SCOPE.md.

Consumes

Input Type Source
📁 resource_group_name string tf-mod-azure-resource-group.name
📍 location string tf-mod-azure-resource-group.location
🖥️ global_vm_shutdown_schedules[*].virtual_machine_id string tf-mod-azure-linux-virtual-machine.id / tf-mod-azure-windows-virtual-machine.id (standalone ARM VMs)
🌐 *.lab_virtual_network_id (when external) string An existing lab VNet id, or this module's own virtual_network_ids

Emits

Output Description Consumed by
global_vm_shutdown_schedule_ids Map key → primary schedule ID tf-mod-azure-role-assignment, diagnostic/monitoring modules
lab_ids / lab_names Map key → lab ID / name tf-mod-azure-role-assignment, any lab-scoped sibling
lab_key_vault_ids Map key → lab's auto-provisioned Key Vault ID tf-mod-azure-role-assignment (KV access), tf-mod-azure-key-vault policy modules
lab_unique_identifiers Map key → lab immutable unique identifier Audit / tagging
virtual_network_ids / virtual_network_names Map key → lab VNet ID / name VM collections (in-call), downstream refs
virtual_network_subnet_names Map key → provider-generated subnet name VM lab_subnet_name wiring
linux_virtual_machine_ids / _fqdns Map key → Linux VM ID / FQDN tf-mod-azure-role-assignment, DNS/connection tooling
windows_virtual_machine_ids / _fqdns Map key → Windows VM ID / FQDN tf-mod-azure-role-assignment, DNS/connection tooling
policy_ids / schedule_ids Map key → policy / schedule ID Audit, governance reporting
lab_lookup_ids / lab_lookup_key_vault_ids Map key → resolved existing-lab IDs Brownfield wiring
virtual_network_lookup_ids / _allowed_subnets Map key → resolved existing-VNet data Brownfield VM placement
resource_group_name Pass-through default RG Sibling modules in the same RG

📚 Example Library

Every example uses the ADO git source pinned at ?ref=v1.0.0. Keys (primary, dev, …) are identity — see Architecture Notes.

1️⃣ Minimal — a single lab
module "dev_test" {
  source              = "git::https://github.com/microsoftexpert/tf-mod-azure-dev-test?ref=v1.0.0"
  resource_group_name = "rg-casey-devtest-dev"
  location            = "East US 2"

  labs = {
    primary = { name = "dtl-casey-dev-001" }
  }
}
2️⃣ Lab + virtual network (secure subnet defaults)
labs = {
  primary = { name = "dtl-casey-dev-001" }
}

virtual_networks = {
  core = {
    name        = "dtlvnet-core"
    lab_key     = "primary" # in-call wiring → creates the dependency edge
    description = "Core lab subnet"
    subnet = {
      # use_public_ip_address defaults to "Deny" (secure), use_in_virtual_machine_creation to "Allow"
    }
  }
}

🔒 The subnet defaults to use_public_ip_address = "Deny" — VMs land on private IPs unless you explicitly opt in.

3️⃣ Linux lab VM (SSH key auth, no public IP)
labs             = { primary = { name = "dtl-casey-dev-001" } }
virtual_networks = { core = { name = "dtlvnet-core", lab_key = "primary", subnet = {} } }

linux_virtual_machines = {
  build01 = {
    name                = "build01"
    lab_key             = "primary"
    virtual_network_key = "core"
    lab_subnet_name     = module.dev_test.virtual_network_subnet_names["core"]
    size                = "Standard_D4_v5"
    username            = "caseyadmin"
    storage_type        = "Premium"
    ssh_key             = file("~/.ssh/id_rsa.pub")
    gallery_image_reference = {
      publisher = "Canonical"
      offer     = "0001-com-ubuntu-server-jammy"
      sku       = "22_04-lts"
      version   = "latest"
    }
    # disallow_public_ip_address defaults to true
  }
}

⚠️ lab_subnet_name is required. The subnet name is provider-generated, so read it back from virtual_network_subnet_names.

4️⃣ Windows lab VM (password from the sensitive companion map)
windows_virtual_machines = {
  win01 = {
    name                = "win01" # 1–15 chars for Windows
    lab_key             = "primary"
    virtual_network_key = "core"
    lab_subnet_name     = module.dev_test.virtual_network_subnet_names["core"]
    size                = "Standard_D4_v5"
    username            = "caseyadmin"
    storage_type        = "Premium"
    gallery_image_reference = {
      publisher = "MicrosoftWindowsDesktop"
      offer     = "windows-11"
      sku       = "win11-23h2-pro"
      version   = "latest"
    }
  }
}

# Passwords live OUTSIDE the iterable map (sensitive, keyed identically):
virtual_machine_secrets = {
  win01 = { password = var.win01_admin_password }
}

🔒 Passwords are kept in virtual_machine_secrets so the VM config map stays non-sensitive and for_each-able. Windows VMs require a password.

5️⃣ Cost-control policies (MaxValue + AllowedValues)
policies = {
  max_vms = {
    name           = "LabVmCount"
    lab_key        = "primary"
    evaluator_type = "MaxValuePolicy"
    threshold      = "10"
  }
  allowed_sizes = {
    name           = "LabVmSize"
    lab_key        = "primary"
    evaluator_type = "AllowedValuesPolicy"
    threshold      = jsonencode(["Standard_D2_v5", "Standard_D4_v5"])
  }
}

💡 policy_set_name defaults to "default". AllowedValuesPolicy thresholds are JSON-array strings; MaxValuePolicy thresholds are numeric strings.

6️⃣ Lab start/stop schedules
schedules = {
  nightly_shutdown = {
    name             = "LabVmsShutdown"
    lab_key          = "primary"
    task_type        = "LabVmsShutdownTask"
    time_zone_id     = "Eastern Standard Time"
    status           = "Enabled"
    daily_recurrence = { time = "1900" }
    notification_settings = {
      status          = "Enabled"
      time_in_minutes = 30
    }
  }
  weekday_startup = {
    name         = "LabVmAutoStart" # name is dictated by task_type
    lab_key      = "primary"
    task_type    = "LabVmsStartupTask"
    time_zone_id = "Eastern Standard Time"
    status       = "Enabled"
    weekly_recurrence = {
      time      = "0700"
      week_days = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"]
    }
  }
}

⚠️ For a LabVmsStartupTask the name must be LabVmAutoStart. See Troubleshooting.

7️⃣ Global shutdown schedule for a standalone ARM VM (the primary `this`)
global_vm_shutdown_schedules = {
  app01 = {
    virtual_machine_id    = module.app_vm.id # a NON-lab azurerm_*_virtual_machine
    timezone              = "Eastern Standard Time"
    daily_recurrence_time = "2300"
    notification_settings = {
      enabled         = true
      time_in_minutes = 30
      email           = "platform-team@financialpartners.com"
    }
  }
}

💡 This resource targets standard ARM VMs outside any lab — it is independent of every lab* collection in this module.

8️⃣ Shared public IP via subnet allowed ports (UPPERCASE protocol)
virtual_networks = {
  shared = {
    name    = "dtlvnet-shared"
    lab_key = "primary"
    subnet = {
      use_public_ip_address           = "Allow"
      use_in_virtual_machine_creation = "Allow"
      shared_public_ip_address = {
        allowed_ports = [
          { backend_port = 22, transport_protocol = "TCP" },
          { backend_port = 3389, transport_protocol = "TCP" },
        ]
      }
    }
  }
}

⚠️ transport_protocol here is UPPERCASE (TCP/UDP) — different from VM NAT rules.

9️⃣ Linux VM with inbound NAT rule (forces private IP)
linux_virtual_machines = {
  jump = {
    name                       = "jump01"
    lab_key                    = "primary"
    virtual_network_key        = "shared"
    lab_subnet_name            = module.dev_test.virtual_network_subnet_names["shared"]
    size                       = "Standard_D2_v5"
    username                   = "caseyadmin"
    storage_type               = "Standard"
    ssh_key                    = file("~/.ssh/id_rsa.pub")
    disallow_public_ip_address = true # mandatory with NAT rules
    inbound_nat_rule = [
      { backend_port = 22, protocol = "Tcp" }, # mixed-case here
    ]
    gallery_image_reference = {
      publisher = "Canonical"
      offer     = "0001-com-ubuntu-server-jammy"
      sku       = "22_04-lts"
      version   = "latest"
    }
  }
}

⚠️ Any inbound_nat_rule requires disallow_public_ip_address = true — enforced by a validation block.

🔟 Brownfield — read an existing lab & VNet, then place a VM
lab_lookups             = { legacy = { name = "dtl-legacy-001" } }
virtual_network_lookups = { legacy = { name = "dtlvnet-legacy", lab_name = "dtl-legacy-001" } }

linux_virtual_machines = {
  migrated = {
    name                   = "migrated01"
    lab_name               = "dtl-legacy-001" # external lab
    lab_virtual_network_id = module.dev_test.virtual_network_lookup_ids["legacy"]
    lab_subnet_name        = module.dev_test.virtual_network_lookup_allowed_subnets["legacy"][0].lab_subnet_name
    size                   = "Standard_D2_v5"
    username               = "caseyadmin"
    storage_type           = "Standard"
    ssh_key                = file("~/.ssh/id_rsa.pub")
    gallery_image_reference = {
      publisher = "Canonical"
      offer     = "0001-com-ubuntu-server-jammy"
      sku       = "22_04-lts"
      version   = "latest"
    }
  }
}

💡 Use lab_name / lab_virtual_network_id (external) instead of lab_key / virtual_network_key (in-call) when wiring to pre-existing resources.

1️⃣1️⃣ for_each at scale — a fleet of Linux VMs from a map(object)
locals {
  build_agents = {
    agent01 = { size = "Standard_D4_v5" }
    agent02 = { size = "Standard_D4_v5" }
    agent03 = { size = "Standard_D8_v5" }
  }
}

linux_virtual_machines = {
  for k, v in local.build_agents : k => {
    name                = k # stable key == VM name
    lab_key             = "primary"
    virtual_network_key = "core"
    lab_subnet_name     = module.dev_test.virtual_network_subnet_names["core"]
    size                = v.size
    username            = "caseyadmin"
    storage_type        = "Premium"
    ssh_key             = file("~/.ssh/id_rsa.pub")
    gallery_image_reference = {
      publisher = "Canonical"
      offer     = "0001-com-ubuntu-server-jammy"
      sku       = "22_04-lts"
      version   = "latest"
    }
  }
}

⚠️ The map key is the resource's identity. Renaming agent01agent1 destroys and recreates that VM. Choose keys you will never need to change.

1️⃣2️⃣ Security / hardening — -compliant locked-down lab
labs = { secure = { name = "dtl-casey-prod-001", tags = { data_class = "internal" } } }

virtual_networks = {
  core = {
    name    = "dtlvnet-core"
    lab_key = "secure"
    subnet = {
      use_public_ip_address           = "Deny" # no public IPs at all
      use_in_virtual_machine_creation = "Allow"
    }
  }
}

policies = {
  cap_vms  = { name = "LabVmCount", lab_key = "secure", evaluator_type = "MaxValuePolicy", threshold = "5" }
  cap_cost = { name = "LabTargetCost", lab_key = "secure", evaluator_type = "MaxValuePolicy", threshold = "500" }
}

linux_virtual_machines = {
  app01 = {
    name                       = "app01"
    lab_key                    = "secure"
    virtual_network_key        = "core"
    lab_subnet_name            = module.dev_test.virtual_network_subnet_names["core"]
    size                       = "Standard_D4_v5"
    username                   = "caseyadmin"
    storage_type               = "Premium"
    ssh_key                    = file("~/.ssh/id_rsa.pub") # key auth, no password
    allow_claim                = false                     # locked to its owner
    disallow_public_ip_address = true
    gallery_image_reference = {
      publisher = "Canonical", offer = "0001-com-ubuntu-server-jammy", sku = "22_04-lts", version = "latest"
    }
  }
}

schedules = {
  shutdown = {
    name                  = "LabVmsShutdown", lab_key = "secure", task_type = "LabVmsShutdownTask"
    time_zone_id          = "Eastern Standard Time", status = "Enabled"
    daily_recurrence      = { time = "1900" }
    notification_settings = { status = "Enabled", time_in_minutes = 30 }
  }
}

tags = { owner = "platform-team", cost_center = "-IT" }

🔒 Private IPs only, claim disabled, VM-count and cost caps enforced, nightly shutdown — the secure-by-default posture expects.

1️⃣3️⃣ 🏗️ End-to-end composition (mandatory) — RG → lab → VNet → VMs → schedules, plus a standalone-VM shutdown
module "resource_group" {
  source   = "git::https://github.com/microsoftexpert/tf-mod-azure-resource-group?ref=v1.0.0"
  name     = "rg-casey-devtest-dev"
  location = "East US 2"
}

# A standalone (non-lab) ARM VM whose shutdown we want this module to manage:
module "app_vm" {
  source              = "git::https://github.com/microsoftexpert/tf-mod-azure-linux-virtual-machine?ref=v1.0.0"
  name                = "app-shared-01"
  resource_group_name = module.resource_group.name
  location            = module.resource_group.location
  #... vm config...
}

module "dev_test" {
  source              = "git::https://github.com/microsoftexpert/tf-mod-azure-dev-test?ref=v1.0.0"
  resource_group_name = module.resource_group.name
  location            = module.resource_group.location

  labs             = { primary = { name = "dtl-casey-dev-001" } }
  virtual_networks = { core = { name = "dtlvnet-core", lab_key = "primary", subnet = {} } }

  linux_virtual_machines = {
    build01 = {
      name                = "build01"
      lab_key             = "primary"
      virtual_network_key = "core"
      lab_subnet_name     = module.dev_test.virtual_network_subnet_names["core"]
      size                = "Standard_D4_v5"
      username            = "caseyadmin"
      storage_type        = "Premium"
      ssh_key             = file("~/.ssh/id_rsa.pub")
      gallery_image_reference = {
        publisher = "Canonical", offer = "0001-com-ubuntu-server-jammy", sku = "22_04-lts", version = "latest"
      }
    }
  }

  policies = { cap = { name = "LabVmCount", lab_key = "primary", evaluator_type = "MaxValuePolicy", threshold = "10" } }

  schedules = {
    shutdown = {
      name                  = "LabVmsShutdown", lab_key = "primary", task_type = "LabVmsShutdownTask"
      time_zone_id          = "Eastern Standard Time", status = "Enabled"
      daily_recurrence      = { time = "1900" }
      notification_settings = { status = "Enabled", time_in_minutes = 30 }
    }
  }

  # Primary `this` — shutdown for the standalone VM (NOT a lab VM):
  global_vm_shutdown_schedules = {
    app = {
      virtual_machine_id    = module.app_vm.id
      timezone              = "Eastern Standard Time"
      daily_recurrence_time = "2300"
      notification_settings = { enabled = true, time_in_minutes = 30 }
    }
  }

  tags = { owner = "platform-team", environment = "dev" }
}

💡 Inside one call the lab and VNet are created first (the lab_key / virtual_network_key handles create real dependency edges), then VMs/policies/schedules, all wired by output → input.


📥 Inputs

Core (required):

  • resource_group_name (string) — default RG for all lab-scoped resources; per-entry overridable.
  • location (string) — default region for all location-bearing resources; per-entry overridable.

Collections (all map(object), default {}):

  • labs, virtual_networks, linux_virtual_machines, windows_virtual_machines, policies, schedules, global_vm_shutdown_schedules
  • lab_lookups, virtual_network_lookups — read-only data-source collections.

Secrets:

  • virtual_machine_secrets (map(object), sensitive) — VM passwords, keyed identically to the VM maps.

Universal tail:

  • tags (map(string)) — merged into every resource (per-entry tags win).
  • timeouts (object) — applied to every resource (data sources use read only).
Full object schemas (mirrors variables.tf)
labs = map(object({
  name                = string           # IMMUTABLE
  location            = optional(string) # defaults to var.location
  resource_group_name = optional(string) # defaults to var.resource_group_name (IMMUTABLE)
  tags                = optional(map(string), {})
}))

virtual_networks = map(object({
  name                = string           # IMMUTABLE
  lab_key             = optional(string) # in-call wiring → var.labs key
  lab_name            = optional(string) # external lab (IMMUTABLE) — exactly one of lab_key/lab_name
  resource_group_name = optional(string) # IMMUTABLE
  description         = optional(string)
  subnet = optional(object({
    use_public_ip_address           = optional(string, "Deny")  # Allow | Default | Deny
    use_in_virtual_machine_creation = optional(string, "Allow") # Allow | Default | Deny
    shared_public_ip_address = optional(object({
      allowed_ports = optional(list(object({
        backend_port       = optional(number)
        transport_protocol = optional(string) # "TCP" | "UDP" (UPPERCASE)
      })), [])
    }))
  }))
  tags = optional(map(string), {})
}))

linux_virtual_machines = map(object({
  name                       = string # IMMUTABLE — 1-62 chars
  lab_key                    = optional(string)
  lab_name                   = optional(string) # IMMUTABLE — exactly one of lab_key/lab_name
  resource_group_name        = optional(string) # IMMUTABLE
  location                   = optional(string) # IMMUTABLE
  size                       = string           # IMMUTABLE
  username                   = string           # IMMUTABLE
  storage_type               = string           # IMMUTABLE — "Standard" | "Premium"
  virtual_network_key        = optional(string)
  lab_virtual_network_id     = optional(string) # IMMUTABLE — exactly one of virtual_network_key/lab_virtual_network_id
  lab_subnet_name            = string           # REQUIRED (IMMUTABLE)
  ssh_key                    = optional(string) # IMMUTABLE — this OR a password
  allow_claim                = optional(bool, true)
  disallow_public_ip_address = optional(bool, true) # SECURE DEFAULT (IMMUTABLE)
  notes                      = optional(string)
  gallery_image_reference    = object({ publisher = string, offer = string, sku = string, version = string }) # IMMUTABLE
  inbound_nat_rule = optional(list(object({
    backend_port = number
    protocol     = string # "Tcp" | "Udp"
  })), [])
  tags = optional(map(string), {})
}))

windows_virtual_machines = map(object({ /* same as linux, minus ssh_key; password REQUIRED via virtual_machine_secrets; name 1-15 chars */ }))

virtual_machine_secrets = map(object({ password = optional(string) })) # sensitive

policies = map(object({
  name                = string                      # IMMUTABLE — closed set of policy fact names
  policy_set_name     = optional(string, "default") # IMMUTABLE
  lab_key             = optional(string)
  lab_name            = optional(string) # IMMUTABLE
  resource_group_name = optional(string) # IMMUTABLE
  evaluator_type      = string           # IMMUTABLE — "AllowedValuesPolicy" | "MaxValuePolicy"
  threshold           = string
  fact_data           = optional(string)
  description         = optional(string)
  tags                = optional(map(string), {})
}))

schedules = map(object({
  name                = string # IMMUTABLE
  lab_key             = optional(string)
  lab_name            = optional(string) # IMMUTABLE
  resource_group_name = optional(string) # IMMUTABLE
  location            = optional(string) # IMMUTABLE
  task_type           = string           # "LabVmsShutdownTask" | "LabVmsStartupTask" | "LabVmAutoStart"
  time_zone_id        = string
  status              = optional(string, "Disabled") # "Enabled" | "Disabled"
  daily_recurrence    = optional(object({ time = string }))
  weekly_recurrence   = optional(object({ time = string, week_days = optional(list(string)) }))
  hourly_recurrence   = optional(object({ minute = number }))
  notification_settings = optional(object({
    status          = optional(string, "Disabled") # "Enabled" | "Disabled"
    time_in_minutes = optional(number)
    webhook_url     = optional(string)
  }), {})
  tags = optional(map(string), {})
}))

global_vm_shutdown_schedules = map(object({
  virtual_machine_id    = string           # IMMUTABLE
  location              = optional(string) # IMMUTABLE
  enabled               = optional(bool, true)
  timezone              = string
  daily_recurrence_time = string # "HHmm"
  notification_settings = optional(object({
    enabled         = optional(bool, true)
    email           = optional(string)
    time_in_minutes = optional(number, 30) # 15-120
    webhook_url     = optional(string)
  }), {})
  tags = optional(map(string), {})
}))

🧾 Outputs

Output Description
global_vm_shutdown_schedule_ids Map key → primary global VM shutdown schedule ID
lab_ids Map key → Dev Test Lab ID
lab_names Map key → Dev Test Lab name
lab_key_vault_ids Map key → lab's auto-provisioned Key Vault ID
lab_unique_identifiers Map key → lab immutable unique identifier
virtual_network_ids Map key → lab VNet ID
virtual_network_names Map key → lab VNet name
virtual_network_subnet_names Map key → provider-generated subnet name (null when no subnet block was defined)
linux_virtual_machine_ids Map key → Linux VM ID
linux_virtual_machine_fqdns Map key → Linux VM FQDN
windows_virtual_machine_ids Map key → Windows VM ID
windows_virtual_machine_fqdns Map key → Windows VM FQDN
policy_ids Map key → policy ID
schedule_ids Map key → schedule ID
lab_lookup_ids Map key → resolved existing-lab ID
lab_lookup_key_vault_ids Map key → resolved existing-lab Key Vault ID
virtual_network_lookup_ids Map key → resolved existing-VNet ID
virtual_network_lookup_allowed_subnets Map key → resolved existing-VNet allowed_subnets list
resource_group_name Pass-through default RG name

ℹ️ Every output is a map keyed by your for_each key (empty {} when the collection is unused). virtual_network_subnet_names entries are null when an entry omitted its subnet block. No output is marked sensitive — secrets live only in virtual_machine_secrets (input) and are never echoed back.


🧠 Architecture Notes

  • for_each keys are identity — and they are forever. Every collection is a for_each over a map(object) keyed by a caller string. The key is not cosmetic: it is the resource's address in state. Renaming a key destroys and recreates that resource — and for a VM that means a rebuild, for a lab that means tearing down everything addressed off it. Pick keys you will never need to change, and never re-key to "tidy up."
  • In-call wiring vs. external refs. Lab-scoped children resolve their parent two ways: lab_key points at a lab created in the same call (main.tf reads azurerm_dev_test_lab.lab[key].name, which creates a real dependency edge so ordering is correct), or lab_name names an external lab. The same pattern applies to virtual_network_key vs lab_virtual_network_id. A validation enforces exactly one of each pair — you can't half-wire a resource.
  • The sensitive split is deliberate (and where nonsensitive earns its place). Terraform forbids for_each over a sensitive value, so VM passwords live in a separate sensitive = true map (virtual_machine_secrets) keyed identically to the non-sensitive VM config maps. main.tf joins them per-key: password = try(var.virtual_machine_secrets[each.key].password, null). The cross-variable validations that check "every Windows VM has a password" must read those sensitive keys inside a condition, so they wrap the result in nonsensitive — the one correct use of it here.
  • The primary this is lab-independent by design. azurerm_dev_test_global_vm_shutdown_schedule manages auto-shutdown for standard ARM VMs that are not in any lab. It shares the module only because it belongs to the Microsoft.DevTestLab family — it never references a lab, takes a virtual_machine_id instead, and its Azure name is forced to shutdown-computevm-<VM Name>.
  • VNet integration is lab-registration, not subnet delegation. Dev Test Labs does not use subnet delegation or private endpoints. A azurerm_dev_test_virtual_network registers a VNet with the lab and exposes a single subnet whose name is provider-generated (read it from virtual_network_subnet_names). VM placement is then lab_subnet_name + lab_virtual_network_id. The VNet must be in the same region as the lab. Plan IP space generously — Azure guidance is a few VNets with large prefixes and large subnets rather than many small ones (especially under ExpressRoute/VPN).
  • Secure subnet default the provider doesn't give you. The provider defaults subnet.use_public_ip_address to Allow; this module defaults it to Deny. Allowing public IPs on lab VMs is an explicit caller decision that shows up in review. VM disallow_public_ip_address likewise defaults to true.
  • Casing is a trap, encoded into the types. VNet allowed_ports.transport_protocol is UPPERCASE (TCP/UDP); VM inbound_nat_rule.protocol is mixed-case (Tcp/Udp). Both are validated, so a wrong-case value fails at plan, not apply.
  • One timeouts object fans out to nine collections. All resources accept create/read/update/delete; the two data sources accept read only, so they render just the read timeout when set.

🧱 Design Principles

  • 🔐 Secrets stay out of the iterable surface — passwords isolated in a sensitive companion map, never echoed in outputs.
  • 🧱 Deeply-typed inputs — every provider block mirrored as a nested object; no any, no loose map.
  • 💥 Small blast radius — independent for_each collections mean a VM change never re-plans a lab.
  • 🚫 main.tf is a total rendererdynamic blocks + try(x, null) only; no business logic, no count.
  • 🛡️ Secure by default — private IPs, no claim leakage when locked, certificate-of-intent for any public exposure.
  • 🔁 Composable — declares the provider requirement, never configures one.

🚀 Runbook

terraform init -backend=false
terraform validate
terraform fmt -check
terraform plan
terraform apply
terraform output

⚠️ Pin the version. Always source this module at an immutable tag — ?ref=v1.0.0 — never a branch. Branch refs move under you and turn a plan into a surprise.


🧪 Testing

The offline proof gate — all three must pass before committing:

terraform fmt -check
terraform validate # passes with -backend=false
tflint

💬 Example Output

lab_ids = {
 "primary" = "/subscriptions/.../resourceGroups/rg-casey-devtest-dev/providers/Microsoft.DevTestLab/labs/dtl-casey-dev-001"
}
lab_key_vault_ids = {
 "primary" = "/subscriptions/.../providers/Microsoft.KeyVault/vaults/dtlcaseydev001kv..."
}
virtual_network_subnet_names = {
 "core" = "dtlvnet-coreSubnet"
}
linux_virtual_machine_fqdns = {
 "build01" = "build01.eastus2.cloudapp.azure.com"
}
global_vm_shutdown_schedule_ids = {
 "app" = "/subscriptions/.../providers/Microsoft.DevTestLab/schedules/shutdown-computevm-app-shared-01"
}

🔍 Troubleshooting

  • 🛑 task_typename mismatch. A LabVmsStartupTask schedule's name must be LabVmAutoStart; a shutdown schedule is typically named LabVmsShutdown. The provider docs list task_type values inconsistently (LabVmAutoStart vs LabVmsStartupTask) — this module's validation accepts all three observed values, but the API enforces the name/type correlation at apply. If apply fails with a task-type error, fix the name.
  • 🔤 Protocol casing. transport_protocol (VNet shared-IP ports) is UPPERCASE TCP/UDP; protocol (VM inbound_nat_rule) is mixed-case Tcp/Udp. Mixing them up trips a validation block at plan time — match the case shown in the examples.
  • 🌐 NAT rule requires private IP. Adding any inbound_nat_rule to a VM requires disallow_public_ip_address = true. The module validates this, but if you hit it from a hand-edited plan, set the flag.
  • 🏷️ Subnet name is provider-generated. You cannot choose lab_subnet_name freely — Dev Test Labs derives the subnet name (e.g. <vnet>Subnet). Read it from virtual_network_subnet_names["key"]; guessing it produces a "subnet not found" apply error.
  • 📍 Region mismatch. A lab VNet (and its VMs) must be in the same region as the lab. The lab portal only lists VNets in its region. If a VM apply fails to find the network, check location.
  • 📦 Resource-group exhaustion → the real VM ceiling. Dev Test Labs creates a new resource group for every VM (unless shared public IPs are used). With the default 800 resource groups per subscription, that caps a subscription at roughly ~79 VMs per lab across 10 labs. There is no DevTest-service VM quota — every limit you hit is a subscription quota (default 20 cores, public IPs, 50→100 VNets). Use shared public IPs to collapse per-VM resource groups, or raise quotas via a support request.
  • 🔑 Auth required, or apply fails. Linux VMs need ssh_key or a virtual_machine_secrets[key].password; Windows VMs require a password. The module's cross-variable validations catch a missing password at plan, but a malformed key is only rejected by Azure at apply.
  • ⬆️ AzureRM 4.x upgrade. The v4 provider requires a subscription_id on the provider block and removed many 3.x aliases. None of the azurerm_dev_test_* attributes were renamed in 4.x, but VM-adjacent resources changed (e.g. vm_agent_platform_updates_enabled became read-only in 4.26) — pin to >= 4.0, < 5.0 and re-run terraform plan after any provider bump.
  • ♻️ "Why is my VM being recreated?" Almost every VM and lab attribute is ForceNew (size, username, storage_type, gallery_image_reference, lab_subnet_name, disallow_public_ip_address, …). Changing any of them — or renaming the for_each key — destroys and recreates. Treat these as immutable; rebuild deliberately.
  • 📥 Importing an existing global shutdown schedule. Its Azure resource name is always shutdown-computevm-<VM Name>, e.g. terraform import 'module.dev_test.azurerm_dev_test_global_vm_shutdown_schedule.this["app"]' /subscriptions/.../providers/Microsoft.DevTestLab/schedules/shutdown-computevm-app-shared-01.

🔗 Related Docs

  • Terraform Registry — azurerm_dev_test_lab, azurerm_dev_test_virtual_network, azurerm_dev_test_linux_virtual_machine, azurerm_dev_test_windows_virtual_machine, azurerm_dev_test_policy, azurerm_dev_test_schedule, azurerm_dev_test_global_vm_shutdown_schedule
  • Microsoft Learn — About Azure DevTest Labs, Configure a virtual network in DevTest Labs, Manage lab policies, Understand shared IP addresses, DevTest Labs enterprise reference architecture
  • Microsoft Learn — Scale quotas and limits in DevTest Labs, Azure subscription and service limits
  • HashiCorp — AzureRM provider 4.0 upgrade guide
  • tf-mod-azure-resource-group, tf-mod-azure-linux-virtual-machine, tf-mod-azure-windows-virtual-machine, tf-mod-azure-role-assignment

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

Packages

 
 
 

Contributors

Languages