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

Worked review 3: CI pipeline และ policy check

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 จริง

# .github/workflows/terraform.yml, as generated -- fragment, not a collected fence
name: terraform
on:
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 above
db_password = "Sup3rSecret!2024"
api_token = "ghp_1234567890abcdefghijklmnopqrstuvwxyz"
# policy/deny_no_tags.rego, as generated -- fragment, not a collected fence
package 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 fence
plugin "azurerm" {
enabled = true
version = "0.28.0"
source = "github.com/terraform-linters/tflint-ruleset-azurerm"
}

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

อาการ — 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"

อาการ — จริง env ทำซ้ำสิ่งที่ environment ของ CI runner หน้าตาเป็นเมื่อ env: ตั้งตัวแปรที่มาจาก secret:

$ export TF_VAR_db_password="SuperSecret123"
$ env | grep TF_VAR_db_password
TF_VAR_db_password=SuperSecret123

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

- name: plan
run: |
echo "TF_VAR_db_password=$TF_VAR_db_password"
terraform plan -detailed-exitcode -out=$PLAN_FILE
env:
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-approve
terraform_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 tfplan
Apply 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, as generated
.terraform/
.terraform.lock.hcl
*.tfstate
*.tfstate.backup

bug เดียวกับ AI bug catalog entry 4 วิธีแก้:

# .gitignore, fixed
.terraform/
*.tfstate
*.tfstate.backup
# terraform.tfvars, as generated
db_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, as generated -- @expect-error rego_parse_error: `if` keyword is required before rule body
package 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.json
Error: 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 body
policy/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, fixed
package 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.json
FAIL - 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/tflint
1 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 --quiet
Passed 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.app
Check: CKV_AZURE_50: "Ensure Virtual Machine Extensions are not Installed"
FAILED for resource: azurerm_linux_virtual_machine.app

อาการ — 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

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วิธีแก้
1actions/checkout@master — ref เปลี่ยนแปลงได้ actionlint ยอมรับเหมือน SHAPin เป็น commit SHA เต็ม
2-out=$PLAN_FILE ไม่ quote ใน run: script-out="$PLAN_FILE" — actionlint/shellcheck SC2086 จริง
3TF_VAR_db_password ที่มาจาก secret ถูก echo ในบรรทัด debugอย่า echo เลย — env output จริงพิสูจน์ว่ามันเป็นตัวแปรธรรมดาที่อ่านได้ทันทีที่ตั้งแล้ว
4apply -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/
7terraform.tfvars มี secret จริง commit ไว้ลบทิ้ง; secret มาจาก TF_VAR_* CI secret เท่านั้น
8Rego ก่อน 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 ในคอร์สนี้

เก้าใน 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
แต่ละ bug อยู่ตรงไหนใน toolchain จริงของ pipeline นี้
actions/checkout@master กับ uses: actions/checkout เปล่าไม่มี ref เลย ทั้งคู่เป็นปัญหาที่เป็นไปได้ actionlint จริงแสดงอะไรสำหรับแต่ละแบบ?
terraform apply -auto-approve รันโดยไม่มี plan file ที่บันทึกไว้จาก step plan ก่อนหน้า output ของ apply จริงแสดงอะไร เทียบกับการ apply plan file ที่บันทึกไว้?
มีทั้ง .tflint.hcl และ checkov config อยู่ใน repository แต่ workflow GitHub Actions ที่ generate มาไม่เคยมี step ที่รันเครื่องมือไหนเลย รันตรงกับ main.tf ของ pipeline เอง -- เจออะไร?
workflow ที่ generate มาแสดง step azure/login จริงที่มี client-id/tenant-id/subscription-id แต่ไม่มี federated credential ของ App Registration นี่สำคัญยังไง?
workflow step ตั้ง env: TF_VAR_db_password: ${{ secrets.DB_PASSWORD }} และบรรทัดถัดมาทำ echo "TF_VAR_db_password=$TF_VAR_db_password" ทำไมการมาสก์ log ของ GitHub ถึงไม่ใช่การป้องกันที่เชื่อถือได้ที่นี่?