# FUSE — 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 contribution: GitHub-to-GitLab migration, Terraform changes and production releases; the existing Django application was not built from scratch by the user.
- **Migration boundary:** the documented GitLab migration covers development; QA/production use the separate release path described below.
- **Production delivery:** personally managed 3 successful production deployments with no downtime observed during those deployments.
- **Unmeasured:** deployment duration, build/deploy improvements, incident-detection improvements and counts of issues or CVEs caught. Do not describe deployment as instantaneous.

> 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 first).** FUSE is a Django task-management platform that I did not
> build from scratch. My work was on the **platform / DevOps side**:
>
> 1. **Migrating the delivery pipeline from GitHub (Actions + GHCR) to a self-hosted GitLab**
>    (`.gitlab-ci.yml`, `.gitlab/ci/**`, `docker-bake.hcl`, `docker/ci/terraform-tools/`).
> 2. **Updating the Terraform infrastructure** to fit GitLab: OIDC federation, CI IAM roles, ECR, the
>    GitLab project as code, drift detection, monitoring, and module refactoring
>    (`terraform/environments/dev/*.tf`, `terraform/gitlab/`, `terraform/modules/main/moved.tf`).
> 3. **Releasing fixes and updates to production** through the Terraform-driven release flow
>    (`terraform/environments/prod/terraform.auto.tfvars`, `docs/devops.md#deploying`).
>
> Everything below is written from that angle. The application layer is described only as context for
> what the pipeline builds, tests and ships.

---

## 1. High-Level Project Overview

**One-line pitch.** FUSE is an internal Django + Celery web platform that ALTEN uses to create, assign,
track and report on engineering tasks across 12 sites for its client Stellantis. It runs on AWS ECS
Fargate behind an ALB/WAF and is provisioned entirely with Terraform. I moved its CI/CD from GitHub to
GitLab for development, updated Terraform, and personally managed **3 successful production deployments with no downtime** through the separate production release path.

**Problem.**
- The code, private libraries (`qromponents`, `notifiqrations`, `qr-utils`, `django-health-check`, `slippers`)
  and CI lived on a vendor's GitHub organisation (QuickRelease). Builds used shared reusable workflows
  (`QuickRelease/.github/...@main`) and pushed mutable `:latest` images to GHCR. Deploying dev meant running
  `aws ecs update-service --force-new-deployment` (`.github/workflows/build-latest.yml`). The deployed
  version was therefore never pinned or reviewable.
- ALTEN needed the project on its own GitLab (`gitlab-free.altengroup.net`) with no long-lived AWS keys,
  a reviewable infrastructure plan, and a way back if the new instance failed.
- Several things in the AWS accounts had been created by hand in the console (OIDC provider, CI role,
  pipeline schedules), so no one could see them or review changes to them.

**Solution (architecture of the migration).**
- **GitLab CI with 6 stages and 16 jobs** (`checks → test → build → terraform → terraform-apply → verify`)
  replaces the 12 GitHub workflows for dev.
- **Keyless AWS auth:** each job exchanges a GitLab **OIDC ID token** for short-lived STS credentials
  (`assume-role-with-web-identity`), with role chaining to a scoped Terraform role.
- **Immutable, scanned images:** BuildKit `bake` builds both images in one session and pushes them to
  **ECR with `IMMUTABLE` tags = commit SHA**, with scan-on-push enabled for the whole registry.
- **Plan on MR, manual apply on master, smoke test afterwards.** There is also a separate
  **read-only IAM path**, so MR plans can never write to AWS.
- **Operational guardrails:** weekly security rebuild, weekly drift detection to SNS, CloudWatch alarms
  and a budget, all written in Terraform. A **tested rollback runbook** lets dev be deployed from GitHub
  again if GitLab is down.

---

## 2. Comprehensive Tech Stack & Tooling

