Commit Graph

8 Commits

Author SHA1 Message Date
julien c080d1ad89 feat(infra): dockerised full-stack dev mode — apps compose profile (ADR-0030) (#258)
CI / scan (push) Successful in 3m8s
CI / commits (push) Has been skipped
CI / check (push) Successful in 3m58s
CI / a11y (push) Successful in 4m38s
Docs site / build (push) Successful in 7m47s
CI / perf (push) Successful in 8m53s
## Summary

Implements [ADR-0030](../docs/decisions/0030-dockerised-dev-mode.md) (now `accepted`): a Docker Compose `apps` profile that runs the three Nx dev servers (`portal-bff`, `portal-shell`, `portal-admin`) from a shared `Dockerfile.dev`, so a developer can boot the whole stack with **no native Node/pnpm**:

```bash
./infra/local/dev.sh up apps   # infra + portal-bff:3000 + portal-shell:4200 + portal-admin:4300
```

Purely additive and profile-gated — the native `nx serve` flow and the devcontainer are untouched. Dev-only; no production images (those stay with the ADR-0028 Container Registry work).

## What lands

| File | Change |
| --- | --- |
| `docs/decisions/0030-dockerised-dev-mode.md` | Status `proposed` → `accepted`. |
| `docs/decisions/README.md` | Index status → `accepted`. |
| `infra/local/Dockerfile.dev` | **New.** `node:24-bookworm` + corepack (pnpm resolved from `packageManager` at runtime — no pinned version to drift). No COPY/install at build time. `NX_DAEMON=false`, `NODE_OPTIONS=--max-old-space-size=4096`. |
| `infra/local/dev-entrypoint.sh` | **New.** Shared entrypoint: BFF (`APF_ROLE=bff`) runs `prisma generate` + `prisma migrate deploy` then serves; SPA services go straight to `nx serve`. |
| `infra/local/dev.compose.yml` | **New `apps` profile.** A one-shot `apps-deps` service installs into a shared `node_modules` volume once (the 3 servers gate on its `service_completed_successfully`, avoiding a 3-way install race); `portal-bff` / `portal-shell` / `portal-admin` services from the shared image via a `x-app-base` anchor. Repo bind-mounted; `node_modules` + `.nx` in named volumes. |
| `infra/local/dev.sh` | `apps` added to `ALL_PROFILES` (so teardown / status / logs catch it) + usage / examples. |
| `infra/README.md` | New "Dockerised app dev mode" section + cheat-sheet / file-table rows. |
| `docs/setup/01-dev-debian-vm-setup.md` | "Three dev modes — which when" table at the top of Step 5. |
| `CLAUDE.md` | Architecture roll-up bullet + ADR-count line + environment-conventions note. |

## Key design decisions

- **One image, one install.** The monorepo means a single `Dockerfile.dev` + a single `pnpm install` serves all three apps.
- **`node_modules` + `.nx` in named volumes, not bind-mounted.** The container's install (native modules — `esbuild`, `@swc/core`, Prisma engines, `lmdb`, `@parcel/watcher` — built for this image) must never be shadowed by the host's `node_modules`. The repo source is bind-mounted for hot reload; these two directories are overlaid with named volumes.
- **`apps-deps` one-shot avoids the install race.** Three services sharing one `node_modules` volume can't all run `pnpm install` concurrently. A dedicated install service runs first; the three app services `depends_on` its completion.
- **`NX_DAEMON=false`** in the containers — three containers sharing one workspace would otherwise contend on the Nx daemon.
- **Env wiring.** The BFF reuses its own `apps/portal-bff/.env` (Entra / session / jwks secrets) via `env_file: { required: false }`; the host-specific URLs (`DATABASE_URL` / `REDIS_URL` / OTel endpoint) are overridden in `environment:` — rebuilt from `infra/local/.env` creds → Compose service names. Compose `environment` wins over `env_file`, so the localhost values in the BFF `.env` don't leak into the container.
- **BFF still needs its secrets.** "No native toolchain" ≠ "no config". `apps/portal-bff/.env` must exist (same as native dev); `required: false` lets SPA-only devs `up` without it (the BFF then fails its own boot validators with a clear message).

## Validation on the VM

- [x] `docker compose -f dev.compose.yml --profile apps config` validates (YAML, anchors / merge, env interpolation).
- [x] `bash -n` clean on `dev-entrypoint.sh` and `dev.sh`.
- [x] **Full boot on vm-dev** — `./infra/local/dev.sh up apps` brings up postgres / redis / otel + `apps-deps` (one-shot, exit 0) + portal-bff / portal-shell / portal-admin, all containers report healthy or running.
- [x] `apps-deps` populates the shared `node_modules` volume; the three servers reach their `nx serve` step without re-installing.
- [x] Ports published as expected: BFF :3000, portal-shell :4200, portal-admin :4300.
- [x] `./infra/local/dev.sh up` (no `apps`) unchanged for native devs.

## Follow-ups identified during VM validation

- **SPA → BFF reachability from a remote browser.** Opening `http://<vm-ip>:4200/` from the workstation surfaces a "Backend unreachable" message: the SPA's hardcoded `bffApiBaseUrl: 'http://localhost:3000/api'` (ADR-0018 build-time env) plus the BFF's `CORS_ALLOWED_ORIGINS=http://localhost:4200,…` both assume "browser on the same machine as the BFF", which doesn't hold here. Fixed in the **stacked follow-up PR `feat/spa-dev-proxy`** (proxy `/api` in the Angular dev-server + relative `bffApiBaseUrl`), which lands right after this PR.
- The OTel HTTP exporter URL (`environment.otlpEndpoint`) and the cross-SPA links (`adminAppUrl`, `shellAppUrl`) remain absolute and hit the same remote-browser limit; not blocking for v1, can be revisited if needed.

## Related

- [ADR-0030](docs/decisions/0030-dockerised-dev-mode.md) — the decision (accepted in this PR's chain).
- [ADR-0020](docs/decisions/0020-portal-admin-app.md) — the devcontainer this complements.
- [ADR-0028](docs/decisions/0028-migrate-cicd-and-git-hosting-to-gitlab.md) — production images / Container Registry (deferred).
- Follow-up branch `feat/spa-dev-proxy` — the SPA-side proxy fix that makes the dockerised mode usable from a remote browser.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #258
2026-06-01 11:11:44 +02:00
julien fe180fd125 feat(infra): local serve-static profile — Caddy reverse proxy for the prod build (#97)
CI / check (push) Successful in 2m29s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m9s
CI / a11y (push) Successful in 1m35s
CI / perf (push) Successful in 4m16s
## Summary

Add a Caddy reverse proxy behind a new `--profile serve-static` so a contributor can exercise the production build locally with the per-locale routing the on-prem reverse proxy will use (per ADR-0019). Closes the gap surfaced by PR #96: the locale switcher / accessibility route fusion / cookie plumbing all need a prod-faithful local setup, and `nx serve-static` falls short (no SPA fallback per locale, no smart `/` redirect, exposes the `http-server` directory-listing footgun we hit during the perf-gate fix in PR #92).

## What lands

- **[`infra/local/Caddyfile`](infra/local/Caddyfile)** — explicit `route` block:
  - `GET /` → 302 to `/{locale}/` based on `Accept-Language`, falling back to `/fr/` (APF audience).
  - `/fr/*` → `dist/.../browser/fr/` with SPA fallback to `fr/index.html`.
  - `/en/*` → mirror.
  - Catch-all → 302 to `/fr/`.
- **[`infra/local/dev.compose.yml`](infra/local/dev.compose.yml)** — new `serve-static` service on the `serve-static` profile. Bind-mounts the Caddyfile and `dist/apps/portal-shell/browser/` read-only. Port 4200, overridable via `SERVE_STATIC_PORT`.
- **[`infra/local/.env.example`](infra/local/.env.example)** — adds `SERVE_STATIC_PORT=4200`.
- **[`infra/local/dev.sh`](infra/local/dev.sh)** — registers `serve-static` in `ALL_PROFILES` so `dev.sh down|status|logs` catches the new container, and `dev.sh up serve-static` works.
- **[`infra/README.md`](infra/README.md)** — file row, workflow snippet, cheat-sheet row, and a service-endpoint row with the `nx build … -c=production` prerequisite called out.

## Workflow

```
pnpm exec nx build portal-shell --configuration=production
./infra/local/dev.sh up serve-static
open http://localhost:4200/   # → /fr/ or /en/ per Accept-Language
```

## Decision worth flagging

Used **Caddy** rather than nginx or Traefik. Reason: minimal Caddyfile, single binary, no daemon config drift, sensible defaults (TLS off explicitly for local-only). Same family of choice as the rest of `infra/local/` — small, single-purpose images.

`redir` in a Caddyfile is **ambiguous** when the first arg starts with `/`: Caddy reads it as a path matcher rather than a redirect target. Using `redir * /fr/ 302` (explicit `*` matcher) avoids the gotcha. Documented inline in the Caddyfile via a comment block.

## What this PR explicitly does NOT do

- Wire TLS. Local convenience only, binds to `localhost`.
- Replace `nx run portal-shell:serve-static` (still used by Lighthouse CI in `ci:perf`).
- Set the `__Host-portal_locale` cookie or honour it for the smart redirect. Cookie handling needs the BFF route (ADR-0019 future PR).
- Land an on-prem reverse-proxy ADR. The on-prem infra ADR is phase 3b.

## Verified locally

| Probe | Expected | Actual |
|---|---|---|
| `GET / -H 'Accept-Language: fr'` | 302 `/fr/` | ✓ |
| `GET / -H 'Accept-Language: en'` | 302 `/en/` | ✓ |
| `GET /unknown` | 302 `/fr/` | ✓ |
| `GET /fr/deep/route` | 200 (SPA fallback to `fr/index.html`) | ✓ |
| `GET /fr/favicons/favicon.svg` | 200 (asset under locale folder) | ✓ |
| `/fr/index.html` markup | `lang="fr"`, `<base href="/fr/">` | ✓ |
| `/en/index.html` markup | `lang="en"`, `<base href="/en/">` | ✓ |

## Test plan

- [x] `docker compose -f infra/local/dev.compose.yml --profile serve-static config` validates clean.
- [x] `dev.sh up serve-static` brings the container up; `dev.sh status` lists it; `dev.sh stop serve-static` brings it down.
- [x] Routing probes above all pass.
- [ ] Manual: build + serve-static + click the locale switcher → URL becomes `/{other-locale}/`, the matching bundle boots, no console errors. (Verifies PR #95 + #96 end-to-end against a prod-faithful proxy.)
- [ ] Manual: `/fr/accessibilite` → router-level redirect to `/fr/accessibility` (verifies PR #94 under SPA fallback).
- [ ] Manual: `Accept-Language: en` in browser settings → root URL lands on `/en/`.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #97
2026-05-12 00:06:33 +02:00
julien a893f8e06b chore(infra): migrate Jaeger to v2 for built-in dark theme (#64)
CI / check (push) Successful in 1m23s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 57s
CI / a11y (push) Successful in 49s
CI / perf (push) Successful in 2m5s
## Summary

Jaeger v1's web UI has no theme selector — it ships light-only. v2 (the OTel-Collector-based rewrite, image `jaegertracing/jaeger`, distinct from v1's `jaegertracing/all-in-one`) ships a **Light / Dark / Auto** switcher in the UI nav. v2 is also the actively-developed line; v1 is on the way out within ~6-12 months.

Migration is mechanical:

- Image: `jaegertracing/all-in-one:1.76.0` → `jaegertracing/jaeger:2.17.0`.
- Drop `COLLECTOR_OTLP_ENABLED: 'true'` env — v2 enables OTLP receivers by default.
- UI port (`16686`) and OTLP ports (`4317` / `4318`) are unchanged, so the collector → `jaeger:4317` forwarding pipeline keeps working without touching `otel-collector.yaml`.

## Test plan

- [ ] After merge: `docker compose -f infra/local/dev.compose.yml --profile observability up -d --force-recreate jaeger` → container stays healthy.
- [ ] http://localhost:16686 loads the v2 UI; nav shows the Light/Dark/Auto switcher.
- [ ] Send a synthetic OTLP trace to `localhost:4317` (e.g., via grpcurl or once the BFF is wired in B) → it appears in the Jaeger UI.
- [ ] OTel Collector logs no longer warn abou

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #64
2026-05-08 23:31:16 +02:00
julien 171f21b99b fix(infra): pass pgweb credentials via discrete CLI flags (#63)
CI / check (push) Successful in 1m30s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 1m37s
CI / a11y (push) Successful in 42s
CI / perf (push) Successful in 2m18s
## Summary

`PGWEB_DATABASE_URL` (post-#62 rename) still fails at boot:

```
Error: Invalid URL. Valid format: postgres://user:password@host:port/db?sslmode=mode
```

Root cause: the userinfo portion of a Postgres URL must be URL-encoded — any `@`, `#`, `:`, `/`, `?`, `%`, `&`, `=`, `+`, `;` etc. in the password breaks the parser. Compose has no built-in URL encoding, so the URL we construct in YAML is fragile by design and depends on the developer happening to pick a URL-safe password.

Switch to pgweb's discrete CLI flags (`--host`, `--port`, `--user`, `--pass`, `--db`, `--ssl`). Compose interpolates each value literally — no URL encoding required, any password works.

The image's ENTRYPOINT already passes `--bind=0.0.0.0 --listen=8081`; our args are appended to those.

## Side benefit

A missing password now yields an explicit Compose error (`POSTGRES_PASSWORD must be set in infra/local/.env`) rather than an opaque pgweb crash with a vague "Invalid URL" message.

## Test plan

- [ ] After merge: `docker compose -f infra/local/dev.compose.yml --profile dbtools up -d` → pgweb stays up (no `Restarting (1)` loop).
- [ ] http://localhost:8081 loads pgweb's UI; you can navigate to the `audit` schema.
- [ ] Confirm with a password that contains a special char (e.g., `dev@pass#2026!`) — should still work.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #63
2026-05-08 23:13:17 +02:00
julien 10f8565957 fix(infra): rename pgweb DATABASE_URL to PGWEB_DATABASE_URL (#62)
CI / check (push) Successful in 1m29s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 1m26s
CI / a11y (push) Successful in 48s
CI / perf (push) Successful in 2m23s
## Summary

pgweb 0.16 deprecated `DATABASE_URL` in favour of `PGWEB_DATABASE_URL`. With the old name, the container starts up, emits a `[DEPRECATION]` warning, then crashes:

```
[DEPRECATION] Usage of DATABASE_URL env var is deprecated, please use PGWEB_DATABASE_URL variable instead
Pgweb v0.16.2 ...
Error: Invalid URL. Valid format: postgres://user:password@host:port/db?sslmode=mode
```

— because the new code path reads `PGWEB_DATABASE_URL` (empty) and the URL parser rejects an empty string.

Rename the env key in the compose file. Same value, just the new name.

## Test plan

- [ ] After merge: `docker compose -f infra/local/dev.compose.yml --profile dbtools up -d` → pgweb stays running (not in `Restarting` loop).
- [ ] http://localhost:8081 loads pgweb's UI; click through to the `audit` schema and the four `audit_*` roles to confirm DB connection.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #62
2026-05-08 22:58:21 +02:00
julien d5c5c45175 fix(infra): correct OTel collector image tag (#59)
CI / commits (push) Has been skipped
CI / check (push) Successful in 1m1s
CI / scan (push) Successful in 1m9s
CI / a11y (push) Successful in 39s
CI / perf (push) Successful in 1m58s
## Summary
`otel/opentelemetry-collector-contrib:0.115.0` doesn't exist — release 0.115 was published as `0.115.1` only. Same memory-not-Docker-Hub mistake as Jaeger in #58.

```
Error response from daemon: failed to resolve reference
"docker.io/otel/opentelemetry-collector-contrib:0.115.0": not found
```

Pin to `0.150.1` (latest stable). The collector's stable-feature backward-compat covers our `otel-collector.yaml` (otlp receiver, batch processor, debug exporter, otlp/jaeger exporter) — no config change needed.

Postgres `17.2-alpine`, Redis `7.4-alpine`, pgweb `0.16.2` were verified against Docker Hub at the same time; they're all correct.

## Test plan
- [ ] `cd infra/local && docker compose -f dev.compose.yml up -d` — all 3 core services pull and start healthy.
- [ ] `--profile observability up -d` adds Jaeger 1.76.0 (after #58).
- [ ] `--profile dbtools up -d` adds pgweb 0.16.2.
- [ ] `docker compose ps` shows everything healthy.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #59
2026-05-08 20:41:00 +02:00
julien f0372adaae fix(infra): correct Jaeger image tag (#58)
CI / check (push) Successful in 1m19s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 1m21s
CI / a11y (push) Successful in 48s
CI / perf (push) Successful in 2m13s
## Summary
`jaegertracing/all-in-one:1.62` doesn't exist on Docker Hub — I picked it from memory in #57 without verification. Jaeger 1.x publishes only full-semver tags (1.X.Y), not rolling minor (`1.X`) tags.

Activating `--profile observability` errored at image-pull time:

```
Error response from daemon: failed to resolve reference
"docker.io/jaegertracing/all-in-one:1.62": not found
```

Pin to `1.76.0` (latest stable in the 1.x line, verified against Docker Hub).

## Test plan
- [ ] `cd infra/local && docker compose -f dev.compose.yml --profile observability up -d` → all images pull, all services healthy.
- [ ] http://localhost:16686 → Jaeger UI loads (empty until the BFF starts emitting traces, which lands in the upcoming **B — Observability foundations** PR).
- [ ] Renovate's docker-compose manager picks up future Jaeger 1.x bumps and surfaces them as PRs.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #58
2026-05-08 20:08:33 +02:00
julien 0f00d6d93f feat(infra): add local-dev Docker Compose stack (#57)
CI / commits (push) Has been skipped
CI / check (push) Successful in 1m12s
CI / scan (push) Successful in 1m20s
CI / a11y (push) Successful in 41s
CI / perf (push) Successful in 2m9s
## Summary
Bring up Postgres + Redis + OTel Collector in one command so contributors can run the BFF end-to-end without manually wiring each service. Replaces the throwaway `docker run postgres:17-alpine` one-liner that was in `docs/development.md` §3.

### What lands
- **`infra/local/dev.compose.yml`** — three core services (`postgres:17.2-alpine`, `redis:7.4-alpine`, `otel/opentelemetry-collector-contrib:0.115.0`) plus two viewers gated behind Compose profiles:
  - `--profile dbtools` → `sosedoff/pgweb:0.16.2` (Postgres GUI on port 8081)
  - `--profile observability` → `jaegertracing/all-in-one:1.62` (Jaeger UI on 16686)
  - All ports overridable via `.env`. State in named volumes. Healthchecks on data services.
- **`infra/local/.env.example`** — credentials + ports template. `POSTGRES_PASSWORD` and `REDIS_PASSWORD` are mandatory (compose refuses to boot without them); other keys default sensibly.
- **`infra/local/init/postgres/01-init.sql`** — bootstrap SQL per **ADR-0013**: `audit_owner` / `audit_writer` / `audit_reader` / `audit_archiver` roles + `audit` schema. Default privileges encode the append-only contract (INSERT to writer, SELECT to reader, DELETE to archiver, no UPDATE/TRUNCATE to anyone). Applied on first Postgres boot only; documented re-run procedure.
- **`infra/local/otel-collector.yaml`** — pipeline: OTLP gRPC/HTTP → batch → debug exporter (always) + forward to `jaeger:4317`. When the observability profile is off, the Jaeger export logs warn-level retries but doesn't block the debug pipeline.

### Surrounding doc updates
- **`infra/README.md`** — new "Local-dev stack" section: service inventory, port table, first-time setup walkthrough, persistence/bootstrap-replay tips. The previous `local/` placeholder line is removed.
- **`docs/development.md`** §3 — rewritten to walk through the compose-based setup; cross-links to `infra/README.md` for the full reference. Roadmap entry for "Local infra recipe" removed from §8 (now implemented); "Observability dev-loop" line adjusted to point at the new Jaeger profile.

### Out of scope
- **Production parity** — HA Postgres, Redis Sentinel, real OTel backend (Tempo / Loki / etc.) — defer to the on-prem infrastructure ADR (phase 3b). The dev-only nature of this stack is called out explicitly in `infra/README.md`.
- **Wiring the BFF** to actually use these endpoints (NestJS config, Prisma datasource URL, OTel SDK init) — that's the **B — Observability foundations** chantier, next up.

## Test plan
- [ ] `cd infra/local && cp .env.example .env && docker compose -f dev.compose.yml up -d` → all three core services come up healthy; verify with `docker compose ps`.
- [ ] `psql postgres://portal:<pwd>@localhost:5432/portal_dev -c "\dn"` shows the `audit` schema; `\dg` shows the four audit roles.
- [ ] `redis-cli -a <pwd> PING` → `PONG`.
- [ ] Send a fake OTLP trace via grpcurl → see it printed by `docker compose logs otel-collector`.
- [ ] `--profile dbtools up -d` → http://localhost:8081 shows pgweb UI, can navigate to the audit schema.
- [ ] `--profile observability up -d` → http://localhost:16686 shows Jaeger UI; collector logs no longer report Jaeger export retries.
- [ ] `docker compose down -v` cleanly removes everything; next `up -d` re-runs the bootstrap SQL.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #57
2026-05-08 19:23:43 +02:00