CI/CD for Infrastructure
ไอเดียในหนึ่งประโยค
หัวข้อที่มีชื่อว่า “ไอเดียในหนึ่งประโยค”pipeline มาตรฐานสำหรับ infrastructure รัน plan อัตโนมัติทุกครั้งที่มี pull request เพื่อให้คน review และเก็บ apply ไว้หลัง merge เข้า main branch เท่านั้น โดยใช้ Azure credential แบบ short-lived ที่ federate ผ่าน OIDC แทน client secret แบบ long-lived ที่เก็บไว้ใน CI
Plan ทุก pull request, apply หลัง merge เท่านั้น
หัวข้อที่มีชื่อว่า “Plan ทุก pull request, apply หลัง merge เท่านั้น”pattern ที่ทีมส่วนใหญ่ลงเอยด้วยกันนั้นคล้าย CI/CD ของ application แต่มีจุดต่างสำคัญตรงว่าแต่ละ stage ทำอะไรจริง ๆ
- เปิดหรืออัปเดต pull request → รัน
terraform plan(หรือterragrunt run --all planสำหรับ setup ที่มีหลาย unit ใน Terragrunt) แล้ว post diff ที่ได้เป็น comment บน pull request ยังไม่มีอะไรเปลี่ยนบน Azure จริง ๆ ตรงนี้เป็นแค่ 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: azure/login@v3 with: client-id: ${{ secrets.AZURE_CLIENT_ID }} tenant-id: ${{ secrets.AZURE_TENANT_ID }} subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - 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: azure/login@v3 with: client-id: ${{ secrets.AZURE_CLIENT_ID }} tenant-id: ${{ secrets.AZURE_TENANT_ID }} subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - 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 client secret ที่เก็บไว้เลย ทั้งสอง job authenticate ผ่าน OIDC ซึ่งจะพูดถึงต่อไป
ทำไม plan ต้อง review ละเอียดกว่า code diff ทั่วไป
หัวข้อที่มีชื่อว่า “ทำไม plan ต้อง review ละเอียดกว่า code diff ทั่วไป”pull request ของ application code จะได้รับการ review เรื่อง logic, test, และ style worst case คือ bug หลุดไปแล้วต้อง roll back แต่ infrastructure apply ทำอะไรที่ code deploy ทำไม่ได้ คือทำลาย resource จริงที่มี state บน Azure แล้วสร้างขึ้นใหม่ทั้งหมด บางครั้งแค่เพราะ change บรรทัดเดียว
Terraform เรียกสิ่งนี้ว่า forced replacement argument บางตัวของ resource บางประเภทอัปเดตแบบ in place ไม่ได้ เปลี่ยนค่าเหล่านั้นแปลว่า provider ไม่มี API call สำหรับ “แก้ attribute นี้” มีแต่ “ทำลายแล้วสร้างใหม่” การเปลี่ยนชื่อ azurerm_linux_virtual_machine, การเปลี่ยน account_tier ของ storage account หรือการย้าย resource บางตัวไป location อื่นก็เข้าข่ายนี้ แล้วแต่ resource เมื่อเจอแบบนี้ terraform plan จะทำเครื่องหมาย resource นั้นด้วย -/+ แทนที่จะเป็น ~ แบบ in-place
# terraform plan output — this is the line to stop and read carefully # azurerm_mssql_database.main must be replaced-/+ resource "azurerm_mssql_database" "main" { ~ name = "app-db" -> "app-db-renamed" # forces replacement sku_name = "S1" max_size_gb = 50 }-/+ บน resource ที่มี state อย่าง database หรือ managed disk แปลว่าอาจมี data loss จริงหรือ downtime จริงทันทีที่มีคนกด approve resource เก่าหายไปก่อนที่ resource ใหม่จะมีอยู่ด้วยซ้ำ นี่คือเหตุผลที่ review infrastructure plan จะ skim ผ่าน ๆ ไม่ได้ reviewer ต้องสแกนหาบรรทัด -/+ โดยเฉพาะ แล้วถามว่า replacement นั้น expected และ safe หรือเปล่า ไม่ใช่แค่อ่าน plan แบบเดียวกับที่อ่าน code diff เพื่อดู style กับ logic
OIDC credential แบบ short-lived แทน client secret ที่เก็บไว้
หัวข้อที่มีชื่อว่า “OIDC credential แบบ short-lived แทน client secret ที่เก็บไว้”pipeline รุ่นเก่าเก็บ Azure AD application client secret แบบ long-lived ไว้เป็น CI secret แล้วส่งให้ terraform login หรือ azurerm provider block ใช้ทุกครั้งที่รัน secret นั้นใช้ได้ทั้ง plan และ apply เหมือนกันหมด ซึ่งนั่นแหละคือปัญหา ถ้าหลุดออกไปเมื่อไหร่ จาก log ที่ config ผิด, dependency ที่ถูก compromise, หรือ workflow file ที่ echo environment variable ออกมา ก็ยังใช้งานได้ต่อไปจนกว่าจะมีคน rotate หรือ revoke ด้วยมือ
practice ปัจจุบันแทนที่ secret นั้นด้วย OIDC ผ่าน federated credential บางคนเรียกว่า Workload Identity Federation สำหรับ Azure AD GitHub Actions ส่ง identity token แบบ short-lived ที่ sign ด้วย cryptographic ให้ Azure AD ได้ในทุก workflow run federated credential ที่ config ไว้บน Azure AD App Registration (หรือ user-assigned managed identity) เชื่อถือ token issuer ของ GitHub เฉพาะ repository และ branch ที่กำหนด azure/login action แลก token นั้นเป็น Azure credential ชั่วคราวที่ scope ไว้กับ role ที่กำหนด อายุเท่ากับ job นั้นเท่านั้น ไม่มากไปกว่านั้น
permissions: id-token: write contents: read
jobs: plan: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7
- name: Log in to Azure via OIDC uses: azure/login@v3 with: client-id: ${{ secrets.AZURE_CLIENT_ID }} tenant-id: ${{ secrets.AZURE_TENANT_ID }} subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Terraform plan run: terraform planclient-id, tenant-id, และ subscription-id เป็นแค่ identifier ไม่ใช่ secret แค่บอก Azure AD ว่าจะใช้ App Registration และ subscription ไหน ที่เก็บเป็น GitHub Actions secret ก็เพื่อความสะดวกและ scope environment เท่านั้น ไม่ใช่เพราะตัวค่าเองมี sensitivity ไม่ต้องมี Azure client secret ที่เก็บเป็น CI secret เลยสักตัว ไม่มีอะไร long-lived ให้หลุด ให้ rotate หรือให้ลืม permissions: id-token: write คือตัวที่ทำให้ workflow ขอ identity token นั้นได้ตั้งแต่แรก ถ้าไม่มีบรรทัดนี้ การแลก OIDC ก็ไม่มีอะไรให้ยื่นให้ Azure AD
อ้างอิงจาก docs ของ GitHub เอง (มาจาก docs ไม่ได้รัน — ไม่มี Azure tenant ให้สร้างจริง) สามขั้นตอนคือ: “1. Create an Entra ID application and a service principal. 2. Add federated credentials for the Entra ID application. 3. Create GitHub secrets for storing Azure configuration.” — docs.github.com/en/actions/how-tos/secure-your-work/security-harden-deployments/oidc-in-azure ขั้นที่ 2 คือ federated credential ตัวจริง: มัน bind App Registration เข้ากับ claim subject ที่ scope เฉพาะ repository, environment หรือ branch หนึ่ง (เช่น repo:my-org/my-infra:ref:refs/heads/main) ซึ่งเทียบเท่ากับ condition sub ที่ IAM trust policy ของ AWS เช็คฝั่ง Azure
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 ใน subscription 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 กับ azurerm provider block จริง (sandbox นี้ไม่มี Azure login เลย ก็เลยเป็น error แบบเดียวกับที่ azurerm plan แบบ offline จริงจะเจอ), 0 กับ 2 กับ resource local_file แบบ cloud-agnostic (กลไก exit code ของ -detailed-exitcode เป็นของ Terraform-core ไม่ใช่เฉพาะ provider):
# 1 — error จริงจาก azurerm: sandbox นี้ไม่มี Azure CLI/OIDC session เลย$ terraform plan -input=false -detailed-exitcodePlanning failed. Terraform encountered an error while generating this plan.╷│ Error: unable to build authorizer for Resource Manager API: could not│ configure AzureCli Authorizer: could not parse Azure CLI version: launching│ Azure CLI: exec: "az": executable file not found in $PATH││ with provider["registry.terraform.io/hashicorp/azurerm"],│ on main.tf line 10, in provider "azurerm":╵$ 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 จะเห็นอะไรถ้า Azure auth เองพังไปแล้ว”: azurerm provider จริง ที่ไม่มี CLI session และไม่มี federated-credential token เลย (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 แบบนี้กับ azurerm resource ที่ stateful ก่อน 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 - uses: azure/login@v3 with: client-id: ${{ secrets.AZURE_CLIENT_ID }} tenant-id: ${{ secrets.AZURE_TENANT_ID }} subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - 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 บอกว่า subscription 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["Azure AD federated credential"] -->|short-lived credentials| plan oidc -->|short-lived credentials| apply
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”infrastructure pipeline มีหน้าที่แค่สองอย่าง และการปนกันสองอย่างนี้คือ root cause ของ incident ส่วนใหญ่: คำนวณ plan ให้คนดู (เร็ว ปลอดภัย อ่านอย่างเดียวจาก Azure ไม่มีอะไรเปลี่ยนจริง) กับ execute plan ที่ผ่าน review แล้ว (step เดียวที่แตะ resource จริงได้ และแตะได้ก็ต่อเมื่อผ่านทั้ง automated gate — -detailed-exitcode, policy check และ human gate แล้วเท่านั้น) มองกลไกอื่น ๆ ในบทนี้ว่ารับใช้สองหน้าที่นี้อย่างใดอย่างหนึ่ง: OIDC federated credential มีไว้เพื่อให้ job execute ไม่ต้องมี client secret แบบ standing เลย; --out-dir มีไว้เพื่อให้ execute รัน byte เดียวกับที่ compute สร้างไว้ ไม่ใช่คำนวณใหม่; scheduled drift job คือ job compute ที่ไม่มี job execute ตามหลังเลย มีไว้เพื่อเผยว่า reality เบี่ยงไปจาก plan อย่างเงียบ ๆ หรือเปล่าเท่านั้น
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| ทางเลือก | ได้อะไร | เสียอะไร |
|---|---|---|
| OIDC federated credential แทน client secret ที่เก็บไว้ | ไม่มี Azure AD client secret แบบ long-lived อยู่ที่ไหนให้หลุดหรือลืม rotate | ต้อง setup federated credential บน App Registration ต่อ repository/branch ครั้งเดียว และใช้ได้แค่กับ 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 ถ้ามีอะไรเปลี่ยนใน subscription หรือ module registry ระหว่าง review กับ merge plan นั้นอาจต่างจากที่คนอ่านจริง - “
-detailed-exitcodereturn ค่าไม่ใช่ 0 แปลว่ามีอะไรผิดพลาดเสมอ” มัน return2สำหรับ “มี change ต้อง apply” ซึ่งปกติดี และ1เท่านั้นที่แปลว่า error จริง ถ้า script treat สองค่านี้เหมือนกัน จะบล็อก deploy ปกติ หรือแย่กว่านั้นคือเมิน error จริงไปเฉย ๆ - “OIDC ทำให้ไม่ต้องมี Azure AD App Registration เลย” มันทำให้ไม่ต้องมี client secret แบบ long-lived เท่านั้น App Registration จริงพร้อม federated credential ที่ scope เฉพาะ repository/branch ที่ถูกต้อง ยังจำเป็นอยู่ และเป็น authorization boundary ตัวจริง
- “scheduled drift-detection job ต้องมี
applystep ถึงจะมีประโยชน์” job ที่พิสูจน์ข้างบนไม่ apply อะไรเลย คุณค่าทั้งหมดอยู่ที่plan -detailed-exitcodefail ดัง ๆ ตามตาราง ซึ่งจับ drift ได้ก่อนที่ change ครั้งถัดไปจะเผยมันออกมาเอง
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”ตอน review CI/CD workflow ของ Terraform/Terragrunt บน Azure ที่ AI เขียน ให้เช็ค:
- Azure AD
client secretที่เก็บไว้ ใช้กับaz login --service-principalแทนที่จะเป็นazure/loginกับclient-id/tenant-id/subscription-idและไม่มี inputclient-secretเลย สัญญาณของ secret แบบ long-lived ที่ควรเป็น federated credential แทน applyjob ที่ไม่มีenvironment:(หรือ required-reviewers gate เทียบเท่า) บน job ไม่มีอะไรหยุด PR ที่ merge แล้วจาก apply เข้า production แบบไม่มีคนดูterraform apply(ไม่มี-input=falseไม่มี saved plan file) รันตรง ๆ ใน CI แทนที่จะ apply plan artifact ที่ stepplanก่อนหน้าสร้างไว้ นี่คือช่องเดียวกับที่ proof--out-dirข้างบนปิดไป- step drift-detection หรือ CI
planที่เช็คif: failure()แทนที่จะ capture ค่า-detailed-exitcodeจริงfailure()แยกไม่ออกระหว่าง “มี change” (exit2) กับ “plan error จริง” (exit1) - ไม่มี
permissions: id-token: writeบน workflow ที่ดูเหมือนใช้ OIDC ถ้าไม่มีบรรทัดนี้ การแลก token ก็ไม่มีอะไรยื่นให้ Azure AD แล้ว job จะ fail ที่ stepazure/loginไม่ใช่แบบเงียบ ๆ
🐛 หา 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 เอง 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' โดยเฉพาะ