ЁЯПл The SchoolтА║ЁЯЫдя╕П Platform EngineeringтА║ЁЯзк рдзрдбрд╛ 06 тАФ рдорд╛рдЧрдгреАрдиреБрд╕рд╛рд░ environments: рдкреНрд░рддреНрдпреЗрдХ рдмрджрд▓рд╛рд╕рд╛рдареА рдПрдХ рдЪрд╛рдЪрдгреА рд╡рд░реНрдЧрдЦреЛрд▓реА
ЁЯЦ╝я╕П See the drawing + lab ЁЯПа Course home ЁЯМ┐ Branch on GitHub тЬПя╕П View source
ЁЯЦ╝я╕П рдЖрдХреГрддреА рдЖрдгрд┐ labThe drawing + lab рдкреВрд░реНрдг рдкрд╛рдирд╛рд╡рд░ рдЙрдШрдбрд╛ тЖЧOpen full page тЖЧ

ЁЯзк рдзрдбрд╛ 06 тАФ рдорд╛рдЧрдгреАрдиреБрд╕рд╛рд░ environments: рдкреНрд░рддреНрдпреЗрдХ рдмрджрд▓рд╛рд╕рд╛рдареА рдПрдХ рдЪрд╛рдЪрдгреА рд╡рд░реНрдЧрдЦреЛрд▓реА

ЁЯУН рддреБрдореНрд╣реА рдЗрдереЗ рдЖрд╣рд╛рдд: 12 рдкреИрдХреА рдзрдбрд╛ 06 ┬╖ рдорд╛рдЧреЗ: lesson-05-self-service-infra ┬╖ рдкреБрдвреЗ: lesson-07-paved-pipelines


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

рдзрдбреЗ 01тАУ05, рдЖрдгрд┐ preview environments: рдкреНрд░рддреНрдпреЗрдХ pull request рд▓рд╛ service рдЪреА рд╕реНрд╡рддрдГрдЪреА, рдереЛрдбреНрдпрд╛ рдХрд╛рд│рд╛рдЪреА copy рдорд┐рд│рддреЗ, рд╕реНрд╡рддрдГрдЪреНрдпрд╛ рдкрддреНрддреНрдпрд╛рд╡рд░, рдореНрд╣рдгрдЬреЗ reviewers merge рд╣реЛрдгреНрдпрд╛рдЖрдзреА рдмрджрд▓ рдХреНрд▓рд┐рдХ рдХрд░реВрди рдкрд╛рд╣реВ рд╢рдХрддрд╛рдд. PR рдмрдВрдж рдЭрд╛рд▓рд╛ рдХреА, рдХрд┐рдВрд╡рд╛ рддреНрдпрд╛рдЪрд╛ time-to-live (TTL) рд╕рдВрдкрд▓рд╛ рдХреА environment рд╣рдЯрд╡рд▓реЗ рдЬрд╛рддреЗ, рдЖрдгрд┐ рдПрдХ cap рдПрдХрд╛ рд╡реЗрд│реА рдХрд┐рддреА рдЪрд╛рд▓реВ рд╢рдХрддрд╛рдд рддреЗ рдорд░реНрдпрд╛рджрд┐рдд рдХрд░рддреЗ. idp/selfserve.py рдордзрд▓реЗ PreviewEnvs, idp/demo.py рдордзрд▓реЗ previews().

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

рд╡рд┐рдЬреНрдЮрд╛рди рдкреНрд░рдпреЛрдЧрд╢рд╛рд│рд╛ рдЦрд░реЛрдЦрд░ рдмрджрд▓рдгреНрдпрд╛рдЖрдзреА рдХрддрд░рд┐рдирд╛рд▓рд╛ рдирд╡реА рдорд╛рдВрдбрдгреА рдХрд░реВрди рдкрд╛рд╣рд╛рдпрдЪреА рдЖрд╣реЗ. рд╕реБрд╡рд┐рдзрд╛ рдХрд╛рд░реНрдпрд╛рд▓рдп рддрд┐рд▓рд╛ рдПрдХ рдЪрд╛рдЪрдгреА рд╡рд░реНрдЧрдЦреЛрд▓реА ЁЯзк рджреЗрддреЗ, рджрд╛рд░рд╛рд╡рд░ рдкрд╛рдЯреА рд▓рд╛рд╡реВрди: "pr-101 тАФ рдЪрд╛рдЪрдгреА рдорд╛рдВрдбрдгреА". рдЗрддрд░ рд╢рд┐рдХреНрд╖рд┐рдХрд╛ рдЖрдд рдпреЗрддрд╛рдд рдЖрдгрд┐ рддреНрдпрд╛рдВрдирд╛ рдХрд╛рдп рд╡рд╛рдЯрддреЗ рддреЗ рд╕рд╛рдВрдЧрддрд╛рдд.

