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

CI/CD for Infrastructure

pipeline มาตรฐานสำหรับ infrastructure รัน plan อัตโนมัติทุกครั้งที่มี pull request เพื่อให้คน review และเก็บ apply ไว้หลัง merge เข้า main branch เท่านั้น — โดยใช้ AWS credential แบบ short-lived ที่ federate ผ่าน OIDC แทน access key แบบ long-lived ที่เก็บไว้ใน CI

pattern ที่ทีมส่วนใหญ่ลงเอยด้วยกันนั้นคล้าย CI/CD ของ application แต่มีจุดต่างสำคัญตรงว่าแต่ละ stage ทำอะไรจริง ๆ

  • เปิดหรืออัปเดต pull request → รัน terraform plan (หรือ terragrunt run --all plan สำหรับ setup ที่มีหลาย unit ใน Terragrunt) แล้ว post diff ที่ได้เป็น comment บน pull request ยังไม่มีอะไรเปลี่ยนบน AWS จริง ๆ ตรงนี้เป็นแค่ dry run ที่หน้าที่เดียวคือทำให้ change ที่เสนอมามองเห็นได้ก่อนที่ใครจะกด approve
  • merge เข้า main → รัน terraform apply (หรือ terragrunt run --all apply) จาก pipeline เอง ไม่ใช่จาก laptop ของใคร สำหรับ production โดยเฉพาะ step นี้มักถูก gate ไว้ด้วย manual approval — reviewer หรือทีมที่กำหนดต้องกด “approve” ใน CI system ก่อนที่ apply job จะรันได้ ถึงแม้จะ review plan ไปแล้วตอน pull request
name: terraform
on:
pull_request:
paths:
- 'infra/**'
push:
branches:
- main
paths:
- 'infra/**'
permissions:
id-token: write
contents: read
pull-requests: write
jobs:
plan:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: arn:aws:iam::111122223333:role/github-actions-terraform
aws-region: us-east-1
- uses: hashicorp/setup-terraform@v4
- run: terragrunt run --all plan -no-color | tee plan.txt
- run: gh pr comment ${{ github.event.pull_request.number }} --body-file plan.txt
env:
GH_TOKEN: ${{ github.token }}
apply:
if: github.event_name == 'push'
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v7
- uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: arn:aws:iam::111122223333:role/github-actions-terraform
aws-region: us-east-1
- uses: hashicorp/setup-terraform@v4
- run: terragrunt run --all apply --non-interactive

บรรทัด environment: production คือตัวที่ทำให้เกิด manual approval gate — GitHub Actions จะกัน apply job ไว้จนกว่าคนที่มีสิทธิ์บน environment นั้นจะ approve run นี้ สังเกตว่าไม่มี job ไหน checkout AWS access key ที่เก็บไว้เลย ทั้งสอง job assume role ผ่าน OIDC ซึ่งจะพูดถึงต่อไป

pull request ของ application code จะได้รับการ review เรื่อง logic, test, และ style — worst case คือ bug หลุดไปแล้วต้อง roll back แต่ infrastructure apply ทำอะไรที่ code deploy ทำไม่ได้ คือทำลาย resource จริงที่มี state บน AWS แล้วสร้างขึ้นใหม่ทั้งหมด บางครั้งแค่เพราะ change บรรทัดเดียว

Terraform เรียกสิ่งนี้ว่า forced replacement argument บางตัวของ resource บางประเภทอัปเดตแบบ in place ไม่ได้ — เปลี่ยนค่าเหล่านั้นแปลว่า provider ไม่มี API call สำหรับ “แก้ attribute นี้” มีแต่ “ทำลายแล้วสร้างใหม่” identifier ของ RDS instance, availability_zone ของ EBS volume หรือแม้แต่ชื่อของ resource เองใน provider บางตัวก็เข้าข่ายนี้ทั้งนั้น เมื่อเจอแบบนี้ terraform plan จะทำเครื่องหมาย resource นั้นด้วย -/+ แทนที่จะเป็น ~ แบบ in-place

# terraform plan output — this is the line to stop and read carefully
# aws_db_instance.main must be replaced
-/+ resource "aws_db_instance" "main" {
~ identifier = "app-db" -> "app-db-renamed" # forces replacement
engine = "postgres"
instance_class = "db.t3.micro"
}

