Worked review 1: network + compute module
Feature ที่ต้องทำ
หัวข้อที่มีชื่อว่า “Feature ที่ต้องทำ”Prompt คือ: “เขียน Terraform module ที่ reuse ได้สำหรับ virtual network พร้อม subnets, network security group, และ Linux VM แล้วเรียกใช้ครั้งละหนึ่ง environment” สิ่งที่ตามมาคือ draft แรกแบบที่ AI assistant ส่งกลับมาให้กับ prompt แบบนี้พอดี — ทุก block ดูสมเหตุสมผลเป็น HCL รายตัว resource type เป็นของจริง ชื่อ argument ส่วนใหญ่ก็เป็นของจริง อ่านตามที่ review checklist แนะนำ: provider และ version ก่อน แล้วตามด้วย backend แล้วค่อย resources กับ lifecycle แล้วจึงถึงขอบเขตของ module เอง มี bug จริง 9 ข้อ; ดู AI bug catalog สำหรับ pattern ทั่วไปเบื้องหลังแต่ละข้อ ทุกข้อที่กล่าวถึงด้านล่างพิสูจน์ด้วย terraform init/validate/test จริง กับ Terraform 1.16.4 และ hashicorp/azurerm 5.7.0 ส่วน terraform plan จริงกับ provider นี้ต้องมี subscription จริงและ login Azure CLI จริงซึ่งคอร์สนี้ไม่มีเลย — พิสูจน์ตรงด้านล่างนี้เอง — ดังนั้น bug ที่ review ฝั่ง AWS พิสูจน์ด้วย plan จะพิสูจน์ที่นี่ด้วย terraform test และ mock_provider "azurerm" {} แทน เหมือนที่ harness ของคอร์สนี้ทำทุกที่
โค้ดที่ AI สร้าง
หัวข้อที่มีชื่อว่า “โค้ดที่ AI สร้าง”# 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]}เดินรีวิวทีละ bug
หัวข้อที่มีชื่อว่า “เดินรีวิวทีละ bug”Module: provider block อยู่ใน module ที่ reuse ได้ เรียกด้วย count
หัวข้อที่มีชื่อว่า “Module: provider block อยู่ใน module ที่ reuse ได้ เรียกด้วย 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)}อาการ — จริง ตอน terraform init (พังตอน install module ก่อน validate จะรันด้วยซ้ำ):
$ 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.🐛 หา bug ในโค้ดนี้
resource "azurerm_linux_virtual_machine" "app" {# ...}
module "app" {source = "./modules/network-compute"count = length(var.environments)}เฉลย
bug ประเภทเดียวกับ AI bug catalog entry 3 ไม่ขึ้นกับ provider เลย — Terraform core ปฏิเสธ count/for_each/depends_on บนการเรียก module ใดๆ ที่ module เองมี provider block ของตัวเอง ไม่ว่า provider นั้นจะเล็งไป cloud ไหน วิธีแก้: ลบ provider block ออกจาก module ทั้งหมด แล้วให้ provider configuration ระดับ root ของ caller เอง apply กับทุก resource ข้างใน
Module: เรียก cidrsubnet() สลับลำดับ argument
หัวข้อที่มีชื่อว่า “Module: เรียก cidrsubnet() สลับลำดับ argument”# 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)]}สลับแบบเดียวกับ review ฝั่ง AWS: cidrsubnet(prefix, newbits, netnum) ต้องการ CIDR string มาก่อนแล้ว index มาท้ายสุด อันนี้สลับกัน
อาการ: terraform validate ผ่าน — count.index แปลงชนิดเป็น string ได้โดยไม่มีปัญหา เพราะ plan จริงกับ azurerm ต้องมี subscription จริงที่คอร์สนี้ไม่มีเลย ความพังเลยพิสูจน์ด้วย terraform test จริงที่ใช้ mock_provider "azurerm" {} และ command = plan แทน ซึ่งประเมิน function call เดียวกันแบบ 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.🐛 หา bug ในโค้ดนี้
address_prefixes = [cidrsubnet(count.index, var.subnet_newbits, var.vnet_cidr)]เฉลย
mechanics เดียวกับ entry 2 ของ review ฝั่ง AWS — cidrsubnet เป็นฟังก์ชัน core ของ Terraform ไม่ใช่ฟังก์ชันเฉพาะ azurerm กับดักลำดับ argument นี้เลยใช้ได้กับการแบ่ง subnet ของ cloud ไหนก็ได้เหมือนกัน วิธีแก้: cidrsubnet(var.vnet_cidr, var.subnet_newbits, count.index)
Module → root: output อ้าง attribute ที่ module ไม่เคย export
หัวข้อที่มีชื่อว่า “Module → root: output อ้าง attribute ที่ module ไม่เคย export”# 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]}outputs.tf ของ module เองมีแค่ vnet_address_space, subnet_ids, nsg_id, และ vm_id — ไม่เคยมี vnet_id เปล่าๆ
อาการ — terraform test จริง, mock_provider "azurerm" {}, command = apply, หลังแก้ 2 bug ด้านบนแล้ว:
$ 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".🐛 หา bug ในโค้ดนี้
output "vnet_ids" {value = [for m in module.app : m.vnet_id]}เฉลย
รูปแบบเดียวกับ entry 3 ของ review ฝั่ง AWS — module ถูกสั่งให้ export แค่ address_space ของ VNet ไม่เคยมี ID เปล่าๆ วิธีแก้: เพิ่ม output "vnet_id" { value = azurerm_virtual_network.this.id } จริงเข้าไปใน module แล้วอ่าน m.vnet_id จาก root ตอนนี้ที่มันมีอยู่จริงแล้ว
Root: ไม่มี features {} block ใน provider configuration
หัวข้อที่มีชื่อว่า “Root: ไม่มี features {} block ใน 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"}ตาม เอกสาร provider azurerm features {} block จำเป็นต้องมีบนทุก provider "azurerm" configuration แม้จะเป็น block เปล่าก็ตาม
อาการ — terraform validate จริง (provider block ที่มี resource จริงอย่างน้อยหนึ่งตัวบังคับให้ validate configure provider เต็มรูปแบบแล้วจับได้ — provider block เดี่ยวๆ ที่ไม่มี resource เลยจะไม่จับ จนกว่า plan จะรันเท่านั้น — ยืนยันแยกต่างหากแล้ว):
$ 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.🐛 หา bug ในโค้ดนี้
provider "azurerm" {subscription_id = "00000000-0000-0000-0000-000000000000"}เฉลย
นี่คือธีม “ไม่ใช่ทุกอย่างจะถูกจับแบบเดียวกัน” เวอร์ชันของ azurerm เอง เหมือน bug 2 — รันกับ provider block เปล่าๆ ที่ไม่มี resource เลย terraform validate ผ่าน มีแค่ plan เท่านั้นที่จับ features {} ที่หายไป — พิสูจน์แยกต่างหากด้วย error “Missing required argument” ตัวเดียวกันเป๊ะตอน plan พอมี resource จริงอยู่ใน configuration (แบบที่นี่) validate เองต้อง configure provider เพื่อเช็ค resource เหล่านั้น เลยจับได้ตั้งแต่ validate แทน วิธีแก้เหมือนเดิมเสมอ: features {} บนทุก provider "azurerm" block ไม่ว่าจะเปล่าหรือไม่
Module: argument delete_os_disk_on_termination ที่ยกมาจาก resource รุ่นเก่า azurerm_virtual_machine
หัวข้อที่มีชื่อว่า “Module: argument delete_os_disk_on_termination ที่ยกมาจาก resource รุ่นเก่า azurerm_virtual_machine”# 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}อาการ — 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.🐛 หา bug ในโค้ดนี้
delete_os_disk_on_termination = trueเฉลย
delete_os_disk_on_termination เป็น argument จริงของ provider azurerm — แต่อยู่บน resource รุ่นเก่า classic azurerm_virtual_machine ไม่เคยเป็น argument ของ azurerm_linux_virtual_machine (resource สำหรับ Linux VM รุ่นใหม่กว่าที่ module นี้ใช้จริง) ซึ่งจัดการ OS disk lifecycle ผ่าน os_disk block ของตัวเองแทน bug ประเภทเดียวกับ entry deletion_protection บน aws_instance ของ review ฝั่ง AWS: argument จริง แต่หลอนไปอยู่บน resource type ผิด เพราะทั้งคู่เป็น “VM” ในเชิง concept วิธีแก้: ลบ argument ออก os_disk block ควบคุมพฤติกรรม disk ของ resource type นี้อยู่แล้ว
Versions: provider constraint แบบลอย
หัวข้อที่มีชื่อว่า “Versions: provider constraint แบบลอย”terraform { required_providers { azurerm = { source = "hashicorp/azurerm", version = ">= 5.0" } }}อาการ — .terraform.lock.hcl จริง หลัง terraform init:
provider "registry.terraform.io/hashicorp/azurerm" { version = "5.7.0" constraints = ">= 5.0.0"}mechanics เดียวกับ AI bug catalog entry 1 — >= 5.0 resolve ไปเป็น release ล่าสุดที่ registry มี ณ วันนี้ ไม่มีอะไรรับประกันว่า init -upgrade ครั้งถัดไปจะ resolve อยู่ใน major version เดียวกัน วิธีแก้: version = "~> 5.0"
Resources: count บน list ของชื่อ subnet
หัวข้อที่มีชื่อว่า “Resources: count บน list ของชื่อ subnet”# excerpt, as generated -- entries/07-count-index-shift/main.tfresource "azurerm_subnet" "this" { count = length(var.subnet_names) name = var.subnet_names[count.index]}อาการ — พิสูจน์จริงด้วย terraform_data ที่ apply แล้วแทน resource ที่ index (mechanics count.index เหมือนกันเป๊ะกับ azurerm_subnet; apply จริงกับ Azure ต้องใช้ credential จริงซึ่งคอร์สนี้ไม่ใช้เลย) apply 3 ชื่อ subnet แล้วลบตัวกลางออก:
$ 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.mechanics เดียวกับ AI bug catalog entry 9 (index shift) — count.index อิงตำแหน่ง การลบชื่อ subnet ตัวกลางทำให้ "data" เลื่อนจาก index 2 มาเป็น index 1 ซึ่ง Terraform อ่านว่า “index 1 เปลี่ยน” บวกกับ “index 2 ถูกทำลาย” ไม่ใช่ “ลบ subnet ตัวเดียว อีกสองตัวไม่โดนแตะ” วิธีแก้: for_each = toset(var.subnet_names) โดย key ด้วยชื่อ
Resources: ไม่มี lifecycle block บน VM ตัวที่สำคัญที่สุด
หัวข้อที่มีชื่อว่า “Resources: ไม่มี lifecycle block บน VM ตัวที่สำคัญที่สุด”# excerpt, as generated -- entries/08-no-lifecycle/main.tfresource "azurerm_linux_virtual_machine" "app" { # ... # no lifecycle block at all}อาการ — พิสูจน์จริงด้วย terraform_data ที่ apply แล้วแทน 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.เพิ่ม lifecycle { prevent_destroy = true } แล้วรัน 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.mechanics เดียวกับ AI bug catalog entry 8 — prevent_destroy ป้องกันแค่ resource ที่ประกาศไว้บนตัวมันเองเท่านั้น VM ที่ไม่มีมันเลยจะถูกทำลายโดยไม่มีแรงต้านจาก -destroy ที่พลาดมา, module block ที่ถูกลบ, หรือการเปลี่ยนชื่อที่ไม่ได้ใช้ moved
location hardcode ไม่มีทางเปลี่ยนได้ และตัวแปร subscription_id ที่หลอก
หัวข้อที่มีชื่อว่า “location hardcode ไม่มีทางเปลี่ยนได้ และตัวแปร subscription_id ที่หลอก”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"}อาการ — จริง บนไฟล์ที่ AI สร้างจริง:
$ 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" {สอง bug ที่เกี่ยวข้องกันแต่ต่างกันจริง: location ของ module เป็น literal เปล่าๆ ไม่มีตัวแปรรองรับเลย — ทุก environment ที่เรียก module นี้ (dev, staging, หรือตัวไหนในอนาคต) ถูกบังคับให้ไปที่ Azure region เดียวกันเป๊ะ ไม่มีทางเปลี่ยนได้เลยนอกจากแก้ source ของ module subscription_id แนบเนียนกว่า: variable "subscription_id" มีอยู่จริงที่ root พร้อม default ที่บังเอิญตรงกับ literal ใน provider block — แต่ provider block อ่าน literal ไม่ใช่ var.subscription_id ตัวแปรเลยเป็นตัวหลอกที่เลิกควบคุมอะไรแบบเงียบๆ ทันทีที่มีใครเปลี่ยนมันโดยคาดว่า deploy target จะขยับตาม
วิธีแก้: เพิ่ม variable "location" { type = string } เข้า module ส่งผ่านเป็น location = var.location; เปลี่ยน provider block เป็น subscription_id = var.subscription_id
โค้ดที่แก้แล้ว
หัวข้อที่มีชื่อว่า “โค้ดที่แก้แล้ว”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 กับ module+root ที่แก้แล้ว: Success! The configuration is valid. — error ศูนย์ warning ศูนย์ terraform plan ยัง offline กับ azurerm จริงไม่ได้อยู่ดี (พิสูจน์แล้วด้านบน) terraform test จริงด้วย mock_provider "azurerm" {} เลยเป็นบทพิสูจน์ offline จริงของ module นี้:
$ 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.อะไรเปลี่ยนและทำไม
หัวข้อที่มีชื่อว่า “อะไรเปลี่ยนและทำไม”| # | Bug | วิธีแก้ |
|---|---|---|
| 1 | provider block อยู่ใน module เรียกด้วย count | ลบ provider block ออกจาก module — terraform init จริงขึ้น “Module is incompatible with count, for_each, and depends_on” ก่อนแก้ |
| 2 | cidrsubnet(count.index, var.subnet_newbits, var.vnet_cidr) — argument ลำดับผิด | cidrsubnet(var.vnet_cidr, var.subnet_newbits, count.index) — terraform test จริง (mock_provider, command = plan) พัง ที่ validate มองไม่เห็น |
| 3 | root output อ่าน m.vnet_id แต่ module export แค่ vnet_address_space | module export vnet_id จริง; root อ่านมัน — terraform test จริง (command = apply) “Unsupported attribute” ก่อนแก้ |
| 4 | ไม่มี features {} block ใน provider configuration azurerm | เพิ่ม features {} — “Missing required argument” จริงที่ validate (ทั้งต้น) / plan (แยกเดี่ยว) ก่อนแก้ |
| 5 | delete_os_disk_on_termination บน azurerm_linux_virtual_machine (เป็นของ resource รุ่นเก่า azurerm_virtual_machine) | ลบออก; os_disk block ควบคุมพฤติกรรม disk อยู่แล้ว — “Unsupported argument” จริงก่อนแก้ |
| 6 | required_providers ใช้ >= 5.0 | ~> 5.0 — .terraform.lock.hcl จริงแสดงว่า constraint ลอยตัว resolve ไปเป็น 5.7.0 แบบเงียบๆ |
| 7 | count บน subnet_names, index เลื่อนตอนลบ | for_each = toset(var.subnet_names) — plan จริงพิสูจน์ว่าลบชื่อตัวกลางแล้ว replace/destroy index ผิดตัว |
| 8 | ไม่มี lifecycle block บน azurerm_linux_virtual_machine.app | lifecycle { prevent_destroy = true } — plan -destroy จริงทำลายมันโดยไม่มีแรงต้านก่อนแก้ |
| 9 | location = "East US" hardcode ไม่มีตัวแปรเลย; subscription_id literal บังตัวแปรหลอก | location ส่งผ่านเป็นตัวแปรจริง; provider { subscription_id = var.subscription_id } — พิสูจน์ด้วย grep จริงกับไฟล์ที่ AI สร้าง |
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”ห้าใน bug เก้าข้อนี้ (1, 2, 3, 4, 5) เป็น hard failure — แต่พิสูจน์ด้วยเครื่องมือสามแบบต่างกัน เพราะ azurerm plan offline ในคอร์สนี้ไม่ได้เลย: bug 1 พังตอน terraform init, bug 2 กับ 3 โผล่ผ่าน terraform test ด้วย mock_provider เท่านั้น (ตัวแทน offline ของ plan), bug 4 พังตอน validate พอมี resource จริงบังคับให้ configure provider, และ bug 5 พังที่ validate ธรรมดา อีกสี่ bug ที่เหลือไม่ error เลย: version ที่ไม่ pin, list ที่ index ด้วย count, lifecycle block ที่หายไป, และ region hardcode พร้อมตัวแปรหลอกข้างๆ — ข้อสุดท้ายพิสูจน์ได้ที่นี่ด้วยการอ่านไฟล์ที่ AI สร้างจริงเท่านั้น เพราะเพดานแบบ offline ของ cloud นี้คือ validate กับ test ไม่ใช่ plan
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “ถ้า
azurermต้องมี subscription จริงถึงจะplanได้ แปลว่า bug ของ module นี้พิสูจน์แบบ offline ไม่ได้เลย” พิสูจน์แล้วด้านบน: ห้าใน เก้า bug เป็น hard failure ที่init/validateเพียวๆ จับได้ และterraform testด้วยmock_provider "azurerm" {}ก็ทำซ้ำทุกอย่างที่planจริงจะแสดงสำหรับที่เหลือ — รวมถึง errorcidrsubnetและ output ที่ไม่มีอยู่จริงเดียวกันเป๊ะกับที่ review ฝั่ง AWS จับด้วยplanตรงๆ - “
mock_providerยอมรับ resource ทุกรูปทรง” พิสูจน์แล้วด้านบน: test รอบแรกพังด้วย error parsing Azure resource ID จริงจาก ID ที่ mock แล้วแบบสุ่ม — SDK ของazurermเองตรวจ field ที่เป็นรูปทรง ID แม้จะ mock อยู่ก็ตาม ต่างจาก ID แบบ string ตามใจของ AWS - “ตัวแปรที่ไม่ถูกใช้อย่างน้อย
validateก็จับได้” พิสูจน์แล้วด้านบน (การไม่มีหลักฐาน รายงานตรงไปตรงมา):terraform validateกับ root ที่มี bug คืนค่าสะอาด ไม่มี diagnostic ไหนพูดถึงsubscription_idที่ประกาศเป็นตัวแปรแล้วไม่เคยถูกอ้างจากproviderblock ที่ hardcode ค่า default เดียวกันเป๊ะไว้แทนเลย
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