ЁЯПл The SchoolтА║ЁЯУЬ TerraformтА║ЁЯЪА рдзрдбрд╛ 12 тАФ CI/CD рдордзреАрд▓ IaC рдЖрдгрд┐ рд╕рдВрдкреВрд░реНрдг рдЪрд┐рддреНрд░: PR рд╡рд░ plan, merge рд╡рд░ apply
ЁЯЦ╝я╕П See the drawing + lab ЁЯПа Course home ЁЯМ┐ Branch on GitHub тЬПя╕П View source
ЁЯЦ╝я╕П рдЖрдХреГрддреА рдЖрдгрд┐ labThe drawing + lab рдкреВрд░реНрдг рдкрд╛рдирд╛рд╡рд░ рдЙрдШрдбрд╛ тЖЧOpen full page тЖЧ

ЁЯЪА рдзрдбрд╛ 12 тАФ CI/CD рдордзреАрд▓ IaC рдЖрдгрд┐ рд╕рдВрдкреВрд░реНрдг рдЪрд┐рддреНрд░: PR рд╡рд░ plan, merge рд╡рд░ apply

ЁЯУН рддреБрдореНрд╣реА рдЗрдереЗ рдЖрд╣рд╛рдд: 12 рдкреИрдХреА рдзрдбрд╛ 12 ┬╖ рдорд╛рдЧреАрд▓: lesson-11-testing-policy


ЁЯУж рдпрд╛ рдмреНрд░рдБрдЪрдордзреНрдпреЗ рдХрд╛рдп рдЖрд╣реЗ

рдзрдбреЗ 01тАУ11, рдЖрдгрд┐ рддреНрдпрд╛ рд╕рдЧрд│реНрдпрд╛рдВрдирд╛ рдПрдХрддреНрд░ рдмрд╛рдВрдзрдгрд╛рд░рд╛ conveyor belt. рдЖрд░рд╛рдЦрдбреНрдпрд╛рддреАрд▓ рдкреНрд░рддреНрдпреЗрдХ рдмрджрд▓ рдореНрд╣рдгрдЬреЗ рдПрдХ pull request. CI рддреНрдпрд╛рд╡рд░ validate, plan, policy рдЖрдгрд┐ cost рдЪрд╛рд▓рд╡рддреЗ рдЖрдгрд┐ review рд╕рд╛рдареА plan post рдХрд░рддреЗ. рдлрдХреНрдд main рдордзреАрд▓ merge рдЪ apply рдХрд░рддреЛ тАФ рдЖрдгрд┐ рддреЛ review рдЭрд╛рд▓реЗрд▓рд╛рдЪ plan apply рдХрд░рддреЛ, рдирд╛рд╣реАрддрд░ рдерд╛рдВрдмрддреЛ. CI рдереЛрдбрд╛ рд╡реЗрд│ рдЯрд┐рдХрдгрд╛рд▒реНрдпрд╛ OIDC token рдиреЗ рдЖрдкрд▓реА рдУрд│рдЦ рд╕рд┐рджреНрдз рдХрд░рддреЗ, рддреНрдпрд╛рдореБрд│реЗ рдХреЛрдгрддреАрд╣реА cloud key рдХреБрдареЗрд╣реА рд╕рд╛рдард╡рд▓реЗрд▓реА рдирд╕рддреЗ. pipeline() рд╣реЗ iac/demo.py рдордзреНрдпреЗ рдЖрд╣реЗ; oidc_token(), assume(), pr_pipeline() рдЖрдгрд┐ merge_pipeline() рд╣реЗ iac/checks.py рдордзреНрдпреЗ; stale-plan рддрдкрд╛рд╕рдгреА iac/engine.py рдордзреАрд▓ apply() рдордзреНрдпреЗ.

ЁЯзТ 5 рд╡рд░реНрд╖рд╛рдВрдЪреНрдпрд╛ рдореБрд▓рд╛рд▓рд╛ рд╕рдордЬрд╛рд╡рд▓реНрдпрд╛рд╕рд╛рд░рдЦреЗ

рдЖрддрд╛ рдХреЛрдгреАрд╣реА рдЦрд┐рд╢рд╛рдд рдЖрд░рд╛рдЦрдбрд╛ рдШреЗрдКрди рдХрдВрддреНрд░рд╛рдЯрджрд╛рд░рд╛рдХрдбреЗ рдЬрд╛рдд рдирд╛рд╣реА. ЁЯУм рдкреНрд░рддреНрдпреЗрдХ рдирд╡рд╛ рдЖрд░рд╛рдЦрдбрд╛ рдХрд╛рд░реНрдпрд╛рд▓рдпрд╛рдЪреНрдпрд╛ рдЯреНрд░реЗрдордзреНрдпреЗ (рдПрдХ pull request) рдЬрд╛рддреЛ. рдПрдХ robot clerk ЁЯдЦ рддреЛ рдЙрдЪрд▓рддреЛ, рдзрдбрд╛ 11 рдордзреАрд▓ рдЪрд╛рд░рд╣реА рддрдкрд╛рд╕рдиреАрд╕ рдЪрд╛рд▓рд╡рддреЛ, рдЖрдгрд┐ рдХрдВрддреНрд░рд╛рдЯрджрд╛рд░рд╛рдЪреА рдпрд╛рджреА рд╕реВрдЪрдирд╛ рдлрд▓рдХрд╛рд╡рд░ рд▓рд╛рд╡рддреЛ: "рд╣рд╛ plan рдПрдХ library рдЬреЛрдбрддреЛ: рджрд░ рдорд╣рд┐рдиреНрдпрд╛рд▓рд╛ +тВ╣1200." рдПрдХ рд╡реНрдпрдХреНрддреА рддреЗ рд╡рд╛рдЪрддреЗ рдЖрдгрд┐ "рдордВрдЬреВрд░" рдореНрд╣рдгрддреЗ.