### Languages & Runtimes
- **Python 3.13** (`.python-version`, `pyproject.toml`), managed with **uv** (`uv.lock`, `exclude-newer = "7 days"` supply-chain cooldown)
- **HCL / Terraform 1.15.8** (pinned in the CI tools image), **Bash/POSIX sh** (CI scripts), **jq** (plan/drift filtering)
- **JavaScript / SCSS** on **Node 24 / npm 11**
- **Go** (Hugo user-guide site under `docs/user-guide/`)
- **PowerShell / Batch** (`local-deploy.ps1`, `terraform/terraform-alten-fuse.bat`, `exec-alten-fuse.bat`)

### Frontend / UI (context — built and bundled by the pipeline)
- Server-rendered Django templates (36 templates) + **HTMX 1.9**, **Bootstrap 5** (`django-bootstrap5`), `django-select2`, `django-sekizai`
- Internal component libraries **qromponents** and **notifiqrations** (private Git deps, now served from GitLab)
- **Webpack 5** + Babel, Sass, MiniCssExtract, CSS minimiser, Compression plugin, `webpack-bundle-tracker`
- **Sentry** browser SDK + Sentry webpack plugin (source maps tied to `RELEASE_VERSION = commit SHA`)

### Backend / API (context)
- **Django 5.2**, **Django REST Framework 3.17** + `djangorestframework-datatables` (server-side tables)
- **Django Channels 4 / Daphne / channels-redis** (WebSockets, `src/ws/`, `django-async-downloads[ws]`)
- **Celery 5.6** + `django-celery-beat` + `django-celery-results` (worker and beat run as separate ECS services)
- **Polars**, **fastexcel**, **tablib**, `django-import-export` (Excel/CSV ETL in `src/imports/`, `src/presence/`)
- **django-pghistory** (Postgres trigger-based audit history), `django-filter`, `django-solo`
- **Gunicorn / Uvicorn** in production, **Nginx 1.27** reverse proxy container

### Database & Storage
- **Amazon RDS PostgreSQL** (engine 18 in dev), managed with the `cyrilgdn/postgresql` provider through an **SSM port-forwarding tunnel**
- **Amazon ElastiCache Redis** (Celery broker, Channels layer, cache, sessions)
- **Amazon S3** (`django-storages[boto3]`) + **Terraform state in S3 with native S3 locking** (`use_lockfile = true`)
- **Amazon ECR** (immutable, scanned, lifecycle-pruned) and the **GitLab Container Registry** (BuildKit cache + CI tools image)
- **SSM Parameter Store / Secrets Manager** for app secrets

### DevOps, Testing & Tooling
- **CI/CD:** GitLab CI (16 jobs across 15 YAML include files), legacy GitHub Actions (12 workflows, still used for qa/prod)
- **IaC:** Terraform with providers `aws 5.47.0`, `tls 4.0.5`, `random`, `null`, `postgresql`, `sentry`, `gitlabhq/gitlab 17.11.0`; **27 modules**, **3 isolated environments** (dev/qa/prod in separate AWS accounts)
- **AWS:** ECS Fargate, ALB + ACM + **WAFv2**, VPC (NAT, flow logs, S3 endpoint), Cloud Map service discovery, IAM OIDC, STS, SSM Session Manager, SES, CloudWatch, SNS, AWS Budgets, ECR scanning, SSM Patch Manager (`modules/patcher`)
- **Containers:** multi-stage `docker/production/Dockerfile` (uv → node → Hugo → web → nginx), **Docker Buildx Bake**, Docker-in-Docker with TLS, BuildKit **secret mounts** (`.netrc`), registry cache (`mode=max`)
- **Quality gates:** pre-commit (black, isort, flake8 + bugbear/builtins/django, prettier, djhtml, custom `qr-hooks`), **terraform fmt / tflint (AWS ruleset 0.44) / terraform-docs / Trivy**, `terraform validate`, Conventional Commits checks on commits and MR titles, `package-lock` drift check
- **Testing:** Django test runner on Postgres 17 + Redis 8 service containers; `model-bakery`, `parameterized`, `freezegun`, `time-machine`, `coverage` (branch coverage)
- **Observability:** Sentry (Terraform-managed via `modules/sentry`), CloudWatch alarms → SNS email, drift alerts → SNS

---

## 3. Architecture & Technical Highlights

