# PROCURE — Resume & Interview Portfolio

## User-confirmed context — 2026-10-18

Professional context: this work was performed in my current Consultant Data Analyst role at ALTEN, September 2025–present.

> Source: direct user answers in the portfolio review. These are user-confirmed statements, not independently rerun repository or production measurements.

- **Ownership:** platform/DevOps work focused on migrating CI/CD and supporting infrastructure from GitHub to GitLab.
- **Deployment scope:** personally managed QA deployments only, with no downtime observed. No production deployments or production cutover are attributed to the user.
- **Application boundary:** the existing Django application was built by another team. Application and production architecture below are project context, not personal application authorship or production-release ownership.
- **Unmeasured:** QA deployment count, deployment/rollback duration, CI savings and vulnerability counts.

> Unsupported outcome clauses have been removed from the resume bullets. Repository-derived technical details are retained; the user-confirmed scope below governs personal attribution.


> **Scope of my contribution (read this first).** PROCURE is an existing Django application
> built by another team (QuickRelease). **My role was Platform / DevOps:** I migrated the
> project's source control, CI/CD and container supply chain from **GitHub (Actions + GHCR)**
> to **self-hosted GitLab (GitLab CI + GitLab Registry + AWS ECR)**, updated the **Terraform**
> infrastructure for the new pipeline, and I managed **QA deployments only, with no downtime observed**.
> I did not perform production deployments or the production cutover.
>
> Everything below that describes the application itself (models, Celery, views) is context
> I need to *operate and deploy* it — not code I claim to have written. The resume bullets
> in Section 5 are written to stay truthful to that boundary. Before using any bullet,
> delete anything you did not personally do.

---

## 1. High-Level Project Overview

**One-Line Pitch**
PROCURE (formerly SECHEL) is an internal Django web platform that tracks automotive
part-procurement (purchase-order lines, missing parts, supplier delivery, impact reports)
for ~150 Alten & Stellantis/PSA users across multiple assembly plants, running on AWS ECS
Fargate in two isolated AWS accounts (QA and prod).

**Problem & Solution (from my angle)**

| | |
|---|---|
| **Problem** | The project was built, hosted and released from a contractor's GitHub organisation: CI relied on 8 GitHub Actions workflows (several of them *reusable workflows owned by the contractor's org*), private Python packages were fetched over SSH with a long-lived deploy key, and prod pulled its images from the contractor's GHCR using credentials stored in Secrets Manager. Alten could not build, release or roll back prod without the contractor. |
| **Solution** | I re-platformed the delivery chain onto Alten's GitLab: a modular GitLab CI pipeline (11 YAML files, 4 stages), keyless AWS access via **GitLab OIDC → STS `AssumeRoleWithWebIdentity`**, images published to **per-account ECR** repositories provisioned by a new Terraform module, **digest-pinned** QA deployments, built-in **rollback tags**. My deployment work was limited to QA; the production cutover material below is project context, not a personal delivery claim. |

---

## 2. Comprehensive Tech Stack & Tooling

### Languages & Runtimes
- **Python 3.12** (app), **HCL** (Terraform), **POSIX shell** (CI scripts, entrypoints), **YAML** (GitLab CI), **Nginx config**.

### Frontend / UI (server-rendered — context, not my work)
- Django templates (**134** HTML templates), **django-bootstrap3**, **django-crispy-forms** + crispy-bootstrap3, **django-select2**, **django-sekizai**.
- i18n: Django `locale/fr`, custom `en` / `en_GB` / `fr` format modules, **Babel**.

### Backend / API (context)
- **Django 5.2**, **Gunicorn**, server-rendered views + AJAX endpoints (no REST/GraphQL framework).
- **Celery 5.4** with **django-celery-beat** (DB scheduler) and **django-celery-results**; **Flower** (proxied behind `staff_member_required`).
- **django-guardian** (object-level permissions), **django-simple-history** (audit trail), **django-import-export**, **django-async-downloads**, **django-health-check** (internal fork on GitLab).
- Data processing: **pandas**, **numpy**, **pandera** (DataFrame schema validation), **openpyxl**.
- Observability: **Sentry SDK** (Django, Celery, Redis integrations, custom `traces_sampler`).

### Database & Storage
- **MySQL 8.0** on **Amazon RDS** (`db.m6g.2xlarge`, encrypted, 7-day backups), **django_mysql**.
- **Redis 6.x** on **ElastiCache** (Celery broker + Django cache/session backend via **django-redis**).
- **Amazon S3** via **django-storages[boto3]** for media; **WhiteNoise** / Nginx for static.