Robot рдХрдбреЗ campus рдЪреА рдХрд┐рд▓реНрд▓реА рдирд╕рддреЗ. рдлрд╛рдЯрдХрд╛рд╡рд░ рддреЛ рдПрдХ рджрд┐рд╡рд╕рд╛рдЪрд╛ pass ЁЯОл рджрд╛рдЦрд╡рддреЛ рдЬреНрдпрд╛рд╡рд░ рд▓рд┐рд╣рд┐рд▓реЗрд▓реЗ рдЕрд╕рддреЗ "рдореА campus-infra рдХрд╛рд░реНрдпрд╛рд▓рдпрд╛рдЪрд╛ robot рдЖрд╣реЗ, main рд╡рд░ рдХрд╛рдо рдХрд░рддреЛ". рдкрд╣рд╛рд░реЗрдХрд░реА pass рддрдкрд╛рд╕рддреЛ рдЖрдгрд┐ рддреНрдпрд╛рд▓рд╛ рдПрдХрд╛ рддрд╛рд╕рд╛рд╕рд╛рдареА рдЖрдд рд╕реЛрдбрддреЛ. Pull request рд╡рд░ рдХрд╛рдо рдХрд░рдгрд╛рд▒реНрдпрд╛ robot рд▓рд╛ рдЕрд╕рд╛ pass рдорд┐рд│рддреЛ рдЬреЛ рдлрдХреНрдд read-only рджрд╛рд░ рдЙрдШрдбрддреЛ.

рд╢реБрдХреНрд░рд╡рд╛рд░реА рджреЛрди plans рдордВрдЬреВрд░ рд╣реЛрддрд╛рдд: #42 (рдПрдХ library) рдЖрдгрд┐ #43 (рдПрдХ gate). #43 рдЖрдзреА рдмрд╛рдВрдзрд▓рд╛ рдЬрд╛рддреЛ. #42 рдЪреА рдкрд╛рд│реА рдпреЗрддреЗ рддреЗрд╡реНрд╣рд╛ robot рдкрд╛рд╣рддреЛ рдХреА #42 рддрдкрд╛рд╕рд▓реНрдпрд╛рдирдВрддрд░ рдиреЛрдВрджрд╡рд╣реА рдмрджрд▓рд▓реА рдЖрд╣реЗ. рддреЛ рдкреБрдиреНрд╣рд╛ plan рдХрд░рддреЛ тАФ рдЖрдгрд┐ рдирд╡рд╛ plan рдирд╡реЗ gate рдкрд╛рдбреВрди рдЯрд╛рдХреЗрд▓, рдХрд╛рд░рдг gate рдЕрд╕реНрддрд┐рддреНрд╡рд╛рдд рдпреЗрдгреНрдпрд╛рдЖрдзреА #42 рдЪрд╛ рдЖрд░рд╛рдЦрдбрд╛ рд▓рд┐рд╣рд┐рд▓рд╛ рдЧреЗрд▓рд╛ рд╣реЛрддрд╛. рдХреЛрдгреАрдЪ рд╣реЗ рдордВрдЬреВрд░ рдХреЗрд▓реЗ рдирд╡реНрд╣рддреЗ, рдореНрд╣рдгреВрди robot рдерд╛рдВрдмрддреЛ. рджреАрдкрд┐рдХрд╛ #42 рддрд╛рдЬреНрдпрд╛ рдЖрд░рд╛рдЦрдбреНрдпрд╛рдиреЗ update рдХрд░рддреЗ, рдирд╡рд╛ plan рдкреБрдиреНрд╣рд╛ рдлрдХреНрдд "рдПрдХ library рдЬреЛрдбрд╛" рдЕрд╕рд╛ рд╣реЛрддреЛ, рдЖрдгрд┐ рддреЛ рдкреБрдвреЗ рдЬрд╛рддреЛ.

ЁЯЧ║я╕П рдЖрдХреГрддреА