### 3.1 Keyless, least-privilege CI → AWS authentication (OIDC + role chaining)
**Files:** `terraform/environments/dev/ci-plan-roles.tf`, `.gitlab/ci/terraform/plan-apply.yml`, `.gitlab/ci/build/docker-build.yml`
- GitLab jobs request an `id_tokens: GITLAB_OIDC_TOKEN` and call `sts assume-role-with-web-identity`, so no static AWS key is stored in GitLab.
- The **OIDC provider was adopted into Terraform** (`import`, applied in MR !509) with `prevent_destroy`. Its thumbprints come from the live TLS chain (`data "tls_certificate"`), so Let's Encrypt renewals show up as a plan diff and can't silently go stale.
- **Two IAM paths:**
  - *Deploy path:* the protected variable `AWS_ROLE_ARN_DEV` → `gitlab-ci-fuse-dev` → admin `terraform` role. Only master can use it.
  - *Read-only MR path:* `gitlab-ci-fuse-dev-plan` (trust policy restricted to `project_id:1088:ref_type:branch:ref:*`) → `terraform-plan-readonly` (`ReadOnlyAccess`). An explicit **Deny on `s3:GetObject` outside the state bucket** stops the role from reading application uploads.
  - Narrow extras for that path: `ecr:BatchGetImage` on 2 repos, `ecs:DescribeServices`, `sns:Publish` on one topic, and `ssm:StartSession` on one bastion + one SSM document.
- The **threat model is written into the code**: it says what the read path exposes (RDS and IAM secrets in state) and the condition under which that becomes unacceptable (any non-Owner joins the project).

### 3.2 Immutable, reproducible image pipeline with safe tag resolution
**Files:** `.gitlab/ci/build/docker-build.yml`, `docker-bake.hcl`, `.gitlab/ci/terraform/plan-apply.yml`, `terraform/environments/dev/main.tf`
- **Buildx Bake** builds `web` and `nginx` in **one BuildKit session**, so the shared stages (Python deps, JS bundle, Hugo docs) are built once. The **registry cache** lives in GitLab's registry because ECR tags are immutable and the cache needs mutable tags.
- Tags are the **commit SHA**. Scheduled rebuilds use `<sha>-<pipeline_id>` so they don't collide with the immutable tag. Builds are **idempotent**: before pushing, the job asks ECR (`batch-get-image`) which targets already exist and builds only the missing ones.
- `--provenance=false` because BuildKit attestations wrap the push in an OCI index, which **ECR basic scanning rejects** (`UnsupportedImageTypeException`). Registry-level `aws_ecr_registry_scanning_configuration` was added because the per-repo `scan_on_push` flag was being ignored, so no image had ever been scanned.
- **Tag resolution with a safe floor:** when no new build ran, the plan reads the **image currently running in ECS**. It then looks only at commits *newer* than that one (`git merge-base --is-ancestor` + `git log running..HEAD`), and falls back to the running image. This replaced an unbounded "walk back 50 commits" heuristic that had **silently redeployed a five-day-old, unpatched image**.
- Private Git dependencies are fetched over GitLab HTTPS through a **BuildKit secret mount** (`--mount=type=secret,id=git_credentials,target=/root/.netrc`), so the credential never lands in an image layer.

### 3.3 GitOps-style infrastructure delivery: MR plan → manual apply → smoke test
**Files:** `.gitlab/ci/rules.yml`, `.gitlab/ci/terraform/*.yml`, `.gitlab/ci/verify/smoke-test.yml`, `.gitlab/ci/checks/terraform-validate.yml`
- **Path-based rules** (`.rules-build`, `.rules-terraform-plan`, …) reuse YAML anchors so a docs-only change doesn't rebuild or deploy, while any app *or* infra change ends in a plan.
- MRs run a read-only plan, and `post-plan-summary` posts **only action + resource address** (never attribute values, because plans contain secrets) as an MR comment. Saved master plans are artifacts restricted to `access: developer` and expire after 1 day.
- `notify-apply-pending` @-mentions the MR author when a plan has more than 0 changes and the manual apply hasn't run, because a saved plan expires after 24 h.
- **Smoke test:** `aws ecs wait services-stable` on 3 services. It then checks `running == desired`, `pending == 0`, `deployments == 1`, and that `/accounts/login/` returns HTTP 200 containing "Sign in". It deliberately does **not** check `rolloutState`, which lags behind and gave a false failure on services without a load balancer.
- A **custom CI tools image** (Terraform + AWS CLI + Session Manager plugin, with **6 providers pre-cached** and checked against the committed `.terraform.lock.hcl`) removes per-job downloads.
- Terraform jobs reach the private RDS through an **SSM port-forwarding tunnel** to a bastion (`localPortNumber=5443`, 15-second readiness probe), with no inbound SSH.