### DevOps, Testing & Tooling — **my area**
| Area | Tools (as pinned in the repo) |
|---|---|
| Source control & CI | **GitLab CI** (self-hosted), modular `include:` pipeline, `workflow: rules`, `needs:` DAG, `rules: changes`, pipeline **schedules**, `resource_group`, `id_tokens` (OIDC) |
| Legacy CI (migrated from) | GitHub Actions (8 workflows), Release Drafter, reusable org workflows, `webfactory/ssh-agent` |
| Containers | **Docker 24** + **docker:dind** with TLS, **BuildKit** (`--secret`), multi-stage Dockerfile (`build-python` → `web` → `nginx`), `docker buildx imagetools` |
| Registries | **GitLab Container Registry**, **AWS ECR** (scan-on-push, lifecycle policy), GHCR (decommissioned for prod) |
| Cloud | AWS **ECS Fargate**, **ALB** (TLS 1.2 policy, HTTP→HTTPS redirect), **ACM**, **RDS**, **ElastiCache**, **S3**, **SES**, **Secrets Manager**, **SSM Parameter Store**, **Cloud Map** service discovery, **CloudWatch Logs**, **VPC Flow Logs**, **IAM / STS**, AWS SSO |
| IaC | **Terraform** (AWS provider `~> 3.27`), **S3 remote state with `use_lockfile`**, cross-account `assume_role`, `allowed_account_ids` guard rails |
| IaC quality gates | **TFLint v0.60.0** + `tflint-ruleset-aws 0.43.0`, **Trivy v0.69.3** (IaC misconfig scanning), **terraform-docs v0.24.0**, `terraform_fmt` |
| Supply-chain security | **pip-audit 2.9.0** (MR-triggered + weekly schedule), ECR image scanning, `third_party_license_file_generator` (licence manifest baked into the image) |
| Code quality | **pre-commit**, **Black 24.4.2**, **isort 5.13.2**, flake8 config, Conventional-Commits enforcement |
| Testing | Django test runner in a dedicated `Dockerfile.test` image, **coverage.py** (coverage regex parsed by GitLab), **model_bakery**, **freezegun** |
| Other | `docker-compose` local stack, Procfile (legacy Heroku), PlantUML C4 container diagrams, ADRs |

---

## 3. Architecture & Technical Highlights

### 3.1 Keyless CI → AWS authentication with GitLab OIDC *(my work)*
**Files:** [.gitlab/ci/build/dev.yml](.gitlab/ci/build/dev.yml), [.gitlab/ci/build/prod.yml](.gitlab/ci/build/prod.yml), [.gitlab/ci/deploy/dev.yml](.gitlab/ci/deploy/dev.yml)

- Each build job requests a GitLab **`id_tokens: AWS_ID_TOKEN`** (audience = the GitLab instance) and exchanges it via `sts assume-role-with-web-identity` for **1-hour (`--duration-seconds 3600`)** credentials scoped to a per-environment role (`AWS_ROLE_ARN_DEV` / `AWS_ROLE_ARN_PROD`).
- Session names embed the pipeline ID (`gitlab-procure-prod-${CI_PIPELINE_ID}`), so every AWS action is traceable in CloudTrail back to a specific pipeline.
- **No static AWS keys exist in CI variables.** Dev and prod are separate AWS accounts with separate roles, so a QA pipeline physically cannot push to prod ECR.
- Prod credentials are only minted on `vX.Y.Z` tag pipelines (`rules: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+/`).

### 3.2 Secret-safe Docker builds for private dependencies *(my work)*
**Files:** [docker/production/Dockerfile](docker/production/Dockerfile), [Dockerfile.test](Dockerfile.test), [requirements/base.txt](requirements/base.txt), [docker-compose.yml](docker-compose.yml)

- The GitHub setup loaded a long-lived **SSH private key** into an ssh-agent to install private packages. I replaced this with a **BuildKit secret mount** (`RUN --mount=type=secret,id=netrc,...`) fed from an ephemeral `.netrc` built from GitLab's per-job **`CI_JOB_TOKEN`**, then deleted in `after_script`.
- The token never lands in an image layer, and it expires with the job. Private deps (`qr-utils`, `django-health-check`, `entrypoint-scripts`) are now pinned to tags on the internal GitLab.
- Multi-stage build: dependencies compiled in `build-python`, copied into `web`; the `nginx` target reuses cached `web` layers and copies only `staticfiles`. Build metadata (`RELEASE_VERSION` → `VERSION` file + `SENTRY_RELEASE`, OCI `revision` label) links every running container to a commit and a Sentry release.

