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

AI Bug Catalog

ทุก snippet ด้านล่างเขียนให้ดูเหมือน HCL ที่ผู้ช่วย AI สร้างขึ้นจริงๆ — ลื่นไหล น่าเชื่อถือ ตาม training data ที่ยังจำ dynamodb_table แบบยุค Terraform 0.11 หรือความเคยชินกับ count แบบก่อนมี for_each — แล้วรันจริง: terraform validate, terraform plan (ด้วย mock AWS credentials ที่ harness ของคอร์สนี้ใช้ทุกที่ — access_key = "mock", skip_credentials_validation = true, ไม่เรียก AWS จริงเลย), terraform test, terragrunt hcl validate, terragrunt run --all plan, และการรัน docker run จริงของ tflint/checkov/conftest output ด้านล่าง copy มาจากการรันจริงๆ ไม่ได้แต่งขึ้นจากความจำ สังเกตว่ามีกี่ตัวที่ terraform validate/plan ปฏิเสธตรงๆ — เป็นส่วนน้อย ส่วนใหญ่ของ 23 pattern นี้คือ Terraform ที่ schema valid, plan สะอาด แต่พังทีหลัง: ตอน apply จริงกับ AWS จริง, ตอน plan ครั้งถัดไปหลังมีคนแก้ list ที่ไม่เกี่ยวข้อง, หรือไม่พังเลยแต่ทำสิ่งผิดอย่างเงียบๆ ตลอดไป 23 pattern นี้จัดกลุ่มตามลำดับเดียวกับ วิธีอ่าน Terraform ที่ไม่ได้เขียนเอง และ Review Checklist

entries/01-unpinned-provider/main.tf
terraform {
required_providers {
google = { source = "hashicorp/google", version = ">= 8.0" }
}
}

อาการ — .terraform.lock.hcl จริงหลัง terraform init:

provider "registry.terraform.io/hashicorp/google" {
version = "8.4.0"
constraints = ">= 8.0.0"
hashes = [ ... ]
}

>= 8.0 resolve ไปที่ release ใหม่สุดที่ registry มีวันนี้ — 8.4.0 ถ้า .terraform.lock.hcl ไม่ถูก commit (ดู entry 4) หรือมีใครรัน init -upgrade การ init ครั้งถัดไปอาจ resolve release ใหม่สุดที่ต่างออกไป เปลี่ยนพฤติกรรม provider อย่างเงียบๆ ระหว่างการรัน main.tf ไฟล์เดียวกันสองครั้ง ไม่มีอะไรใน >= 8.0 ที่สัญญาว่าการ resolve ครั้งถัดไปจะอยู่ในขอบเขตการรับประกันของ major version เดียวกันแบบที่ ~> 8.0 ให้ได้

วิธีแก้:

entries/01-unpinned-provider-fixed/main.tf
terraform {
required_providers {
google = { source = "hashicorp/google", version = "~> 8.0" }
}
}

จับได้โดย: ไม่มีอะไรจับอัตโนมัติเลย — terraform init/validate/plan ผ่านทั้งคู่ไม่ว่าจะใช้ constraint แบบไหน มีแค่การ review diff หรือ policy ที่บังคับ pin version เท่านั้นที่จับได้ ดู Verification Tools

Warning การ deprecate จริงที่ดึงมาจาก schema ของ provider hashicorp/google 8.4.0 ที่ติดตั้งจริง (terraform providers schema -json) ไม่ได้เดาเอา:

entries/02-deprecated-args/main.tf
resource "google_compute_network" "vpc" {
name = "acme-vpc"
}
resource "google_compute_firewall" "allow_ssh" {
name = "allow-ssh"
network = google_compute_network.vpc.name
enable_logging = true
allow {
protocol = "tcp"
ports = ["22"]
}
}

อาการ — terraform validate -json จริง:

warning - Argument is deprecated - Deprecated in favor of log_config

ยัง validate ผ่านสำเร็จ — เป็นแค่ warning ไม่ใช่ error ดังนั้น CI ที่เช็คแค่ exit code (ดู entry 18) จะปล่อยผ่านไปเฉยๆ

วิธีแก้:

entries/02-deprecated-args-fixed/main.tf
resource "google_compute_network" "vpc" {
name = "acme-vpc"
}
resource "google_compute_firewall" "allow_ssh" {
name = "allow-ssh"
network = google_compute_network.vpc.name
log_config {
metadata = "INCLUDE_ALL_METADATA"
}
allow {
protocol = "tcp"
ports = ["22"]
}
}

จับได้โดย: terraform validate -json | jq '.diagnostics[] | select(.severity=="warning")' — grep หา warning ใน CI ไม่ใช่แค่ exit code เริ่มต้น

entries/03-provider-in-module/modules/net/main.tf
provider "google" {
project = "mock-project"
region = "us-central1"
}
resource "google_compute_network" "this" {
name = "acme-vpc"
}
# entries/03-provider-in-module/main.tf — @expect-error Module is incompatible with count, for_each, and depends_on
module "net" {
source = "./modules/net"
count = 2
}

ตัวนี้ resolve ผ่านสบายๆ จนกว่าผู้เรียกใช้จะพยายามใช้ count/for_each/depends_on บน module call ซึ่งเป็นสิ่งที่ environment ที่สองมักต้องการพอดี

อาการ — terraform init จริง, Terraform 1.16.4:

Upgrading modules...
- net in modules/net
Error: Module is incompatible with count, for_each, and depends_on
on main.tf line 3, in module "net":
3: count = 2
The module at module.net 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/net", consider updating this
module to instead expect provider configurations to be passed by its
caller.

วิธีแก้:

entries/03-provider-in-module-fixed/modules/net/main.tf
resource "google_compute_network" "this" {
name = "acme-vpc"
}
entries/03-provider-in-module-fixed/main.tf
provider "google" {
project = "mock-project"
region = "us-central1"
}
module "net" {
source = "./modules/net"
count = 2
}