рджреЛрди рдирд┐рдпрдо рд╢рд╛рд│рд╛ рдиреАрдЯрдиреЗрдЯрдХреА рдареЗрд╡рддрд╛рдд:

  1. рдХрддрд░рд┐рдирд╛ "рдЭрд╛рд▓реЗ" рдореНрд╣рдгрддреЗ (PR рдмрдВрдж рдХрд┐рдВрд╡рд╛ merge рд╣реЛрддреЛ) рддреЗрд╡реНрд╣рд╛ рдЪрд╛рдЪрдгреА рдЦреЛрд▓реА рд░рд┐рдХрд╛рдореА рдХреЗрд▓реА рдЬрд╛рддреЗ.
  2. 48 рддрд╛рд╕ рдХреЛрдгреАрдЪ рдЪрд╛рдЪрдгреА рдЦреЛрд▓реАрд▓рд╛ рд╣рд╛рдд рд▓рд╛рд╡рд▓рд╛ рдирд╛рд╣реА рддрд░ рддреА рддрд░реАрд╣реА рд░рд┐рдХрд╛рдореА рдХреЗрд▓реА рдЬрд╛рддреЗ. тП░ рдкреНрд░рддреНрдпреЗрдХ рд╡реЗрд│реА рдХрддрд░рд┐рдирд╛ рдХрд╛рд╣реА рдмрджрд▓рддреЗ (рдПрдХ push), рддреЗрд╡реНрд╣рд╛ 48 рддрд╛рд╕ рдкреБрдиреНрд╣рд╛ рд╕реБрд░реВ рд╣реЛрддрд╛рдд.

рдЖрдгрд┐ рдЪрд╛рдЪрдгреА рдЦреЛрд▓реНрдпрд╛ рдлрдХреНрдд 3 рдЖрд╣реЗрдд. рддрд┐рдиреНрд╣реА рд╡рд╛рдкрд░рд╛рдд рдЕрд╕рддреАрд▓ рддрд░ рдкреБрдврдЪреНрдпрд╛ рд╢рд┐рдХреНрд╖рд┐рдХреЗрд▓рд╛ рд╕рд╛рдВрдЧрд┐рддрд▓реЗ рдЬрд╛рддреЗ "рдХреЛрдгрддреАрдЪ рдЦреЛрд▓реА рдореЛрдХрд│реА рдирд╛рд╣реА тАФ рдПрдЦрд╛рджреА рдмрдВрдж рдЭрд╛рд▓реА рдХреА рдкреБрдиреНрд╣рд╛ рдкреНрд░рдпрддреНрди рдХрд░рд╛."

рдирд┐рдпрдо 2 рдирд╕рддрд╛ рддрд░ рдХреЛрдгрд╛рд▓рд╛рдЪ рдЖрдард╡рдд рдирд╕рд▓реЗрд▓реНрдпрд╛ рдЪрд╛рдЪрдгреА рдЦреЛрд▓реНрдпрд╛ рдХрд╛рдпрдордЪреНрдпрд╛ рддрд╢рд╛рдЪ рдорд╛рдВрдбрд▓реЗрд▓реНрдпрд╛ рд░рд╛рд╣рд┐рд▓реНрдпрд╛ рдЕрд╕рддреНрдпрд╛, рдЖрдгрд┐ рд╢рд╛рд│реЗрдиреЗ рд╡рд░реНрд╖рднрд░ рддреНрдпрд╛рдВрдЪреНрдпрд╛ рджрд┐рд╡реНрдпрд╛рдВрдЪреЗ рдмрд┐рд▓ рднрд░рд▓реЗ рдЕрд╕рддреЗ.

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

