Worked review 3: CI pipeline และ policy check
Feature ที่ต้องทำ
หัวข้อที่มีชื่อว่า “Feature ที่ต้องทำ”Prompt คือ: “เขียน GitHub Actions pipeline ที่รัน Terraform plan บน pull request และ apply บน merge เข้า main โดยใช้ OIDC แทน credential Azure ที่เก็บไว้ พร้อม policy check” สิ่งที่ตามมาคือ draft แรกแบบที่ AI assistant ส่งกลับมาให้กับ prompt แบบนี้พอดี — YAML ที่ดูสมเหตุสมผล, action OIDC จริง azure/login, Rego policy ที่อ่านแล้วเหมือน OPA tutorial ทุกอัน มี bug จริง 10 ข้อ; ดู AI bug catalog สำหรับ pattern ทั่วไปเบื้องหลังหลายข้อ (entry 4, 18, 19, 20, 22) เก้าในสิบข้อนี้เป็น CI/policy mechanics เดียวกันเป๊ะที่ไม่ขึ้นกับ provider เหมือนกับ worked review 3 ของ review ฝั่ง AWS — พิสูจน์สดใหม่ที่นี่ ใน repo นี้ — บวกอีกหนึ่ง bug ที่เฉพาะ OIDC ของ azurerm จริงๆ บทนี้เสริม — และลิงก์ไปหา — CI/CD for Infrastructure ที่แสดง pipeline ที่แก้แล้วเต็มรูปแบบ ทุกข้อที่กล่าวถึงด้านล่างพิสูจน์ด้วย actionlint, tflint, checkov, และ conftest จริง (ผ่าน docker run --rm กับ image rhysd/actionlint, ghcr.io/terraform-linters/tflint, bridgecrew/checkov, และ openpolicyagent/conftest ที่ pin ไว้) บวก exit code จริงของ Terraform และ terragrunt run --all plan --out-dir จริง
โค้ดที่ AI สร้าง
หัวข้อที่มีชื่อว่า “โค้ดที่ AI สร้าง”# .github/workflows/terraform.yml, as generated -- fragment, not a collected fencename: terraformon: pull_request: push: branches: [main]
jobs: plan: runs-on: ubuntu-latest permissions: id-token: write contents: read steps: - uses: actions/checkout@master - uses: hashicorp/setup-terraform@v3 - 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 }} - run: terraform init - name: plan run: | echo "TF_VAR_db_password=$TF_VAR_db_password" terraform plan -detailed-exitcode -out=$PLAN_FILE env: PLAN_FILE: tfplan TF_VAR_db_password: ${{ secrets.DB_PASSWORD }}
apply: needs: plan if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest steps: - uses: actions/checkout@master - uses: hashicorp/setup-terraform@v3 - run: terraform init - run: terraform apply -auto-approve# ci/gitignore, as generated -- fragment, not a collected fence.terraform/.terraform.lock.hcl*.tfstate*.tfstate.backup# terraform.tfvars, as generated -- fragment, committed alongside the workflow abovedb_password = "Sup3rSecret!2024"api_token = "ghp_1234567890abcdefghijklmnopqrstuvwxyz"# policy/deny_no_tags.rego, as generated -- fragment, not a collected fencepackage main
deny[msg] { resource := input.resource_changes[_] resource.type == "azurerm_linux_virtual_machine" msg := sprintf("%s must set tags", [resource.address])}# .tflint.hcl, as generated -- fragment, not a collected fenceplugin "azurerm" { enabled = true version = "0.28.0" source = "github.com/terraform-linters/tflint-ruleset-azurerm"}เดินรีวิวทีละ bug
หัวข้อที่มีชื่อว่า “เดินรีวิวทีละ bug”Workflow: actions/checkout@master — branch ที่เปลี่ยนแปลงได้ ถูกใช้ราวกับ pin ไว้แล้ว
หัวข้อที่มีชื่อว่า “Workflow: actions/checkout@master — branch ที่เปลี่ยนแปลงได้ ถูกใช้ราวกับ pin ไว้แล้ว”อาการ — actionlint จริง, rhysd/actionlint, container แบบ one-shot:
$ docker run --rm -v "$PWD:/repo" -w /repo rhysd/actionlint:latest -color(no output -- 0 exit code)🐛 หา bug ในโค้ดนี้
- uses: actions/checkout@masterเฉลย
bug เดียวกับ AI bug catalog entry 19 และ mechanics เดียวกับ entry 1 ของ review ฝั่ง AWS — พิสูจน์สดใหม่ที่นี่ actionlint ต้องการแค่มี token @ref อยู่จริงตาม syntax เท่านั้น การลบมันออกไปเลย (uses: actions/checkout ไม่มี @ เลย) คือ error จริงที่มันจับได้ — ยืนยันแยกต่างหาก: specifying action "actions/checkout" in invalid format because ref is missing แต่ @master ให้ ref ที่ valid ตาม syntax actionlint เลยยอมรับเหมือนกับ commit SHA ที่ pin ไว้ วิธีแก้: uses: actions/checkout@<40-char-sha> # v5.0.0
Workflow: ปัญหา shellcheck ที่ actionlint จับได้จริง
หัวข้อที่มีชื่อว่า “Workflow: ปัญหา shellcheck ที่ actionlint จับได้จริง”อาการ — actionlint จริง กับ run: terraform plan -detailed-exitcode -out=$PLAN_FILE:
$ docker run --rm -v "$PWD:/repo" -w /repo rhysd/actionlint:latest -color.github/workflows/terraform.yml:17:9: shellcheck reported issue in this script: SC2086:info:1:40: Double quote to prevent globbing and word splitting [shellcheck] |17 | run: | | ^~~~🐛 หา bug ในโค้ดนี้
run: |terraform plan -detailed-exitcode -out=$PLAN_FILEเฉลย
actionlint ฝัง shellcheck เข้าไปกับทุก multi-line run: script $PLAN_FILE ที่ไม่ quote เจอ word-splitting และ glob expansion จาก shell ได้ วิธีแก้: -out="$PLAN_FILE"
Workflow: env: TF_VAR_db_password ถูก echo ในบรรทัด debug
หัวข้อที่มีชื่อว่า “Workflow: env: TF_VAR_db_password ถูก echo ในบรรทัด debug”อาการ — จริง env ทำซ้ำสิ่งที่ environment ของ CI runner หน้าตาเป็นเมื่อ env: ตั้งตัวแปรที่มาจาก secret:
$ export TF_VAR_db_password="SuperSecret123"$ env | grep TF_VAR_db_passwordTF_VAR_db_password=SuperSecret123🐛 หา bug ในโค้ดนี้
- name: planrun: | echo "TF_VAR_db_password=$TF_VAR_db_password" terraform plan -detailed-exitcode -out=$PLAN_FILEenv: TF_VAR_db_password: ${{ secrets.DB_PASSWORD }}เฉลย
GitHub Actions มาสก์ ค่า secret ที่รู้จักแล้ว ใน raw log output ก็จริง แต่พิสูจน์แล้วด้านบน ข้อเท็จจริงทาง shell ที่แท้จริงคือ TF_VAR_db_password เป็น environment variable ธรรมดาสามัญทันทีที่ env: ตั้งมัน วิธีแก้: อย่า echo ตัวแปรที่มาจาก secret เลย แม้จะเพื่อ debug ก็ตาม
apply: ไม่มี plan ที่บันทึกไว้ — terraform apply -auto-approve plan ใหม่ตั้งแต่ต้น
หัวข้อที่มีชื่อว่า “apply: ไม่มี plan ที่บันทึกไว้ — terraform apply -auto-approve plan ใหม่ตั้งแต่ต้น”อาการ — จริง เทียบกันตรงๆ ก่อนอื่น apply ที่ไม่มี plan file ที่บันทึกไว้:
$ terraform apply -auto-approveterraform_data.x: Refreshing state...Plan: 1 to add, 0 to change, 0 to destroy.Apply complete! Resources: 1 added, 0 changed, 0 destroyed.แล้ว plan ที่บันทึกจริงถูก apply ผ่าน path ของไฟล์แทน:
$ terraform plan -out=tfplan$ terraform apply tfplanApply complete! Resources: 1 added, 0 changed, 0 destroyed.🐛 หา bug ในโค้ดนี้
- run: terraform init- run: terraform apply -auto-approveเฉลย
job apply ไม่เคย download plan ที่ job plan คำนวณไว้เลย พิสูจน์แล้วด้านบน: apply -auto-approve ที่ไม่มี plan file พิมพ์ Refreshing state... / Plan: ของตัวเองสดใหม่ก่อน apply การ apply plan file จริงที่บันทึกไว้แทนไม่พิมพ์บรรทัดไหนเลย วิธีแก้: terraform plan -out=tfplan เป็น build artifact ใน job plan download แล้ว apply ตรงตัว (terraform apply tfplan) ใน job apply
plan: คำนวณ -detailed-exitcode แต่ exit code สามแบบไม่เคยถูกอ่านจริง
หัวข้อที่มีชื่อว่า “plan: คำนวณ -detailed-exitcode แต่ exit code สามแบบไม่เคยถูกอ่านจริง”อาการ — จริง command เดียวกันเป๊ะ exit code จริงต่างกันในสองสถานะ:
$ terraform plan -detailed-exitcode # a resource actually needs to change$ echo $?2
$ terraform plan -detailed-exitcode # re-run immediately after, nothing left to change$ echo $?0🐛 หา bug ในโค้ดนี้
terraform plan -detailed-exitcode -out=$PLAN_FILE# (workflow never checks $? here at all)เฉลย
-detailed-exitcode มีไว้เจาะจงเพื่อให้ script แยกได้ว่า “มี change จริงพร้อมให้รีวิว” (2) กับ “เกิด error จริง” (1) กับ “ไม่มีอะไรต้องทำ” (0) — พิสูจน์แล้วด้านบน วิธีแก้: จับ $? ชัดเจน แยกตามมัน แบบที่บท CI/CD for Infrastructure ของคอร์สนี้ทำใน pipeline ของมัน
.gitignore: .terraform.lock.hcl ถูก gitignore ไปพร้อมกับ .terraform/
หัวข้อที่มีชื่อว่า “.gitignore: .terraform.lock.hcl ถูก gitignore ไปพร้อมกับ .terraform/”# .gitignore, as generated.terraform/.terraform.lock.hcl*.tfstate*.tfstate.backupbug เดียวกับ AI bug catalog entry 4 วิธีแก้:
# .gitignore, fixed.terraform/*.tfstate*.tfstate.backupterraform.tfvars: secret จริง commit ไปพร้อมกับ workflow
หัวข้อที่มีชื่อว่า “terraform.tfvars: secret จริง commit ไปพร้อมกับ workflow”# terraform.tfvars, as generateddb_password = "Sup3rSecret!2024"api_token = "ghp_1234567890abcdefghijklmnopqrstuvwxyz"bug เดียวกับ AI bug catalog entry 22 — แย่กว่าเดิมในบริบท CI เจาะจง เพราะ .tfvars ที่ commit แล้วไปถึงทุก checkout, ทุก pull request จาก fork, และทุก CI log ที่บังเอิญ cat ไฟล์เพื่อ debug วิธีแก้: ลบไฟล์ออกจาก working tree และจาก git history; ดึง db_password/api_token จาก environment variable แบบ TF_VAR_* ที่มาจาก CI secret เท่านั้น
policy/deny_no_tags.rego: syntax Rego ก่อน v1 load ไม่ผ่าน
หัวข้อที่มีชื่อว่า “policy/deny_no_tags.rego: syntax Rego ก่อน v1 load ไม่ผ่าน”# policy/deny_no_tags.rego, as generated -- @expect-error rego_parse_error: `if` keyword is required before rule bodypackage main
deny[msg] { resource := input.resource_changes[_] resource.type == "azurerm_linux_virtual_machine" msg := sprintf("%s must set tags", [resource.address])}อาการ — conftest test จริง, openpolicyagent/conftest เวอร์ชันปัจจุบัน, กับ terraform show -json plan จริง:
$ docker run --rm -v "$(pwd)":/project openpolicyagent/conftest test --policy policy plan.jsonError: running test: load: loading policies: load: 2 errors occurred during loading:policy/deny_no_tags.rego:3: rego_parse_error: `if` keyword is required before rule bodypolicy/deny_no_tags.rego:3: rego_parse_error: `contains` keyword is required for partial set rules🐛 หา bug ในโค้ดนี้
deny[msg] {resource := input.resource_changes[_]resource.type == "azurerm_linux_virtual_machine"msg := sprintf("%s must set tags", [resource.address])}เฉลย
bug เดียวกับ AI bug catalog entry 20 วิธีแก้:
# policy/deny_no_tags.rego, fixedpackage main
import rego.v1
deny contains msg if { resource := input.resource_changes[_] resource.type == "azurerm_linux_virtual_machine" msg := sprintf("%s must set tags", [resource.address])}จริง กับ plan เดียวกันเป๊ะ:
$ docker run --rm -v "$(pwd)":/project openpolicyagent/conftest test --policy policy plan.jsonFAIL - plan.json - main - azurerm_linux_virtual_machine.app must set tags
1 test, 0 passed, 0 warnings, 1 failure, 0 exceptions.tflint.hcl/checkov: ใช้ได้จริง ทำงานจริง แต่ไม่เคยต่อเข้า CI เลย
หัวข้อที่มีชื่อว่า “.tflint.hcl/checkov: ใช้ได้จริง ทำงานจริง แต่ไม่เคยต่อเข้า CI เลย”อาการ — tflint กับ checkov จริง, container แบบ one-shot, กับ main.tf ของ pipeline เอง (azurerm_linux_virtual_machine ที่ปิด password auth แต่ไม่มีการบังคับ SSH key แสดงไว้ ไม่มีการจำกัด VM extensions):
$ docker run --rm -v "$(pwd)":/data -w /data ghcr.io/terraform-linters/tflint1 issue(s) found:
Warning: terraform "required_version" attribute is required (terraform_required_version)
on main.tf line 1: 1: terraform {$ docker run --rm -v "$(pwd)":/tf bridgecrew/checkov -d /tf --compact --quietPassed checks: 4, Failed checks: 2, Skipped checks: 0
Check: CKV_AZURE_178: "Ensure linux VM enables SSH with keys for secure communication"FAILED for resource: azurerm_linux_virtual_machine.appCheck: CKV_AZURE_50: "Ensure Virtual Machine Extensions are not Installed"FAILED for resource: azurerm_linux_virtual_machine.apprun --all plan ไม่มี --out-dir ที่ชั้น Terragrunt
หัวข้อที่มีชื่อว่า “run --all plan ไม่มี --out-dir ที่ชั้น Terragrunt”อาการ — terragrunt run --all plan --out-dir จริง plan file จริงต่อ unit สำหรับทุก unit ใน tree และ command ถัดไปที่ต้องรันพิมพ์ออกมาตรงๆ:
$ terragrunt run --all --non-interactive --no-color --out-dir /tmp/tg-plans -- plan[live/prod/app] terraform apply "/tmp/tg-plans/live/prod/app/tfplan.tfplan"
❯❯ Run Summary 4 units Succeeded 4$ find /tmp/tg-plans -maxdepth 3/tmp/tg-plans/live/dev/network/tmp/tg-plans/live/dev/app/tmp/tg-plans/live/prod/network/tmp/tg-plans/live/prod/app🐛 หา bug ในโค้ดนี้
terragrunt run --all -- plan# ... later, in a separate job/step ...terragrunt run --all -- apply -auto-approveเฉลย
bug เดียวกันเป๊ะกับ entry “ไม่มี -out” ของ plain Terraform ด้านบน แค่ขึ้นไปอีกชั้น วิธีแก้: run --all plan --out-dir <dir> ใน job plan, run --all apply --out-dir <dir> (directory เดียวกัน) ใน job apply
Federated credential ของ OIDC scope กว้างเกินไป
หัวข้อที่มีชื่อว่า “Federated credential ของ OIDC scope กว้างเกินไป”workflow ที่ generate มามี step azure/login ที่มี client-id/tenant-id/subscription-id ที่จริงและรูปทรงถูกต้อง แต่ไม่มีอะไรในโค้ดที่ generate มาของบทนี้แสดง federated credential ของ App Registration เองเลย — ส่วนที่ตัดสินใจจริงว่า identity ของ GitHub ตัวไหน authenticate เป็นมันได้ federated credential ที่ subject scope แค่ repository โดยไม่มี ref (หรือปล่อยไว้เป็น default ที่ match branch ไหนก็ได้) เปิดให้ branch ไหนก็ได้ — รวมถึง pull request จาก fork — mint token ที่ authenticate เป็น identity นี้ได้ วิธีแก้: scope subject ของ federated credential ไปที่ branch ที่รัน apply จริงเจาะจง แล้วใช้ App Registration แยกที่ scope แคบกว่า (หรือไม่ใช้เลย) สำหรับ job plan ที่รันบน pull request จาก fork ที่ไม่น่าเชื่อถือ
อะไรเปลี่ยนและทำไม
หัวข้อที่มีชื่อว่า “อะไรเปลี่ยนและทำไม”| # | Bug | วิธีแก้ |
|---|---|---|
| 1 | actions/checkout@master — ref เปลี่ยนแปลงได้ actionlint ยอมรับเหมือน SHA | Pin เป็น commit SHA เต็ม |
| 2 | -out=$PLAN_FILE ไม่ quote ใน run: script | -out="$PLAN_FILE" — actionlint/shellcheck SC2086 จริง |
| 3 | TF_VAR_db_password ที่มาจาก secret ถูก echo ในบรรทัด debug | อย่า echo เลย — env output จริงพิสูจน์ว่ามันเป็นตัวแปรธรรมดาที่อ่านได้ทันทีที่ตั้งแล้ว |
| 4 | apply -auto-approve ไม่มี plan file บันทึกไว้ | plan -out=tfplan เป็น artifact, apply tfplan ใน apply job |
| 5 | คำนวณ -detailed-exitcode แล้วไม่เคยแยก exit code 3 แบบ | จับ $? แยกตาม 0/1/2 ชัดเจน |
| 6 | .terraform.lock.hcl ถูก gitignore ไปพร้อม .terraform/ | ignore แค่ .terraform/ |
| 7 | terraform.tfvars มี secret จริง commit ไว้ | ลบทิ้ง; secret มาจาก TF_VAR_* CI secret เท่านั้น |
| 8 | Rego ก่อน v1 (deny[msg] { ... }) | import rego.v1 + deny contains msg if { ... } |
| 9 | มี config .tflint.hcl/checkov แต่ไม่มี CI step ไหนรันเลยสักตัว | เพิ่ม step tflint/checkov จริง — ทั้งสองเครื่องมือพิสูจน์แล้วว่าเจอ issue จริง |
| 10 | ไม่มี --out-dir บน terragrunt run --all plan | --out-dir <dir> ใน plan, dir เดียวกันใน apply |
| — | subject ของ federated credential OIDC หายไปหรือกว้างเกินไป | Scope เป็น repo:org/repo:ref:refs/heads/main — อ้างอิงจาก docs ไม่มี Azure tenant ในคอร์สนี้ |
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”เก้าใน bug สิบข้อนี้เป็น CI/policy mechanics เดียวกันเป๊ะที่ไม่ขึ้นกับ provider เหมือน review ฝั่ง AWS — พฤติกรรม actionlint เดียวกัน ช่องว่าง -out/-detailed-exitcode เดียวกัน linter config ที่แยกขาดจาก CI แบบเดียวกัน วิธีแก้ terragrunt --out-dir เดียวกัน bug ข้อเดียวที่เฉพาะ azurerm จริงๆ คือขอบเขตความไว้ใจของ OIDC: AWS scope เงื่อนไข sub ของ trust policy ของ IAM role, azurerm scope subject ของ federated credential ของ App Registration — mechanism ต่างกัน แต่รูปแบบความล้มเหลวเหมือนกันเป๊ะเมื่อมันกว้างเกินไป
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “ถ้ามีไฟล์
.tflint.hcl/checkov config อยู่ใน repo แปลว่ามันถูก enforce” พิสูจน์แล้วด้านบน: ทั้งสองเครื่องมือ รันตรงกับmain.tfของ pipeline เอง เจอ issue จริงที่ CI run ซึ่งอิงแค่ว่ามี config file อยู่จะไม่เห็นเลย - “
actionlintจับ third-party action ที่ไม่ pin ได้” พิสูจน์แล้วด้านบน: มันต้องการแค่มี@refอยู่ตาม syntax แต่ไม่แยกแยะ branch name เปลี่ยนแปลงได้แบบ@masterจาก commit SHA แบบ immutable - “การมาสก์ secret ของ GitHub แปลว่า secret แบบ
TF_VAR_*รั่วผ่าน CI log ไม่ได้” พิสูจน์แล้วด้านบน: ทันทีที่ secret กลายเป็น environment variable ธรรมดา มันอ่านได้ด้วยเครื่องมือไหนก็ตามที่ dump environment ของมัน
flowchart TD Y["workflow YAML"] --> AL["actionlint"] AL -->|"1: @master accepted, ref exists"| AL AL -->|"2: unquoted $PLAN_FILE, real SC2086"| AL Y --> R["terraform plan -detailed-exitcode"] R -->|"3: TF_VAR_* echoed"| R R -->|"5: exit code 0/1/2 never branched"| R R --> A["terraform apply"] A -->|"4: no -out, re-plans from scratch"| A G[".gitignore / terraform.tfvars"] -->|"6: lockfile ignored"| G G -->|"7: secret committed"| G P["policy/*.rego"] --> CF["conftest"] CF -->|"8: pre-v1 Rego, load error"| CF L[".tflint.hcl / checkov config"] -->|"9: never wired into CI"| L TG["terragrunt run --all plan"] -->|"10: no --out-dir"| TG OIDC["App Registration federated credential"] -->|"subject too broad -- docs"| OIDC