จับได้โดย: terraform init/validate — แต่เฉพาะตอนที่มีคนเพิ่ม count/for_each/depends_on จริงๆ ซึ่งมักช้ากว่าตอนที่ module ถูกเขียนและ review ผ่านครั้งแรกไปทั้ง environment หนึ่ง

.gitignore ที่ copy มาจาก template “Terraform” ทั่วไปมักรวม .terraform.lock.hcl เข้าไปพร้อมกับ .terraform/ — แต่มีแค่อันเดียวในสองอันนี้เท่านั้นที่ควรถูก ignore

entries/04-lockfile-gitignored/.gitignore
.terraform/
.terraform.lock.hcl
*.tfstate
*.tfstate.backup

อาการ — reproduce จริงในเครื่อง: ลบ .terraform.lock.hcl แล้วรัน terraform init ใหม่ด้วย constraint ที่ลอยตัวจาก entry 1 (>= 6.0):

$ rm .terraform.lock.hcl && terraform init -backend=false
Initializing provider plugins...
- Finding hashicorp/aws versions matching ">= 6.0.0"...
- Installing hashicorp/aws v6.66.0...

ไม่มีอะไรเตือนว่า version ที่ resolve อาจต่างจากที่เพื่อนร่วมทีม — หรือ CI ในวันอื่น — resolve ได้ lock file มีไว้เพื่อการันตีว่าการ resolve นี้ reproduce ได้ข้ามเครื่องและข้ามเวลาพอดี การ gitignore มันทิ้งการรับประกันนั้นไปเลย

วิธีแก้:

entries/04-lockfile-gitignored-fixed/.gitignore
.terraform/
*.tfstate
*.tfstate.backup

จับได้โดย: git status หลัง init สะอาดๆ แสดง .terraform.lock.hcl เป็น untracked — หรือ policy ของ repo ที่ทำให้ CI fail ถ้าไม่มี lock file

5. Copy pattern การล็อก dynamodb_table ของ S3 backend มาใส่ gcs ที่ไม่เคยมีจริง

หัวข้อที่มีชื่อว่า “5. Copy pattern การล็อก dynamodb_table ของ S3 backend มาใส่ gcs ที่ไม่เคยมีจริง”
entries/05-backend-locking/backend.tf
terraform {
backend "gcs" {
bucket = "acme-tfstate"
prefix = "network"
dynamodb_table = "acme-tf-locks"
}
}

dynamodb_table เป็นของจริง — บน backend s3 สำหรับ cloud ที่ต่างกันเลย training data ของ AI ผสม backend example ข้าม cloud กันตลอดเวลา และ argument นี้ไม่มีอยู่ใน schema ของ backend gcs เลย ตาม เอกสาร GCS backend: “This backend supports state locking” — โดยตรง ผ่าน GCS object generation precondition บน state object เอง โดยไม่ต้องมี table, resource, หรือ argument เพิ่มให้ provision, จัดการ IAM-permission, หรือจ่ายเงินเลย ต่างจาก dynamodb_table ของ S3 ที่ deprecated แล้ว (ดู aws sibling ของคอร์สนี้)

วิธีแก้:

entries/05-backend-locking-fixed/backend.tf
terraform {
backend "gcs" {
bucket = "acme-tfstate"
prefix = "network"
}
}

จับได้โดย: ไม่มีอะไรใน validate/plan กับ -backend=false เลย — argument นี้ถูกยอมรับอย่างเงียบๆ โดยไม่เคยถูกเช็คกับ backend schema จริง มีแค่ terraform init จริงที่มี credential (ซึ่งคอร์สนี้ไม่เคยรัน) หรือ reviewer ที่รู้ว่า locking ของ gcs ไม่ต้องมี table เลยเท่านั้นที่จับได้

# entries/06-remote-state-cross-backend/main.tf — @expect-error No stored state was found for the given workspace in the given backend.
data "terraform_remote_state" "network" {
backend = "local"
config = {
path = "../does-not-exist/terraform.tfstate"
}
}

อาการ — terraform plan จริง:

data.terraform_remote_state.network: Reading...
Planning failed. Terraform encountered an error while generating this plan.
Error: Unable to find remote state
with data.terraform_remote_state.network,
on main.tf line 1, in data "terraform_remote_state" "network":
1: data "terraform_remote_state" "network" {
No stored state was found for the given workspace in the given backend.

ไม่มี mock fallback เลย — terraform_remote_state พัง plan ทั้งหมดหนักๆ ทันทีที่ state ที่อ้างอิงไม่มีอยู่ ซึ่งเป็นสถานการณ์ปกติระหว่างการ apply ครั้งแรกของ stack ที่ stack อื่นต้องพึ่งพา หรือหลัง state file ถูกย้ายไป key/bucket อื่นโดยไม่ได้อัปเดต consumer ทุกตัว

วิธีแก้: ชี้ config.path/config.key ไปที่ state file ที่ apply ไปแล้วจริง หรือเปลี่ยนการอ่าน cross-stack เป็น pattern dependency block ของ Terragrunt (mock_outputs) ที่บทเรียน dependencies-between-units ของคอร์สนี้สอนไว้ ซึ่งมี offline fallback จริงๆ

จับได้โดย: terraform plan — ดังและทันทีทุกครั้ง — แต่จับได้เฉพาะ plan ของ consumer ไม่ใช่ของ producer ถ้า producer stack เปลี่ยน backend key อย่างเงียบๆ consumer ทุกตัวจะพังตอน plan ครั้งถัดไป ไม่ใช่ producer

entries/07-nullable-false/child/variables.tf
variable "instance_count" {
type = number
default = 3
nullable = false
}
entries/07-nullable-false/child/main.tf
output "effective_count" {
value = var.instance_count
}
entries/07-nullable-false/variables.tf
variable "override" {
type = number
default = null
}
entries/07-nullable-false/main.tf
module "child" {
source = "./child"
instance_count = var.override
}
output "child_effective_count" {
value = module.child.effective_count
}

อาการ — terraform apply จริง, var.override เป็น null:

Changes to Outputs:
+ child_effective_count = 3
Apply complete! Resources: 0 added, 0 changed, 0 destroyed.
Outputs:
child_effective_count = 3

ด้วย nullable = false (ค่า default สำหรับ variable ที่ไม่ได้ตั้ง nullable ไว้เลย — AI มักเขียนออกมาตรงๆ โดยคิดว่ามันจะปฏิเสธ null) การส่ง null มาตรงๆ ไม่ error: Terraform แทนที่ default อย่างเงียบๆ เป็น 3 โดยไม่มีสัญญาณใดๆ ว่ามีการ override เกิดขึ้นเลย การเปลี่ยน variable ตัวเดียวกันเป็น nullable = true แล้วรันซ้ำพิสูจน์ความต่างตรงๆ — output กลายเป็น null จริง:

Changes to Outputs:
- child_effective_count = 3 -> null
Apply complete! Resources: 0 added, 0 changed, 0 destroyed.

วิธีแก้: ถ้า null ควรเป็น input ที่แยกแยะได้จริง ตั้ง nullable = true แล้วให้ logic ด้านล่าง branch ตาม var.x == null ตรงๆ ถ้า null ควรถูกปฏิเสธจริงๆ อย่าตั้ง nullable = false แล้วพึ่งการแทนที่อย่างเงียบๆ — validate มันแทน:

entries/07-nullable-false-fixed/child/variables.tf
variable "instance_count" {
type = number
default = 3
validation {
condition = var.instance_count != null
error_message = "instance_count must not be null."
}
}

จับได้โดย: ไม่มีอะไรใน plan/apply output เลยโดย default — มีแค่ terraform plan ที่ diff ค่า ก่อน กับ หลัง ข้าม refactor หรือ validation block ที่เขียนมาปฏิเสธ null ตรงๆ

entries/08-ephemeral-leak/main.tf
variable "api_token" {
type = string
ephemeral = true
default = "shh-do-not-print-me"
}
resource "terraform_data" "example" {
input = "static-value"
}

อาการ — terraform show -json จริงบน plan ที่ save ไว้:

$ terraform plan -out=plan.out && terraform show -json plan.out | jq '.variables'
{
"api_token": {
"value": "shh-do-not-print-me"
}
}

ephemeral = true (Terraform 1.10+) กันค่าออกจาก state แต่ไม่กัน default ตรงๆ ออกจาก variables block ของ plan file ที่ save ไว้ — โครงสร้าง JSON เดียวกับที่ policy conftest/OPA ใน CI อ่าน (ดู entry 20) default ที่ตั้งใจแค่เป็น fallback ในเครื่องหลุดตรงเข้าไปใน artifact ที่ CI archive ไว้เลย

วิธีแก้: อย่าให้ ephemeral variable มี default เป็น secret ตรงๆ เด็ดขาด (ไม่มี default, หรือใช้ ephemeral resource เป็นแหล่งข้อมูลจริง):

entries/08-ephemeral-leak-fixed/main.tf
variable "api_token" {
type = string
ephemeral = true
}
resource "terraform_data" "example" {
input = "static-value"
}

จับได้โดย: grep หา string ที่ดูเหมือน secret ใน plan artifact ที่ archive จาก show -json — validate/plan ไม่เคย flag เรื่องนี้เอง

9. prevent_destroy อยู่บน resource ที่ทิ้งได้ ไม่ใช่ตัวที่สำคัญ

หัวข้อที่มีชื่อว่า “9. prevent_destroy อยู่บน resource ที่ทิ้งได้ ไม่ใช่ตัวที่สำคัญ”
entries/09-prevent-destroy-wrong-resource/main.tf
resource "google_storage_bucket" "scratch_logs" {
name = "acme-scratch-logs"
location = "US"
lifecycle {
prevent_destroy = true
}
}
resource "google_sql_database_instance" "primary" {
name = "primary-db"
database_version = "POSTGRES_15"
region = "us-central1"
deletion_protection = false
settings {
tier = "db-f1-micro"
}
}

prevent_destroy เป็นกลไก lifecycle หลักของ Terraform เหมือนกันทุก provider — พิสูจน์แล้วครั้งเดียวเต็มรูปแบบด้วย resource AWS จริงใน aws sibling ของคอร์สนี้: plan -destroy กับ stack ที่มีแค่ aws_iam_role ที่ทิ้งได้มี prevent_destroy = true บล็อกแค่การ destroy ของ role นั้นเท่านั้น ในขณะที่ aws_db_instance จริงข้างๆ destroy ได้โดยไม่มีอะไรต้านเลย กลไกระดับ graph เดียวกันเป๊ะใช้กับ resource ของ provider ไหนก็ได้ รวมถึง google

วิธีแก้: ย้าย prevent_destroy ไปยัง resource ที่ต้องการมันจริงๆ:

entries/09-prevent-destroy-wrong-resource-fixed/main.tf
resource "google_sql_database_instance" "primary" {
name = "primary-db"
database_version = "POSTGRES_15"
region = "us-central1"
deletion_protection = true
settings {
tier = "db-f1-micro"
}
lifecycle {
prevent_destroy = true
}
}

จับได้โดย: plan -destroy/terraform plan สำหรับ resource ที่เจาะจงจริงๆ — prevent_destroy ป้องกันเฉพาะ resource ที่ประกาศไว้เท่านั้น ไม่เคยป้องกัน “สิ่งที่สำคัญที่อยู่ใกล้ๆ”

entries/10-count-index-shift/main.tf
variable "bucket_names" {
type = list(string)
default = ["logs", "assets", "backups"]
}
resource "google_storage_bucket" "this" {
count = length(var.bucket_names)
name = "acme-${var.bucket_names[count.index]}-${count.index}"
location = "US"
}

อาการ — terraform test จริงกับ mock_provider "google" {}: seed bucket สามตัวด้วย apply แล้ว plan โดยเอา element ตรงกลาง ("assets") ออกจาก bucket_names:

# google_storage_bucket.this[1] will be updated in-place
~ resource "google_storage_bucket" "this" {
id = "wzbwjbrf"
~ name = "acme-assets-1" -> "acme-backups-1"
# (13 unchanged attributes hidden)
}
# google_storage_bucket.this[2] will be destroyed
# (because index [2] is out of range for count)
- resource "google_storage_bucket" "this" {
- name = "acme-backups-2" -> null
# (other attributes omitted)
}

count.index อิงตำแหน่ง ไม่ใช่ identity การเอา "assets" ออกจากตรงกลางเลื่อน "backups" จาก index 2 ไปเป็น index 1 — Terraform อ่านว่า “index 1 เปลี่ยนจาก bucket assets เป็น bucket backups” (การ update-in-place จริง — google_storage_bucket.name เปลี่ยนได้ใน schema ของ provider เอง แม้ในความเป็นจริงการ rename bucket ของ GCS จริงทำไม่ได้เลย ซึ่งเป็นกับดักแยกต่างหากของมันเอง) และ “index 2 ไม่มีอยู่แล้ว” (การ destroy) ทั้งที่เจตนาของมนุษย์คือแค่ “ลบ bucket เดียว เหลืออีกสองตัวไว้เฉยๆ”

วิธีแก้: key ด้วย identity ที่แน่นอนด้วย for_each ไม่ใช่ตำแหน่งเลย:

entries/10-count-index-shift-fixed/main.tf
variable "bucket_names" {
type = list(string)
default = ["logs", "backups"]
}
resource "google_storage_bucket" "this" {
for_each = toset(var.bucket_names)
name = "acme-${each.value}"
location = "US"
}

จับได้โดย: terraform plan -detailed-exitcode ทุกครั้งที่มีการเปลี่ยน list ที่ index ด้วย count — คู่ destroy/replace มองเห็นได้ทุก plan แต่เฉพาะถ้ามีคนอ่าน plan จริงๆ แทนที่จะเชื่อ apply ที่ขึ้นสีเขียว

entries/11-for-each-unknown/main.tf
resource "google_service_account" "generator" {
account_id = "generator"
display_name = "generator"
}
resource "google_project_iam_member" "per_role" {
for_each = toset(google_service_account.generator.disabled == true ? [] : ["roles/viewer"])
project = "mock-project"
role = each.value
member = "serviceAccount:${google_service_account.generator.email}"
}

count ยอมรับตัวเลขที่รู้เฉพาะหลัง apply ได้ในบางบริบท แต่ for_each ไม่เคยยอมรับ set ของ key ที่ไม่รู้ตอน plan เลย — key คือ identity และ Terraform ต้องรู้ identity ของทุก resource ก่อนจะคำนวณ plan ได้เลย นี่คือพฤติกรรม graph-evaluation หลักของ Terraform เหมือนกันทุก provider — พิสูจน์ตรงๆ เต็มรูปแบบด้วย resource AWS จริงใน aws sibling ของคอร์สนี้: error “Invalid for_each argument” จริงและปัจจุบัน ระบุ computed attribute ที่ชัดเจน (tags_all is a map of string, known only after apply) ที่ทำให้ set ไม่รู้ค่า

วิธีแก้: derive set ของ for_each จากสิ่งที่รู้ตอน plan — variable, ค่า locals, หรือ attribute ของ data source ที่รู้ตอน plan — ไม่ใช่จาก output ที่ computed ของ managed resource อีกตัว

จับได้โดย: terraform plan — เสมอ ดังทันที ก่อนมีอะไรถูกสร้างเลย กับ provider จริง ตัวนี้ไม่เคยหลุดไปแบบเงียบๆ

entries/12-null-resource/main.tf
resource "null_resource" "seed" {
triggers = { always = timestamp() }
provisioner "local-exec" {
command = "echo seeding"
}
}

ตัวนี้ยัง validate ผ่านสะอาด — null_resource ไม่ถูกเอาออก — แต่มันพึ่งพา provider แยกต่างหาก hashicorp/null สำหรับสิ่งที่ Terraform 1.4+ มีให้เป็น resource type ในตัวโดยไม่ต้องพึ่ง provider: terraform_data ส่วนหนึ่งของ built-in provider terraform.io/builtin/terraform ของ Terraform เอง (ใช้ทั่ว catalog นี้แล้ว, entry 6, 8, 11, 17) terraform_data เพิ่ม attribute input/output ที่ null_resource ไม่เคยมี ทำให้ reference ที่อยู่ปลายทางได้ค่าจริงที่มี type แทนที่จะมีแค่ id/triggers

วิธีแก้:

entries/12-null-resource-fixed/main.tf
resource "terraform_data" "seed" {
triggers_replace = [timestamp()]
provisioner "local-exec" {
command = "echo seeding"
}
}

จับได้โดย: ไม่มีอะไรใน validate/plan เลย — resource type ทั้งสองทำงานได้ มีแค่ reviewer ที่รู้ว่า terraform_data ตัดทั้ง provider dependency เพิ่มออกไป (hashicorp/null — อีกหนึ่งบรรทัดใน .terraform.lock.hcl อีกหนึ่ง plugin ที่ต้อง download และ pin) สำหรับพฤติกรรมที่เท่ากันเท่านั้นที่จับได้

# entries/13-hallucinated-argument/main.tf — @expect-error An argument named "force_destroy" is not expected here.
resource "google_compute_instance" "app" {
name = "app-instance"
machine_type = "e2-medium"
zone = "us-central1-a"
force_destroy = true
boot_disk {
initialize_params {
image = "debian-cloud/debian-12"
}
}
network_interface {
network = "default"
}
}

force_destroy เป็น argument จริงของ Terraform — บน google_storage_bucket สำหรับอนุญาตให้ destroy bucket ที่ไม่ว่างเปล่าได้ มันไม่มีอยู่บน google_compute_instance เลย training data ของ AI ผสม argument แบบ lifecycle-shortcut ข้าม resource type กันตลอดเวลา

อาการ — terraform validate จริง:

Error: Unsupported argument
on main.tf line 5, in resource "google_compute_instance" "app":
5: force_destroy = true
An argument named "force_destroy" is not expected here.

วิธีแก้: เอา argument ที่ hallucinate มาออก — google_compute_instance ไม่มี concept แบบ force-destroy ของ bucket เลย ตัวที่ใกล้เคียงที่สุดจริงๆ คือ deletion_protection:

entries/13-hallucinated-argument-fixed/main.tf
resource "google_compute_instance" "app" {
name = "app-instance"
machine_type = "e2-medium"
zone = "us-central1-a"
deletion_protection = true
boot_disk {
initialize_params {
image = "debian-cloud/debian-12"
}
}
network_interface {
network = "default"
}
}

จับได้โดย: terraform validate — ตัวนี้ดังและทันทีเสมอตอน plan รูปแบบที่อันตรายของ pattern นี้คือ entry 2/9 ที่ argument ผิดแต่จริงไม่ error เลย

# not a real path — a git clone against a live, network-reachable repo is out of scope for this offline harness; see the callout below
module "vpc" {
source = "git::https://example.com/vpc.git"
}

ตาม เอกสาร module sources: “You can add the ref query parameter to the location specified in the source argument to reference any value supported by the git checkout command, such as a branch, SHA-1 hash, or tag.” ถ้าไม่มี: “Terraform clones and uses the default branch referenced by HEAD” — หมายความว่า configuration เดียวกันเป๊ะ ที่ re-initialize คนละวัน สามารถดึง commit ของ module คนละตัวได้อย่างเงียบๆ

วิธีแก้:

# not a real path — same reason as above
module "vpc" {
source = "git::https://example.com/vpc.git?ref=v1.4.0"
}

จับได้โดย: ไม่มีอะไรใน validate/plan เลย — resolve สำเร็จทั้งคู่ .terraform.lock.hcl บันทึก hash ของ provider ไม่เคยบันทึก revision ของ module source เลย จึงไม่มีตัวจับ drift ในตัวสำหรับเรื่องนี้เลย มีแค่ tag/SHA ที่ pin ไว้และการ review ของมนุษย์บนค่า ref= เท่านั้นที่จับได้

# entries/15-depends-on-hides-cycle/main.tf — @expect-error Error: Cycle
resource "terraform_data" "a" {
input = terraform_data.b.output
}
resource "terraform_data" "b" {
input = terraform_data.a.output
}

อาการ — terraform validate จริง:

Error: Cycle:
terraform_data.a
terraform_data.b

การเพิ่ม depends_on ตรงๆ ให้ฝั่งใดฝั่งหนึ่ง หวังว่าจะ “บังคับ” ลำดับ ไม่ได้แก้ปัญหานี้เลย — depends_on เพิ่มแค่ constraint ลำดับเพิ่มเติมบน graph ที่ Terraform สร้างจาก reference จริง มันเอา cycle ที่ reference เหล่านั้นสร้างขึ้นเองออกไม่ได้ วิธีแก้ที่ถูกต้องคือทำลาย reference loop จริงๆ ไม่ใช่ปิดบัง graph error ด้วยคำใบ้เรื่องลำดับ

วิธีแก้: ทำลาย cycle โดยให้ทั้งสองฝั่งมีแหล่งความจริงที่เป็นอิสระต่อกัน ไม่วนซ้ำ:

entries/15-depends-on-hides-cycle-fixed/main.tf
locals {
shared_seed = "2024-01-shared"
}
resource "terraform_data" "a" {
input = local.shared_seed
}
resource "terraform_data" "b" {
input = local.shared_seed
}

จับได้โดย: terraform validate — เสมอ ทันที และไม่มี depends_on ใดกดไว้ได้ ความเสี่ยงจริงๆ คือเวลาที่ผู้ช่วย AI ใช้ไปกับการเพิ่ม depends_on block พยายามแก้ error ที่สาเหตุจริงคือ circular reference แท้ๆ

$ terragrunt plan-all --working-dir live/dev/vpc
ERROR unknown command: "plan-all". Terragrunt no longer forwards unknown commands by default. Use 'terragrunt run -- plan-all ...' or a supported shortcut. Learn more: https://docs.terragrunt.com/migrate/cli-redesign/#use-the-new-run-command
$ terragrunt run-all plan --working-dir .
ERROR unknown command: "run-all". Terragrunt no longer forwards unknown commands by default. Use 'terragrunt run -- run-all ...' or a supported shortcut. Learn more: https://docs.terragrunt.com/migrate/cli-redesign/#use-the-new-run-command

ทั้งสองคำสั่งอยู่เต็มไปหมดใน tutorial ยุคก่อน 1.1 ดังนั้นอยู่เต็ม training data ของ AI ด้วย CLI redesign ของ Terragrunt 1.1 แทนที่ทั้งคู่

วิธีแก้ — คำสั่งจริงปัจจุบัน รันจริงกับ tree สองหน่วย:

entries/16-run-all-removed/root.hcl
remote_state {
backend = "local"
generate = { path = "backend.tf", if_exists = "overwrite" }
config = { path = "${get_terragrunt_dir()}/terraform.tfstate" }
}
entries/16-run-all-removed/modules/vpc/main.tf
variable "cidr" {
type = string
default = "10.0.0.0/16"
}
resource "terraform_data" "vpc" {
input = var.cidr
}
entries/16-run-all-removed/live/dev/vpc/terragrunt.hcl
include "root" {
path = find_in_parent_folders("root.hcl")
}
terraform {
source = "${get_terragrunt_dir()}/../../../modules/vpc"
}
$ terragrunt run --all plan --working-dir .
[live/dev/vpc] terraform: Plan: 1 to add, 0 to change, 0 to destroy.
❯❯ Run Summary 1 units 56ms
Succeeded 1

จับได้โดย: ทันที ทุกครั้ง — Terragrunt ปฏิเสธไม่ยอมเริ่มด้วยซ้ำ ไม่เคยหลุดไปแบบเงียบๆ

17. terragrunt.hcl เดียวกัน copy-paste จาก dev มา prod โดยไม่แตะ inputs เลย

หัวข้อที่มีชื่อว่า “17. terragrunt.hcl เดียวกัน copy-paste จาก dev มา prod โดยไม่แตะ inputs เลย”
entries/17-hardcoded-account-copy/live/dev/vpc/terragrunt.hcl
terraform {
source = "${get_terragrunt_dir()}/../../../modules/vpc"
}
inputs = {
account_id = "111111111111"
region = "us-east-1"
}
entries/17-hardcoded-account-copy/live/prod/vpc/terragrunt.hcl
terraform {
source = "${get_terragrunt_dir()}/../../../modules/vpc"
}
inputs = {
account_id = "111111111111"
region = "us-east-1"
}

อาการ — reproduce จริงในเครื่อง:

$ terragrunt hcl validate --working-dir live/prod/vpc
(no output — both files are syntactically valid HCL)
$ diff live/dev/vpc/terragrunt.hcl live/prod/vpc/terragrunt.hcl
(no output — the files are byte-identical)

ไม่มีอะไรในนี้เป็น HCL ที่ invalid เลย prod/vpc deploy ทับ account AWS ของ dev อย่างเงียบๆ — terragrunt hcl validate ไม่มีทางรู้ว่า account_id = "111111111111" ภายใต้ live/prod/ ผิด เพราะไม่มีอะไรในไฟล์ที่บอกว่า account ไหนคือ “ถูกต้อง” สำหรับ path นั้น

วิธีแก้: derive account_id/region จาก directory path เอง เพื่อให้ dev กับ prod ไม่สามารถแชร์ค่าผ่าน copy-paste ได้จริงๆ:

entries/17-hardcoded-account-copy-fixed/live/prod/env.hcl
locals {
account_id = "222222222222"
region = "us-east-1"
}
entries/17-hardcoded-account-copy-fixed/live/prod/vpc/terragrunt.hcl
locals {
env_vars = read_terragrunt_config(find_in_parent_folders("env.hcl"))
}
terraform {
source = "${get_terragrunt_dir()}/../../../modules/vpc"
}
inputs = {
account_id = local.env_vars.locals.account_id
region = local.env_vars.locals.region
}

จับได้โดย: ไม่มีอะไรอัตโนมัติเลย — hcl validate/hcl format --check ผ่านทั้งคู่ มีแค่ terragrunt run --all plan จริงต่อ environment ที่ diff โดยมนุษย์ที่รู้ว่า environment ไหนควรเล็งไป account ไหนเท่านั้นที่จับ copy-paste ที่สะอาดขนาดนี้ได้

อาการ — จริง เทียบข้างกัน:

$ terraform apply
Do you want to perform these actions?
Terraform will perform the actions described above.
Only 'yes' will be accepted to approve.
Enter a value: no
Apply cancelled.
$ terraform apply -auto-approve
terraform_data.release: Creating...
terraform_data.release: Creation complete after 0s [id=bb8b46af-eb1e-0b33-104a-b4b39a2559a6]
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

-auto-approve ไม่ได้แค่ข้าม prompt ที่ CI ตอบไม่ได้อยู่แล้ว — มันเอาจุดสุดท้ายที่มนุษย์ (หรือ policy gate ที่อ่าน plan ที่กำลังจะถูก apply เป๊ะๆ) จะหยุดการเปลี่ยนแปลงที่ทำลายข้อมูลได้ออกไป ทางแก้ที่ถูกต้องคือแยกขั้นตอน plan -out=tfplan, policy check (entry 20) หรือ human approval gate กับ plan ที่ save ไว้นั้น แล้วค่อย apply tfplan — ไม่ใช่ apply -auto-approve กับ plan สดใหม่ที่ยังไม่ review เลย

วิธีแก้:

# .github/workflows/terraform.yml (fragment, not a collected fence)
- run: terraform plan -out=tfplan -detailed-exitcode
- run: conftest test --policy policy $(terraform show -json tfplan | tee plan.json | true)
- run: terraform apply tfplan # applies the reviewed plan file, not a fresh -auto-approve

จับได้โดย: การ review pipeline เท่านั้น ไม่เคยเป็น validate/plan — ทั้งสอง flag เป็นการใช้ CLI ที่ valid

19. checkov-action@master — tag ที่เปลี่ยนได้ถูกใช้ราวกับ pin แล้ว

หัวข้อที่มีชื่อว่า “19. checkov-action@master — tag ที่เปลี่ยนได้ถูกใช้ราวกับ pin แล้ว”
# .github/workflows/checkov.yml (fragment, not a collected fence)
- uses: bridgecrewio/checkov-action@master
with:
directory: .

ตาม คู่มือ hardening ของ GitHub เอง: “Pinning an action to a full-length commit SHA is currently the only way to use an action as an immutable release.” @master (หรือ @main, @v1) เป็น branch reference — มันชี้ไปที่ commit ที่ต่างออกไปพรุ่งนี้ก็ได้ โดยไม่มี diff ใน history ของ repo นี้เองให้ review เลย เหตุผลของ GitHub, quote ตรงๆ: bad actor ที่ compromise repo ของ action นั้นต้องใช้ “a SHA-1 collision for a valid Git object payload” เพื่อเอาชนะ SHA ที่ pin ไว้ — ทำไม่ได้ในทางคำนวณ — ในขณะที่ tag หรือ branch ที่เปลี่ยนได้สามารถถูกย้ายได้เฉยๆ

วิธีแก้:

# .github/workflows/checkov.yml (fragment, not a collected fence)
- uses: bridgecrewio/checkov-action@c98096a34955fc70f684e0c1ce43b4e1b2ea3f9 # v12.2919.0
with:
directory: .

จับได้โดย: ไม่มีอะไรใน output ของ CI เองเลย — workflow รันเหมือนกันทั้งคู่ จนกว่าจะถึงวันที่ไม่เหมือน มีแค่ linter ที่ pin dependency (zizmor, การเตือนเรื่อง pin version ของ Dependabot เอง) หรือการ review ด้วยมือเท่านั้นที่จับได้

20. Rego ก่อน v1 (deny[msg] { ... }) load ไม่ผ่านอย่างเงียบๆ ภายใต้ OPA v1

หัวข้อที่มีชื่อว่า “20. Rego ก่อน v1 (deny[msg] { ... }) load ไม่ผ่านอย่างเงียบๆ ภายใต้ OPA v1”
# entries/20-rego-v1/policy/deny_no_labels.rego — @expect-error rego_parse_error: `if` keyword is required before rule body
package main
deny[msg] {
resource := input.resource_changes[_]
resource.type == "terraform_data"
msg := sprintf("%s must set labels for cost allocation", [resource.address])
}

อาการ — conftest test จริง, openpolicyagent/conftest ปัจจุบันที่ default เป็น syntax Rego v1, กับ plan terraform show -json จริง (plan ของ config provider google สำหรับ resource marker terraform_data — plan จริงของ google ต้องการ Application Default Credentials จริงซึ่งคอร์สนี้ไม่มี ดู entry 9 ดังนั้น policy target ที่นี่คือ resource terraform_data ที่ไม่ผูกกับ provider เดียวกับที่ใช้ทั่ว catalog นี้):

Error: running test: load: loading policies: load: 2 errors occurred during loading:
policy/deny_no_labels.rego:3: rego_parse_error: `if` keyword is required before rule body
policy/deny_no_labels.rego:3: rego_parse_error: `contains` keyword is required for partial set rules

รูปแบบ bracket-set rule (deny[msg] { ... }) ที่เต็มไป tutorial Rego มาหลายปี ซึ่ง training data ของ AI ดึงมา คือสิ่งที่ OPA v1 (default ตั้งแต่ OPA 1.0 / conftest ปัจจุบัน) ปฏิเสธตรงๆ — deny กลายเป็น error ที่ load ไม่ผ่านเลย ไม่ใช่แค่ rule ที่ไม่เคยทำงาน

วิธีแก้ — syntax v1 จริง, policy เดียวกัน, รันกับ plan จริง:

entries/20-rego-v1-fixed/policy/deny_no_labels.rego
package main
import rego.v1
deny contains msg if {
resource := input.resource_changes[_]
resource.type == "terraform_data"
msg := sprintf("%s must set labels for cost allocation", [resource.address])
}
$ docker run --rm -v "$(pwd)":/project openpolicyagent/conftest test --policy policy plan.json
FAIL - plan.json - main - terraform_data.app_marker must set labels for cost allocation
1 test, 0 passed, 0 warnings, 1 failure, 0 exceptions

จับได้โดย: conftest test เอง จริงและทันที — แต่ในรูปแบบ load error ที่ดูเหมือนปัญหา CI/tooling ได้ ทั้งที่จริงๆ แล้วคือ syntax policy ที่ล้าสมัย

21. tflint check ที่ไม่มีอยู่จริงสำหรับ resource type นี้ — และไม่เหมือน ruleset บางตัว จับได้ทันที

หัวข้อที่มีชื่อว่า “21. tflint check ที่ไม่มีอยู่จริงสำหรับ resource type นี้ — และไม่เหมือน ruleset บางตัว จับได้ทันที”
# entries/21-invented-rule-id/.tflint.hcl — @expect-error Rule not found: google_compute_instance_invalid_zone
plugin "google" {
enabled = true
version = "0.30.0"
source = "github.com/terraform-linters/tflint-ruleset-google"
}
rule "google_compute_instance_invalid_zone" {
enabled = true
}

Rule catalog จริงของ tflint-ruleset-google (ยืนยันกับ github.com/terraform-linters/tflint-ruleset-google/tree/master/docs/rules) มีแค่ หก rule ทั้งหมด ทุกตัวเป็น check _invalid_member บน resource ของ IAM (google_project_iam_member_invalid_member, google_project_iam_binding_invalid_member และคล้ายกัน) — ไม่มี rule เช็ค zone แบบไหนเลย สำหรับ resource ใดก็ตาม ผู้ช่วย AI จับรูปแบบชื่อ rule ต่อ resource แบบ aws_instance_previous_type จาก ruleset ที่ใหญ่กว่าของ cloud อื่น แล้ว hallucinate ตัวที่เทียบเท่ามาให้ ruleset เล็กๆ ที่เน้น IAM นี้ ซึ่งไม่เคยถูกสร้างมาให้มีแบบนั้นเลย

อาการ — reproduce จริงด้วย image ghcr.io/terraform-linters/tflint ที่ pin ไว้:

$ docker run --rm -v "$(pwd)":/data -v <plugin-cache>:/root/.tflint.d -w /data ghcr.io/terraform-linters/tflint
Failed to check rule config; Rule not found: google_compute_instance_invalid_zone

ต่างจาก entry ของ aws sibling ของคอร์สนี้ (ที่ rule aws_instance_invalid_type ที่คิดขึ้นเองไม่เคยถูกเอาไปใช้เลยอย่างเงียบๆ โดย tflint รายงานว่าการรันสำเร็จ) ruleset plugin ของ google validate ชื่อ rule อย่างเข้มงวดตอนเริ่มและปฏิเสธไม่ยอมรันเลย — error จริง ดัง ทันที การจับชื่อ rule ที่คิดขึ้นเองได้หรือไม่ขึ้นอยู่กับ implementation ของ ruleset plugin นั้นๆ เอง ไม่ใช่ตัว tflint เอง

วิธีแก้: ไม่มี tflint rule ต่อ zone จริงให้เปิดใช้สำหรับ google_compute_instance เลย — เอา rule ที่คิดขึ้นเองออก แล้วพึ่ง terraform validate/policy Sentinel หรือ OPA ที่กำหนดเอง (entry 20) สำหรับ check รูปแบบ zone ที่ ruleset นี้ไม่ครอบคลุม:

entries/21-invented-rule-id-fixed/.tflint.hcl
plugin "google" {
enabled = true
version = "0.30.0"
source = "github.com/terraform-linters/tflint-ruleset-google"
}

จับได้โดย: tflint เอง ทันที ใน ruleset นี้เจาะจง — บทเรียนตรงข้ามจาก entry ของ aws sibling ของคอร์สนี้ และเป็นข้อพิสูจน์ว่า “tflint จะจับชื่อ rule ที่คิดขึ้นเองได้” ไม่ควรสมมติเป็นกฎเดียวกันทุก ruleset ของทุก cloud

entries/22-tfvars-secret-committed/terraform.tfvars
db_password = "Sup3rSecret!2024"
api_token = "ghp_1234567890abcdefghijklmnopqrstuvwxyz"
entries/22-tfvars-secret-committed/.gitignore
.terraform/
*.tfstate

*.tfvars ไม่อยู่ใน .gitignore เลย — .gitignore ของ Terraform ทั่วไป (ดู entry 4) ป้องกัน state และ provider cache แต่หลาย template จริงไม่เคยพูดถึง .tfvars เลย เพราะไฟล์ *.tfvars.example/ไฟล์ .tfvars ที่ ไม่ใช่ secret ที่ commit เข้าไปเป็น pattern ที่ถูกต้องและพบได้ทั่วไป ผู้ช่วย AI ที่สร้าง .gitignore จาก prompt ทั่วไปก็สร้างช่องว่างแบบเดียวกันซ้ำ

วิธีแก้:

entries/22-tfvars-secret-committed-fixed/.gitignore
.terraform/
*.tfstate
*.tfvars
!*.tfvars.example

…บวกกับการย้าย secret จริงออกจากไฟล์ใดๆ เลย — environment variable (TF_VAR_db_password), reference ไปยัง secrets manager, หรือ write-only argument (entry 23) — เพราะการแก้ .gitignore ทีหลังไม่ได้เอา secret ที่ commit เข้าไปแล้วออกจาก git history เลย

จับได้โดย: git log -p -- '*.tfvars' บน repo ที่มีอยู่แล้ว หรือ pre-commit secret scanner (gitleaks, trufflehog) — ไม่เคยเป็น validate/plan ซึ่งไม่ตรวจ .gitignore เลย

entries/23-sensitive-state-plaintext/main.tf
resource "random_password" "db" {
length = 16
special = false
}
output "db_password" {
value = random_password.db.result
sensitive = true
}

อาการ — จริง เทียบข้างกัน:

$ terraform apply -auto-approve
Outputs:
db_password = <sensitive>
$ grep -A1 '"result"' terraform.tfstate
"result": "QxDS75iFGBWDNsqc",

sensitive = true ซ่อน CLI output และ terraform show โดย default — มันไม่ encrypt หรือละค่าออกจาก terraform.tfstate บน disk เลย ใครก็ตามที่อ่าน state file ได้ (disk ในเครื่อง, GCS bucket ที่ไม่มี IAM รัดกุม, CI log artifact ที่ดันไป cat state) อ่าน password จริงเป็น plaintext ได้เลย ไม่ว่าจะติด label “sensitive” หรือไม่ random_password ไม่มี write-only argument ให้หลีกเลี่ยงเรื่องนี้ — แต่ google_sql_database_instance มี: root_password_wo + root_password_wo_version ยืนยันตรงกับ schema ของ provider hashicorp/google 8.4.0 ที่ติดตั้งจริง ซึ่งไม่ถูกเขียนเข้า state เลย

วิธีแก้: สำหรับค่าที่ไม่จำเป็นต้อง persist ใน state เลยจริงๆ ใช้ write-only argument ที่ resource ปลายทางรองรับ และถือว่า sensitive = true เป็นแค่ตัวควบคุม CLI output — ไม่ใช่ security boundary เลย:

entries/23-sensitive-state-plaintext-fixed/main.tf
resource "random_password" "db" {
length = 16
special = false
}
resource "google_sql_database_instance" "primary" {
name = "primary-db"
database_version = "POSTGRES_15"
region = "us-central1"
root_password_wo = random_password.db.result
root_password_wo_version = 1
settings {
tier = "db-f1-micro"
}
}

จับได้โดย: grep บน terraform.tfstate/terraform show -json ดิบๆ — ไม่เคยเป็น CLI output ที่ถูกซ่อนไว้เอง ซึ่งเป็นสิ่งที่ทำให้ pattern นี้ถูกเชื่อผิดๆ ได้ง่าย

ทำไม provider block ข้างใน module ของ entry 3 ถึง validate ผ่านสบายๆ ด้วยตัวเอง และพังก็ต่อเมื่อเพิ่ม count = 2 เข้าไปที่ module call เท่านั้น?
จาก var.override = null ที่ส่งเข้า variable ที่ nullable = false พร้อม default = 3 output จริงนี้พิสูจน์อะไร?
$ terraform apply -auto-approve
Changes to Outputs:
  + child_effective_count = 3
ทำไม entry 9 ถึงย้าย lifecycle { prevent_destroy = true } จาก google_storage_bucket.scratch_logs ไปยัง google_sql_database_instance.primary แทนที่จะเพิ่มมันเข้าไปทั้งสองตัว?
Rego ก่อน v1 ของ entry 20 (deny[msg] { ... }) และ tflint rule ที่คิดขึ้นเองของ entry 21 (google_compute_instance_invalid_zone) ทั้งคู่เป็น policy check ที่พัง — แต่พังต่างกันอย่างไรที่นี่?
ทำไม entry 23 ถึงบอกว่า sensitive = true ไม่ใช่ security boundary ทั้งที่มันซ่อน db_password จาก output ของ terraform apply จริงที่แสดงไว้?