### 3.4 Continuous compliance: scheduled security rebuild + self-tested drift detection
**Files:** `.gitlab/ci/terraform/drift-detection.yml`, `terraform/gitlab/main.tf`, `.gitlab/ci/rules.yml`
- The **GitLab project's schedules are now Terraform code** (`gitlabhq/gitlab` provider, `import` blocks): a Monday 06:00 security rebuild and a Thursday 07:00 drift check (Europe/Paris). The file also records **why some things were deliberately not adopted**: CI variables would copy secrets into state, and adopting branch protection would force a replace that leaves `master` unprotected for a moment.
- The **security rebuild applies automatically** (base-image CVE fixes reach dev without anyone clicking). This is safe because ECS keeps old tasks until new ones are healthy (min 100 % / max 200 %).
- **Drift detection** uses `terraform plan -refresh-only` with the read-only role and no lock. A **jq filter** reports changed attributes per resource and ignores known noise using `type.attribute` keys (`aws_db_instance.latest_restorable_time`, `aws_sns_topic.policy`), so a name like `policy` stays significant on other resource types.
- The filter **tests itself against a fixture before it runs**. This check caught two real bugs in earlier versions of the filter. Alerts go to the same SNS topic as the alarms, and the 30-day artifact contains **names only, never values**.
- A **guard rule** (`DRIFT_CHECK == "true" → never`) is on every deploy job. Without it, GitLab treats `changes:` as true on schedules, so the read-only check would have rebuilt and deployed.

### 3.5 Terraform refactor, monitoring and cost controls
**Files:** `terraform/modules/main/moved.tf`, `terraform/environments/dev/monitoring.tf`, `terraform/environments/dev/main.tf`, `.gitattributes`
- **119 `moved` blocks** re-address the whole stack (VPC, ALB/WAF, RDS, Redis, ECS services, IAM, SES, patcher) from remote `qrodules_*` modules to **in-repo modules**, **with no resources destroyed or recreated**.
- A `registry_requires_credentials` toggle lets the same ECS module pull from **ECR** (execution-role auth, dev) or **GHCR** (`repositoryCredentials`, qa/prod). Dev images are switched with `*_image_override` variables, so one module serves both registries.
- **Monitoring from measured baselines:** 10 alarm resources (12 alarms, since the ECS no-task alarm uses `for_each` over 3 services): RDS free storage < 12 GB, RDS CPU > 80 % for 15 min, ECS LiveTaskCount < 1 (`treat_missing_data = breaching`), ALB 5xx ≥ 5/5 min, unhealthy host, latency > 2 s against a measured 0.042 s average, Redis evictions > 0, Redis swap > 128 MB, and others. The SNS topic policy keeps the default owner statement because overwriting it **had silently broken alarm delivery**.
- An **AWS Budget** of 400 USD/month (measured spend ≈ 320 USD) alerts at 80 % actual and 100 % forecast. The **ECR lifecycle** was raised from 30 to 100 images so rollback targets aren't pruned while task definitions still reference them.
- **`.gitattributes` (LF everywhere except `.bat`)** fixed a cross-platform bug: Windows CRLF checkouts made `terraform plan` want to rewrite the bastion's `user_data` script on every run.