flowchart LR
    o["ЁЯФА PR opened"] --> cap{"ЁЯзк fewer than 3 live?"}
    cap -->|"no"| rf["тЭМ refused тАФ retry<br/>when one closes"]
    cap -->|"yes"| env["ЁЯМР pr-101.preview.school.test"]
    env -->|"push: TTL restarts"| env
    env -->|"PR closed"| del["ЁЯз╣ deleted"]
    env -->|"48 h with no push"| exp["тП░ expired"]
    sum["one week: 223 env-hours with cleanup<br/>674 if nobody deleted them"]

ЁЯЧ║я╕П рдХрд╛рдврд▓реЗрд▓реА рдЖрдХреГрддреА + рдПрдХ lab: https://school-edh.pages.dev/platform-engineering/lesson-diagrams.html#l06

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

ЁЯдФ рдХрд╛

рдХрд╛рд░рдг рдПрдХрдЪ рд╕рд╛рдорд╛рдпрд┐рдХ "staging" environment рд╕реНрд╡рддрдГрдЪ рдПрдХ рд░рд╛рдВрдЧ рдмрдирддреЗ: рдПрдХрд╛ team рдЪрд╛ рдЕрд░реНрдзрд╡рдЯ рдмрджрд▓ рд╕рдЧрд│реНрдпрд╛рдВрдЪреЗ testing рдЕрдбрд╡рддреЛ. Per-PR environments рддреА рд╡рд╛рдЯ рдХрд╛рдвреВрди рдЯрд╛рдХрддрд╛рдд рдЖрдгрд┐ reviewers рдирд╛ рдЦрд░рд╛ рдмрджрд▓ рдкрд╛рд╣реВ рджреЗрддрд╛рдд. рдкрдг рдорд╛рдЧрдгреАрдиреБрд╕рд╛рд░ рдмрдирд╡рд▓реЗрд▓реЗ environments рд╡рд┐рд╕рд░рдгреЗ рд╕реЛрдкреЗ рдЕрд╕рддреЗ, рдЖрдгрд┐ рд╡рд┐рд╕рд░рд▓реЗрд▓реА environments рджрд░ рддрд╛рд╕рд╛рд▓рд╛ рдкреИрд╕реЗ рдЦрд╛рддрд╛рдд. рд╕рд╛рдлрд╕рдлрд╛рдИрдЪреЗ рдирд┐рдпрдордЪ рдорд╛рдЧрдгреАрдиреБрд╕рд╛рд░ environments рдкрд░рд╡рдбрдгрд╛рд░реА рдмрдирд╡рддрд╛рдд.

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

idp/selfserve.py рдордзрд▓реЗ PreviewEnvs(ttl_h, max_live) рддреАрди events рд╣рд╛рддрд╛рд│рддреЗ: open (cap рдЧрд╛рдард▓рд╛ рдирд╕реЗрд▓ рддрд░ рдмрдирд╡рд╛), push (TTL рдкреБрдиреНрд╣рд╛ рд╕реБрд░реВ рдХрд░рд╛) рдЖрдгрд┐ close (рд╣рдЯрд╡рд╛). рдкреНрд░рддреНрдпреЗрдХ event рдЖрдзреА рдЬреНрдпрд╛рдВрдЪреА рд╢реЗрд╡рдЯрдЪреА рд╣рд╛рд▓рдЪрд╛рд▓ TTL рдкреЗрдХреНрд╖рд╛ рдЬреБрдиреА рдЖрд╣реЗ рдЕрд╢реА environments рддреЗ expire рдХрд░рддреЗ. finish(end) рдЙрд░рд▓реЗрд▓реА expire рдХрд░рддреЗ рдЖрдгрд┐ рдПрдХреВрдг environment-hours рдкрд░рдд рджреЗрддреЗ. env_hours_without_cleanup() рд╣реА "рдХреЛрдгреАрдЪ рдХрд╛рд╣реАрдЪ рдХрдзреА рд╣рдЯрд╡рдд рдирд╛рд╣реА" рдЕрд╢реА рддреБрд▓рдирд╛ рдЖрд╣реЗ: рдмрдирд╡рд▓реЗрд▓реЗ рдкреНрд░рддреНрдпреЗрдХ environment рдЖрдард╡рдбреНрдпрд╛рдЪреНрдпрд╛ рд╢реЗрд╡рдЯрдкрд░реНрдпрдВрдд рдЪрд╛рд▓рддреЗ. idp/demo.py рдордзрд▓реЗ previews() 6 PRs рдЪрд╛ рдПрдХ рдЖрдард╡рдбрд╛ (168 рддрд╛рд╕) рдЦреЗрд│реВрди рджрд╛рдЦрд╡рддреЗ.

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