### 3.3 Registry migration to per-account ECR, with rollback built into the pipeline *(my work)*
**Files:** [terraform/modules/ecr/main.tf](terraform/modules/ecr/main.tf), [.gitlab/ci/build/dev.yml](.gitlab/ci/build/dev.yml), [terraform/modules/ecs/app/main.tf](terraform/modules/ecs/app/main.tf)

- New Terraform `ecr` module: `procure-web` + `procure-nginx` repos, **`scan_on_push = true`**, lifecycle policy expiring **untagged images after 7 days**. Trivy findings are suppressed **with written justification** (e.g. `MUTABLE` is required because `build:dev` overwrites `:dev`).
- Images are dual-published to the **GitLab registry** and **ECR** (ECS pulls from ECR in its own account; `github_registry_secrets_arn = null` removes the GHCR pull-secret path).
- A `rotate_tag()` shell function in `build:dev` keeps **2 rollback points** (`:dev → :dev-prev → :dev-prev2`) in **both** registries before pushing a new `:dev`, so QA rollback is a one-line tfvars change.

### 3.4 Digest-pinned deployments for mutable tags *(my Terraform change)*
**Files:** [terraform/modules/ecs/app/main.tf](terraform/modules/ecs/app/main.tf), [terraform/modules/ecs/variables.tf](terraform/modules/ecs/variables.tf), [terraform/environments/qa/main.tf](terraform/environments/qa/main.tf)

- Problem: QA always deploys tag `:dev`. With tag references, Terraform sees **no diff** after a new push, so nothing redeploys.
- Fix: new `use_ecr_digest` flag. When on, `data "aws_ecr_image"` resolves the tag to its **sha256 digest**, and task definitions reference `image@sha256:...`. A new push now produces a real plan diff → new task-definition revisions → service redeploy → the `null_resource` migration trigger re-fires.
- Prod keeps `use_ecr_digest = false` because it uses **immutable semver tags** (`v2.31.0`), keeping prod plans readable and explicit.

### 3.5 Multi-service ECS Fargate topology driven by Celery queues *(QA infrastructure work; production topology is context)*
**Files:** [terraform/modules/ecs/app/main.tf](terraform/modules/ecs/app/main.tf), [terraform/modules/ecs/app/worker/main.tf](terraform/modules/ecs/app/worker/main.tf), [terraform/environments/prod/terraform.auto.tfvars](terraform/environments/prod/terraform.auto.tfvars), [docs/domain/imports/queues.md](docs/domain/imports/queues.md)

- One image, many roles: `web+nginx` (sidecar task behind the ALB), `beat`, `flower`, and **one Fargate service per Celery queue** generated with `for_each = var.workers` (`default`, `import`, `export`, `import_mp`, `import_impact`), each sized independently (up to **4 vCPU / 16 GB** for import/export).
- Migrations run as a **one-off Fargate task** (`task-migrate`) triggered by a `null_resource` keyed on the web task-definition ARN; all services `depends_on` it so new code never starts against an un-migrated schema.
- Flower is reachable only through **Cloud Map private DNS** (`flower.<env>`, TTL 10s) and proxied by Django behind `staff_member_required`.
- Secrets reach containers only via ECS `secrets.valueFrom` (Secrets Manager / SSM SecureString), with a least-scope IAM policy that `jsonencode`s the exact ARNs.

### 3.6 Shift-left quality & security gates in GitLab CI *(my work)*
**Files:** [.gitlab-ci.yml](.gitlab-ci.yml), [.gitlab/ci/checks/](.gitlab/ci/checks/), [.gitlab/ci/build/mr.yml](.gitlab/ci/build/mr.yml), [.pre-commit-config.yaml](.pre-commit-config.yaml), [.tflint.hcl](.tflint.hcl)