-/+ บน resource ที่มี state อย่าง database หรือ persistent volume แปลว่าอาจมี data loss จริงหรือ downtime จริงทันทีที่มีคนกด approve — resource เก่าหายไปก่อนที่ resource ใหม่จะมีอยู่ด้วยซ้ำ นี่คือเหตุผลที่ review infrastructure plan จะ skim ผ่าน ๆ ไม่ได้ reviewer ต้องสแกนหาบรรทัด -/+ โดยเฉพาะ แล้วถามว่า replacement นั้น expected และ safe หรือเปล่า ไม่ใช่แค่อ่าน plan แบบเดียวกับที่อ่าน code diff เพื่อดู style กับ logic

pipeline รุ่นเก่าเก็บ AWS access key กับ secret แบบ long-lived ไว้เป็น CI secret แล้ว export เป็น environment variable ก่อนรันทุกครั้ง key นั้นใช้ได้ทั้ง plan และ apply เหมือนกันหมด ซึ่งนั่นแหละคือปัญหา ถ้าหลุดออกไปเมื่อไหร่ — จาก log ที่ config ผิด, dependency ที่ถูก compromise, หรือ workflow file ที่ echo environment variable ออกมา — ก็ยังใช้งานได้ต่อไปจนกว่าจะมีคน rotate หรือ revoke ด้วยมือ

practice ปัจจุบันแทนที่ key นั้นด้วย OIDC federation GitHub Actions ส่ง identity token แบบ short-lived ที่ sign ด้วย cryptographic ให้ AWS ได้ในทุก workflow run IAM OIDC identity provider ใน AWS account เชื่อถือ token issuer ของ GitHub และ trust policy ของ IAM role อนุญาตเฉพาะ repository และ branch ที่กำหนดให้ assume ได้ aws-actions/configure-aws-credentials แลก token นั้นเป็น AWS credential ชั่วคราวที่ scope ไว้กับ role ที่ assume มา อายุเท่ากับ job นั้นเท่านั้น ไม่มากไปกว่านั้น

permissions:
id-token: write
contents: read
jobs:
plan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Assume AWS role via OIDC
uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: arn:aws:iam::111122223333:role/github-actions-terraform
aws-region: us-east-1
- name: Terraform plan
run: terraform plan

ไม่ต้องมี AWS access key ที่เก็บเป็น CI secret เลยสักตัว — ไม่มีอะไร long-lived ให้หลุด ให้ rotate หรือให้ลืม permissions: id-token: write คือตัวที่ทำให้ workflow ขอ identity token นั้นได้ตั้งแต่แรก ถ้าไม่มีบรรทัดนี้ การแลก OIDC ก็ไม่มีอะไรให้ยื่นให้ AWS

อ้างอิงจาก docs ของ GitHub เอง (มาจาก docs ไม่ได้รัน — ไม่มี AWS account ให้สร้างจริง): “For the provider URL: Use https://token.actions.githubusercontent.com” และ “For the ‘Audience’: Use sts.amazonaws.com” แล้ว “Edit the trust policy, adding the sub field to the validation conditions” บน IAM role — docs.github.com/en/actions/how-tos/secure-your-work/security-harden-deployments/oidc-in-aws claim sub คือตัวที่ trust policy ใช้ scope ให้เฉพาะ repository/branch ที่กำหนด (เช่น repo:my-org/my-infra:ref:refs/heads/main) — ถ้าไม่มี field นี้ repository ไหนก็ได้ในองค์กร GitHub ที่ mint OIDC token ให้ sts.amazonaws.com ได้ก็ assume role นี้ได้หมด

workflow YAML ข้างบนพิสูจน์ด้วยการรัน actionlint (rhysd/actionlint) ใส่มันจริงในคอนเทนเนอร์แบบ one-shot — เครื่องมือตัวเดียวกับที่ pre-commit hook หรือ CI job แยกต่างหากจะรันทุกครั้งที่ workflow เปลี่ยน:

$ docker run --rm -v "$PWD:/repo" -w /repo rhysd/actionlint:latest -color