### 3.6 Production release management & disaster recovery
**Files:** `terraform/environments/prod/terraform.auto.tfvars`, `docs/devops.md`, `docs/deployments.md`, `docs/runbooks/*.md`, `terraform/modules/ecs/ecs-service/ecs-task-definition/main.tf`
- Prod lives in a **separate AWS account** with its own state backend. A release means bumping a semantic `docker_tag` (currently `0.22.0`), logging in with AWS SSO, opening the bastion tunnel (port 5445), and running `terraform apply`.
- A new task definition triggers a `null_resource` that **runs the Django migration task** before services roll. ECS rolling deploys keep the old tasks until the new ones pass health checks.
- **Rollback runbook** (`docs/runbooks/rollback-dev-to-github.md`): Scenario A (restart in place) and Scenario B (point dev back to GHCR `latest` and deploy from GitHub). It was **rehearsed live on 2026-09-15** with **no downtime** (HTTP 200 throughout), and the runbook records exactly which steps were rehearsed and which weren't.
- Additional runbooks cover restoring the dev database and the cost/risk analysis for encrypting Redis (not possible in place on a standalone 6.2 cluster).

---

## 4. Quantifiable Metrics & Scope (From the Codebase)

### Platform / my scope
| Metric | Value | Source |
|---|---|---|
| GitLab CI jobs / stages / include files | **16 jobs**, **6 stages**, **15 YAML files** | `.gitlab-ci.yml`, `.gitlab/ci/**` |
| Legacy GitHub workflows (dev path replaced) | **12** | `.github/workflows/` |
| Terraform modules | **27** | `terraform/modules/**/main.tf` |
| Terraform `resource` blocks | **126** total, **31** in the dev root | `terraform/**/*.tf` |
| State-preserving `moved` refactors | **119** | `terraform/modules/main/moved.tf` |
| Isolated environments / AWS accounts | **3** (dev, qa, prod) | `terraform/environments/` |
| ECS services deployed and smoke-tested | **3** (web+nginx, Celery worker, Celery beat) | `.gitlab/ci/verify/smoke-test.yml` |
| CloudWatch alarms | **10** resources → **12** alarms | `terraform/environments/dev/monitoring.tf` |
| Terraform providers pre-cached in CI image | **6** | `docker/ci/terraform-tools/Dockerfile` |
| Scheduled pipelines as code | **2** (weekly rebuild, weekly drift) | `terraform/gitlab/main.tf` |
| Ops runbooks / ADRs | **3** runbooks, **1** ADR | `docs/runbooks/`, `docs/decisions/` |

### Timers, thresholds & limits visible in code
- Saved plan artifact TTL: **1 day**. Drift report retention: **30 days**.
- ECR retention: **100 images per repo** (raised from 30).
- Schedules: **Mon 06:00** security rebuild, **Thu 07:00** drift check (Europe/Paris).
- Docker daemon readiness: **30 × 2 s**. SSM tunnel readiness: **15 × 1 s**. ECS stability wait: **up to 10 min**.
- ECS rolling deploy: **min 100 % / max 200 %** healthy.
- Budget: **400 USD/month** (measured ≈ **320 USD**), alerts at **80 % actual / 100 % forecast**.
- Latency alarm **2 s** vs measured **0.042 s** average. 5xx alarm at **≥ 5 per 5 min** (measured baseline 0).

### Application scope (context — what the pipeline ships)
- **8 domain apps** + core/dashboard/ws; **~38 model classes** (`fup_tasks` 13, `imports` 9, `nvs_tasks` 8, `users` 3, …)
- **~90 URL routes** across `urls.py` files
- **36 HTML templates**, **21 JS entry modules**, a modular SCSS architecture
- **365 test functions in 36 test files**, run in CI on Postgres 17 + Redis 8

---

## 5. Ready-to-Use Resume Bullet Points (XYZ Format)

> Operational outcomes below are limited to user-confirmed delivery history. No build/deploy speed improvements or incident-reduction metrics are asserted.

