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

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 {
aws = { source = "hashicorp/aws", version = ">= 6.0" }
}
}

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

provider "registry.terraform.io/hashicorp/aws" {
version = "6.66.0"
constraints = ">= 6.0.0"
hashes = [ ... ]
}

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

วิธีแก้:

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

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

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

entries/02-deprecated-args/main.tf
resource "aws_s3_bucket" "logs" {
bucket = "acme-logs-bucket"
acl = "private"
}
resource "aws_iam_role" "ci" {
name = "ci-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{ Action = "sts:AssumeRole", Effect = "Allow", Principal = { Service = "ec2.amazonaws.com" } }]
})
managed_policy_arns = ["arn:aws:iam::aws:policy/ReadOnlyAccess"]
}

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

warning - Argument is deprecated - acl is deprecated. Use the aws_s3_bucket_acl resource instead.
warning - Argument is deprecated - managed_policy_arns is deprecated. Use the aws_iam_role_policy_attachment resource instead. If Terraform should exclusively manage all managed policy attachments (the current behavior of this argument), use the aws_iam_role_policy_attachments_exclusive resource as well.

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

วิธีแก้:

entries/02-deprecated-args-fixed/main.tf
resource "aws_s3_bucket" "logs" {
bucket = "acme-logs-bucket"
}
resource "aws_s3_bucket_acl" "logs" {
bucket = aws_s3_bucket.logs.id
acl = "private"
}
resource "aws_iam_role" "ci" {
name = "ci-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{ Action = "sts:AssumeRole", Effect = "Allow", Principal = { Service = "ec2.amazonaws.com" } }]
})
}
resource "aws_iam_role_policy_attachment" "ci_readonly" {
role = aws_iam_role.ci.name
policy_arn = "arn:aws:iam::aws:policy/ReadOnlyAccess"
}

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

entries/03-provider-in-module/modules/vpc/main.tf
provider "aws" {
region = "us-east-1"
}
resource "aws_vpc" "this" {
cidr_block = "10.0.0.0/16"
}
# entries/03-provider-in-module/main.tf — @expect-error Module is incompatible with count, for_each, and depends_on
module "vpc" {
source = "./modules/vpc"
count = 2
}

ตัวนี้ validate ผ่านสบายๆ ด้วยตัวเอง — ความล้มเหลวปรากฏก็ต่อเมื่อผู้เรียกใช้พยายามใช้ count/for_each/depends_on บน module call ซึ่งเป็นสิ่งที่ environment ที่สองมักต้องการพอดี

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

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

วิธีแก้:

entries/03-provider-in-module-fixed/modules/vpc/main.tf
resource "aws_vpc" "this" {
cidr_block = "10.0.0.0/16"
}
entries/03-provider-in-module-fixed/main.tf
provider "aws" {
region = "us-east-1"
}
module "vpc" {
source = "./modules/vpc"
count = 2
}

จับได้โดย: terraform 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. คิด argument ล็อก S3 backend ขึ้นมาเองที่ไม่ตรงกับ default จริงอีกต่อไป

หัวข้อที่มีชื่อว่า “5. คิด argument ล็อก S3 backend ขึ้นมาเองที่ไม่ตรงกับ default จริงอีกต่อไป”
entries/05-backend-locking/backend.tf
terraform {
backend "s3" {
bucket = "acme-tfstate"
key = "network/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "acme-tf-locks"
}
}

ตัวนี้ valid ทาง syntax — dynamodb_table ยังอยู่ใน schema ของ backend s3 — แต่ตาม เอกสาร S3 backend: dynamodb_table — “(Optional, Deprecated) Name of the DynamoDB Table to use for state locking and consistency… DynamoDB-based locking is deprecated and will be removed in a future minor version.” ตัวแทนที่ ตามหน้าเดียวกัน: use_lockfile — “(Optional) Whether to use a lockfile for locking the state file. Defaults to false,” ใช้ conditional-write locking ของ S3 เอง (stable ตั้งแต่ Terraform 1.11; experimental ใน 1.10) โดยไม่ต้อง provision, จัดการ IAM-permission, หรือจ่ายเงินสำหรับ DynamoDB table เลย

วิธีแก้:

entries/05-backend-locking-fixed/backend.tf
terraform {
backend "s3" {
bucket = "acme-tfstate"
key = "network/terraform.tfstate"
region = "us-east-1"
use_lockfile = true
}
}

จับได้โดย: ไม่มีอะไรใน validate/plan เลย — ทั้งสอง configuration เป็น HCL ที่ valid มีแค่ reviewer ที่รู้ว่า DynamoDB table ที่ account นี้ยังจ่ายเงินอยู่ถูก decommission แล้วเท่านั้นที่จับได้