flowchart LR
    pr["ЁЯУм pull request"] --> ci["ЁЯдЦ CI (OIDC: campus-plan, read-only)<br/>validate ┬╖ plan ┬╖ policy ┬╖ cost"]
    ci --> review["ЁЯСА plan posted, reviewed"]
    review --> merge["merge to main"]
    merge --> ap["ЁЯдЦ CI (OIDC: campus-apply)<br/>lock ┬╖ apply the saved plan"]
    ap -->|"state changed since plan"| stale["stale тЖТ re-plan"]
    stale -->|"same changes"| ok["apply"]
    stale -->|"different changes"| stop["stop, back to review"]
    ap --> reg["ЁЯУТ remote register, locked"]

ЁЯЧ║я╕П рд░реЗрдЦрд╛рдЯрд▓реЗрд▓реА рдЖрд╡реГрддреНрддреА + рдПрдХ lab: https://school-edh.pages.dev/terraform/lesson-diagrams.html#l12

тЭУ рдХрд╛рдп

ЁЯдФ рдХрд╛

рдХрд╛рд░рдг рдЖрдзреАрдЪреНрдпрд╛ рдкреНрд░рддреНрдпреЗрдХ рдзрдбреНрдпрд╛рдд рдЕрдкрдпрд╢рд╛рдЪрд╛ рдПрдХ рдЕрд╕рд╛ рдкреНрд░рдХрд╛рд░ рдЖрд╣реЗ рдЬреЛ рдорд╛рдгреВрд╕ рджрдбрдкрдгрд╛рдЦрд╛рд▓реА рд╡рд┐рд╕рд░рддреЛ: рди рд╡рд╛рдЪрд▓реЗрд▓рд╛ plan (03), leak рдЭрд╛рд▓реЗрд▓реА state (05), рджреЛрди рд▓рд┐рд╣рд┐рдгрд╛рд░реЗ (08), console рдордзреАрд▓ рджреБрд░реБрд╕реНрддреА (09), рдЪреБрдХреАрдЪреЗ workspace (10), рдЧрд╣рд╛рд│ tag (11). Pipeline рд╕реБрд░рдХреНрд╖рд┐рдд рдорд╛рд░реНрдЧрд╛рд▓рд╛рдЪ рдПрдХрдореЗрд╡ рдорд╛рд░реНрдЧ рдмрдирд╡рддреЗ. OIDC рдореБрд│реЗ CI рдордзрд▓реА рд╕рд░реНрд╡рд╛рдд рдЬрд╛рд╕реНрдд рдЪреЛрд░реАрд▓рд╛ рдЬрд╛рдгрд╛рд░реА рдЧреЛрд╖реНрдЯ рдирд╛рд╣реАрд╢реА рд╣реЛрддреЗ тАФ secret рдордзрд▓реА long-lived cloud access key. рдЖрдгрд┐ apply рд╣реЛрдгрд╛рд░рд╛ plan рд╣рд╛рдЪ review рдЭрд╛рд▓реЗрд▓рд╛ plan рдЖрд╣реЗ рд╣реЗ рддрдкрд╛рд╕рд▓реНрдпрд╛рдиреЗ "рдордВрдЬреВрд░" рдЖрдгрд┐ "рдкреВрд░реНрдг" рдпрд╛рдВрдЪреНрдпрд╛рддреАрд▓ рдлрдЯ рдмрдВрдж рд╣реЛрддреЗ.

ЁЯФз рдХрд╕реЗ (рдпрд╛ repo рдордзреНрдпреЗ)

iac/checks.py рдордзреАрд▓ oidc_token(repo, event) GitHub Actions token рдордзреНрдпреЗ рдЕрд╕рдгрд╛рд░реЗ claims (iss, aud, sub) рддрдпрд╛рд░ рдХрд░рддреЗ. assume(role, token) рддреЗ ROLES рд╢реА shell-style matching рдиреЗ рддрдкрд╛рд╕рддреЗ, StringLike рдЕрд╕рд▓реЗрд▓реНрдпрд╛ IAM trust policy рд╕рд╛рд░рдЦреЗ. pr_pipeline() validate тЖТ plan тЖТ policy тЖТ cost рдЪрд╛рд▓рд╡рддреЗ рдЖрдгрд┐ saved plan рдкрд░рдд рджреЗрддреЗ. merge_pipeline() lock рдШреЗрддреЗ, saved plan apply рдХрд░рддреЗ, рдЖрдгрд┐ StalePlan рдЖрд▓рд╛ рддрд░ рдкреБрдиреНрд╣рд╛ plan рдХрд░рддреЗ рдЖрдгрд┐ рдирд╡рд╛ plan рддреЗрдЪ рдмрджрд▓ рдХрд░рдд рдЕрд╕реЗрд▓ рддрд░рдЪ apply рдХрд░рддреЗ. iac/engine.py рдордзреАрд▓ apply() backend рдЪрд╛ serial рдХрд┐рдВрд╡рд╛ lineage plan рдкреЗрдХреНрд╖рд╛ рд╡реЗрдЧрд│рд╛ рдЕрд╕реЗрд▓ рддреЗрд╡реНрд╣рд╛ StalePlan рдЙрдард╡рддреЗ.

ЁЯзк рдХрд░реВрди рдкрд╛рд╣рд╛