output จริงกับทั้งสอง workflow ในบทนี้ (pipeline plan/apply กับ drift-detection job ด้านล่าง): ไม่มี output อะไรเลย และ exit code เป็น 0 — actionlint จะไม่พิมพ์อะไรเลยเมื่อไม่เจอปัญหา นั่นคือหลักฐานว่าไม่มี ${{ }} expression ที่ไม่มีจริง, ค่า runs-on ที่ผิด, uses: ที่ไม่ pin version, หรือปัญหาระดับ shellcheck ใน run: script ที่ฝังอยู่

ช่องโหว่เล็ก ๆ ใน pipeline สอง job ข้างบน: apply job รัน terragrunt run --all apply --non-interactive ซึ่งคำนวณ plan ของตัวเอง ตอน apply ถ้ามีอะไรเปลี่ยนระหว่างที่ PR ถูก review กับตอนที่ merge เข้าจริง — เพื่อนร่วมทีม merge อะไรคู่ขนานมา, module registry update, หรือ drift ใน account — plan ที่คนอนุมัติกับ plan ที่ apply จริงก็ไม่ใช่ plan เดียวกันแบบพิสูจน์ได้ terragrunt run --all plan --out-dir <dir> (ยืนยันจาก terragrunt run --help: --out-dir value Directory to store plan files.) ปิดช่องนี้ด้วยการเขียน saved plan file หนึ่งไฟล์ต่อ unit แล้ว terragrunt run --all apply --out-dir <dir> ก็ apply ไฟล์นั้นตรง ๆ แทนที่จะ re-plan ใหม่

$ terragrunt run --all plan --out-dir plan-output --non-interactive
...
Saved the plan to:
.../plan-output/tfplan.tfplan
To perform exactly these actions, run the following command to apply:
terraform apply ".../plan-output/tfplan.tfplan"
❯❯ Run Summary 1 units
Succeeded 1
$ terragrunt run --all apply --out-dir plan-output --non-interactive
local_file.config: Creating...
local_file.config: Creation complete after 0s [id=f572d396fae9206628714fb2ce00f72e94f2258f]
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
❯❯ Run Summary 1 units
Succeeded 1

output จริงจาก Terragrunt 1.1.0 พิสูจน์ในเครื่องกับ local backend: คำสั่งที่สองก็ apply ไฟล์ที่คำสั่งแรก save ไว้เป๊ะ ๆ — ไม่มี re-plan ไม่มี prompt ใน CI จริง plan job upload plan-output/ เป็น build artifact ผูกกับ commit SHA แล้ว apply job download artifact เดียวกันนั้นมาก่อนรัน apply --out-dir สิ่งที่ apply จริงจึงพิสูจน์ได้ว่าเป็นสิ่งเดียวกับที่ reviewer อ่าน

terraform plan -detailed-exitcode (terraform plan -help: “Return detailed exit codes when the command exits… 0 - Succeeded, diff is empty (no changes)”) เปลี่ยน exit code 0 ให้แปลว่า ไม่มีอะไรเปลี่ยน, 1 แปลว่า error, และ 2 แปลว่า มี change pipeline step เอาไปใช้ skip apply ที่ไม่จำเป็นได้ หรือ — รันแบบ schedule แทน push — ใช้ตรวจ drift โดยไม่มี apply step เลย

ทั้งสาม exit code จริง พิสูจน์ในเครื่องด้วย saved plan file (-out=tfplan) เพื่อให้ pattern reuse artifact ข้างบนกับการเช็ค exit code ข้างล่างนี้เป็นคำสั่งชุดเดียวกัน:

# 2 — มี change (aws_security_group + aws_security_group_rule, mock credentials)
$ terraform plan -input=false -out=tfplan -detailed-exitcode
...
Plan: 2 to add, 0 to change, 0 to destroy.
Saved the plan to: tfplan
$ echo $?
2
# 1 — error จริง (data source ที่ AWS ปฏิเสธจริง แม้จะมี mock credentials)
$ terraform plan -input=false -detailed-exitcode
╷
│ Error: reading EC2 VPC: operation error EC2: DescribeVpcs, https response
│ error StatusCode: 401, ... api error AuthFailure: AWS was not able to
│ validate the provided access credentials
│
│ with data.aws_vpc.missing,
│ on main.tf line 26, in data "aws_vpc" "missing":
╵
$ echo $?
1
# 0 — ไม่มี change (config เดิม apply ไปแล้วรอบหนึ่ง, resource local_file)
$ terraform plan -input=false -detailed-exitcode
...
Terraform has compared your real infrastructure against your configuration
and found no differences, so no changes are needed.
$ echo $?
0

