Worked review 1: a network + compute module
The feature
Section titled “The feature”The prompt was: “Write a reusable Terraform module for a VPC with subnets, a firewall rule, and a Compute Engine instance, 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/google 8.4.0. A real terraform plan against this provider needs Application Default Credentials 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 "google" {} instead, exactly as the azurerm sibling review does too.
The generated code
Section titled “The generated code”# network+compute module, as generated -- modules/network-compute/main.tfprovider "google" { project = "test-project"}
resource "google_compute_network" "this" { name = "${var.name}-vpc" auto_create_subnetworks = false}
resource "google_compute_subnetwork" "this" { count = length(var.subnet_names) name = var.subnet_names[count.index] network = google_compute_network.this.id ip_cidr_range = cidrsubnet(count.index, var.subnet_newbits, var.vpc_cidr) region = var.region}
resource "google_compute_firewall" "web" { name = "${var.name}-allow-https" network = google_compute_network.this.name enable_logging = true
allow { protocol = "tcp" ports = ["443"] }
source_ranges = ["0.0.0.0/0"]}
resource "google_compute_instance" "app" { name = "${var.name}-app" machine_type = var.machine_type zone = var.zone
boot_disk { initialize_params { image = "debian-cloud/debian-12" } }}# network+compute module, as generated -- modules/network-compute/variables.tf and outputs.tfvariable "name" { type = string }variable "region" { type = string default = "us-central1"}variable "zone" { type = string default = "us-central1-a"}variable "vpc_cidr" { type = string default = "10.0.0.0/16"}variable "subnet_newbits" { type = number default = 4}variable "subnet_names" { type = list(string) }variable "machine_type" { type = string default = "e2-micro"}
output "network_self_link" { value = google_compute_network.this.self_link}output "subnet_ids" { value = google_compute_subnetwork.this[*].id}output "firewall_id" { value = google_compute_firewall.web.id}output "instance_id" { value = google_compute_instance.app.id}# network+compute module, as generated -- root main.tf, variables.tf, outputs.tfterraform { required_providers { google = { source = "hashicorp/google", version = ">= 8.0" } }}
provider "google" { project = "test-project-hardcoded"}
variable "project" { type = string default = "test-project-hardcoded"}variable "environments" { type = list(string) default = ["dev", "staging"]}
module "app" { source = "./modules/network-compute" count = length(var.environments)
name = var.environments[count.index] subnet_names = ["web", "app"]}
output "network_ids" { value = [for m in module.app : m.network_id]}output "instance_ids" { value = [for m in module.app : m.instance_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 "google" { project = "test-project"}# 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 "google_compute_instance" "app" {# ...}
module "app" {source = "./modules/network-compute"count = length(var.environments)}Show the answer
Same class of failure as the AWS and azurerm sibling reviews’ entry 1, 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 google 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 "google_compute_subnetwork" "this" { count = length(var.subnet_names) ip_cidr_range = cidrsubnet(count.index, var.subnet_newbits, var.vpc_cidr)}Same swap as both sibling reviews: 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. A real plan against google needs Application Default Credentials this course never has, so the failure is proven instead with a real terraform test using mock_provider "google" {} and command = plan, which evaluates the same function call fully offline:
$ terraform test run "cidrsubnet_args_swapped"... fail
Error: Invalid function argument
on modules/nc/main.tf line 11, in resource "google_compute_subnetwork" "this": 11: ip_cidr_range = cidrsubnet(count.index, var.subnet_newbits, var.vpc_cidr) ├──────────────── │ var.vpc_cidr is "10.0.0.0/16"
Invalid value for "netnum" parameter: a number is required.🐛 Spot the bug
ip_cidr_range = cidrsubnet(count.index, var.subnet_newbits, var.vpc_cidr)Show the answer
Identical mechanics to both sibling reviews’ entry 2 — cidrsubnet is a core Terraform function, not a google-specific one, so the same argument-order trap applies to any cloud’s subnet carving. The fix: cidrsubnet(var.vpc_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 "network_id"output "network_ids" { value = [for m in module.app : m.network_id]}The module’s own outputs.tf only defines network_self_link, subnet_ids, firewall_id, and instance_id — never a plain network_id.
Symptom — real terraform test, mock_provider "google" {}, command = apply, after fixing the bugs above:
$ terraform test run "wrong_output_attr"... fail
Error: Unsupported attribute
on outputs.tf line 2, in output "network_ids": 2: value = [for m in module.app : m.network_id]
This object does not have an attribute named "network_id".🐛 Spot the bug
output "network_ids" {value = [for m in module.app : m.network_id]}Show the answer
Same shape as both sibling reviews’ entry 3 — the module was only ever asked to export the network’s self_link, never a plain ID. Fix: add a real output "network_id" { value = google_compute_network.this.id } to the module, and reference m.network_id from the root now that it exists.
Resources: google_compute_instance with no network_interface block at all
Section titled “Resources: google_compute_instance with no network_interface block at all”# entries/04-missing-network-interface/main.tf -- @expect-error At least 1 "network_interface" blocks are required.resource "google_compute_instance" "app" { name = "app" machine_type = "e2-micro" zone = "us-central1-a"
boot_disk { initialize_params { image = "debian-cloud/debian-12" } }}Symptom — real terraform validate:
Error: Insufficient network_interface blocks
on main.tf line 10, in resource "google_compute_instance" "app": 10: resource "google_compute_instance" "app" {
At least 1 "network_interface" blocks are required.🐛 Spot the bug
resource "google_compute_instance" "app" {boot_disk { initialize_params { image = "debian-cloud/debian-12" } }# no network_interface block at all}Show the answer
Unlike the sibling AWS review, where a similarly-shaped omission (no subnet_id/vpc_security_group_ids set) validates cleanly and only fails later, google_compute_instance’s schema itself declares network_interface with a minimum of 1 block — the provider’s own schema rejects the omission immediately, every time, with zero ambiguity about which resource or field is wrong. Fix: add at least one network_interface { network = ... } block.
Resources: google_compute_firewall.enable_logging — deprecated in favor of log_config
Section titled “Resources: google_compute_firewall.enable_logging — deprecated in favor of log_config”resource "google_compute_network" "vpc" { name = "test-vpc" auto_create_subnetworks = false}
resource "google_compute_firewall" "web" { name = "allow-https" network = google_compute_network.vpc.name enable_logging = true
allow { protocol = "tcp" ports = ["443"] }}Symptom — real terraform validate (an attribute-level deprecation, confirmed to survive through a module call exactly like the AWS sibling review’s entry 2, unlike the azurerm sibling review’s block-level network_interface case):
Warning: Argument is deprecated
with module.app.google_compute_firewall.web, on modules/network-compute/main.tf line 18, in resource "google_compute_firewall" "web": 18: enable_logging = true
Deprecated in favor of log_config🐛 Spot the bug
enable_logging = trueShow the answer
Same class of bug as the AWS sibling review’s aws_s3_bucket.acl — schema-declared, attribute-level deprecation (confirmed via terraform providers schema -json, deprecated: true directly on the enable_logging attribute, not the resource’s own block). Both validate and plan succeed regardless; only a check that greps warnings out of validate -json catches it. Fix: replace with a log_config { metadata = "INCLUDE_ALL_METADATA" } block, which the real schema requires a metadata value for.
Versions: a floating provider constraint
Section titled “Versions: a floating provider constraint”terraform { required_providers { google = { source = "hashicorp/google", version = ">= 8.0" } }}Symptom — real .terraform.lock.hcl after terraform init:
provider "registry.terraform.io/hashicorp/google" { version = "8.4.0" constraints = ">= 8.0.0"}Same mechanics as AI bug catalog entry 1 — >= 8.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 = "~> 8.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 "google_compute_subnetwork" "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 google_compute_subnetwork; a real GCP apply needs real Application Default 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 instance that matters
Section titled “Resources: no lifecycle block on the one instance that matters”# excerpt, as generated -- entries/08-no-lifecycle/main.tfresource "google_compute_instance" "app" { # ... # no lifecycle block at all}Symptom — real, proven with an applied terraform_data standing in for the instance:
$ 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" "app" {
Resource terraform_data.app 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 Compute Engine instance without it destroys with zero resistance from any stray -destroy, removed module block, or unmoved rename.
Root: project hardcoded as a literal, a decoy variable next to it
Section titled “Root: project hardcoded as a literal, a decoy variable next to it”provider "google" { project = "test-project-hardcoded"}variable "project" { type = string default = "test-project-hardcoded"}Symptom — real, on the actual generated files:
$ grep -n "project" main.tf variables.tfmain.tf:8: project = "test-project-hardcoded"variables.tf:1:variable "project" {variables.tf:3: default = "test-project-hardcoded"A variable "project" 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.project, 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: provider "google" { project = var.project }.
The corrected code
Section titled “The corrected code”resource "google_compute_network" "this" { name = "${var.name}-vpc" auto_create_subnetworks = false}
resource "google_compute_subnetwork" "this" { for_each = toset(var.subnet_names) name = each.value network = google_compute_network.this.id ip_cidr_range = cidrsubnet(var.vpc_cidr, var.subnet_newbits, index(var.subnet_names, each.value)) region = var.region}
resource "google_compute_firewall" "web" { name = "${var.name}-allow-https" network = google_compute_network.this.name
log_config { metadata = "INCLUDE_ALL_METADATA" }
allow { protocol = "tcp" ports = ["443"] }
source_ranges = ["0.0.0.0/0"]}
resource "google_compute_instance" "app" { name = "${var.name}-app" machine_type = var.machine_type zone = var.zone
boot_disk { initialize_params { image = "debian-cloud/debian-12" } }
network_interface { network = google_compute_network.this.id subnetwork = google_compute_subnetwork.this[var.subnet_names[0]].id }
lifecycle { prevent_destroy = true }}variable "name" { type = string}variable "region" { type = string default = "us-central1"}variable "zone" { type = string default = "us-central1-a"}variable "vpc_cidr" { type = string default = "10.0.0.0/16"}variable "subnet_newbits" { type = number default = 4}variable "subnet_names" { type = list(string)}variable "machine_type" { type = string default = "e2-micro"}output "network_id" { value = google_compute_network.this.id}output "network_self_link" { value = google_compute_network.this.self_link}output "subnet_ids" { value = { for k, s in google_compute_subnetwork.this : k => s.id }}output "firewall_id" { value = google_compute_firewall.web.id}output "instance_id" { value = google_compute_instance.app.id}terraform { required_providers { google = { source = "hashicorp/google", version = "~> 8.0" } }}
provider "google" { project = var.project}
module "app" { source = "./modules/network-compute" for_each = toset(var.environments)
name = each.value subnet_names = ["web", "app"]}variable "project" { type = string default = "test-project-hardcoded"}variable "environments" { type = list(string) default = ["dev", "staging"]}output "network_ids" { value = { for k, m in module.app : k => m.network_id }}output "instance_ids" { value = { for k, m in module.app : k => m.instance_id }}mock_provider "google" {}
variables { name = "test" vpc_cidr = "10.5.0.0/16" subnet_names = ["web", "app"]}
run "creates_one_subnet_per_name" { command = apply
module { source = "./modules/network-compute" }
assert { condition = length(google_compute_subnetwork.this) == 2 error_message = "expected one subnetwork per subnet name" }
assert { condition = google_compute_instance.app.network_interface[0].subnetwork == google_compute_subnetwork.this["web"].id error_message = "instance should attach to the first subnetwork" }}
run "prevent_destroy_is_set" { command = apply
module { source = "./modules/network-compute" }
assert { condition = google_compute_subnetwork.this["web"].ip_cidr_range != google_compute_subnetwork.this["app"].ip_cidr_range error_message = "subnetworks must not share a CIDR range" }}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 google (Application Default Credentials required), so terraform test with real mock_provider "google" {} is this module’s actual offline proof — and unlike the azurerm sibling review, google’s mocked resource IDs need no override_resource at all:
$ 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.vpc_cidr) — arguments in the wrong order | cidrsubnet(var.vpc_cidr, var.subnet_newbits, count.index) — real terraform test (mock_provider, command = plan) failure, invisible to validate |
| 3 | Root output reads m.network_id, the module only exports network_self_link | Module now exports a real network_id; root reads it — real terraform test (command = apply) “Unsupported attribute” before the fix |
| 4 | google_compute_instance with no network_interface block | network_interface { network = ... } added — real “Insufficient network_interface blocks” at validate before the fix |
| 5 | google_compute_firewall.enable_logging (deprecated) | Replaced with log_config { metadata = "INCLUDE_ALL_METADATA" } — real deprecation warning at validate, confirmed to survive through the module call |
| 6 | required_providers uses >= 8.0 | ~> 8.0 — real .terraform.lock.hcl shows the floating constraint silently resolved to 8.4.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 google_compute_instance.app | lifecycle { prevent_destroy = true } — real plan -destroy destroyed it with zero resistance before the fix |
| 9 | project hardcoded as a literal, variable "project" never actually read | provider { project = var.project } — proven with real grep against the generated files |
Mental Model
Section titled “Mental Model”Four of these nine bugs (1, 2, 3, 4) are hard failures — but proven across two different tools, because google 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), and bug 4 fails at plain validate — the only one of the four hard failures across all three sibling reviews that’s caught by a required-block-count check baked directly into the resource’s own schema, with zero ambiguity. Bug 5 is a warning, not an error, and — unlike the azurerm sibling review’s block-level deprecation — survives a module call because it’s declared at the attribute level. The remaining four never produce any error at all: an unpinned version, a count-indexed list, a missing lifecycle block, and a hardcoded project ID with a decoy variable next to it, provable here only by reading the real generated files directly.
Common Misconceptions
Section titled “Common Misconceptions”- “A missing required nested block always behaves like AWS’s missing
subnet_id— valid at apply, silently wrong.” Proven above:google_compute_instancewith zeronetwork_interfaceblocks failsterraform validateimmediately, with an exact count-based message (“At least 1 … blocks are required”) — GCP’s schema enforces this structurally, where AWS’s equivalent omission does not. - “A deprecated argument always behaves the same way whether it’s an attribute or a block.” Proven across all three sibling reviews:
google_compute_firewall.enable_logging(an attribute) warns identically whether declared at the root or behind a module call, exactly like AWS’sacl/managed_policy_arns; azurerm’snetwork_interface(a block) warns only at the root and silently stops warning behind a module call. The deprecation mechanism genuinely differs by kind, not just by provider. - “If
googleneeds Application Default Credentials toplan, none of this module’s bugs can be proven offline.” Proven above: four of nine bugs are hard failures caught byinit/validatealone, andterraform testwithmock_provider "google" {}reproduces everything a realplanwould have shown for the rest — with nooverride_resourceworkaround needed at all, unlike the azurerm sibling review’s mocked-ID gotcha.
flowchart TD A["terraform init"] -->|"1: provider in module + count"| A A --> B["terraform validate"] B -->|"4: network_interface missing, schema min_items"| B B -->|"5: enable_logging deprecated, survives module"| B B --> C["terraform test, mock_provider"] C -->|"2: cidrsubnet args swapped"| C C -->|"3: output reads m.network_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 8.4.0"| E F["grep against generated files"] -->|"9: project hardcoded, decoy variable"| F