- `workflow: rules` allow-lists only 4 pipeline sources (MR, push to default branch, semver tag, schedule) — no duplicate branch+MR pipelines.
- MR gates: Black, isort, `terraform fmt`, **TFLint** (AWS ruleset), **terraform-docs** drift, **Trivy** IaC scan, Conventional-Commit checks on **every commit and MR title**, Dockerised Django tests with coverage parsing.
- `build:mr` only runs when build-relevant paths change (`rules: changes`), and depends on `pre-commit` via `needs:` so broken code doesn't burn dind minutes.
- `pip-audit` audits the **full transitive dependency tree** inside an isolated venv, on dependency MRs **and** on a weekly schedule (`SCHEDULED_JOB=pip-audit`) to catch newly published CVEs.
- `auto-label` replaces GitHub's Release-Drafter autolabeler by parsing the Conventional-Commit type and calling the **GitLab REST API** to set MR labels (`feat` ⇒ `minor`).
- Drafted CD jobs (`migrate:dev`, `deploy:dev`) use `resource_group: deploy-dev` for mutual exclusion, run migrations via `aws ecs run-task`, wait with `aws ecs wait tasks-stopped`, and **fail the pipeline on non-zero container exit code**. They are intentionally **not enabled** yet (see 6.3).

**Application-side highlights I operate (not authored):** object-level RBAC via `django-guardian`; `StrictSessionMiddleware` and `ForcePasswordReset` middleware ([sechel/middleware.py](sechel/middleware.py)); `CeleryConfig` wrapper that hides non-Celery settings from Flower ([sechel/celery.py](sechel/celery.py)); DB-lock strategy for concurrent programme imports ([ADR 0001](docs/decisions/0001_programme_concurrent_imports.md)); `pandera` schema validation of Excel/SAP imports.

---

## 4. Quantifiable Metrics & Scope (from the codebase)

### Delivery platform (my scope)
| Metric | Value |
|---|---|
| GitHub Actions workflows migrated/retired | **8** (incl. 5 reusable org-level workflows) |
| GitLab CI config | **11** YAML files, **~426** lines, **4** stages |
| CI jobs | **9 active** (`pre-commit`, `commit-title-check`, `mr-title-check`, `auto-label`, `pip-audit`, `test`, `build:mr`, `build:dev`, `build:prod`) + **2 drafted** CD jobs |
| Automated gates on every MR | **8** pre-commit hooks + 2 title checks + tests + conditional image build |
| Images built per release | **2** (`procure-web`, `procure-nginx`) pushed to **2** registries |
| QA rollback depth | **2** previous versions (`dev-prev`, `dev-prev2`) |
| CI → AWS credential lifetime | **3,600 s**, zero static keys |
| Dependency audit cadence | every dependency MR + **weekly** (Mon 06:00) |
| Pinned tool versions | Terraform 1.15.8, TFLint v0.60.0, Trivy v0.69.3, terraform-docs v0.24.0, pip-audit 2.9.0, AWS CLI 2.31.9, Docker 24 |

### Infrastructure (Terraform)
| Metric | Value |
|---|---|
| Terraform files / lines | **39** `.tf` files, **~2,770** lines |
| Managed resources | **83** `resource` blocks, **8** `data` sources, **17** module directories |
| Environments | **2** (QA, prod), each in its **own AWS account** with its own S3 state bucket + lockfile |
| ECS Fargate services per env | **8** (web+nginx ×2 tasks, beat, flower, 5 queue workers) + 1 one-off migrate task |
| Worker sizing | import/export **4 vCPU / 16 GB**; import_mp/import_impact **2 vCPU / 8 GB**; default **2 vCPU / 4 GB** |
| Web tier | 2 tasks; QA 1 vCPU / 4 GB, prod 2 vCPU / 4 GB; 60 s health-check grace |
| Data tier | RDS MySQL 8.0 `db.m6g.2xlarge`, encrypted, **7-day** backup retention; ElastiCache Redis 6.x |
| ECR hygiene | untagged images expire after **7 days**; scan on push |
| Documented prod cutover (project context, not my deployment) | `2.30.5` (GitHub/GHCR) → **`v2.31.0`** (GitLab/ECR) |

### Application (context)
| Metric | Value |
|---|---|
| Python | **101** non-migration files, **~25,000** LOC |
| URL patterns | **~166** (`core` 125, `users` 19, `sechel` 17, `milestones` 5) |
| Django models | **~48** models; **60** ForeignKeys, **6** M2M, **2** One-to-One; **272** migrations |
| UI | **134** Django templates |
| Tests | **216** test methods in **90** test classes |
| Async | **~38** Celery tasks, **5** queues, **6** scheduled beat jobs |
| Sentry tracing | 10% sample on the heavy main-table route, 1% elsewhere |
| Users | ~150 across multiple assembly plants (README) |