python3 iac/demo.py pipeline
python3 - <<'EOF'
import sys; sys.path.insert(0, "iac")
from checks import oidc_token, assume
for event, ref in (("push", "refs/heads/main"), ("push", "refs/heads/feature-gym"), ("pull_request", None)):
    t = oidc_token("school/campus-infra", event, ref)
    print(f"{t['sub']:<52} тЖТ {assume('campus-apply', t)}")
t = oidc_token("someone/other-repo", "push")
print(f"{t['sub']:<52} тЖТ {assume('campus-plan', t)}")
EOF
python3 iac/test_iac.py

тЬЕ рддрдкрд╛рд╕рд╛ тАФ рддреБрдореНрд╣рд╛рд▓рд╛ рдХрд╛рдп рджрд┐рд╕рд╛рдпрд▓рд╛ рд╣рд╡реЗ

pipeline рд╣реЗ рдЫрд╛рдкрддреЗ:

      тЖТ campus-apply: AccessDenied тАФ sub 'repo:school/campus-infra:pull_request' does not match
   PR #42: plan (state serial 1) тЖТ Plan: 1 to add, 0 to change, 0 to destroy.
   PR #42: cost тЖТ тВ╣1200/month тЖТ тВ╣2400/month (+1200)
   merge #43: Apply complete! Resources: 1 added, 0 changed, 0 destroyed. (state serial 2)
   merge #42: Saved plan is stale: the state was changed by another operation after the plan was created.
   merge #42: re-plan (state serial 2) тЖТ Plan: 1 to add, 0 to change, 1 to destroy.
   merge #42: the new plan is NOT the plan that was reviewed тЖТ stop, nothing applied
   PR #42 (rebased on main): plan (state serial 2) тЖТ Plan: 1 to add, 0 to change, 0 to destroy.
   merge #42: Apply complete! Resources: 1 added, 0 changed, 0 destroyed. (state serial 3)
тФАтФА main = campus: register serial 3 ┬╖ 3 resources ┬╖ campus 3 objects

рддреБрдордЪрд╛ snippet рд╣реЗ рдЫрд╛рдкрддреЛ:

repo:school/campus-infra:ref:refs/heads/main         тЖТ campus-apply: granted, read-write (apply), session 3600 s
repo:school/campus-infra:ref:refs/heads/feature-gym  тЖТ campus-apply: AccessDenied тАФ sub 'repo:school/campus-infra:ref:refs/heads/feature-gym' does not match
repo:school/campus-infra:pull_request                тЖТ campus-apply: AccessDenied тАФ sub 'repo:school/campus-infra:pull_request' does not match
repo:someone/other-repo:ref:refs/heads/main          тЖТ campus-plan: AccessDenied тАФ sub 'repo:someone/other-repo:ref:refs/heads/main' does not match

Tests 12/12 passed рдиреЗ рд╕рдВрдкрддрд╛рдд.

ЁЯПБ рддреБрдореНрд╣реА рдЖрддреНрддрд╛рдЪ рдХрд╛рдп рд╕рд┐рджреНрдз рдХреЗрд▓реЗ

рдлрдХреНрдд рдпреЛрдЧреНрдп repository рдЪреА main рдЪ apply role assume рдХрд░реВ рд╢рдХрд▓реА; feature branch, pull request рдЖрдгрд┐ рджреБрд╕рд░реА repository рдХрд░реВ рд╢рдХрд▓реА рдирд╛рд╣реА. рджреЛрди рдордВрдЬреВрд░ PRs рдордзреНрдпреЗ рд╢рд░реНрдпрдд рд▓рд╛рдЧрд▓реА: рджреБрд╕рд▒реНрдпрд╛рдЪрд╛ saved plan stale рдЭрд╛рд▓рд╛ рд╣реЛрддрд╛, рдЖрдгрд┐ рддреНрдпрд╛рдЪрд╛ рддрд╛рдЬрд╛ plan рдкрд╣рд┐рд▓реНрдпрд╛рдЪреЗ gate destroy рдХрд░рдгрд╛рд░ рд╣реЛрддрд╛ тАФ рдореНрд╣рдгреВрди рдХреЛрдгреАрд╣реА review рди рдХреЗрд▓реЗрд▓реЗ рдХрд╛рд╣реАрддрд░реА apply рдХрд░рдгреНрдпрд╛рдРрд╡рдЬреА pipeline рдерд╛рдВрдмрд▓реА. Rebase рдирдВрддрд░ plan рдкреБрдиреНрд╣рд╛ review рдЭрд╛рд▓реЗрд▓рд╛рдЪ рдЭрд╛рд▓рд╛, рдЖрдгрд┐ main, рдиреЛрдВрджрд╡рд╣реА (3 resources, serial 3) рдЖрдгрд┐ campus (3 objects) рдпрд╛ рд╕рдЧрд│реНрдпрд╛рдВрдЪреЗ рдПрдХрдордд рдЖрд╣реЗ.