python3 idp/demo.py previews
python3 - <<'EOF'
import sys; sys.path.insert(0, "idp"); from selfserve import PreviewEnvs
events = [(1, "open", 101), (5, "open", 102), (10, "open", 103), (20, "close", 101), (30, "open", 104), (40, "push", 103),
          (41, "open", 105), (70, "close", 103), (120, "open", 106)]
for ttl, cap in ((24, 3), (48, 3), (48, 5), (168, 5)):
    pe = PreviewEnvs(ttl_h=ttl, max_live=cap)
    for t, a, pr in events: pe.event(t, a, pr)
    hours = pe.finish(168)
    refused = [m for _, m in pe.log if "refused" in m]
    print(f"TTL {ttl:>3} h, cap {cap} тЖТ {len(pe.created)} made ┬╖ {hours:>3} env-hours ┬╖ refused {len(refused)}")
EOF

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

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

   h40   pr-103 updated, TTL restarts
   h41   pr-105 refused тАФ 3 previews already live; retry when one closes
   h53   pr-102 expired (TTL 48 h)
   h70   pr-103 deleted (PR closed)
   h78   pr-104 expired (TTL 48 h)
тФАтФА after one week (168 h): 5 previews made ┬╖ 223 environment-hours with cleanup ┬╖ 674 if nobody ever deleted them

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

TTL  24 h, cap 3 тЖТ 6 made ┬╖ 139 env-hours ┬╖ refused 0
TTL  48 h, cap 3 тЖТ 5 made ┬╖ 223 env-hours ┬╖ refused 1
TTL  48 h, cap 5 тЖТ 6 made ┬╖ 271 env-hours ┬╖ refused 0
TTL 168 h, cap 5 тЖТ 6 made ┬╖ 555 env-hours ┬╖ refused 0

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

рд╕рд╛рдлрд╕рдлрд╛рдИрдиреЗ рддреНрдпрд╛рдЪ PRs рд╕рд╣ рдПрдХрд╛ рдЖрдард╡рдбреНрдпрд╛рдЪреЗ 674 environment-hours 223 рд╡рд░ рдЖрдгрд▓реЗ тАФ рд╕реБрдорд╛рд░реЗ рдПрдХ рддреГрддреАрдпрд╛рдВрд╢. pr-103 рд╡рд░ рддрд╛рд╕ 40 рд▓рд╛ push рдЭрд╛рд▓рд╛, рдореНрд╣рдгреВрди рддреЛ рддрд╛рд╕ 58 рд▓рд╛ expire рд╣реЛрдгреНрдпрд╛рдРрд╡рдЬреА рддрд╛рд╕ 70 рд▓рд╛ рдмрдВрдж рд╣реЛрдИрдкрд░реНрдпрдВрдд рдЯрд┐рдХрд▓рд╛. рд▓рд╣рд╛рди TTL (24 h) рдЖрдгрдЦреА рд╕реНрд╡рд╕реНрдд рдЖрд╣реЗ (139) рдЖрдгрд┐ рддреНрдпрд╛рдиреЗ рдирдХрд╛рд░рд╣реА рдЯрд╛рд│рд▓рд╛, рдХрд╛рд░рдг рдЦреЛрд▓реНрдпрд╛ рд▓рд╡рдХрд░ рдореЛрдХрд│реНрдпрд╛ рдЭрд╛рд▓реНрдпрд╛ тАФ рдкрдг рддреЛ reviewer рджреБрд╕рд▒реНрдпрд╛ рджрд┐рд╡рд╢реА рд╕рдХрд╛рд│реА рдЙрдШрдбреВ рд╢рдХреЗрд▓ рдЕрд╕реЗ environment рд╕реБрджреНрдзрд╛ рд╣рдЯрд╡рддреЛ. рдХрдбрдХ cap рд╢рд┐рд╡рд╛рдп рд▓рд╛рдВрдм TTL (168 h) 555 рдЦрд░реНрдЪ рдХрд░рддреЛ. TTL рдЖрдгрд┐ cap рд╣реЗ product рдирд┐рд░реНрдгрдп рдЖрд╣реЗрдд: рд╕реЛрдпреАрдЪреНрдпрд╛ рд╡рд┐рд░реБрджреНрдз рдЦрд░реНрдЪ.