---

## 5. Ready-to-Use Resume Bullet Points (XYZ format)

> Personal scope is CI/CD and infrastructure migration plus QA deployments. Production details are context; no unmeasured performance or incident-reduction metrics are asserted.

### Variation 1 — Full-Stack / General Software Engineering
1. **Migrated** the CI/CD and supporting infrastructure of an existing Django procurement platform from GitHub to self-hosted GitLab, rebuilding the delivery pipeline as **11 modular YAML files** with lint, test, security and build stages. Personal deployments were to **QA only**.
2. **Managed QA deployments with no downtime observed**. Production releases and the production cutover were outside my deployment scope.
3. **Eliminated** long-lived CI secrets (SSH deploy key, static registry credentials) as measured by **0 static AWS keys** in CI, by implementing GitLab **OIDC → AWS STS** federation and BuildKit secret mounts driven by ephemeral job tokens.
4. **Added** automatic `:dev → :dev-prev → :dev-prev2` image rotation across GitLab Registry and ECR, providing two QA rollback image points selectable through Terraform variables.

### Variation 2 — Backend / Systems Architecture / DevOps
1. **Architected** a keyless, multi-account AWS delivery pipeline in GitLab CI, issuing **1-hour** STS credentials per pipeline via `AssumeRoleWithWebIdentity`, isolating QA and prod into separate accounts and roles so non-release pipelines cannot reach production.
2. **Engineered** a new Terraform ECR module and refactored the ECS module so **8 Fargate services per environment** pull from in-account ECR (scan-on-push, 7-day untagged expiry), removing the runtime dependency on a third-party GHCR registry.
3. **Solved** silent non-deployments of mutable `:dev` tags by introducing **digest-pinned task definitions** (`data.aws_ecr_image` + `use_ecr_digest`), so every new image produces a Terraform diff that redeploys services and re-triggers DB migrations automatically.
4. **Supported the GitHub-to-GitLab CI/CD and infrastructure migration**, with personal release execution limited to QA. The production cutover runbook is retained below as project context.
5. **Hardened** the supply chain with **Trivy** IaC scanning, **TFLint** (AWS ruleset), **pip-audit** across the full transitive dependency tree (per-MR + weekly schedule) and ECR image scanning.
6. **Configured** `workflow:rules` allow-listing, path-filtered `rules:changes` builds and a `needs:` DAG that skips Docker-in-Docker builds when lint fails.

### Variation 3 — Product / Delivery Engineering
> I did not do frontend work on this project, so these bullets focus on **product delivery**
> instead. Don't claim UI work in interviews for this project.
1. **Managed QA delivery** for an existing procurement platform, alongside its CI/CD and infrastructure migration from GitHub to GitLab; no personal production deployments are claimed.
2. **Enforced** Conventional Commits on every commit and MR title and **automated** MR labelling via the GitLab API, enabling semver-driven releases (`feat` ⇒ minor).
3. **Worked with QA deployment and rollback procedures** as part of the migration. Production deployment and DB-restore documentation are project context, not a claim of production execution or colleagues trained.

---

## 6. Interview Prep: Deep-Dive Talking Points

### 6.1 Moving CI from static secrets to federated identity
**Files:** [.gitlab/ci/build/prod.yml](.gitlab/ci/build/prod.yml), [.gitlab/ci/build/dev.yml](.gitlab/ci/build/dev.yml), [docker/production/Dockerfile](docker/production/Dockerfile)

- **Situation:** On GitHub, CI installed private packages with a long-lived SSH key, and prod ECS pulled images from the contractor's GHCR using credentials kept in Secrets Manager. Moving to GitLab meant either copying those secrets over or redesigning access.
- **Task:** Let GitLab build images with private deps and push to two AWS accounts **without storing any long-lived credential**.
- **Action:** Used GitLab `id_tokens` with an audience bound to our GitLab instance, exchanged for STS credentials per environment role (1 h, session name = pipeline ID). Ran the AWS CLI as a pinned container image (`amazon/aws-cli:2.31.9`) instead of installing it in the dind image. For private pip/git deps, replaced the SSH agent with a BuildKit `--secret id=netrc` built from `CI_JOB_TOKEN` and removed in `after_script`. Prod credentials are only minted on semver tag pipelines.
- **Result:** Zero static AWS keys or SSH keys in CI; every AWS call is attributable to a pipeline; secrets never reach image layers. **Trade-off:** each environment needs an IAM OIDC provider + trust policy set up once outside this repo, and job tokens only work while the job runs. That's fine for builds but means local builds need a personal `.netrc`.