тЪая╕П рдиреЗрд╣рдореАрдЪреНрдпрд╛ рдЪреБрдХрд╛

ЁЯПн рдкреНрд░рддреНрдпрдХреНрд╖ рд╡рд╛рдкрд░рд╛рдд

рдЦрд▒реНрдпрд╛ account рд╡рд░ тАФ AWS рдХрдбреЗ OIDC рд╕рд╣ GitHub Actions. Apply job prod рдирд╛рд╡рд╛рдЪреЗ GitHub environment рд╡рд╛рдкрд░рддреЛ (рдЬреНрдпрд╛рд▓рд╛ reviewer рдЖрд╡рд╢реНрдпрдХ рдХрд░рддрд╛ рдпреЗрддреЛ), рдореНрд╣рдгреВрди рддреНрдпрд╛рдЪрд╛ sub repo:school/campus-infra:environment:prod рдЕрд╕рддреЛ:

name: terraform
on:
  pull_request:
  push:
    branches: [main]
permissions:
  id-token: write          # allow the job to request an OIDC token
  contents: read
  pull-requests: write     # to comment the plan
jobs:
  plan:
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::111122223333:role/campus-plan
          aws-region: ap-south-1
      - run: terraform init -input=false
      - run: terraform validate
      - run: terraform plan -input=false -out=tfplan
  apply:
    if: github.event_name == 'push'
    runs-on: ubuntu-latest
    environment: prod
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::111122223333:role/campus-apply
          aws-region: ap-south-1
      - run: terraform init -input=false
      - run: terraform plan -input=false -out=tfplan
      - run: terraform apply -input=false tfplan

Apply role рдЪреА trust policy:

{
  "Effect": "Allow",
  "Principal": { "Federated": "arn:aws:iam::111122223333:oidc-provider/token.actions.githubusercontent.com" },
  "Action": "sts:AssumeRoleWithWebIdentity",
  "Condition": {
    "StringEquals": {
      "token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
      "token.actions.githubusercontent.com:sub": "repo:school/campus-infra:environment:prod"
    }
  }
}

рд╣рд╛ workflow merge рд╡рд░ рдкреБрдиреНрд╣рд╛ plan рдХрд░рддреЛ рдЖрдгрд┐ рддреЛ plan apply рдХрд░рддреЛ. review рдЭрд╛рд▓реЗрд▓рд╛ plan рдиреЗрдордХрд╛ apply рдХрд░рд╛рдпрдЪрд╛ рдЕрд╕реЗрд▓, рддрд░ PR рдЪрд╛ tfplan artifact рдореНрд╣рдгреВрди рдареЗрд╡рд╛ рдЖрдгрд┐ рддреЛрдЪ apply рдХрд░рд╛ (stale рдЕрд╕реЗрд▓ рддрд░ Terraform рддреЛ рдирд╛рдХрд╛рд░рддреЗ), рдХрд┐рдВрд╡рд╛ merge рдЖрдзреАрдЪ pull request рдордзреВрди apply рдХрд░рдгрд╛рд░реЗ Atlantis рд╕рд╛рд░рдЦреЗ tool рд╡рд╛рдкрд░рд╛. CI/CD рд╢рд╛рд│рд╛ рдЖрдгрд┐ IAM рд╢рд╛рд│рд╛ pipelines рдЖрдгрд┐ trust policies рдмрджреНрджрд▓ рдЕрдзрд┐рдХ рдЦреЛрд▓рд╛рдд рдЬрд╛рддрд╛рдд.

ЁЯПн рдкреНрд░рддреНрдпрдХреНрд╖ рд╡рд╛рдкрд░рд╛рдд рд╣реЗ рдХрд╛ рдорд╣рддреНрддреНрд╡рд╛рдЪреЗ рдЖрд╣реЗ: рддреБрдордЪреНрдпрд╛ CI secrets рдордзреНрдпреЗ cloud access keys рд╢реЛрдзрд╛. рдкреНрд░рддреНрдпреЗрдХ key рдЪреНрдпрд╛ рдЬрд╛рдЧреА рдЕрд╕рд╛ OIDC role рдареЗрд╡рд╛ рдЬреНрдпрд╛рдЪреА trust policy рдПрдХ repository рдЖрдгрд┐ рдПрдХ branch рдХрд┐рдВрд╡рд╛ environment рдирдореВрдж рдХрд░рддреЗ тАФ рдЖрдгрд┐ plan job рд▓рд╛ read-only role рджреНрдпрд╛.

ЁЯОУ рдЖрд░рд╛рдЦрдбрд╛ рдкреВрд░реНрдг рдЭрд╛рд▓рд╛