ЁЯУП рд╢рд┐рдХрд╡рдгреНрдпрд╛рдЪреЗ рдореЙрдбреЗрд▓: рдЦрд░реА environments рдмрдирд╛рдпрд▓рд╛ рдХрд╛рд╣реА рдорд┐рдирд┐рдЯреЗ рд▓рд╛рдЧрддрд╛рдд рдЖрдгрд┐ рддреНрдпрд╛рдВрдЪрд╛ рдПрдХ рдард░рд▓реЗрд▓рд╛ рдЦрд░реНрдЪ рдЕрд╕рддреЛ (databases, load balancers). Environment-hours рд╣рд╛ рдЦрд░реНрдЪрд╛рдЪрд╛ рдвреЛрдмрд│ рд╕рдВрдХреЗрдд рдЖрд╣реЗ, bill рдирд╛рд╣реА.

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

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

Argo CD ApplicationSets рдордзреНрдпреЗ рдПрдХ Pull Request generator рдЕрд╕рддреЛ: рддреЛ Git host рдХрдбреВрди open PRs рд╡рд┐рдЪрд╛рд░рддреЛ рдЖрдгрд┐ рдкреНрд░рддреНрдпреЗрдХ PR рд╕рд╛рдареА рдПрдХ Argo CD Application рдмрдирд╡рддреЛ. рдЦрд▒реНрдпрд╛ cluster рд╡рд░:

apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
  name: exam-results-previews
  namespace: argocd
spec:
  goTemplate: true
  generators:
    - pullRequest:
        github:
          owner: school
          repo: exam-results
          labels: [preview]          # only PRs with this label get an environment
        requeueAfterSeconds: 300     # how often to ask GitHub for open PRs
  template:
    metadata:
      name: 'exam-results-pr-{{.number}}'
    spec:
      project: default
      source:
        repoURL: https://github.com/school/exam-results.git
        targetRevision: '{{.head_sha}}'
        path: deploy
      destination:
        server: https://kubernetes.default.svc
        namespace: 'preview-pr-{{.number}}'
      syncPolicy:
        automated: {prune: true}
        syncOptions: [CreateNamespace=true]

PR рдмрдВрдж рдЭрд╛рд▓рд╛ рдХреА generator рддреНрдпрд╛рд▓рд╛ рдпрд╛рджреАрдд рджрд╛рдЦрд╡рдд рдирд╛рд╣реА, рдЖрдгрд┐ ApplicationSet рддреЗ Application рдХрд╛рдвреВрди рдЯрд╛рдХрддреЛ. CreateNamespace=true рдиреЗ рдмрдирд╡рд▓реЗрд▓реЗ namespace рддреНрдпрд╛рдЪреНрдпрд╛рдмрд░реЛрдмрд░ рд╣рдЯрд╡рд▓реЗ рдЬрд╛рдд рдирд╛рд╣реА тАФ рддреЗ рд╡реЗрдЧрд│реЗ рд╕рд╛рдл рдХрд░рд╛, рдХрд┐рдВрд╡рд╛ Namespace chart рдордзреНрдпреЗ рдареЗрд╡рд╛. Argo CD рд▓рд╛ рд╕реНрд╡рддрдГрдЪрд╛ TTL рдирд╛рд╣реА; kube-janitor рд╣реЗ рдПрдХ open-source tool рдЖрд╣реЗ рдЬреЗ janitor/ttl: "48h" рд╕рд╛рд░рдЦреА annotation рдЕрд╕рд▓реЗрд▓реЗ resources рд╣рдЯрд╡рддреЗ.