# 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 "aws_iam_role" "logging_helper" {
name = "logging-helper"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{ Action = "sts:AssumeRole", Effect = "Allow", Principal = { Service = "ec2.amazonaws.com" } }]
})
lifecycle {
prevent_destroy = true
}
}
resource "aws_db_instance" "primary" {
identifier = "primary-db"
engine = "postgres"
instance_class = "db.t3.micro"
allocated_storage = 20
username = "admin"
password_wo = "unused-in-this-proof"
password_wo_version = 1
skip_final_snapshot = true
}

อาการ — terraform plan -destroy จริงกับ state ที่มี resource ทั้งสองอยู่:

Plan: 0 to add, 0 to change, 2 to destroy.
Error: Instance cannot be destroyed
on main.tf line 1:
1: resource "aws_iam_role" "logging_helper" {
Resource aws_iam_role.logging_helper 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.

Error ระบุแค่ aws_iam_role.logging_helper เท่านั้น การเอา prevent_destroy ออกจาก role แล้วรัน plan -destroy ซ้ำแบบเดิมพิสูจน์ปัญหาจริง: aws_db_instance.primary — database production จริง — destroy ได้โดยไม่มีอะไรต้านเลย:

Plan: 0 to add, 0 to change, 2 to destroy.

ไม่มี error ไม่มี confirmation gate เฉพาะสำหรับ resource นั้น — มันไม่เคยถูกป้องกันเลยตั้งแต่แรก

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

entries/09-prevent-destroy-wrong-resource-fixed/main.tf
resource "aws_db_instance" "primary" {
identifier = "primary-db"
engine = "postgres"
instance_class = "db.t3.micro"
allocated_storage = 20
username = "admin"
password_wo = "unused-in-this-proof"
password_wo_version = 1
skip_final_snapshot = true
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 "aws_s3_bucket" "this" {
count = length(var.bucket_names)
bucket = "acme-${var.bucket_names[count.index]}-${count.index}"
}

อาการ — terraform plan จริง, bucket_names เปลี่ยนจาก ["logs", "assets", "backups"] เป็น ["logs", "backups"] (เอา element ตรงกลางออก):

# aws_s3_bucket.this[1] will be updated in-place
~ resource "aws_s3_bucket" "this" {
~ bucket = "acme-assets-1" -> "acme-backups-1" # forces replacement
}
# aws_s3_bucket.this[2] will be destroyed
Plan: 1 to add, 1 to change, 2 to destroy.

count.index อิงตำแหน่ง ไม่ใช่ identity การเอา "assets" ออกจากตรงกลางเลื่อน "backups" จาก index 2 ไปเป็น index 1 — Terraform อ่านว่า “index 1 เปลี่ยนจาก bucket assets เป็น bucket backups” (การ rename/replace ที่ทำลายข้อมูล) และ “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 "aws_s3_bucket" "this" {
for_each = toset(var.bucket_names)
bucket = "acme-${each.value}"
}

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

# entries/11-for-each-unknown/main.tf — @expect-error Invalid for_each argument
resource "aws_iam_role" "generator" {
name = "role-generator"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{ Action = "sts:AssumeRole", Effect = "Allow", Principal = { Service = "ec2.amazonaws.com" } }]
})
}
resource "aws_iam_policy" "per_action" {
for_each = toset(aws_iam_role.generator.tags_all == {} ? [] : keys(aws_iam_role.generator.tags_all))
name = "policy-${each.key}"
policy = jsonencode({ Version = "2012-10-17", Statement = [] })
}

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

Error: Invalid for_each argument
on main.tf line 10, in resource "aws_iam_policy" "per_action":
10: for_each = toset(aws_iam_role.generator.tags_all == {} ? [] : keys(aws_iam_role.generator.tags_all))
├────────────────
│ aws_iam_role.generator.tags_all is a map of string, known only after apply
The "for_each" set includes values derived from resource attributes that
cannot be determined until apply, and so Terraform cannot determine the
full set of keys that will identify the instances of this resource.

count ยอมรับตัวเลขที่รู้เฉพาะหลัง apply ได้ในบางบริบท แต่ for_each ไม่เคยยอมรับ set ของ key ที่ไม่รู้ตอน plan เลย — key คือ identity และ Terraform ต้องรู้ identity ของทุก resource ก่อนจะคำนวณ plan ได้เลย

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

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

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 "deletion_protection" is not expected here.
resource "aws_instance" "app" {
ami = "ami-0abcdef1234567890"
instance_type = "t3.micro"
deletion_protection = true
}