Clicks рдкреБрдиреНрд╣рд╛ рдХрд░рддрд╛ рдпреЗрдд рдирд╛рд╣реАрдд, рдЖрд░рд╛рдЦрдбрд╛ рдХрд░рддрд╛ рдпреЗрддреЛ тЖТ provider рдЪреЗ schema рд╕рд╛рдВрдЧрддреЗ рдХреА рдЖрд░рд╛рдЦрдбреНрдпрд╛рдд рдХрд╛рдп рд▓рд┐рд╣рд┐рддрд╛ рдпреЗрддреЗ тЖТ plan рдлрдХреНрдд рдлрд░рдХ рдмрд╛рдВрдзрддреЛ, рдЖрдгрд┐ ForceNew рдореНрд╣рдгрдЬреЗ рдкреБрдирд░реНрдмрд╛рдВрдзрдгреА тЖТ variables рдЖрдгрд┐ for_each рдореБрд│реЗ рдПрдХ design рдЕрдиреЗрдХ рдЦреЛрд▓реНрдпрд╛рдВрдирд╛ рдмрд╕рддреЗ тЖТ рдиреЛрдВрджрд╡рд╣реА рдкреНрд░рддреНрдпреЗрдХ address рдПрдХрд╛ рдЦрд▒реНрдпрд╛ object рд╢реА рдЬреЛрдбрддреЗ, secrets рд╕рдХрдЯ тЖТ graph рдХрд╛рдорд╛рдЪрд╛ рдХреНрд░рдо рдард░рд╡рддреЛ рдЖрдгрд┐ рддреЗ parallel рдЪрд╛рд▓рд╡рддреЛ тЖТ modules рдПрдХрд╛ pattern рд▓рд╛ рдПрдХрд╛ review рдЭрд╛рд▓реЗрд▓реНрдпрд╛ рдЧреЛрд╖реНрдЯреАрдд рдмрджрд▓рддрд╛рдд тЖТ рдПрдХ remote рдиреЛрдВрджрд╡рд╣реА, рдПрдХрд╛ рд╡реЗрд│реА рдПрдХрдЪ рд▓рд┐рд╣рд┐рдгрд╛рд░рд╛ тЖТ refresh drift рд╢реЛрдзрддреЛ, import рдЖрдгрд┐ moved рдиреЛрдВрджрд╡рд╣реА рдкреНрд░рд╛рдорд╛рдгрд┐рдХ рдареЗрд╡рддрд╛рдд тЖТ environments рдирд╛ рд╕реНрд╡рддрдГрдЪреА state рдЖрдгрд┐ рд╕реНрд╡рддрдГрдЪреНрдпрд╛ keys рдорд┐рд│рддрд╛рдд тЖТ рдХрдВрддреНрд░рд╛рдЯрджрд╛рд░рд╛рдЪреНрдпрд╛ рдЖрдзреА рддрдкрд╛рд╕рдиреАрд╕ plan рд╡рд╛рдЪрддрд╛рдд тЖТ рдЖрдгрд┐ pipeline review рдЭрд╛рд▓реЗрд▓реНрдпрд╛ plan рд▓рд╛рдЪ рдЖрдд рдЬрд╛рдгреНрдпрд╛рдЪрд╛ рдПрдХрдореЗрд╡ рдорд╛рд░реНрдЧ рдмрдирд╡рддреЗ. рддреБрдореНрд╣реА рдлрдХреНрдд Terraform рд╢рд┐рдХрд▓рд╛ рдирд╛рд╣реАрдд тАФ рдЖрддрд╛ рддреБрдореНрд╣реА рдХреЛрдгрддрд╛рд╣реА cloud рдкрд╛рд╣реВрди рдпреЛрдЧреНрдп рдкреНрд░рд╢реНрди рд╡рд┐рдЪрд╛рд░реВ рд╢рдХрддрд╛: рддреНрдпрд╛рдЪрд╛ рдЖрд░рд╛рдЦрдбрд╛ рдХреБрдареЗ рдЖрд╣реЗ, рддреНрдпрд╛рдЪреА рдиреЛрдВрджрд╡рд╣реА рдХреБрдареЗ рдЖрд╣реЗ, apply рдХреЛрдг рдХрд░реВ рд╢рдХрддреЗ, рдЖрдгрд┐ рддреНрдпрд╛рдВрдЪреНрдпрд╛ рдмрд╛рд╣реЗрд░ рдХрд╛рдп рдмрджрд▓рд▓реЗ? ЁЯУЬЁЯС╖ЁЯОУ

тПня╕П рдкреБрдвреЗ

рдЗрддрд░ рд╢рд╛рд│рд╛. AWS рд╢рд╛рд│рд╛ рд╣рд╛ campus рдЖрд╣реЗ рдЬреЛ рд╣реЗ рдЖрд░рд╛рдЦрдбреЗ рд╕рд╣рд╕рд╛ рдмрд╛рдВрдзрддрд╛рдд; CI/CD рд╢рд╛рд│рд╛ conveyor belt рдЪрд╛рд▓рд╡рддреЗ; IAM рд╢рд╛рд│рд╛ trust policies рд▓рд┐рд╣рд┐рддреЗ; Argo CD рд╢рд╛рд│рд╛ рддреАрдЪ "desired state in git" рдХрд▓реНрдкрдирд╛ Kubernetes рдордзреНрдпреЗ рд▓рд╛рдЧреВ рдХрд░рддреЗ; рдЖрдгрд┐ School portal рдордзреНрдпреЗ рдмрд╛рдХреА рд╕рдЧрд│реЗ рдЖрд╣реЗ.

