Worked review 1: a network + compute module
The feature
Section titled “The feature”The prompt was: “Write a reusable Terraform module for a virtual network with subnets, a network security group, and a Linux VM, then call it once per environment.” What follows is the kind of first draft an AI assistant hands back for exactly that prompt — every block is individually plausible HCL, resource types are real, most argument names are real. Read it the way the review checklist recommends: providers and versions first, then backend, then resources and lifecycle, then the module boundary itself. It has 9 real bugs; check the AI bug catalog for the general pattern behind each one. Every claim below was proven with real terraform init/validate/test against Terraform 1.16.4 and hashicorp/azurerm 5.7.0. A real terraform plan against this provider needs a real subscription and Azure CLI login this course never has — confirmed directly below — so the bugs that AWS’s sibling review proves with plan are proven here with terraform test and mock_provider "azurerm" {} instead, exactly as this course’s harness does everywhere else.
The generated code
Section titled “The generated code”# network+compute module, as generated -- modules/network-compute/main.tfprovider "azurerm" { features {}}
resource "azurerm_resource_group" "this" { name = "${var.name}-rg" location = "East US"}
resource "azurerm_virtual_network" "this" { name = "${var.name}-vnet" address_space = [var.vnet_cidr] location = azurerm_resource_group.this.location resource_group_name = azurerm_resource_group.this.name}
resource "azurerm_subnet" "this" { count = length(var.subnet_names) name = var.subnet_names[count.index] resource_group_name = azurerm_resource_group.this.name virtual_network_name = azurerm_virtual_network.this.name address_prefixes = [cidrsubnet(count.index, var.subnet_newbits, var.vnet_cidr)]}
resource "azurerm_network_security_group" "web" { name = "${var.name}-web-nsg" location = azurerm_resource_group.this.location resource_group_name = azurerm_resource_group.this.name
security_rule { name = "allow-https" priority = 100 direction = "Inbound" access = "Allow" protocol = "Tcp" source_port_range = "*" destination_port_range = "443" source_address_prefix = "*" destination_address_prefix = "*" }}
resource "azurerm_network_interface" "app" { name = "${var.name}-nic" location = azurerm_resource_group.this.location resource_group_name = azurerm_resource_group.this.name
ip_configuration { name = "internal" subnet_id = azurerm_subnet.this[0].id private_ip_address_allocation = "Dynamic" }}
resource "azurerm_linux_virtual_machine" "app" { name = "${var.name}-vm" resource_group_name = azurerm_resource_group.this.name location = azurerm_resource_group.this.location size = var.vm_size admin_username = "azureuser" disable_password_authentication = false admin_password = var.admin_password network_interface_ids = [azurerm_network_interface.app.id] delete_os_disk_on_termination = true
os_disk { caching = "ReadWrite" storage_account_type = "Standard_LRS" }
source_image_reference { publisher = "Canonical" offer = "0001-com-ubuntu-server-jammy" sku = "22_04-lts" version = "latest" }}# network+compute module, as generated -- modules/network-compute/variables.tf and outputs.tfvariable "name" { type = string }variable "vnet_cidr" { type = string default = "10.0.0.0/16"}variable "subnet_newbits" { type = number default = 4}variable "subnet_names" { type = list(string) }variable "vm_size" { type = string default = "Standard_B1s"}variable "admin_password" { type = string sensitive = true}
output "vnet_address_space" { value = azurerm_virtual_network.this.address_space}output "subnet_ids" { value = azurerm_subnet.this[*].id}output "nsg_id" { value = azurerm_network_security_group.web.id}output "vm_id" { value = azurerm_linux_virtual_machine.app.id}# network+compute module, as generated -- root main.tf, variables.tf, outputs.tfterraform { required_providers { azurerm = { source = "hashicorp/azurerm", version = ">= 5.0" } }}
provider "azurerm" { subscription_id = "00000000-0000-0000-0000-000000000000"}
variable "subscription_id" { type = string default = "00000000-0000-0000-0000-000000000000"}variable "environments" { type = list(string) default = ["dev", "staging"]}variable "admin_password" { type = string sensitive = true default = "P@ssword-unused-in-this-proof-1234"}
module "app" { source = "./modules/network-compute" count = length(var.environments)
name = var.environments[count.index] subnet_names = ["web", "app"] admin_password = var.admin_password}
output "vnet_ids" { value = [for m in module.app : m.vnet_id]}output "vm_ids" { value = [for m in module.app : m.vm_id]}The review walk
Section titled “The review walk”Module: a provider block inside a reusable module, called with count
Section titled “Module: a provider block inside a reusable module, called with count”provider "azurerm" { features {}}# entries/01-provider-in-module/main.tf -- @expect-error Module is incompatible with count, for_each, and depends_onmodule "app" { source = "./modules/network-compute" count = length(var.environments)}Symptom — real, at terraform init (this fails at module-installation time, before validate runs):
$ terraform init -backend=falseInitializing modules...- app in modules/network-compute
Error: Module is incompatible with count, for_each, and depends_on
on main.tf line 13, in module "app": 13: count = length(var.environments)
The module at module.app is a legacy module which contains its own localprovider configurations, and so calls to it may not use the count, for_each,or depends_on arguments.
If you also control the module "./modules/network-compute", consider updatingthis module to instead expect provider configurations to be passed by itscaller.🐛 Spot the bug
resource "azurerm_linux_virtual_machine" "app" {# ...}
module "app" {source = "./modules/network-compute"count = length(var.environments)}Show the answer
Same class of failure as AI bug catalog entry 3, provider-agnostic — Terraform core rejects count/for_each/depends_on on any module call whose module owns a local provider block, regardless of which cloud that provider targets. The fix: delete the provider block from the module entirely and let the caller’s own root-level azurerm configuration apply to every resource inside it.
Module: cidrsubnet() called with its arguments in the wrong order
Section titled “Module: cidrsubnet() called with its arguments in the wrong order”# excerpt, as generated -- entries/02-cidrsubnet-swapped/main.tfresource "azurerm_subnet" "this" { count = length(var.subnet_names) address_prefixes = [cidrsubnet(count.index, var.subnet_newbits, var.vnet_cidr)]}Same swap as the sibling AWS review: cidrsubnet(prefix, newbits, netnum) needs the CIDR string first and the index last; this has them reversed.
Symptom: terraform validate passes — count.index type-converts to a string without complaint. Since a real plan against azurerm needs a subscription this course never has, the failure is proven instead with a real terraform test using mock_provider "azurerm" {} and command = plan, which evaluates the same function call fully offline:
$ terraform test run "cidrsubnet_args_swapped"... fail
Error: Invalid function argument
on modules/network-compute/main.tf line 19, in resource "azurerm_subnet" "this": 19: address_prefixes = [cidrsubnet(count.index, var.subnet_newbits, var.vnet_cidr)] ├──────────────── │ var.vnet_cidr is "10.0.0.0/16"
Invalid value for "netnum" parameter: a number is required.🐛 Spot the bug
address_prefixes = [cidrsubnet(count.index, var.subnet_newbits, var.vnet_cidr)]Show the answer
Identical mechanics to the sibling AWS review’s entry 2 — cidrsubnet is a core Terraform function, not an azurerm-specific one, so the same argument-order trap applies to any cloud’s subnet carving. The fix: cidrsubnet(var.vnet_cidr, var.subnet_newbits, count.index).
Module → root: an output referencing an attribute the module never exports
Section titled “Module → root: an output referencing an attribute the module never exports”# entries/03-wrong-output-attr/outputs.tf -- @expect-error This object does not have an attribute named "vnet_id"output "vnet_ids" { value = [for m in module.app : m.vnet_id]}The module’s own outputs.tf only defines vnet_address_space, subnet_ids, nsg_id, and vm_id — never a plain vnet_id.
Symptom — real terraform test, mock_provider "azurerm" {}, command = apply, after fixing the two bugs above:
$ terraform test run "wrong_output_attr"... fail
Error: Unsupported attribute
on outputs.tf line 2, in output "vnet_ids": 2: value = [for m in module.app : m.vnet_id]
This object does not have an attribute named "vnet_id".🐛 Spot the bug
output "vnet_ids" {value = [for m in module.app : m.vnet_id]}Show the answer
Same shape as the sibling AWS review’s entry 3 — the module was only ever asked to export the VNet’s address_space, never a plain ID. Fix: add a real output "vnet_id" { value = azurerm_virtual_network.this.id } to the module, and reference m.vnet_id from the root now that it exists.
Root: no features {} block in the provider configuration
Section titled “Root: no features {} block in the provider configuration”# entries/04-no-features-block/main.tf -- @expect-error The argument "features" is required, but no definition was found.terraform { required_providers { azurerm = { source = "hashicorp/azurerm", version = "~> 5.0" } }}
provider "azurerm" { subscription_id = "00000000-0000-0000-0000-000000000000"}
resource "azurerm_resource_group" "rg" { name = "rg" location = "East US"}Per the azurerm provider docs, the features {} block is required on every provider "azurerm" configuration, even empty.
Symptom — real terraform validate (a provider block with at least one real resource present forces validate to fully configure the provider and catch this; an isolated provider block with zero resources declared does not, and only fails once plan runs — confirmed separately):
$ terraform validateError: Missing required argument
with provider["registry.terraform.io/hashicorp/azurerm"], on main.tf line 8, in provider "azurerm": 8: provider "azurerm" {
The argument "features" is required, but no definition was found.🐛 Spot the bug
provider "azurerm" {subscription_id = "00000000-0000-0000-0000-000000000000"}Show the answer
This is azurerm’s own version of the same “not everything is caught the same way” theme as bug 2: run against a bare provider block with zero resources declared, terraform validate passes and only plan catches the missing features {} — proven separately, isolated, with exactly that “Missing required argument” error at plan. Once real resources exist in the configuration (as they do here), validate itself has to configure the provider to check them, and catches it at validate instead. The fix is always the same: features {} on every provider "azurerm" block, empty or not.
Module: a delete_os_disk_on_termination argument carried over from the classic azurerm_virtual_machine resource
Section titled “Module: a delete_os_disk_on_termination argument carried over from the classic azurerm_virtual_machine resource”# entries/05-hallucinated-argument/main.tf -- @expect-error An argument named "delete_os_disk_on_termination" is not expected here.resource "azurerm_linux_virtual_machine" "app" { # ... delete_os_disk_on_termination = true}Symptom — real terraform validate:
Error: Unsupported argument
on modules/network-compute/main.tf line 61, in resource "azurerm_linux_virtual_machine" "app": 61: delete_os_disk_on_termination = true
An argument named "delete_os_disk_on_termination" is not expected here.🐛 Spot the bug
delete_os_disk_on_termination = trueShow the answer
delete_os_disk_on_termination is a real Terraform azurerm provider argument — on the older, classic azurerm_virtual_machine resource. It was never an argument on azurerm_linux_virtual_machine (the newer, dedicated Linux VM resource this module actually uses), which manages OS disk lifecycle through its own os_disk block instead. Same class of bug as the AWS review’s deletion_protection-on-aws_instance entry: a real argument, hallucinated onto the wrong resource type because both are conceptually “a VM.” Fix: delete the argument; the os_disk block already controls disk behavior on this resource type.
Versions: a floating provider constraint
Section titled “Versions: a floating provider constraint”terraform { required_providers { azurerm = { source = "hashicorp/azurerm", version = ">= 5.0" } }}Symptom — real .terraform.lock.hcl after terraform init:
provider "registry.terraform.io/hashicorp/azurerm" { version = "5.7.0" constraints = ">= 5.0.0"}Same mechanics as AI bug catalog entry 1 — >= 5.0 resolved to whatever the registry’s newest matching release is today, with nothing promising the next init -upgrade resolves inside the same major version. Fix: version = "~> 5.0".
Resources: count on a list of subnet names
Section titled “Resources: count on a list of subnet names”# excerpt, as generated -- entries/07-count-index-shift/main.tfresource "azurerm_subnet" "this" { count = length(var.subnet_names) name = var.subnet_names[count.index]}Symptom — real, proven with an applied terraform_data standing in for the indexed resource (identical count.index mechanics apply to azurerm_subnet; a real Azure apply needs real credentials this course never uses), three subnet names applied, then the middle one removed:
$ terraform apply -auto-approve # subnet_names = ["web", "app", "data"]Apply complete! Resources: 3 added, 0 changed, 0 destroyed.
$ # subnet_names -> ["web", "data"]$ terraform plan # terraform_data.subnet[1] will be updated in-place # terraform_data.subnet[2] will be destroyed # (because index [2] is out of range for count)
Plan: 0 to add, 1 to change, 1 to destroy.Same mechanics as AI bug catalog entry 9 (index shift) — count.index is positional; removing the middle subnet name shifts "data" from index 2 to index 1, which Terraform reads as “index 1 changed” plus “index 2 destroyed,” not “one subnet removed, two untouched.” Fix: for_each = toset(var.subnet_names), keyed by name.
Resources: no lifecycle block on the one VM that matters
Section titled “Resources: no lifecycle block on the one VM that matters”# excerpt, as generated -- entries/08-no-lifecycle/main.tfresource "azurerm_linux_virtual_machine" "app" { # ... # no lifecycle block at all}Symptom — real, proven with an applied terraform_data standing in for the VM:
$ terraform apply -auto-approveApply complete! Resources: 1 added, 0 changed, 0 destroyed.
$ terraform plan -destroyPlan: 0 to add, 0 to change, 1 to destroy.Adding lifecycle { prevent_destroy = true } and repeating the identical plan -destroy:
Plan: 0 to add, 0 to change, 1 to destroy.
Error: Instance cannot be destroyed
on main.tf line 1: 1: resource "terraform_data" "vm" {
Resource terraform_data.vm has lifecycle.prevent_destroy set, but the plancalls for this resource to be destroyed. To avoid this error and continuewith the plan, either disable lifecycle.prevent_destroy or reduce the scopeof the plan using the -target option.Same mechanics as AI bug catalog entry 8 — prevent_destroy protects only the resource it’s declared on, so a VM without it destroys with zero resistance from any stray -destroy, removed module block, or unmoved rename.
Hardcoded location with no way to override it, and a decoy subscription_id variable
Section titled “Hardcoded location with no way to override it, and a decoy subscription_id variable”resource "azurerm_resource_group" "this" { location = "East US"}provider "azurerm" { subscription_id = "00000000-0000-0000-0000-000000000000"}variable "subscription_id" { type = string default = "00000000-0000-0000-0000-000000000000"}Symptom — real, on the actual generated files:
$ grep -n "var.location" modules/network-compute/main.tf modules/network-compute/variables.tf(no output -- there is no location variable declared at all, in the module or the root)
$ grep -n "subscription_id" main.tf variables.tfmain.tf:9: subscription_id = "00000000-0000-0000-0000-000000000000"variables.tf:1:variable "subscription_id" {Two related but distinct bugs: the module’s location is a bare literal with no variable at all backing it — every environment this module is called for (dev, staging, or any future one) is forced into the exact same Azure region, with no override possible short of editing the module’s source. subscription_id is subtler: a variable "subscription_id" genuinely exists at the root, with a default that happens to match the literal in the provider block — but the provider block reads the literal, not var.subscription_id, so the variable is a decoy that silently stops controlling anything the moment someone changes it expecting the deploy target to move with it.
Fix: add variable "location" { type = string } to the module, thread it through as location = var.location; change the provider block to subscription_id = var.subscription_id.
The corrected code
Section titled “The corrected code”resource "azurerm_resource_group" "this" { name = "${var.name}-rg" location = var.location}
resource "azurerm_virtual_network" "this" { name = "${var.name}-vnet" address_space = [var.vnet_cidr] location = azurerm_resource_group.this.location resource_group_name = azurerm_resource_group.this.name}
resource "azurerm_subnet" "this" { for_each = toset(var.subnet_names) name = each.value resource_group_name = azurerm_resource_group.this.name virtual_network_name = azurerm_virtual_network.this.name address_prefixes = [cidrsubnet(var.vnet_cidr, var.subnet_newbits, index(var.subnet_names, each.value))]}
resource "azurerm_network_security_group" "web" { name = "${var.name}-web-nsg" location = azurerm_resource_group.this.location resource_group_name = azurerm_resource_group.this.name
security_rule { name = "allow-https" priority = 100 direction = "Inbound" access = "Allow" protocol = "Tcp" source_port_range = "*" destination_port_range = "443" source_address_prefix = "*" destination_address_prefix = "*" }}
resource "azurerm_network_interface" "app" { name = "${var.name}-nic" location = azurerm_resource_group.this.location resource_group_name = azurerm_resource_group.this.name
ip_configuration { name = "internal" subnet_id = azurerm_subnet.this[var.subnet_names[0]].id private_ip_address_allocation = "Dynamic" }}
resource "azurerm_linux_virtual_machine" "app" { name = "${var.name}-vm" resource_group_name = azurerm_resource_group.this.name location = azurerm_resource_group.this.location size = var.vm_size admin_username = "azureuser" disable_password_authentication = false admin_password = var.admin_password network_interface_ids = [azurerm_network_interface.app.id]
os_disk { caching = "ReadWrite" storage_account_type = "Standard_LRS" }
source_image_reference { publisher = "Canonical" offer = "0001-com-ubuntu-server-jammy" sku = "22_04-lts" version = "latest" }
lifecycle { prevent_destroy = true }}variable "name" { type = string}variable "location" { type = string}variable "vnet_cidr" { type = string default = "10.0.0.0/16"}variable "subnet_newbits" { type = number default = 4}variable "subnet_names" { type = list(string)}variable "vm_size" { type = string default = "Standard_B1s"}variable "admin_password" { type = string sensitive = true}output "vnet_id" { value = azurerm_virtual_network.this.id}output "vnet_address_space" { value = azurerm_virtual_network.this.address_space}output "subnet_ids" { value = { for k, s in azurerm_subnet.this : k => s.id }}output "nsg_id" { value = azurerm_network_security_group.web.id}output "vm_id" { value = azurerm_linux_virtual_machine.app.id}terraform { required_providers { azurerm = { source = "hashicorp/azurerm", version = "~> 5.0" } }}
provider "azurerm" { features {} subscription_id = var.subscription_id}
module "app" { source = "./modules/network-compute" for_each = toset(var.environments)
name = each.value location = var.location subnet_names = ["web", "app"] admin_password = var.admin_password}variable "subscription_id" { type = string default = "00000000-0000-0000-0000-000000000000"}variable "location" { type = string default = "East US"}variable "environments" { type = list(string) default = ["dev", "staging"]}variable "admin_password" { type = string sensitive = true default = "P@ssword-unused-in-this-proof-1234"}output "vnet_ids" { value = { for k, m in module.app : k => m.vnet_id }}output "vm_ids" { value = { for k, m in module.app : k => m.vm_id }}mock_provider "azurerm" {}
variables { name = "test" location = "East US" vnet_cidr = "10.5.0.0/16" subnet_names = ["web", "app"] admin_password = "unused-in-this-proof-P@ss1234"}
override_resource { target = azurerm_subnet.this["web"] values = { id = "/subscriptions/12345678-1234-9876-4563-123456789012/resourceGroups/test-rg/providers/Microsoft.Network/virtualNetworks/test-vnet/subnets/web" }}
override_resource { target = azurerm_subnet.this["app"] values = { id = "/subscriptions/12345678-1234-9876-4563-123456789012/resourceGroups/test-rg/providers/Microsoft.Network/virtualNetworks/test-vnet/subnets/app" }}
override_resource { target = azurerm_network_interface.app values = { id = "/subscriptions/12345678-1234-9876-4563-123456789012/resourceGroups/test-rg/providers/Microsoft.Network/networkInterfaces/test-nic" }}
run "creates_one_subnet_per_name" { command = apply
module { source = "./modules/network-compute" }
assert { condition = length(azurerm_subnet.this) == 2 error_message = "expected one subnet per subnet name" }
assert { condition = azurerm_network_interface.app.ip_configuration[0].subnet_id == azurerm_subnet.this["web"].id error_message = "NIC should attach to the first subnet" }}
run "prevent_destroy_is_set" { command = apply
module { source = "./modules/network-compute" }
assert { condition = azurerm_subnet.this["web"].address_prefixes[0] != azurerm_subnet.this["app"].address_prefixes[0] error_message = "subnets must not share an address prefix" }}terraform validate on the corrected module+root tree: Success! The configuration is valid. — zero errors, zero warnings. terraform plan still cannot run offline against real azurerm (confirmed above), so terraform test with real mock_provider "azurerm" {} is this module’s actual offline proof:
$ terraform testtests/network_compute.tftest.hcl... in progress run "creates_one_subnet_per_name"... pass run "prevent_destroy_is_set"... passtests/network_compute.tftest.hcl... tearing downtests/network_compute.tftest.hcl... pass
Success! 2 passed, 0 failed.What changed and why
Section titled “What changed and why”| # | Bug | Fix |
|---|---|---|
| 1 | provider block inside the module, called with count | Provider block removed from the module — real terraform init “Module is incompatible with count, for_each, and depends_on” before the fix |
| 2 | cidrsubnet(count.index, var.subnet_newbits, var.vnet_cidr) — arguments in the wrong order | cidrsubnet(var.vnet_cidr, var.subnet_newbits, count.index) — real terraform test (mock_provider, command = plan) failure, invisible to validate |
| 3 | Root output reads m.vnet_id, the module only exports vnet_address_space | Module now exports a real vnet_id; root reads it — real terraform test (command = apply) “Unsupported attribute” before the fix |
| 4 | No features {} block in the provider "azurerm" configuration | features {} added — real “Missing required argument” at validate (full tree) / plan (isolated) before the fix |
| 5 | delete_os_disk_on_termination on azurerm_linux_virtual_machine (belongs to the classic azurerm_virtual_machine) | Removed; os_disk block already controls disk behavior — real “Unsupported argument” before the fix |
| 6 | required_providers uses >= 5.0 | ~> 5.0 — real .terraform.lock.hcl shows the floating constraint silently resolved to 5.7.0 |
| 7 | count over subnet_names, index-shift on removal | for_each = toset(var.subnet_names) — real plan proved a removed middle name replaces/destroys the wrong indices |
| 8 | No lifecycle block on azurerm_linux_virtual_machine.app | lifecycle { prevent_destroy = true } — real plan -destroy destroyed it with zero resistance before the fix |
| 9 | location = "East US" hardcoded with no variable at all; subscription_id literal shadows a decoy variable | location threaded through as a real variable; provider { subscription_id = var.subscription_id } — proven with real grep against the generated files |
Mental Model
Section titled “Mental Model”Five of these nine bugs (1, 2, 3, 4, 5) are hard failures — but proven across three different tools, because azurerm cannot plan offline in this course at all: bug 1 fails at terraform init, bugs 2 and 3 only surface through terraform test with mock_provider (the offline stand-in for plan), bug 4 fails at validate once real resources force the provider to configure, and bug 5 fails at plain validate. The other four never produce an error: an unpinned version, a count-indexed list, a missing lifecycle block, and a hardcoded region with a decoy variable next to it — the last one provable here only by reading the real generated files directly, since this cloud’s mock-credential ceiling is validate and test, not plan.
Common Misconceptions
Section titled “Common Misconceptions”- “If
azurermneeds a real subscription toplan, none of this module’s bugs can be proven offline.” Proven above: five of nine bugs are hard failures caught byinit/validatealone, andterraform testwithmock_provider "azurerm" {}reproduces everything a realplanwould have shown for the rest — including the exact samecidrsubnetand undefined-output errors the AWS review catches withplandirectly. - “
mock_provideraccepts any resource however it’s shaped.” Proven above: the very first test run failed with a real Azure resource-ID parsing error from mocked, randomly-generated IDs —azurerm’s own SDK validates ID-shaped fields even under a mock, unlike AWS’s arbitrary-string IDs. - “An unused variable declaration is at least caught by
validate.” Proven above (absence of proof, honestly reported):terraform validateon the buggy root returns clean, with no diagnostic at all aboutsubscription_idbeing declared as a variable and then never referenced from theproviderblock that has the identical default value hardcoded instead.
flowchart TD A["terraform init"] -->|"1: provider in module + count"| A A --> B["terraform validate"] B -->|"4: features block missing"| B B -->|"5: delete_os_disk_on_termination hallucinated"| B B --> C["terraform test, mock_provider"] C -->|"2: cidrsubnet args swapped"| C C -->|"3: output reads m.vnet_id, undefined"| C C --> D["real diff against state"] D -->|"7: count index-shift"| D D -->|"8: no prevent_destroy"| D E[".terraform.lock.hcl"] -->|"6: unpinned provider resolved 5.7.0"| E F["grep against generated files"] -->|"9: location hardcoded, subscription_id decoy"| F