### 6.2 Making Terraform notice a new image behind the same tag
**Files:** [terraform/modules/ecs/app/main.tf](terraform/modules/ecs/app/main.tf), [terraform/environments/qa/main.tf](terraform/environments/qa/main.tf), [.gitlab/ci/build/dev.yml](.gitlab/ci/build/dev.yml)

- **Situation:** QA deploys the moving `:dev` tag. Because task definitions referenced `repo:dev`, `terraform plan` showed no changes after a new build, so engineers had to force redeploys by hand, and the migration `null_resource` didn't re-run.
- **Task:** Keep a simple tag-based workflow but make deployments deterministic and auditable.
- **Action:** Added a `use_ecr_digest` variable. When enabled, `data "aws_ecr_image"` resolves the tag to its digest and task definitions use `image@sha256:…`. Because the migrate trigger is keyed on the web task-definition ARN, a new digest automatically re-runs migrations before services roll. Added `dev-prev`/`dev-prev2` rotation in CI so a rollback is `docker_tag = "dev-prev"` + apply. Left prod on immutable semver tags (flag off).
- **Result:** "No diff" now reliably means "no new image" (documented as a check in [docs/devops.md](docs/devops.md)); deployments pin exactly what was tested; rollback depth = 2. **Trade-off:** ECR tags must stay `MUTABLE` (documented Trivy suppression), and digest pinning is ECR-only, which is why it's opt-in per environment.

### 6.3 Production cutover documentation — project context, not my deployment
**Files:** [docs/steps-to-deploy-to-prod.md](docs/steps-to-deploy-to-prod.md), [terraform/modules/ecr/main.tf](terraform/modules/ecr/main.tf), [terraform/environments/prod/main.tf](terraform/environments/prod/main.tf), [.gitlab/ci/deploy/dev.yml](.gitlab/ci/deploy/dev.yml)

- **Situation:** Prod ran `2.30.5`, built and deployed by the contractor from GitHub. The GitLab copy of the code could have drifted, the database was live, and any rollback still depended on the contractor's registry.
- **Task:** Switch prod to GitLab-built images in ECR (`v2.31.0`) with no lost changes, no surprise schema changes and a rollback path that doesn't need GitHub.
- **Documented procedure (not attributed to my execution or authorship):** The project has a 10-step runbook with explicit "touches prod?" flags. It checks code parity (`git cherry -v` against the fetched GitHub tag) and migration diffs (`git diff -- '*/migrations/*'`). It creates the ECR repos alone with `terraform plan -target=module.ecr` (expect exactly 4 adds). It copies the running `2.30.5` images registry-to-registry with `docker buildx imagetools create` and verifies matching digests. Only the `linux/amd64` manifest goes to ECR, because the lifecycle policy would expire the untagged multi-arch children. The runbook also takes an RDS snapshot and waits for it, then applies Terraform with a strict plan-review allow-list (only task definitions, services and the migrate trigger may change). Smoke checks cover `/health` release version, Flower workers, email and Sentry.
- **Project context:** The document describes a production release and rollback path through GitLab; I did not execute production deployments or the cutover. **Trade-off to discuss as architecture context:** deploys stay **manual Terraform applies** on purpose. The drafted `migrate:dev`/`deploy:dev` CI jobs (with `resource_group` locking and exit-code checks) are disabled because force-redeploying outside Terraform would drift from the state-managed task definitions. Full CD is the next step once Terraform runs in CI.

---

### Likely follow-up questions to prepare
- Why `resource_group` instead of a manual approval gate? (Serialises deploys per environment; approvals solve a different problem.)
- How would you move `terraform apply` into GitLab CI? (OIDC role for Terraform, saved `plan` artifact, protected-environment manual job, plan-review policy as code.)
- Why are ECR tags mutable, and how would you make them immutable? (Switch QA to SHA tags + digest pinning, then turn `IMMUTABLE` on.)
- What happens if a migration fails mid-deploy? (The migrate task exits non-zero → services keep the old revision when gated in CI; in the manual flow, step 7.1 catches it → rollback + snapshot restore decision with the PO.)