ЁЯПн рдкреНрд░рддреНрдпрдХреНрд╖ рд╡рд╛рдкрд░рд╛рдд рд╣реЗ рдХрд╛ рдорд╣рддреНрддреНрд╡рд╛рдЪреЗ: cost report рд╡рд░ previews рдЪреА рд╕реНрд╡рддрдГрдЪреА рд╡реЗрдЧрд│реА рдУрд│ рдареЗрд╡рд╛. рддреЛ рдЖрдХрдбрд╛ PRs рдЪреНрдпрд╛ рд╕рдВрдЦреНрдпреЗрдкреЗрдХреНрд╖рд╛ рдЬрд╛рд╕реНрдд рд╡реЗрдЧрд╛рдиреЗ рд╡рд╛рдврдд рдЕрд╕реЗрд▓ рддрд░ рд╕рд╛рдлрд╕рдлрд╛рдИрдд рдЧрд│рддреА рдЖрд╣реЗ.

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

рдкреНрд░рддреНрдпреЗрдХ PR build, test рдЖрдгрд┐ deploy рд╣реЛрддреЛ. рдкреБрдвреЗ: рддреА pipeline рд╢рдВрднрд░ copy рдХреЗрд▓реЗрд▓реНрдпрд╛ YAML files рдРрд╡рдЬреА рдПрдХ рд╕рд╛рдорд╛рдпрд┐рдХ, version рдЕрд╕рд▓реЗрд▓рд╛ рд░рд╕реНрддрд╛ рдмрдирд╡рдгреЗ.

git checkout lesson-07-paved-pipelines

ЁЯзк Lesson 06 тАФ Environments on demand: a trial classroom per change

ЁЯУН You are here: Lesson 06 of 12 ┬╖ Previous: lesson-05-self-service-infra ┬╖ Next: lesson-07-paved-pipelines


ЁЯУж What's in this branch

Lessons 01тАУ05, plus preview environments: every pull request gets its own short-lived copy of the service, at its own address, so reviewers can click through the change before it is merged. The environment is deleted when the PR closes, or when its time-to-live (TTL) runs out, and a cap limits how many run at once. PreviewEnvs in idp/selfserve.py, previews() in idp/demo.py.

ЁЯзТ Explain like I'm 5

Before Katrina changes the science lab for real, she wants to try the new layout. The facilities office lends her a trial classroom ЁЯзк with a sign on the door: "pr-101 тАФ trial layout". Other teachers walk in and say what they think.

Two rules keep the school tidy:

  1. When Katrina says "done" (the PR is closed or merged), the trial room is cleared.
  2. If nobody touches the trial room for 48 hours, it is cleared anyway. тП░ Every time Katrina changes something (a push), the 48 hours start again.

And there are only 3 trial rooms. When all three are in use, the next teacher is told "no room free тАФ try again when one closes."

Without rule 2, trial rooms nobody remembered would stay set up forever, and the school would pay for their lights all year.

ЁЯЧ║я╕П Diagram

flowchart LR
    o["ЁЯФА PR opened"] --> cap{"ЁЯзк fewer than 3 live?"}
    cap -->|"no"| rf["тЭМ refused тАФ retry<br/>when one closes"]
    cap -->|"yes"| env["ЁЯМР pr-101.preview.school.test"]
    env -->|"push: TTL restarts"| env
    env -->|"PR closed"| del["ЁЯз╣ deleted"]
    env -->|"48 h with no push"| exp["тП░ expired"]
    sum["one week: 223 env-hours with cleanup<br/>674 if nobody deleted them"]

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

тЭУ What

ЁЯдФ Why

Because a shared "staging" environment becomes a queue of its own: one team's half-done change blocks everyone's testing. Per-PR environments remove that wait and let reviewers see the real change. But created-on-demand environments are easy to forget, and forgotten environments cost money every hour. The cleanup rules are what make on-demand environments affordable.

ЁЯФз How (in this repo)

PreviewEnvs(ttl_h, max_live) in idp/selfserve.py handles three events: open (create, unless the cap is reached), push (restart the TTL) and close (delete). Before each event it expires environments whose last activity is older than the TTL. finish(end) expires the rest and returns total environment-hours. env_hours_without_cleanup() is the "nobody ever deletes anything" comparison: every created environment runs to the end of the week. previews() in idp/demo.py plays one week (168 hours) of 6 PRs.