### Variation 1 — Full-Stack / General Software Engineering
- **Migrated** the development CI/CD path of a Django/Celery platform used across 12 client sites from GitHub Actions to self-hosted GitLab CI, delivering a **16-job, 6-stage pipeline**. QA and production retain the separate GitHub/versioned-image release path described below.
- **Managed 3 successful production deployments with no downtime**, using the Terraform-driven production release flow with versioned image tags, database migrations and ECS rolling updates.
- **Implemented** immutable SHA-tagged development images built in a shared BuildKit Bake session with registry-layer caching.
- **Raised** delivery quality by adding **7 automated MR gates** (Django tests on Postgres/Redis, pre-commit incl. tflint/Trivy, `terraform validate`, read-only Terraform plan, lockfile check, and Conventional Commits checks on commits and MR titles).

### Variation 2 — Backend / Systems / Platform Architecture
- **Eliminated 100 % of long-lived AWS credentials in CI** by implementing GitLab OIDC → STS federation with role chaining. A separate **read-only IAM path** (explicit S3 deny outside the state bucket) lets every merge request run a Terraform plan with no write access.
- **Re-architected** the Terraform codebase (**27 modules, 126 resources, 3 AWS accounts**) through **119 `moved` refactors with zero resource replacement**, and added a registry toggle so one ECS module deploys from ECR or GHCR.
- **Built** weekly infrastructure drift detection (`plan -refresh-only` plus a self-tested jq filter), with SNS alerts for state-versus-reality differences.
- **Hardened** the container supply chain by enforcing immutable ECR tags, enabling registry-wide vulnerability scanning (it had silently never run), disabling the provenance indexes ECR couldn't scan, and automating a **weekly base-image security rebuild**.
- **Fixed** a silent deployment regression that had put a **5-day-old unpatched image** into an environment while every job passed, by redesigning image-tag resolution to only move forward from the running ECS task definition.
- **Instrumented** the environment with **12 CloudWatch alarms** tuned to measured baselines (e.g. 2 s latency vs 0.042 s observed) and a **400 USD budget** with forecast alerts.

### Variation 3 — Frontend / Product Engineering (delivery-focused)
- **Added** reviewable infrastructure plans to merge requests and a manual development deployment followed by a login-page smoke test.
- **Streamlined** frontend builds (Webpack 5, Sass, HTMX, private component libraries) by caching Node/Python/Hugo stages in BuildKit.
- **Improved** release confidence for stakeholders across **12 sites** by tying Sentry releases and source maps to the commit SHA, so each production error maps to the exact code that was deployed.
- **Reduced** onboarding friction by writing deployment, DevOps and rollback documentation, including a **live-rehearsed rollback** with **zero downtime**.

---

## 6. Interview Prep: Deep-Dive Talking Points

### Talking Point 1 — "A green pipeline that silently rolled dev back"
**Where:** `.gitlab/ci/terraform/plan-apply.yml` (`.terraform-plan-script`), `.gitlab/ci/build/docker-build.yml`, `terraform/environments/dev/ci-plan-roles.tf` (`read_running_image`)
- **S — Situation:** After the migration, a pipeline that didn't rebuild images still had to decide which image to deploy. The first version walked back up to 50 commits and took the first SHA that had images in ECR. On 2026-09-16 it put a **five-day-old, unpatched image** back into dev, and **every job reported success**.
- **T — Task:** Make tag resolution correct by construction, so that an empty or ambiguous answer can never become a rollback.
- **A — Action:** I found two causes. Scheduled rebuilds push `<sha>-<pipeline_id>`, so the lookup by plain SHA skipped commits that *had* been built, and the walk had no lower bound. I made the build job hand its exact tag to the plan via a `dotenv` artifact. When no build ran, the plan now reads the **running task definition from ECS**, looks only at commits newer than it (`merge-base --is-ancestor`, `git log running..HEAD`), and otherwise **keeps the running image**. Redeploying what's already running is a no-op, so it's always safe. If the running tag can't be read, the job fails instead of guessing. I added the minimum IAM (`ecs:DescribeServices`, `DescribeTaskDefinition`) to both CI roles through Terraform.
- **R — Result:** Tag resolution can now only move forward or stay put, and it fails loudly when unsure. **Trade-off:** a commit that changes nothing in the app no longer redeploys an older build. To ship a commit without a build, you run the rebuild schedule, and the job log says so.

