CI/CD for Infrastructure
ไอเดียในหนึ่งประโยค
หัวข้อที่มีชื่อว่า “ไอเดียในหนึ่งประโยค”pipeline สำหรับ infrastructure แบบมาตรฐานจะรัน plan อัตโนมัติทุกครั้งที่มี pull request ให้คนมา review และเก็บ apply ไว้ให้รันหลัง merge เข้า main branch เท่านั้น โดยใช้ credential ของ GCP แบบ short-lived ผ่าน Workload Identity Federation แทนที่การเก็บ service account key ที่ดาวน์โหลดมาไว้ใน CI
Plan ทุก pull request, apply หลัง merge เท่านั้น
หัวข้อที่มีชื่อว่า “Plan ทุก pull request, apply หลัง merge เท่านั้น”pattern ที่ทีมส่วนใหญ่มาลงตัวตรงกันคือเลียนแบบ CI/CD ของ application code แต่มีจุดต่างสำคัญตรงว่าแต่ละ stage ทำอะไรจริง ๆ
- Pull request เปิดหรือถูกอัปเดต → รัน
terraform plan(หรือterragrunt run --all planสำหรับ Terragrunt setup แบบหลาย unit) แล้วโพสต์ diff ที่ได้เป็น comment บน pull request ยังไม่มีอะไรใน GCP เปลี่ยนแปลง นี่คือ dry run ที่หน้าที่เดียวคือทำให้ reviewer เห็นการเปลี่ยนแปลงที่เสนอมาก่อนที่ใครจะ approve - Merge เข้า main → รัน
terraform apply(หรือterragrunt run --all apply) จาก pipeline เอง ไม่ใช่จาก laptop ของใครสักคน สำหรับ production โดยเฉพาะ step นี้มักถูก gate ด้วย manual approval คือต้องมี reviewer หรือทีมที่มีชื่อระบุมา approve run ใน 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 - id: auth uses: google-github-actions/auth@v3 with: workload_identity_provider: projects/111122223333/locations/global/workloadIdentityPools/github-pool/providers/github-provider service_account: github-actions-terraform@acme-prod-123456.iam.gserviceaccount.com - 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 - id: auth uses: google-github-actions/auth@v3 with: workload_identity_provider: projects/111122223333/locations/global/workloadIdentityPools/github-pool/providers/github-provider service_account: github-actions-terraform@acme-prod-123456.iam.gserviceaccount.com - 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 service account key file ที่ดาวน์โหลดมา ทั้งสอง job authenticate ผ่าน Workload Identity Federation ซึ่งจะอธิบายต่อไป
ทำไม plan ต้อง review ละเอียดกว่า code diff ทั่วไป
หัวข้อที่มีชื่อว่า “ทำไม plan ต้อง review ละเอียดกว่า code diff ทั่วไป”pull request ของ application code ถูก review เรื่อง logic, test, style — worst case คือ bug หลุดไปแล้วค่อย rollback แต่ infrastructure apply ทำสิ่งที่ code deploy ทำไม่ได้ คือทำลาย GCP resource ที่มี state จริงแล้วสร้างใหม่ตั้งแต่ต้น บางครั้งแค่เปลี่ยนโค้ดบรรทัดเดียว
Terraform เรียกสิ่งนี้ว่า forced replacement argument บาง argument ของ resource บางตัวอัปเดตแบบ in-place ไม่ได้ เปลี่ยนค่าเหล่านั้นแปลว่า provider ไม่มี API call แบบ “แก้ attribute นี้” มีแต่ “ทำลายแล้วสร้างใหม่” อย่าง name ของ Cloud SQL instance, zone ของ Compute Engine instance หรือ name ของ persistent disk ใน configuration บางแบบก็เข้าข่ายนี้ทั้งหมด พอเกิดแบบนี้ terraform plan จะติดเครื่องหมาย resource นั้นด้วย -/+ แทนที่จะเป็น ~ แบบ in-place
# terraform plan output — this is the line to stop and read carefully # google_sql_database_instance.main must be replaced-/+ resource "google_sql_database_instance" "main" { ~ name = "app-db" -> "app-db-renamed" # forces replacement database_version = "POSTGRES_15" region = "us-central1" }-/+ บน stateful resource อย่าง database หรือ persistent disk แปลว่าข้อมูลอาจหายจริงหรือ downtime จริงได้ในวินาทีที่มีคน approve run นั้น — resource เก่าหายไปก่อนที่ resource ใหม่จะเกิดขึ้นด้วยซ้ำ นี่คือเหตุผลตรง ๆ ว่าทำไมการ review infrastructure plan จะสแกนผ่าน ๆ ไม่ได้ reviewer ต้องสแกนหาบรรทัด -/+ โดยเฉพาะแล้วถามว่า replacement นั้นคาดไว้แล้วและปลอดภัยไหม ไม่ใช่แค่อ่าน plan แบบที่อ่าน code diff เพื่อดู style กับ logic
Credential แบบ short-lived ผ่าน Workload Identity Federation
หัวข้อที่มีชื่อว่า “Credential แบบ short-lived ผ่าน Workload Identity Federation”pipeline รุ่นเก่าเก็บ service account key JSON file ที่ดาวน์โหลดมาไว้เป็น CI secret แล้วชี้ GOOGLE_APPLICATION_CREDENTIALS ไปที่ไฟล์นั้นก่อนรันทุกครั้ง key นั้นใช้ได้ทั้ง plan และ apply เท่า ๆ กัน ซึ่งนั่นแหละคือปัญหา ถ้าหลุดออกไปสักครั้ง — จาก log ที่ตั้งค่าผิด, dependency ที่ถูกแฮ็ก, หรือ workflow file ที่ echo environment variable ออกมา — ก็จะยังใช้งานได้ต่อไปจนกว่าจะมีใครไปเจอแล้วเพิกถอนเอง เพราะ key แบบนี้ไม่มี expiry ในตัว
current best practice คือแทนที่ key นั้นด้วย Workload Identity Federation Workload Identity Pool และ provider ที่ตั้งค่าไว้ใน GCP project จะ trust OIDC token issuer ของ GitHub และ attribute condition บน provider จะอนุญาตแค่ repository กับ branch ที่ระบุไว้เท่านั้นให้ใช้งานได้ google-github-actions/auth จะยื่น GitHub identity token แบบ short-lived ที่เซ็นด้วย cryptographic ของ workflow run นั้นให้ provider ตัวนั้น แล้ว provider จะแลกเป็น GCP credential แบบชั่วคราวที่ scope ให้ impersonate service_account ที่ระบุไว้ — ใช้ได้แค่ตลอดอายุของ job นั้นเท่านั้น ไม่มีอะไรเกินกว่านั้น
permissions: id-token: write contents: read
jobs: plan: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7
- name: Authenticate to Google Cloud via Workload Identity Federation id: auth uses: google-github-actions/auth@v3 with: workload_identity_provider: projects/111122223333/locations/global/workloadIdentityPools/github-pool/providers/github-provider service_account: github-actions-terraform@acme-prod-123456.iam.gserviceaccount.com
- name: Terraform plan run: terraform planไม่ต้องมี service account key ให้ดาวน์โหลดหรือเก็บเป็น CI secret เลยสักตัว ไม่มีอะไรระยะยาวให้หลุด, หมุนเวียน, หรือลืมดูแลอีกต่อไป permissions: id-token: write คือสิ่งที่ทำให้ workflow ขอ identity token นั้นได้ตั้งแต่แรก ถ้าไม่มีบรรทัดนี้ google-github-actions/auth จะไม่มีอะไรให้ยื่นให้ Workload Identity Pool provider เลย
อ้างอิงจาก docs ของ google-github-actions/auth เอง (มาจาก docs ไม่ได้รัน — ไม่มี GCP project ให้สร้างจริง): “This action exchanges a GitHub OIDC token for a Google Cloud access token, using Workload Identity Federation.” และ issuer URL ที่ต้อง config บน pool provider คือ https://token.actions.githubusercontent.com — docs.github.com/en/actions/how-tos/secure-your-work/security-harden-deployments/oidc-in-google-cloud-platform setup มีสามส่วน (จาก docs): สร้าง workload identity pool, เพิ่ม provider เข้าไปพร้อม attribute condition จำกัดว่า repository/branch ไหนใช้ได้บ้าง แล้ว grant roles/iam.workloadIdentityUser ให้ principal ของ provider นั้นบน service account เป้าหมาย — workload_identity_provider กับ service_account ข้างบนคือสองส่วนนี้เป๊ะ ๆ
Lint workflow เองด้วย actionlint
หัวข้อที่มีชื่อว่า “Lint workflow เองด้วย actionlint”workflow YAML ข้างบนพิสูจน์ด้วยการรัน actionlint (rhysd/actionlint) ใส่มันจริงในคอนเทนเนอร์แบบ one-shot — เครื่องมือตัวเดียวกับที่ pre-commit hook หรือ CI job แยกต่างหากจะรันทุกครั้งที่ workflow เปลี่ยน:
$ docker run --rm -v "$PWD:/repo" -w /repo rhysd/actionlint:latest -coloroutput จริงกับทั้งสอง workflow ในบทนี้ (pipeline plan/apply กับ drift-detection job ด้านล่าง): ไม่มี output อะไรเลย และ exit code เป็น 0 — actionlint จะไม่พิมพ์อะไรเลยเมื่อไม่เจอปัญหา นั่นคือหลักฐานว่าไม่มี ${{ }} expression ที่ไม่มีจริง, ค่า runs-on ที่ผิด, uses: ที่ไม่ pin version, หรือปัญหาระดับ shellcheck ใน run: script ที่ฝังอยู่
Apply plan เดิมที่ผ่าน review แล้ว ไม่ใช่ plan ใหม่
หัวข้อที่มีชื่อว่า “Apply plan เดิมที่ผ่าน review แล้ว ไม่ใช่ plan ใหม่”ช่องโหว่เล็ก ๆ ใน pipeline สอง job ข้างบน: apply job รัน terragrunt run --all apply --non-interactive ซึ่งคำนวณ plan ของตัวเอง ตอน apply ถ้ามีอะไรเปลี่ยนระหว่างที่ PR ถูก review กับตอนที่ merge เข้าจริง — เพื่อนร่วมทีม merge อะไรคู่ขนานมา, module registry update, หรือ drift ใน project — 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.tfplanTo 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-interactivelocal_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 1output จริงจาก Terragrunt 1.1.0 พิสูจน์ในเครื่องกับ local backend (กลไกนี้เป็นของ Terragrunt-core เหมือนกันไม่ว่าจะขับเคลื่อน unit ของ cloud ไหน): คำสั่งที่สองก็ apply ไฟล์ที่คำสั่งแรก save ไว้เป๊ะ ๆ ไม่มี re-plan ไม่มี prompt ใน CI จริง plan job upload plan-output/ เป็น build artifact ผูกกับ commit SHA แล้ว apply job download artifact เดียวกันนั้นมาก่อนรัน apply --out-dir สิ่งที่ apply จริงจึงพิสูจน์ได้ว่าเป็นสิ่งเดียวกับที่ reviewer อ่าน
Gate apply ด้วย -detailed-exitcode และเช็ค drift ตามตาราง
หัวข้อที่มีชื่อว่า “Gate apply ด้วย -detailed-exitcode และเช็ค drift ตามตาราง”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 ข้างล่างนี้ — 1 กับ google provider block จริง (sandbox นี้ไม่มี Application Default Credentials เลย ก็เลยเป็น error แบบเดียวกับที่ google plan แบบ offline จริงจะเจอ), 0 กับ 2 กับ resource local_file แบบ cloud-agnostic (กลไก exit code ของ -detailed-exitcode เป็นของ Terraform-core ไม่ใช่เฉพาะ provider):
# 1 — error จริงจาก google: sandbox นี้ไม่มี Application Default Credentials เลย$ terraform plan -input=false -detailed-exitcodePlanning failed. Terraform encountered an error while generating this plan.╷│ Error: Attempted to load application default credentials since neither│ `credentials` nor `access_token` was set in the provider block. No│ credentials loaded. To use your gcloud credentials, run 'gcloud auth│ application-default login'. Original error: google: could not find│ default credentials.││ with provider["registry.terraform.io/hashicorp/google"],│ on main.tf line 10, in provider "google":╵$ echo $?1
# 0 — ไม่มี change (local_file apply ไปแล้วรอบหนึ่ง)$ terraform plan -input=false -detailed-exitcode...Terraform has compared your real infrastructure against your configurationand found no differences, so no changes are needed.$ echo $?0
# 2 — มี change (แก้ content ของ local_file — บังคับ replacement)$ terraform plan -input=false -out=tfplan -detailed-exitcode...Plan: 1 to add, 0 to change, 1 to destroy.Saved the plan to: tfplan$ echo $?2รันจริงสามครั้งแยกกัน ได้ exit code จริงสามค่าต่างกัน กรณี 1 คือคำตอบตรง ๆ ของคำถาม “drift job ใน CI จะเห็นอะไรถ้า GCP auth เองพังไปแล้ว”: google provider จริง ที่ไม่มี token จากการแลก Workload Identity เลย (sandbox นี้ไม่มี) จะ fail ตั้งแต่ configure provider ก่อนที่จะไปถึง resource ไหนเลย — workflow ข้างล่างแยกกรณีนี้ (1 = check พังเอง) จาก drift จริง (2) ด้วยเหตุผลนี้เป๊ะ ๆ
terraform show -json ของ saved plan file
หัวข้อที่มีชื่อว่า “terraform show -json ของ saved plan file”tfplan ที่ save จากกรณี 2 ข้างบนเป็น binary file — terraform show -json tfplan render ออกมาเป็น JSON schema แบบเดียวกับที่ the-state-file.mdx ใช้กับ state (มี format_version, แยกจาก state’s version field) เพียงแต่ resource_changes[].change.actions อธิบาย change ที่ เสนอ แทนที่จะเป็น change ที่ apply แล้ว output จริง (ตัดให้สั้น):
{ "format_version": "1.2", "terraform_version": "1.16.4", "resource_changes": [ { "address": "local_file.config", "change": { "actions": ["delete", "create"] } } ]}["delete", "create"] คือรูปแบบ JSON ของ forced-replacement marker -/+ จากก่อนหน้านี้ในบทนี้ — แก้ content ทำให้ local_file ต้อง replace แทนที่จะ update in place และ change.actions ก็คือ array ตัวเดียวกับที่ policy check (policy-as-code-and-static-analysis.mdx) จะอ่านเพื่อ flag หรือบล็อก replacement แบบนี้กับ google resource ที่ stateful (เช่น deletion_policy บน google_sql_database_instance) ก่อน apply จะรันจริงเสียอีก
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 - id: auth uses: google-github-actions/auth@v3 with: workload_identity_provider: projects/111122223333/locations/global/workloadIdentityPools/github-pool/providers/github-provider service_account: github-actions-terraform@acme-prod-123456.iam.gserviceaccount.com - 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 1job นี้ไม่รัน apply เลย — หน้าที่เดียวคือ fail ดัง ๆ (ของจริงคือ page คนหรือ post เข้า chat channel) ทันทีที่ exit code ของ plan บอกว่า project 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"] wif["Workload Identity Federation"] -->|short-lived credentials| plan wif -->|short-lived credentials| apply
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”infrastructure pipeline มีหน้าที่แค่สองอย่าง และการปนกันสองอย่างนี้คือ root cause ของ incident ส่วนใหญ่: คำนวณ plan ให้คนดู (เร็ว ปลอดภัย อ่านอย่างเดียวจาก GCP — ไม่มีอะไรเปลี่ยนจริง) กับ execute plan ที่ผ่าน review แล้ว (step เดียวที่แตะ resource จริงได้ และแตะได้ก็ต่อเมื่อผ่านทั้ง automated gate — -detailed-exitcode, policy check — และ human gate แล้วเท่านั้น) มองกลไกอื่น ๆ ในบทนี้ว่ารับใช้สองหน้าที่นี้อย่างใดอย่างหนึ่ง: Workload Identity Federation มีไว้เพื่อให้ job execute ไม่ต้องมี key file แบบ standing เลย; --out-dir มีไว้เพื่อให้ execute รัน byte เดียวกับที่ compute สร้างไว้ ไม่ใช่คำนวณใหม่; scheduled drift job คือ job compute ที่ไม่มี job execute ตามหลังเลย มีไว้เพื่อเผยว่า reality เบี่ยงไปจาก plan อย่างเงียบ ๆ หรือเปล่าเท่านั้น
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| ทางเลือก | ได้อะไร | เสียอะไร |
|---|---|---|
| Workload Identity Federation แทน key file ที่ดาวน์โหลดมา | ไม่มี GCP credential แบบ long-lived อยู่ที่ไหนให้หลุดหรือลืม rotate | ต้อง setup workload identity pool + provider + attribute condition ต่อ project ครั้งเดียว และใช้ได้แค่กับ CI system ที่ยื่น OIDC token ได้เท่านั้น |
ใช้ --out-dir reuse plan/apply artifact | apply พิสูจน์ได้ว่าเป็น plan เดียวกับที่คนอนุมัติ ไม่ใช่ re-plan ใหม่ | ต้อง upload/download artifact เพิ่มระหว่าง plan job กับ apply job และต้อง treat artifact นั้นเป็นของ sensitive (plan อาจมีค่าที่ data source resolve มา) |
Gate ด้วย -detailed-exitcode | skip 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-dirapply job จะคำนวณ plan ของตัวเองใหม่ตอน apply ถ้ามีอะไรเปลี่ยนใน project หรือ module registry ระหว่าง review กับ merge plan นั้นอาจต่างจากที่คนอ่านจริง - “
-detailed-exitcodereturn ค่าไม่ใช่ 0 แปลว่ามีอะไรผิดพลาดเสมอ” มัน return2สำหรับ “มี change ต้อง apply” ซึ่งปกติดี และ1เท่านั้นที่แปลว่า error จริง พิสูจน์ข้างบนด้วย error auth จริงจากgoogleprovider ถ้า script treat สองค่านี้เหมือนกัน จะบล็อก deploy ปกติ หรือแย่กว่านั้นคือเมิน error จริงไปเฉย ๆ - “Workload Identity Federation ทำให้ไม่ต้องมี service account เลย” มันทำให้ไม่ต้องมี key file ที่ดาวน์โหลดมาเท่านั้น service account จริง ที่ impersonate ผ่าน pool provider และ grant แค่ role ที่ pipeline ต้องใช้ ยังจำเป็นอยู่ และเป็น authorization boundary ตัวจริง
- “scheduled drift-detection job ต้องมี
applystep ถึงจะมีประโยชน์” job ที่พิสูจน์ข้างบนไม่ apply อะไรเลย — คุณค่าทั้งหมดอยู่ที่plan -detailed-exitcodefail ดัง ๆ ตามตาราง ซึ่งจับ drift ได้ก่อนที่ change ครั้งถัดไปจะเผยมันออกมาเอง
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”เวลา review AI-generated CI/CD workflow สำหรับ Terraform/Terragrunt บน GCP ให้เช็ค:
- service account key JSON ที่ดาวน์โหลดมาเก็บเป็น CI secret แล้วอ้างผ่าน
GOOGLE_APPLICATION_CREDENTIALSแทนที่จะเป็นgoogle-github-actions/authกับworkload_identity_provider— สัญญาณของ key แบบ long-lived ที่ควรเป็น Workload Identity Federation แทน applyjob ที่ไม่มีenvironment:(หรือ required-reviewers gate เทียบเท่า) บน job นั้น — ไม่มีอะไรหยุด PR ที่ merge แล้วจาก apply เข้า production แบบไม่มีคนดูแลterraform apply(ไม่มี-input=false, ไม่มี saved plan file) รันตรง ๆ ใน CI แทนที่จะ apply plan artifact จากplanstep ก่อนหน้า — ตรงกับช่องโหว่ที่ proof--out-dirข้างบนปิดไว้พอดี- drift-detection หรือ CI
planstep ที่เช็คif: failure()แทนที่จะ capture ค่า-detailed-exitcodeจริง —failure()แยกไม่ออกว่า “มี change” (exit2) กับ “plan error จริง” (exit1) - ไม่มี
permissions: id-token: writeบน workflow ที่ดูเหมือนใช้ Workload Identity Federation อยู่แล้ว — ถ้าไม่มีบรรทัดนี้ การแลก token ก็ไม่มีอะไรให้ยื่นgoogle-github-actions/authจะ fail ที่ step authenticate ไม่ใช่แบบเงียบ ๆ
🐛 หา bug ในโค้ดนี้
- name: Check for driftid: planrun: terragrunt run --all plan -detailed-exitcode -no-colorcontinue-on-error: true- name: Fail if drift detectedif: steps.plan.outcome == 'failure'run: exit 1เฉลย
-detailed-exitcode ทำให้ shell command ของ step exit ไม่เป็นศูนย์ได้จาก สอง เหตุผลต่างกัน: exit code 2 แปลว่ามี change จริง (กรณีที่ job นี้อยากจับ) แต่ exit code 1 แปลว่า plan เองพัง — Workload Identity exchange ผิดพลาด, syntax error, provider timeout ทั้งคู่ทำให้ continue-on-error: true mark 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' โดยตรง