git checkout main
python3 iac/demo.py     # one last run, for fun

ЁЯЪА Lesson 12 тАФ IaC in CI/CD & the whole picture: plan on the PR, apply on merge

ЁЯУН You are here: Lesson 12 of 12 ┬╖ Previous: lesson-11-testing-policy


ЁЯУж What's in this branch

Lessons 01тАУ11, plus the conveyor belt that ties them together. Every change to the plans is a pull request. CI runs validate, plan, policy and cost on it and posts the plan for review. Only a merge to main applies тАФ and it applies the plan that was reviewed, or stops. CI proves who it is with a short-lived OIDC token, so no cloud key is stored anywhere. pipeline() in iac/demo.py; oidc_token(), assume(), pr_pipeline() and merge_pipeline() in iac/checks.py; the stale-plan check in apply() in iac/engine.py.

ЁЯзТ Explain like I'm 5

Nobody walks up to the contractor with plans in their pocket any more. ЁЯУм Every new plan goes into the office tray (a pull request). A robot clerk ЁЯдЦ picks it up, runs all four inspectors from lesson 11, and pins the contractor's list on the notice board: "This plan adds a library: +тВ╣1200 a month." A person reads it and says "approved".

The robot doesn't carry a key to the campus. At the gate it shows a day pass ЁЯОл that says "I am the robot of the campus-infra office, working on main". The guard checks the pass and lets it in for one hour. A robot working on a pull request gets a pass that opens only the read-only door.

On Friday two plans are approved: #42 (a library) and #43 (a gate). #43 is built first. When #42's turn comes, the robot sees that the register has changed since #42 was checked. It plans again тАФ and the new plan would knock down the new gate, because #42's plans were written before the gate existed. That is not what anyone approved, so the robot stops. Dipika updates #42 with the latest plans, the new plan is just "add a library" again, and it goes through.

ЁЯЧ║я╕П Diagram

flowchart LR
    pr["ЁЯУм pull request"] --> ci["ЁЯдЦ CI (OIDC: campus-plan, read-only)<br/>validate ┬╖ plan ┬╖ policy ┬╖ cost"]
    ci --> review["ЁЯСА plan posted, reviewed"]
    review --> merge["merge to main"]
    merge --> ap["ЁЯдЦ CI (OIDC: campus-apply)<br/>lock ┬╖ apply the saved plan"]
    ap -->|"state changed since plan"| stale["stale тЖТ re-plan"]
    stale -->|"same changes"| ok["apply"]
    stale -->|"different changes"| stop["stop, back to review"]
    ap --> reg["ЁЯУТ remote register, locked"]

ЁЯЧ║я╕П Drawn version + a lab: https://school-edh.pages.dev/terraform/lesson-diagrams.html#l12

тЭУ What

ЁЯдФ Why

Because every earlier lesson has a failure mode that a human forgets under pressure: the unread plan (03), the leaked state (05), two writers (08), a console fix (09), the wrong workspace (10), the missing tag (11). A pipeline makes the safe path the only path. OIDC removes the most-stolen thing in CI тАФ a long-lived cloud access key in a secret. And checking that the applied plan is the reviewed plan closes the gap between "approved" and "done".

ЁЯФз How (in this repo)

oidc_token(repo, event) in iac/checks.py builds the claims a GitHub Actions token carries (iss, aud, sub). assume(role, token) checks them against ROLES with shell-style matching, like an IAM trust policy with StringLike. pr_pipeline() runs validate тЖТ plan тЖТ policy тЖТ cost and returns the saved plan. merge_pipeline() takes the lock, applies the saved plan, and on StalePlan plans again and applies only if the new plan makes the same changes. apply() in iac/engine.py raises StalePlan when the backend's serial or lineage differs from the plan's.

ЁЯзк Try it

python3 iac/demo.py pipeline
python3 - <<'EOF'
import sys; sys.path.insert(0, "iac")
from checks import oidc_token, assume
for event, ref in (("push", "refs/heads/main"), ("push", "refs/heads/feature-gym"), ("pull_request", None)):
    t = oidc_token("school/campus-infra", event, ref)
    print(f"{t['sub']:<52} тЖТ {assume('campus-apply', t)}")
t = oidc_token("someone/other-repo", "push")
print(f"{t['sub']:<52} тЖТ {assume('campus-plan', t)}")
EOF
python3 iac/test_iac.py

тЬЕ Verify тАФ what you should see