### Talking Point 2 — "Letting every MR run a Terraform plan without giving it AWS write access"
**Where:** `terraform/environments/dev/ci-plan-roles.tf`, `terraform/environments/dev/backend-plan-readonly.hcl`, `.gitlab/ci/terraform/plan-apply.yml` (`terraform-plan-mr`), `.gitlab/ci/terraform/post-plan-summary.yml`
- **S — Situation:** On GitHub, infrastructure changes were applied by hand from laptops, and reviewers never saw a plan. In GitLab the deploy role ARN is a **protected variable** (master only), so MR pipelines couldn't reach AWS at all.
- **T — Task:** Show reviewers the real plan for every MR without giving feature branches admin rights or leaking secrets from the plan.
- **A — Action:** I built a second OIDC trust path: `gitlab-ci-fuse-dev-plan` (any branch of project 1088) → `terraform-plan-readonly` with `ReadOnlyAccess` plus an **explicit Deny on object reads outside the state bucket**. A partial backend config switches state access to the read-only role, and the plan runs with `-lock=false` because that role can't write the lock. I adopted the hand-made OIDC provider into Terraform with `prevent_destroy` and thumbprints derived from the live TLS chain. The MR comment contains only `action + address`, never attribute values. I also wrote the residual risk (state contains RDS and IAM secrets) and its trigger condition directly in the file.
- **R — Result:** Every infra-affecting MR now gets an automatic plan summary comment, and **zero write permissions** are reachable from a feature branch. **Trade-off:** Owners can read state secrets through the plan path. That's acceptable only because all 4 members are already Owners, and the file documents that it must be revisited if that changes.

### Talking Point 3 — "Migrating without a cliff-edge: refactor, cut over, and keep a way back"
**Where:** `terraform/modules/main/moved.tf`, `terraform/modules/ecs/app/main.tf` (`registry_requires_credentials`), `terraform/environments/dev/main.tf` (ECR + `*_override` vars), `docs/runbooks/rollback-dev-to-github.md`, `.gitattributes`
- **S — Situation:** The stack depended on a vendor's remote `qrodules_*` modules, GHCR with pull secrets, and GitHub workflows. Qa and prod had to keep running unchanged from GitHub while dev moved to GitLab and ECR, and all three environments share the same modules.
- **T — Task:** Change module structure and image registry with **no resource recreation**, isolate the change to dev, and guarantee a working fallback if the new GitLab instance became unavailable.
- **A — Action:** I re-addressed **119 resources with `moved` blocks**, so the plans showed moves, not destroy and create. I added a `registry_requires_credentials` flag and image/tag override variables so the shared ECS module serves ECR (dev) and GHCR (qa/prod) without forking. Dev-only resources (ECR, CI roles, monitoring) live in the dev root, not the shared module. I wrote and **rehearsed live** a rollback runbook that switches dev back to GHCR `latest` and the GitHub workflow. I also fixed a CRLF issue (`.gitattributes`) that would otherwise have rewritten the bastion's boot script from Windows checkouts.
- **R — Result:** The cut-over and the rollback rehearsal both kept the app returning **HTTP 200 throughout (zero downtime)**, and qa/prod were untouched. **Trade-off:** for now there are two delivery systems (GitLab for dev, GitHub for qa/prod), and they overwrite each other on dev by design. The runbook spells out that "whoever applies last wins" and gives the exact path back.

---

### Quick reference — how prod releases work (be ready to whiteboard this)
1. Code is merged and a versioned image is published (`docker-publish.yml` on GitHub release → GHCR `x.y.z`).
2. Bump `docker_tag` in `terraform/environments/prod/terraform.auto.tfvars` (e.g. `0.22.0`) via a reviewed PR.
3. `aws sso login --sso-session fuse-sso`, set `AWS_PROFILE=fuse-prod`, open the bastion tunnel on port **5445**.
4. `terraform-alten-fuse.bat plan` → review → `apply`. The new task definition triggers the **migration task** (`null_resource` + `local-exec`).
5. ECS rolls the services (old tasks stay until new ones are healthy). Verify the services are stable and the app returns HTTP 200, and watch Sentry.