รันจริงสามครั้งแยกกัน ได้ exit code จริงสามค่าต่างกัน — นี่คือสิ่งที่ script ที่ branch ตาม -detailed-exitcode ต้องแยกให้ออกจริง ๆ (SpotTheBug ข้างล่างแสดง pattern ที่มักทำให้แยกไม่ออก) สังเกตกรณี 1: mock credentials (skip_credentials_validation) ผ่านแค่ auth check ของ plan เอง แต่ data source ที่ต้องอ่านจาก AWS API จริง (aws_vpc, DescribeVpcs) ก็ยังยิง HTTPS call จริงและ fail จริง — mock credentials ทำให้ plan เป็นไปได้ แบบ offline สำหรับ resource ที่ไม่ต้อง read-back เท่านั้น ไม่ใช่ไม่ต้องมี credential เลยในทุกกรณี

tfplan ที่ save จาก -out ข้างบนเป็น binary file — terraform show -json tfplan render ออกมาเป็น JSON schema แบบเดียวกับที่ the-state-file.mdx ใช้กับ state (มี format_version, แยกจาก state’s version field) มี document ไว้และ version ชัดเจน เพียงแต่ resource_changes[].change.actions อธิบาย change ที่ เสนอ แทนที่จะเป็น resources[].instances[].attributes ที่อธิบาย change ที่ apply แล้ว output จริง (ตัดให้สั้น) จาก tfplan ข้างบน:

{
"format_version": "1.2",
"terraform_version": "1.16.4",
"resource_changes": [
{
"address": "aws_security_group.web",
"change": { "actions": ["create"] }
},
{
"address": "aws_security_group_rule.https",
"change": { "actions": ["create"] }
}
]
}

resource_changes[].change.actions คือ field เดียวกับที่ policy-as-code-and-static-analysis.mdx รัน OPA/conftest rego policy ใส่ — policy ที่อยากบล็อก replacement แบบ delete-แล้ว-create หรือห้าม resource type บางตัวถูก destroy เด็ดขาด อ่าน array นี้แทนที่จะพยายาม diff state snapshot สองตัวเอง เป็น flag -json ตัวเดียวกับ terraform show -json บน state (ไม่ใส่ argument เป็นไฟล์ plan) แค่ชี้ไปที่ plan file แทนที่จะเป็น state ปัจจุบัน

name: drift-detection
on:
schedule:
- cron: '0 6 * * *' # every day at 06:00 UTC
workflow_dispatch:
permissions:
id-token: write
contents: read
jobs:
drift:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: arn:aws:iam::111122223333:role/github-actions-terraform
aws-region: us-east-1
- uses: hashicorp/setup-terraform@v4
- name: Check for drift
id: plan
run: |
set +e
terragrunt run --all plan -detailed-exitcode -no-color
echo "exitcode=$?" >> "$GITHUB_OUTPUT"
- name: Fail if drift detected
if: steps.plan.outputs.exitcode == '2'
run: |
echo "::error::Drift detected — real infrastructure no longer matches configuration"
exit 1

job นี้ไม่รัน apply เลย — หน้าที่เดียวคือ fail ดัง ๆ (ของจริงคือ page คนหรือ post เข้า chat channel) ทันทีที่ exit code ของ plan บอกว่า account drift ไปจาก configuration แล้ว จับ drift ได้ตามตารางแทนที่จะรู้ตัวอีกทีตอน apply ครั้งถัดไป

flowchart LR
  pr["Pull request"] -->|triggers| plan["terragrunt run --all plan"]
  plan -->|posted as| comment["PR comment for review"]
  comment -->|approved and merged| main["Merge to main"]
  main -->|triggers, gated by approval| apply["terragrunt run --all apply"]
  oidc["GitHub OIDC provider"] -->|short-lived credentials| plan
  oidc -->|short-lived credentials| apply
pull request trigger plan ให้ review, merge เข้า main trigger apply หลัง approval gate, OIDC ส่ง credential แบบ short-lived ให้ทั้งสองฝั่ง