pipeline prints:

      тЖТ campus-apply: AccessDenied тАФ sub 'repo:school/campus-infra:pull_request' does not match
   PR #42: plan (state serial 1) тЖТ Plan: 1 to add, 0 to change, 0 to destroy.
   PR #42: cost тЖТ тВ╣1200/month тЖТ тВ╣2400/month (+1200)
   merge #43: Apply complete! Resources: 1 added, 0 changed, 0 destroyed. (state serial 2)
   merge #42: Saved plan is stale: the state was changed by another operation after the plan was created.
   merge #42: re-plan (state serial 2) тЖТ Plan: 1 to add, 0 to change, 1 to destroy.
   merge #42: the new plan is NOT the plan that was reviewed тЖТ stop, nothing applied
   PR #42 (rebased on main): plan (state serial 2) тЖТ Plan: 1 to add, 0 to change, 0 to destroy.
   merge #42: Apply complete! Resources: 1 added, 0 changed, 0 destroyed. (state serial 3)
тФАтФА main = campus: register serial 3 ┬╖ 3 resources ┬╖ campus 3 objects

Your snippet prints:

repo:school/campus-infra:ref:refs/heads/main         тЖТ campus-apply: granted, read-write (apply), session 3600 s
repo:school/campus-infra:ref:refs/heads/feature-gym  тЖТ campus-apply: AccessDenied тАФ sub 'repo:school/campus-infra:ref:refs/heads/feature-gym' does not match
repo:school/campus-infra:pull_request                тЖТ campus-apply: AccessDenied тАФ sub 'repo:school/campus-infra:pull_request' does not match
repo:someone/other-repo:ref:refs/heads/main          тЖТ campus-plan: AccessDenied тАФ sub 'repo:someone/other-repo:ref:refs/heads/main' does not match

The tests end with 12/12 passed.

ЁЯПБ What you just proved

Only main of the right repository could assume the apply role; a feature branch, a pull request and another repository could not. Two approved PRs raced: the second one's saved plan was stale, and its fresh plan would have destroyed the first one's gate тАФ so the pipeline stopped instead of applying something nobody reviewed. After the rebase, the plan was the reviewed one again, and main, the register (3 resources, serial 3) and the campus (3 objects) all agree.

тЪая╕П Common mistakes

ЁЯПн In production

On a real account тАФ GitHub Actions with OIDC to AWS. The apply job uses a GitHub environment called prod (which can require a reviewer), so its sub is repo:school/campus-infra:environment:prod:

name: terraform
on:
  pull_request:
  push:
    branches: [main]
permissions:
  id-token: write          # allow the job to request an OIDC token
  contents: read
  pull-requests: write     # to comment the plan
jobs:
  plan:
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::111122223333:role/campus-plan
          aws-region: ap-south-1
      - run: terraform init -input=false
      - run: terraform validate
      - run: terraform plan -input=false -out=tfplan
  apply:
    if: github.event_name == 'push'
    runs-on: ubuntu-latest
    environment: prod
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::111122223333:role/campus-apply
          aws-region: ap-south-1
      - run: terraform init -input=false
      - run: terraform plan -input=false -out=tfplan
      - run: terraform apply -input=false tfplan

The apply role's trust policy:

{
  "Effect": "Allow",
  "Principal": { "Federated": "arn:aws:iam::111122223333:oidc-provider/token.actions.githubusercontent.com" },
  "Action": "sts:AssumeRoleWithWebIdentity",
  "Condition": {
    "StringEquals": {
      "token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
      "token.actions.githubusercontent.com:sub": "repo:school/campus-infra:environment:prod"
    }
  }
}

This workflow plans again on merge and applies that plan. To apply exactly the reviewed plan, keep the PR's tfplan as an artifact and apply it (Terraform refuses it if stale), or use a tool such as Atlantis that applies from the pull request before merge. The CI/CD school and the IAM school go deeper on pipelines and trust policies.

ЁЯПн Why this matters in production: search your CI secrets for cloud access keys. Replace each with an OIDC role whose trust policy names one repository and one branch or environment тАФ and give the plan job a read-only role.

ЁЯОУ The plans are complete

Clicks cannot be repeated, plans can тЖТ a provider's schema says what the plans may say тЖТ the plan builds only the difference, and ForceNew means rebuild тЖТ variables and for_each make one design fit many rooms тЖТ the register binds every address to a real object, secrets included тЖТ the graph orders the work and runs it in parallel тЖТ modules turn a pattern into one reviewed thing тЖТ one remote register, one writer at a time тЖТ refresh finds drift, import and moved keep the register honest тЖТ environments get their own state and their own keys тЖТ inspectors read the plan before the contractor does тЖТ and the pipeline makes the reviewed plan the only way in. You didn't just learn Terraform тАФ you can look at any cloud and ask the right questions: where are its plans, where is its register, who may apply, and what changed outside them? ЁЯУЬЁЯС╖ЁЯОУ

тПня╕П Next

The other schools. The AWS school is the campus these plans usually build; the CI/CD school runs the conveyor belt; the IAM school writes the trust policies; the Argo CD school applies the same "desired state in git" idea inside Kubernetes; the School portal has the rest.

git checkout main
python3 iac/demo.py     # one last run, for fun
тЖР Previoustesting policyFinished! Take the quiz тЖТcheck what stuck

This page is the lesson's README from the lesson-12-iac-cicd branch, shown here so the whole School stays on one site. Code files open on GitHub at the same branch.