ЁЯзк Try it

python3 idp/demo.py previews
python3 - <<'EOF'
import sys; sys.path.insert(0, "idp"); from selfserve import PreviewEnvs
events = [(1, "open", 101), (5, "open", 102), (10, "open", 103), (20, "close", 101), (30, "open", 104), (40, "push", 103),
          (41, "open", 105), (70, "close", 103), (120, "open", 106)]
for ttl, cap in ((24, 3), (48, 3), (48, 5), (168, 5)):
    pe = PreviewEnvs(ttl_h=ttl, max_live=cap)
    for t, a, pr in events: pe.event(t, a, pr)
    hours = pe.finish(168)
    refused = [m for _, m in pe.log if "refused" in m]
    print(f"TTL {ttl:>3} h, cap {cap} тЖТ {len(pe.created)} made ┬╖ {hours:>3} env-hours ┬╖ refused {len(refused)}")
EOF

тЬЕ Verify тАФ what you should see

previews prints:

   h40   pr-103 updated, TTL restarts
   h41   pr-105 refused тАФ 3 previews already live; retry when one closes
   h53   pr-102 expired (TTL 48 h)
   h70   pr-103 deleted (PR closed)
   h78   pr-104 expired (TTL 48 h)
тФАтФА after one week (168 h): 5 previews made ┬╖ 223 environment-hours with cleanup ┬╖ 674 if nobody ever deleted them

Your snippet prints:

TTL  24 h, cap 3 тЖТ 6 made ┬╖ 139 env-hours ┬╖ refused 0
TTL  48 h, cap 3 тЖТ 5 made ┬╖ 223 env-hours ┬╖ refused 1
TTL  48 h, cap 5 тЖТ 6 made ┬╖ 271 env-hours ┬╖ refused 0
TTL 168 h, cap 5 тЖТ 6 made ┬╖ 555 env-hours ┬╖ refused 0

ЁЯПБ What you just proved

Cleanup cut a week from 674 to 223 environment-hours тАФ about a third тАФ with the same PRs. pr-103 was pushed at hour 40, so it lived to its close at hour 70 instead of expiring at hour 58. A shorter TTL (24 h) is cheaper still (139) and even avoided the refusal, because rooms freed up sooner тАФ but it also deletes an environment a reviewer might open the next morning. A long TTL (168 h) without a tight cap costs 555. TTL and cap are product decisions: cost against convenience.

ЁЯУП Teaching model: real environments take minutes to create and have a fixed cost part (databases, load balancers). Environment-hours are a rough cost signal, not a bill.

тЪая╕П Common mistakes

ЁЯПн In production

Argo CD ApplicationSets have a Pull Request generator: it asks the Git host for open PRs and makes one Argo CD Application per PR. On a real cluster:

apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
  name: exam-results-previews
  namespace: argocd
spec:
  goTemplate: true
  generators:
    - pullRequest:
        github:
          owner: school
          repo: exam-results
          labels: [preview]          # only PRs with this label get an environment
        requeueAfterSeconds: 300     # how often to ask GitHub for open PRs
  template:
    metadata:
      name: 'exam-results-pr-{{.number}}'
    spec:
      project: default
      source:
        repoURL: https://github.com/school/exam-results.git
        targetRevision: '{{.head_sha}}'
        path: deploy
      destination:
        server: https://kubernetes.default.svc
        namespace: 'preview-pr-{{.number}}'
      syncPolicy:
        automated: {prune: true}
        syncOptions: [CreateNamespace=true]

When the PR closes, the generator no longer lists it, and the ApplicationSet removes that Application. A namespace created by CreateNamespace=true is not deleted with it тАФ clean it up separately, or put the Namespace in the chart. Argo CD has no TTL of its own; kube-janitor is one open-source tool that deletes resources with an annotation such as janitor/ttl: "48h".

ЁЯПн Why this matters in production: put previews on the cost report as their own line. If the number grows faster than the number of PRs, cleanup is leaking.

тПня╕П Next

Every PR builds, tests and deploys. Next: making that pipeline a shared, versioned road instead of a hundred copied YAML files.

git checkout lesson-07-paved-pipelines
тЖР Previousself service infraNext тЖТpaved pipelines

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