infrastructure pipeline มีหน้าที่แค่สองอย่าง และการปนกันสองอย่างนี้คือ root cause ของ incident ส่วนใหญ่: คำนวณ plan ให้คนดู (เร็ว ปลอดภัย อ่านอย่างเดียวจาก AWS — ไม่มีอะไรเปลี่ยนจริง) กับ execute plan ที่ผ่าน review แล้ว (step เดียวที่แตะ resource จริงได้ และแตะได้ก็ต่อเมื่อผ่านทั้ง automated gate — -detailed-exitcode, policy check — และ human gate แล้วเท่านั้น) มองกลไกอื่น ๆ ในบทนี้ว่ารับใช้สองหน้าที่นี้อย่างใดอย่างหนึ่ง: OIDC มีไว้เพื่อให้ job execute ไม่ต้องมี credential แบบ standing เลย; --out-dir มีไว้เพื่อให้ execute รัน byte เดียวกับที่ compute สร้างไว้ ไม่ใช่คำนวณใหม่; scheduled drift job คือ job compute ที่ไม่มี job execute ตามหลังเลย มีไว้เพื่อเผยว่า reality เบี่ยงไปจาก plan อย่างเงียบ ๆ หรือเปล่าเท่านั้น

ทางเลือกได้อะไรเสียอะไร
OIDC federation แทน access key ที่เก็บไว้ไม่มี AWS credential แบบ long-lived อยู่ที่ไหนให้หลุดหรือลืม rotateต้อง setup IAM OIDC provider + trust policy ต่อ AWS account ครั้งเดียว และใช้ได้แค่กับ CI system ที่ยื่น OIDC token ได้เท่านั้น
ใช้ --out-dir reuse plan/apply artifactapply พิสูจน์ได้ว่าเป็น plan เดียวกับที่คนอนุมัติ ไม่ใช่ re-plan ใหม่ต้อง upload/download artifact เพิ่มระหว่าง plan job กับ apply job และต้อง treat artifact นั้นเป็นของ sensitive (plan อาจมีค่าที่ data source resolve มา)
Gate ด้วย -detailed-exitcodeskip apply ที่ไม่จำเป็น และเปลี่ยน plan แบบ schedule ให้เป็น drift alarm โดยไม่มี apply step เลยexit code 1 (error จริง) กับ 2 (change จริง) ต้องแยกจัดการชัดเจน — ถ้ามองว่า non-zero คือ “มี change” หมด จะกลืน error จริงไปเงียบ ๆ
Manual approval gate environment: productionต้องมีคนที่ระบุชื่อ approve ก่อน resource production จะถูกแตะ แม้ automated check ผ่านหมดแล้วเพิ่ม latency ให้ทุก production change และดีเท่ากับที่ reviewer อ่าน plan จริง ไม่ใช่กด approve แบบอัตโนมัติ
  • “รัน apply ใหม่ใน apply job ก็ได้ plan เดียวกับที่ reviewer เห็นใน pull request” พิสูจน์ข้างบน: ถ้าไม่มี --out-dir apply job จะคำนวณ plan ของตัวเองใหม่ตอน apply ถ้ามีอะไรเปลี่ยนใน account หรือ module registry ระหว่าง review กับ merge plan นั้นอาจต่างจากที่คนอ่านจริง
  • “-detailed-exitcode return ค่าไม่ใช่ 0 แปลว่ามีอะไรผิดพลาดเสมอ” มัน return 2 สำหรับ “มี change ต้อง apply” ซึ่งปกติดี และ 1 เท่านั้นที่แปลว่า error จริง ถ้า script treat สองค่านี้เหมือนกัน จะบล็อก deploy ปกติ หรือแย่กว่านั้นคือเมิน error จริงไปเฉย ๆ
  • “OIDC ทำให้ไม่ต้องมี IAM role เลย” มันทำให้ไม่ต้องมี access key แบบ long-lived เท่านั้น IAM role จริง ที่มี trust policy scope เฉพาะ repository/branch ที่ถูกต้อง ยังจำเป็นอยู่ และเป็น authorization boundary ตัวจริง
  • “scheduled drift-detection job ต้องมี apply step ถึงจะมีประโยชน์” job ที่พิสูจน์ข้างบนไม่ apply อะไรเลย — คุณค่าทั้งหมดอยู่ที่ plan -detailed-exitcode fail ดัง ๆ ตามตาราง ซึ่งจับ drift ได้ก่อนที่ change ครั้งถัดไปจะเผยมันออกมาเอง