deletion_protection เป็น argument จริงของ Terraform AWS provider — บน aws_db_instance (RDS) และ resource อื่นอีกหยิบมือ มันไม่มีอยู่บน aws_instance (EC2) เลย

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

Error: Unsupported argument
on main.tf line 4, in resource "aws_instance" "app":
4: deletion_protection = true
An argument named "deletion_protection" is not expected here.

วิธีแก้: ตัวที่เทียบเท่าจริงสำหรับ EC2 คือ disable_api_termination:

entries/13-hallucinated-argument-fixed/main.tf
resource "aws_instance" "app" {
ami = "ami-0abcdef1234567890"
instance_type = "t3.micro"
disable_api_termination = true
}

จับได้โดย: 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_public_s3.rego — @expect-error rego_parse_error: `if` keyword is required before rule body
package main
deny[msg] {
resource := input.resource_changes[_]
resource.type == "aws_s3_bucket"
msg := sprintf("%s must set tags for cost allocation", [resource.address])
}

อาการ — conftest test จริง, openpolicyagent/conftest ปัจจุบันที่ default เป็น syntax Rego v1:

Error: running test: load: loading policies: load: 2 errors occurred during loading:
policy/deny_public_s3.rego:3: rego_parse_error: `if` keyword is required before rule body
policy/deny_public_s3.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 terraform show -json จริง:

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

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

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

Rule catalog จริงของ tflint-ruleset-aws (ยืนยันกับ github.com/terraform-linters/tflint-ruleset-aws/tree/v0.40.0/docs/rules) มี aws_db_instance_invalid_type, aws_elasticache_cluster_invalid_type, และ aws_elasticache_replication_group_invalid_type — แต่สำหรับ EC2 rule ชื่อ aws_instance_previous_type ไม่ใช่ aws_instance_invalid_type ผู้ช่วย AI จับรูปแบบชื่อ _invalid_type จาก rule ของ RDS/ElastiCache มา hallucinate ตัวของ EC2 ให้เข้ากัน

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

$ docker run --rm -v "$(pwd)":/data -w /data ghcr.io/terraform-linters/tflint
2 issue(s) found:
Warning: Missing version constraint for provider "aws" in `required_providers` (terraform_required_providers)
Warning: terraform "required_version" attribute is required (terraform_required_version)

Rule ที่คิดขึ้นเองไม่สร้าง error “ไม่พบ rule” เลย — tflint แค่ไม่เคยเอาไปใช้ และการรันรายงานว่าสำเร็จบนทุกอย่างที่ผู้ช่วย AI ตั้งใจเช็คจริง configuration ดูเหมือน จะบังคับเช็ค instance type แต่มันไม่บังคับอะไรเลย

วิธีแก้:

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

จับได้โดย: ไม่มีอะไรที่ tflint เองรายงานเลย — นี่คือรูปแบบที่อันตรายที่สุดใน catalog ทั้งหมดนี้: control ด้าน security/cost ที่ไม่ทำอะไรเลยอย่างเงียบๆ พร้อม config file ที่อ่านราวกับมันทำอะไรบางอย่างจริงๆ มีแค่การเทียบชื่อ rule กับเอกสารจริงของ ruleset เท่านั้นที่จับได้

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 ในเครื่อง, S3 bucket ที่ไม่มี IAM รัดกุม, CI log artifact ที่ดันไป cat state) อ่าน password จริงเป็น plaintext ได้เลย ไม่ว่าจะติด label “sensitive” หรือไม่ random_password ไม่มี write-only argument ให้หลีกเลี่ยงเรื่องนี้ — แต่ resource ที่มันมักใช้ป้อนค่าให้ aws_db_instance มี: password_wo + password_wo_version (ใช้ตลอด example ของ entry 9 ด้านบน) ซึ่งไม่ถูกเขียนเข้า 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 "aws_db_instance" "primary" {
identifier = "primary-db"
engine = "postgres"
instance_class = "db.t3.micro"
allocated_storage = 20
username = "admin"
password_wo = random_password.db.result
password_wo_version = 1
skip_final_snapshot = true
}

จับได้โดย: 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 } จาก aws_iam_role.logging_helper ไปยัง aws_db_instance.primary แทนที่จะเพิ่มมันเข้าไปทั้งสองตัว?
Rego ก่อน v1 ของ entry 20 (deny[msg] { ... }) และชื่อ tflint rule ที่คิดขึ้นเองของ entry 21 (aws_instance_invalid_type) ทั้งคู่เป็น policy check ที่พัง — แต่พังต่างกันอย่างไร?
ทำไม entry 23 ถึงบอกว่า sensitive = true ไม่ใช่ security boundary ทั้งที่มันซ่อน db_password จาก output ของ terraform apply จริงที่แสดงไว้?