ข้ามไปยังเนื้อหา

Worked review 1: network + compute module

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 ของคอร์สนี้ทำทุกที่

# network+compute module, as generated -- modules/network-compute/main.tf
provider "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.tf
variable "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.tf
terraform {
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]
}
entries/01-provider-in-module/modules/network-compute/main.tf
provider "azurerm" {
features {}
}
# entries/01-provider-in-module/main.tf -- @expect-error Module is incompatible with count, for_each, and depends_on
module "app" {
source = "./modules/network-compute"
count = length(var.environments)
}

อาการ — จริง ตอน terraform init (พังตอน install module ก่อน validate จะรันด้วยซ้ำ):

$ terraform init -backend=false
Initializing 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 local
provider 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 updating
this module to instead expect provider configurations to be passed by its
caller.

🐛 หา 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 ข้างใน

# excerpt, as generated -- entries/02-cidrsubnet-swapped/main.tf
resource "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)

# 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 ตอนนี้ที่มันมีอยู่จริงแล้ว

# 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 validate
Error: 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 นี้อยู่แล้ว

entries/06-unpinned-provider/main.tf
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"

# excerpt, as generated -- entries/07-count-index-shift/main.tf
resource "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 ด้วยชื่อ

# excerpt, as generated -- entries/08-no-lifecycle/main.tf
resource "azurerm_linux_virtual_machine" "app" {
# ...
# no lifecycle block at all
}

อาการ — พิสูจน์จริงด้วย terraform_data ที่ apply แล้วแทน VM:

$ terraform apply -auto-approve
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
$ terraform plan -destroy
Plan: 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 plan
calls for this resource to be destroyed. To avoid this error and continue
with the plan, either disable lifecycle.prevent_destroy or reduce the scope
of 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 ที่หลอก”
entries/09-hardcoded-region-and-subscription/excerpt
resource "azurerm_resource_group" "this" {
location = "East US"
}
entries/09-hardcoded-region-and-subscription/excerpt-root
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.tf
main.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

modules/network-compute/main.tf
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
}
}
modules/network-compute/variables.tf
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
}
modules/network-compute/outputs.tf
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
}
main.tf
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
}
variables.tf
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"
}
outputs.tf
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 }
}
tests/network_compute.tftest.hcl
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 test
tests/network_compute.tftest.hcl... in progress
run "creates_one_subnet_per_name"... pass
run "prevent_destroy_is_set"... pass
tests/network_compute.tftest.hcl... tearing down
tests/network_compute.tftest.hcl... pass
Success! 2 passed, 0 failed.
#Bugวิธีแก้
1provider block อยู่ใน module เรียกด้วย countลบ provider block ออกจาก module — terraform init จริงขึ้น “Module is incompatible with count, for_each, and depends_on” ก่อนแก้
2cidrsubnet(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 มองไม่เห็น
3root output อ่าน m.vnet_id แต่ module export แค่ vnet_address_spacemodule export vnet_id จริง; root อ่านมัน — terraform test จริง (command = apply) “Unsupported attribute” ก่อนแก้
4ไม่มี features {} block ใน provider configuration azurermเพิ่ม features {} — “Missing required argument” จริงที่ validate (ทั้งต้น) / plan (แยกเดี่ยว) ก่อนแก้
5delete_os_disk_on_termination บน azurerm_linux_virtual_machine (เป็นของ resource รุ่นเก่า azurerm_virtual_machine)ลบออก; os_disk block ควบคุมพฤติกรรม disk อยู่แล้ว — “Unsupported argument” จริงก่อนแก้
6required_providers ใช้ >= 5.0~> 5.0 — .terraform.lock.hcl จริงแสดงว่า constraint ลอยตัว resolve ไปเป็น 5.7.0 แบบเงียบๆ
7count บน subnet_names, index เลื่อนตอนลบfor_each = toset(var.subnet_names) — plan จริงพิสูจน์ว่าลบชื่อตัวกลางแล้ว replace/destroy index ผิดตัว
8ไม่มี lifecycle block บน azurerm_linux_virtual_machine.applifecycle { prevent_destroy = true } — plan -destroy จริงทำลายมันโดยไม่มีแรงต้านก่อนแก้
9location = "East US" hardcode ไม่มีตัวแปรเลย; subscription_id literal บังตัวแปรหลอกlocation ส่งผ่านเป็นตัวแปรจริง; provider { subscription_id = var.subscription_id } — พิสูจน์ด้วย grep จริงกับไฟล์ที่ AI สร้าง

ห้าใน 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 จริงจะแสดงสำหรับที่เหลือ — รวมถึง error cidrsubnet และ 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 ที่ประกาศเป็นตัวแปรแล้วไม่เคยถูกอ้างจาก provider block ที่ 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
แต่ละ bug อยู่ตรงไหนในเส้นทางประเมินของ module นี้
ทำไม error นี้ถึงโผล่ตอน terraform init แทนที่จะเป็น terraform validate?
Error: Module is incompatible with count, for_each, and depends_on
ทำไม review ฝั่ง AWS ถึงพิสูจน์ bug argument ลำดับผิดของ cidrsubnet ด้วย terraform plan จริง แต่ review azurerm นี้พิสูจน์ bug เดียวกันด้วย terraform test และ mock_provider แทน?
เวอร์ชันแรกของ .tftest.hcl พังด้วย parsing "rn1vk2cs": parsing the Subnet ID: the number of segments didn't match ทั้งที่ประกาศ mock_provider "azurerm" {} ไว้แล้ว นี่พิสูจน์อะไร?
provider "azurerm" block ที่ไม่มี resource เลยและไม่มี features {} block ผ่าน terraform validate แต่ features {} ที่หายไปเหมือนกันใน module+root ทั้งต้นที่มี resource จริง พัง validate ด้วย "Missing required argument" อะไรอธิบายความต่างนี้?
azurerm_subnet.this ใช้ count = length(var.subnet_names) apply 3 ชื่อ subnet จริง แล้วลบตัวกลางออกจาก list terraform plan จริงแสดงอะไร?
module hardcode location = "East US" ไม่มีตัวแปรเลย และ root hardcode subscription_id เป็น literal ทั้งที่มี variable "subscription_id" ที่ default ตรงกันอยู่ เพราะ azurerm plan offline ที่นี่ไม่ได้ แล้วพิสูจน์ยังไง?