ตอน review CI/CD workflow ของ Terraform/Terragrunt ที่ AI เขียน ให้เช็ค:

  • secret AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY ที่เก็บไว้ แทนที่จะเป็น aws-actions/configure-aws-credentials กับ role-to-assume — สัญญาณของ long-lived key ที่ควรเป็น OIDC federation แทน
  • apply job ที่ไม่มี environment: (หรือ required-reviewers gate เทียบเท่า) บน job — ไม่มีอะไรหยุด PR ที่ merge แล้วจาก apply เข้า production แบบไม่มีคนดู
  • terraform apply (ไม่มี -input=false ไม่มี saved plan file) รันตรง ๆ ใน CI แทนที่จะ apply plan artifact ที่ step plan ก่อนหน้าสร้างไว้ — นี่คือช่องเดียวกับที่ proof --out-dir ข้างบนปิดไป
  • step drift-detection หรือ CI plan ที่เช็ค if: failure() แทนที่จะ capture ค่า -detailed-exitcode จริง — failure() แยกไม่ออกระหว่าง “มี change” (exit 2) กับ “plan error จริง” (exit 1)
  • ไม่มี permissions: id-token: write บน workflow ที่ดูเหมือนใช้ OIDC — ถ้าไม่มีบรรทัดนี้ การแลก token ก็ไม่มีอะไรยื่นให้ AWS แล้ว job จะ fail ที่ step credential ไม่ใช่แบบเงียบ ๆ

🐛 หา bug ในโค้ดนี้

- name: Check for drift
id: plan
run: terragrunt run --all plan -detailed-exitcode -no-color
continue-on-error: true
- name: Fail if drift detected
if: steps.plan.outcome == 'failure'
run: exit 1
เฉลย

-detailed-exitcode ทำให้ shell command ของ step exit ไม่เป็นศูนย์ได้จาก สอง สาเหตุที่ต่างกัน: exit code 2 แปลว่ามี change จริง (เคสที่ job นี้อยากจับ) แต่ exit code 1 แปลว่า plan เอง error — credential ผิด, syntax ผิด, provider timeout ทั้งสองแบบทำให้ continue-on-error: true ตี steps.plan.outcome เป็น 'failure' เหมือนกันหมด workflow นี้เลยแยกไม่ออกว่า “infrastructure drift” กับ “drift check เองพัง” ต่างกันยังไง และ check ที่พังจะถูกรายงานว่าเป็น drift ทุกวัน ทางแก้ที่พิสูจน์ข้างบนคือ capture exit code ตรง ๆ ด้วย echo "exitcode=$?" >> "$GITHUB_OUTPUT" ภายใต้ set +e (ไม่ต้องมี continue-on-error เลย) แล้ว branch จาก steps.plan.outputs.exitcode == '2' โดยเฉพาะ

ใน IaC CI/CD pipeline มาตรฐาน อะไรควร trigger terraform plan เทียบกับ terraform apply
ทำไม infrastructure plan ต้อง review ละเอียดกว่า code diff ของ application ทั่วไป
exit code 2 จริงตัวนี้แปลว่าอะไร และต่างจาก 1 ยังไง
$ terraform plan -detailed-exitcode -input=false
...
Plan: 2 to add, 0 to change, 0 to destroy.
$ echo $?
2
ทำไม OIDC federation ถึงดีกว่า AWS access key แบบ long-lived ที่เก็บเป็น CI secret
bug จริงใน drift-detection step นี้คืออะไร ตามที่ SpotTheBug ของบทนี้พิสูจน์ไว้
- name: Check for drift
  id: plan
  run: terragrunt run --all plan -detailed-exitcode -no-color
  continue-on-error: true
- if: steps.plan.outcome == 'failure'
  run: exit 1
terragrunt run --all plan --out-dir <dir> ตามด้วย run --all apply --out-dir <dir> (directory เดียวกัน) แก้ปัญหาจริงอะไร ตามที่บทนี้พิสูจน์ด้วย Terragrunt 1.1.0 จริง