Commit Graph

17 Commits

Author SHA1 Message Date
julien a463199728 feat(infra): reactivate act_runner cache by sharing the runners network (#82)
CI / commits (push) Has been skipped
CI / check (push) Successful in 2m3s
CI / scan (push) Successful in 2m18s
CI / a11y (push) Successful in 1m0s
CI / perf (push) Successful in 3m10s
## Summary

Closes the deferred-since-day-one cache-server gap (documented as "Cache server (deferred)" in `infra/README.md` and mentioned every time we hit a slow CI install).

**Root cause.** `act_runner`'s built-in cache server binds inside the runner container and advertises an IP on the compose-defined `apf-portal-act-runners` bridge — but jobs are spawned via the mounted `/var/run/docker.sock`, which puts them on Docker's anonymous default `bridge`. The advertised URL is unreachable from the job, every cache request burns a ~2 min `ETIMEDOUT` (restore + save), the hit rate is zero.

**Fix.** Tell `act_runner` to attach jobs to the same compose-defined bridge as the runners, via `container.network` in the shared `runner-config.yaml`. The advertised cache URL becomes a normal internal-network DNS hop, jobs reach the cache server, `cache: 'pnpm'` works end-to-end.

**Blast-radius trade-off** (bounded). Every container on `apf-portal-act-runners` is one of our runner containers, plus the jobs they spawn — all of which already have full docker-socket access. Sharing a network doesn't widen what a malicious workflow can already do; it just lets jobs reach the cache server.

## What lands

- `infra/runner-config.yaml` — add `container.network: apf-portal-act-runners`. Surface the `cache.enabled: true` default explicitly so the toggle is discoverable.
- `.gitea/workflows/ci.yml` — re-enable `cache: 'pnpm'` on every `actions/setup-node` step (5 jobs). Drop the now-stale block comment that explained the disablement.
- `.gitea/workflows/security-scheduled.yml` — same on the two setup-node steps.
- `infra/README.md` "Cache server" section rewritten — was `"(deferred)"`, now describes the working setup, rationale, and the disable toggle.
- `ci.yml`'s Trivy comment trimmed to drop the cross-reference to the deferred-cache-server section that no longer exists.

## Roll-out (manual, post-merge, on the runner host)

```bash
cd <repo>/infra
git pull
./ci-runners.sh rotate
```

`rotate` recreates the containers with the new `runner-config.yaml` mount intact (rolling restart, ~15 s pause between each runner so the CI pipeline stays online).

## Test plan

- [ ] CI green on this PR (the gates run on the runners as configured **before** rollout, so this PR's run is one last "uncached" cycle).
- [ ] After rollout, the next CI run's `Set up Node.js` step shows the cache restore attempt **succeed quickly** (no ETIMEDOUT). The `Run pnpm install --frozen-lockfile` step on the first post-rollout run still reports `Progress: resolved N, reused 0, downloaded N` (cold seed).
- [ ] The **second** post-rollout run reports `reused N, downloaded 0` (or a small downloaded delta if Renovate moved a dep meanwhile) — the cache hit is real.
- [ ] `Complete job` step at the end no longer shows `reserveCache failed: connect ETIMEDOUT` warnings.
- [ ] Wall-clock for a typical PR's CI drops by ~5-10 min (5 jobs × ~30-90 s saved on `pnpm install` + the 2× ~2 min ETIMEDOUTs we used to eat).

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #82
2026-05-10 19:03:58 +02:00
julien 2d676cc279 feat(infra): add ci-runners.sh wrapper for the runner stack (#81)
CI / check (push) Successful in 1m53s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 1m17s
CI / a11y (push) Successful in 1m3s
CI / perf (push) Successful in 2m27s
## Summary

Mirrors the convenience-script pattern we adopted for `infra/local/dev.sh`: typing `docker compose -f infra/ci-runners.compose.yml ...` for routine ops gets old fast, the pre-pull of the catthehacker job images is documented but easy to forget, and the "rotation of one runner at a time" tip in `infra/README.md` is a sequence the contributor was supposed to hand-roll every time.

`infra/ci-runners.sh` exposes the everyday verbs and automates the rolling-restart pattern.

## What lands

| Command                                       | Effect                                                                            |
| --------------------------------------------- | --------------------------------------------------------------------------------- |
| `./ci-runners.sh up`                          | Bring the three runner containers up                                              |
| `./ci-runners.sh up --prepull`                | Pre-pull the job images (`act-22.04` + `:full-22.04`) on the host first           |
| `./ci-runners.sh down`                        | Stop and remove the containers (**preserves** `data/runner-N/.runner` credentials) |
| `./ci-runners.sh restart <runner>`            | Restart one runner                                                                |
| `./ci-runners.sh rotate`                      | Rolling restart of every runner with a 15 s pause between each — keeps at least N-1 runners online through a config refresh |
| `./ci-runners.sh status`                      | `docker compose ps` for the runner services                                       |
| `./ci-runners.sh logs [runner]`               | Follow logs (one runner or all of them)                                           |
| `./ci-runners.sh pull-images`                 | Pre-pull / refresh the job images (idempotent)                                    |
| `./ci-runners.sh <other>`                     | Pass-through to `docker compose -f ci-runners.compose.yml ...`                    |

The destructive `down -v` (wipes `data/`, forces re-registration with a fresh Gitea token) is intentionally **not** exposed as a verb — invoke `docker compose -f ci-runners.compose.yml down -v` directly so the path is explicit at the typing level.

## Doc updates (`infra/README.md`)

- Inventory table at the top picks up the script.
- "First-time registration" walkthrough swaps the explicit `docker pull` / `docker compose up` steps for `./ci-runners.sh up --prepull`.
- New "Convenience script — `ci-runners.sh`" subsection with the cheat-sheet table.
- "Operational tips" rephrased to point at the script's `rotate` / `restart` / `logs` verbs as the canonical commands; the raw-docker-compose form is kept in parentheses as the underlying mechanism.
- "Adding a fourth runner" tip now reminds to update the `RUNNERS=()` array at the top of the script.

## Trade-off

The 15 s pause in `rotate` is a conservative approximation — `act_runner` doesn't expose a Compose healthcheck, so we can't poll for ready. Adjust the constant at the top of the script if reality argues for a different value.

## Test plan

- [ ] CI green on this PR.
- [ ] On the runner host: `./infra/ci-runners.sh status` shows the three runners running.
- [ ] `./infra/ci-runners.sh logs runner-1` tails runner-1's stdout.
- [ ] `./infra/ci-runners.sh rotate` cycles through runner-1 → runner-2 → runner-3 with the 15 s pauses; `status` between rotations shows N-1 runners online at any moment (with a brief gap for the one currently restarting).
- [ ] `./infra/ci-runners.sh restart runner-99` errors out with the "unknown runner" message.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #81
2026-05-10 06:37:07 +02:00
julien 8f2cd4e068 feat(portal-shell): wire spa-side opentelemetry tracing (#72)
CI / commits (push) Has been skipped
CI / scan (push) Successful in 1m15s
CI / a11y (push) Successful in 1m6s
CI / perf (push) Successful in 2m0s
CI / check (push) Successful in 43s
## Summary

Phase 2 of ADR-0012 — closes the loop SPA → BFF → DB. After this PR, a single user action (initial page load, click, form submit) produces one trace whose root span is owned by the SPA and whose child spans cover the BFF request, Postgres queries through Prisma, and (eventually) Redis / downstream-API hops.

## What lands

**Browser-side OTel libs** (production deps):

- `@opentelemetry/sdk-trace-web` — browser tracer + provider
- `@opentelemetry/exporter-trace-otlp-http` — OTLP/HTTP+JSON exporter
- `@opentelemetry/instrumentation` — auto-instrumentation runtime
- `@opentelemetry/instrumentation-fetch` — `fetch` + W3C `traceparent` propagation
- `@opentelemetry/instrumentation-document-load` — initial-paint timings
- `@opentelemetry/instrumentation-user-interaction` — click / keypress / submit

No `@opentelemetry/context-zone`: the workspace is zoneless per ADR-0004; the default `StackContextManager` covers the auto-instrumented paths. Custom spans across `await` will need explicit `context.with(...)` plumbing — fine, encountered as code lands.

**Code**:

- [`apps/portal-shell/src/observability/tracing.ts`](apps/portal-shell/src/observability/tracing.ts) — `WebTracerProvider` bootstrap. Documents the load-order constraint inline (same pattern as the BFF: must be the very first import of `main.ts`, otherwise auto-instrumentations miss everything imported above).
- `apps/portal-shell/src/main.ts` now imports the tracing module as line 1.

**CORS plumbing** for end-to-end trace propagation:

- BFF (`apps/portal-bff/src/main.ts`) calls `enableCors` with a minimal dev allowlist (`http://localhost:4200`) and explicit permission for the W3C `traceparent` / `tracestate` headers. The full security-grade CORS (per-environment allowlists, helmet, cookie-session, CSRF) belongs to the future phase-2 security ADR — this PR adds the strict minimum for the SPA→BFF trace context to survive cross-origin pre-flight.
- OTel Collector (`infra/local/otel-collector.yaml`) gains a `cors` block on its OTLP/HTTP receiver so the browser's own OTLP POST clears its pre-flight.

**ADR-0012 §Confirmation** rewritten: a new "Wired in the SPA foundation PR (phase 2)" block enumerates what landed here; the carry-over "Wired as features land" list drops the SPA-side SDK item and adds a follow-up note about the security-grade CORS.

## Verification

```bash
pnpm exec nx run-many -t lint test build           # 8 projects green
pnpm audit                                          # 0 vulns
./infra/local/dev.sh up observability               # bring up Collector + Jaeger
./infra/local/dev.sh                                # (separately, BFF stack — your choice)
pnpm nx serve portal-bff                           # localhost:3000
pnpm nx serve portal-shell                         # localhost:4200
```

Open http://localhost:4200 → a `document_load` trace appears in http://localhost:16686 with `service.name=portal-shell`. From DevTools, run `fetch('http://localhost:3000/api/health').then(r => r.json())` → a fetch span appears with a child BFF span on the same trace.

## Test plan

- [ ] CI green on this PR.
- [ ] After local up, `document_load` span visible in Jaeger UI for the SPA.
- [ ] Cross-origin fetch from SPA carries `traceparent` (visible in Network tab) and produces a single end-to-end trace SPA → BFF in Jaeger.
- [ ] DevTools console shows no CORS warnings about `traceparent`, `tracestate`, or the `localhost:4318/v1/traces` POST.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #72
2026-05-09 23:23:18 +02:00
julien b74d3f1b9b feat(portal-bff): observability foundations (Pino + CLS + OTel) (#70)
CI / commits (push) Has been skipped
CI / scan (push) Successful in 1m33s
CI / check (push) Successful in 2m3s
CI / a11y (push) Successful in 1m11s
CI / perf (push) Successful in 2m14s
## Summary

Implements ADR-0012 phase 1, BFF side. The SPA wiring is a separate phase-2 PR.

The BFF now emits structured JSON logs to stdout, tagged with `trace_id` / `span_id` from the active OTel context, and exports OTLP traces over HTTP/Protobuf to the Collector that already runs in the local-dev compose. Anything Nest, Express, HTTP-out, Prisma (Postgres) or `ioredis` does is auto-spanned. A `GET /api/health` liveness endpoint is added to round things out.

## What lands

**Runtime libs added** (production deps):

- `nestjs-pino`, `pino`, `pino-http` — structured logging
- `nestjs-cls` — request-scoped context
- `@opentelemetry/api` / `sdk-node` / `resources` / `semantic-conventions`
- `@opentelemetry/exporter-trace-otlp-proto` (HTTP/Protobuf, port 4318)
- `@opentelemetry/instrumentation-{http,express,nestjs-core,pg,ioredis,pino}` — curated, **no** `auto-instrumentations-node` mass-import (anti-bricolage)

Dev: `pino-pretty` (gated by `NODE_ENV`).

**Code:**

- `apps/portal-bff/src/observability/tracing.ts` — OTel `NodeSDK` bootstrap. Documents the load-order constraint inline (must be the very first import of `main.ts`). Pure side-effect module.
- `apps/portal-bff/src/observability/observability.module.ts` — composes `ClsModule` (UUID per request stored as `request_id`) and `LoggerModule` (`pino-pretty` in dev, raw JSON in prod, `LOG_LEVEL` env-driven, `/health` excluded from auto-logging, `X-Request-Id` honoured if inbound).
- `apps/portal-bff/src/health/{health.controller,health.module,health.controller.spec}.ts` — `GET /api/health` returning `{status, uptimeSeconds, service, version}`. Cheap liveness only — `/readiness` lands when dependencies have a readiness story.
- `apps/portal-bff/src/config/check-database-url.{ts,spec.ts}` — fail-fast validator called from `main.ts` before NestFactory boots. Catches the same family of bug that bit pgweb in #63: a literal special character in `POSTGRES_PASSWORD` that needs URL-encoding in `DATABASE_URL`. Prisma requires a URL string (no discrete-flag escape hatch), so early validation + a clear error message is the v1 mitigation. Six unit tests cover happy path, missing URL, wrong scheme, encoded special chars, literal `@` in password, malformed URL.

**Wiring:**

- `main.ts` imports `./observability/tracing` as line 1, then uses `app.get(Logger)` from `nestjs-pino` with `bufferLogs: true` so early-bootstrap lines are not lost.
- `app.module.ts` imports `ObservabilityModule` first, then `PrismaModule`, then `HealthModule`.
- `apps/portal-bff/.env.example` promotes `LOG_LEVEL`, `OTEL_SERVICE_NAME`, `OTEL_SERVICE_VERSION`, `OTEL_EXPORTER_OTLP_ENDPOINT`, `OTEL_EXPORTER_OTLP_PROTOCOL`, `OTEL_TRACES_SAMPLER` from the "future" comment to active settings — defaults target the local-dev Collector.
- Both `apps/portal-bff/.env.example` and `infra/local/.env.example` now spell out the URL-encoding constraint on `POSTGRES_PASSWORD` with the char-by-char encoding table (`@` → `%40`, etc.).

**ADR-0012 §Confirmation** rewritten to distinguish what landed in this PR from what is wired as the corresponding feature ADRs ship (CLS keys for `session_id` / `user_id_hash` / `audience`, `LOG_USER_ID_SALT` enforcement, redact list, custom spans, SPA-side SDK, full integration tests, prod Collector config).

## Trace ↔ log correlation

Automatic via `@opentelemetry/instrumentation-pino` — every Pino record gets `trace_id` and `span_id` injected from the active OTel context. No CLS gymnastics needed for that concern.

## Verification

```bash
pnpm exec nx run-many -t lint test build       # 8 projects green
pnpm audit --audit-level=moderate              # 0 vulnerabilities
./infra/local/dev.sh up observability          # start Collector + Jaeger
cp apps/portal-bff/.env.example apps/portal-bff/.env
pnpm nx serve portal-bff
curl http://localhost:3000/api/health
# → {"status":"ok","uptimeSeconds":N,"service":"portal-bff","version":"dev"}
```

Then hit `GET http://localhost:3000/api` once or twice and open http://localhost:16686 — the corresponding spans appear in Jaeger, and Pino logs on stdout carry the matching `trace_id`.

## Test plan

- [ ] `nx run-many -t lint test build` green on this PR's CI run.
- [ ] `pnpm audit` clean.
- [ ] BFF boots, `/api/health` returns the expected JSON.
- [ ] Pino logs in dev are colourised one-liners; in prod they would be raw JSON (toggled by `NODE_ENV=production`).
- [ ] With the local-dev stack's `--profile observability` active, traces are visible in Jaeger UI.
- [ ] Each Pino log line for a request carries the same `trace_id` as the trace span in Jaeger.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #70
2026-05-09 22:28:17 +02:00
julien fc9b63f24a feat(infra): add dev.sh wrapper for the local-dev compose stack (#68)
CI / check (push) Successful in 1m36s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m9s
CI / a11y (push) Successful in 1m9s
CI / perf (push) Successful in 3m1s
## Summary

Two recurring frictions on the local-dev stack:

1. **Compose-profile asymmetry** — `docker compose down` only operates on services whose profile is currently active. Anything brought up with `--profile X` keeps running unless the same flag is passed on `down`. pgweb and Jaeger silently survived several `down -v` invocations before we noticed (#67 documented the gotcha; this PR makes it impossible to hit if you use the wrapper).
2. **Verbose invocations** — typing `docker compose -f infra/local/dev.compose.yml --profile … <verb>` for routine ops gets old fast.

Add [`infra/local/dev.sh`](infra/local/dev.sh) as a thin wrapper. Always passes every profile in scope on teardown / status / log commands, exposes ergonomic verbs:

| Command                                       | Effect                                                          |
| --------------------------------------------- | --------------------------------------------------------------- |
| `./infra/local/dev.sh up`                     | Core only (postgres + redis + otel-collector)                   |
| `./infra/local/dev.sh up all`                 | Core + every profile                                            |
| `./infra/local/dev.sh up dbtools`             | Core + pgweb                                                    |
| `./infra/local/dev.sh up observability`       | Core + Jaeger                                                   |
| `./infra/local/dev.sh down [-v]`              | Tear down (every profile in scope, no orphaned services)        |
| `./infra/local/dev.sh stop <service>`         | Stop one service (containers stay around)                       |
| `./infra/local/dev.sh restart <service>`      | Restart one service                                             |
| `./infra/local/dev.sh status`                 | `ps` with every profile visible                                 |
| `./infra/local/dev.sh logs [service]`         | Follow logs                                                     |
| `./infra/local/dev.sh exec <service> <cmd>`   | Run a command inside a container                                |

Anything not matching one of the named verbs is passed through to `docker compose -f dev.compose.yml ...` (with every profile flagged in), so the full Compose surface remains available.

## Doc updates

- **`infra/README.md`** — new "Convenience script" subsection with the cheat-sheet table; "First-time setup" rewritten to use the script; the standalone "Profile symmetry" tip from #67 is collapsed into a one-liner since the script now handles it (the note remains as a fallback for direct `docker compose` users).
- **`docs/development.md` §3** — points at the script for the typical setup flow.

The compose file itself is unchanged.

## Test plan

- [ ] `./infra/local/dev.sh help` prints the usage block.
- [ ] `./infra/local/dev.sh up` brings up the 3 core services (no pgweb / no jaeger).
- [ ] `./infra/local/dev.sh up all` adds pgweb and Jaeger.
- [ ] `./infra/local/dev.sh down -v` stops and removes all 5 containers (incl. pgweb and Jaeger), wipes the postgres-data and redis-data volumes.
- [ ] `./infra/local/dev.sh stop pgweb` stops just pgweb.
- [ ] `./infra/local/dev.sh logs otel-collector` follows that service's logs.
- [ ] `./infra/local/dev.sh exec postgres psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "\du"` lists the audit roles.
- [ ] `./infra/local/dev.sh stop` (no arg) errors with a clear message.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #68
2026-05-09 21:50:37 +02:00
julien 4d2d4d652a docs(infra): document Compose profile + down/up symmetry gotcha (#67)
CI / commits (push) Has been skipped
CI / scan (push) Successful in 1m37s
CI / check (push) Successful in 1m38s
CI / a11y (push) Successful in 50s
CI / perf (push) Successful in 2m35s
## Summary

`docker compose -f infra/local/dev.compose.yml down -v` left pgweb and Jaeger running, with no error and no obvious diagnostic — they kept eating memory until the next reboot. Reason: Compose only operates on services whose profile is currently active. Bringing them up with `--profile dbtools` / `--profile observability` requires the same flag(s) on `down`, otherwise they're invisible to that command.

Document the gotcha in `infra/README.md` "Local-dev stack" → "Operational tips", with the two pragmatic resolutions:

1. Pass the same flags on each command (most explicit).
2. Set `COMPOSE_PROFILES=dbtools,observability` once in the shell or `infra/local/.env`, and let it propagate to every `up` / `down` / `ps` invocation.

## Test plan

- [ ] Bring up the full stack: `docker compose -f infra/local/dev.compose.yml --profile dbtools --profile observability up -d`.
- [ ] `docker compose -f infra/local/dev.compose.yml down -v` (no flags) — confirms pgweb + jaeger keep running.
- [ ] Run the documented "complete" command — confirms everything is gone.
- [ ] Repeat with `COMPOSE_PROFILES` set; same outcome.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #67
2026-05-09 21:40:28 +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 6137486d64 fix(infra): grant audit roles to current_user, not hardcoded portal (#60)
CI / check (push) Successful in 1m32s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 1m6s
CI / a11y (push) Successful in 53s
CI / perf (push) Successful in 2m13s
## Summary

The bootstrap SQL ended with:

```sql
GRANT audit_owner, audit_writer, audit_reader, audit_archiver TO portal;
```

— hard-coded `portal`. The compose file and `.env.example` both document `POSTGRES_USER` as overridable; any contributor who changed it hit:

```
ERROR: role "portal" does not exist
psql: /docker-entrypoint-initdb.d/01-init.sql:48: ERROR: role "portal" does not exist
```

Replace with `current_user`, which resolves at execution time to whoever is running the init SQL — i.e. the superuser Postgres just created from `POSTGRES_USER`, whatever its name.

## Recovery for anyone hit by the bug

The half-failed init left the postgres-data volume in a partially-initialised state. To reset:

```bash
cd infra/local
docker compose -f dev.compose.yml down -v   # wipes the volume
docker compose -f dev.compose.yml up -d     # bootstrap re-runs cleanly
```

## Test plan

- [ ] After merge + recovery: `docker compose ps` shows postgres healthy.
- [ ] `psql postgres://<user>:<pwd>@localhost:5432/portal_dev -c "\du"` lists the four `audit_*` roles, and your superuser is "Member of: {audit_owner, audit_writer, audit_reader, audit_archiver}".
- [ ] `psql ... -c "\dn"` shows the `audit` schema.
- [ ] Test with a non-default `POSTGRES_USER` value (set `POSTGRES_USER=apf_portal` in `.env`, wipe volume, re-up) — init still succeeds.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #60
2026-05-08 21:44:05 +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
julien efa660abab chore(infra): pin act_runner image pull policy (#10)
CI / check (push) Successful in 1m39s
CI / commits (push) Has been skipped
CI / scan (push) Failing after 52s
CI / a11y (push) Successful in 51s
CI / perf (push) Successful in 1m53s
## Summary
`act_runner`'s default `container.force_pull: true` re-issues a `docker pull` at the start of every job, adding 10–30 s of registry round-trip even when every layer is already locally cached. With job images pinned to specific tags (`catthehacker/ubuntu:act-22.04` and `:full-22.04`), the implicit pull is pure overhead and contradicts the deliberate-upgrade policy ADR-0015 spells out for the runner image.

- Add `infra/runner-config.yaml` with `container.force_pull: false`.
- Mount it read-only into all three runners and point each at it via `CONFIG_FILE=/etc/runner/config.yaml`.
- Document the pre-pull procedure and image-upgrade playbook in `infra/README.md` → "Job image pinning and pre-pull".
- Fold the pre-pull into the "First-time registration" walkthrough so a fresh setup is correct end-to-end.

The trade-off: the runner host must hold the images locally before the runner is asked to use them. Documented.

## Roll-out (manual, on the runner host)
```bash
cd infra/

# 1. Pre-pull the job images (one-shot — pays the cold cost once).
docker pull catthehacker/ubuntu:act-22.04
docker pull catthehacker/ubuntu:full-22.04

# 2. Recreate the runners so the new mount + env var take effect.
docker compose -f ci-runners.compose.yml up -d --force-recreate

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #10
2026-05-04 15:55:17 +02:00
julien f2440fbf24 fix(ci): disable broken pnpm cache on actions/setup-node (#8)
CI / a11y (push) Successful in 45s
CI / perf (push) Successful in 2m40s
CI / commits (push) Failing after 12m17s
CI / scan (push) Failing after 12m18s
CI / check (push) Failing after 12m19s
## Summary
`act_runner`'s built-in GitHub-Actions-cache server binds inside the runner container on the compose-defined `apf-portal-act-runners` bridge. Jobs spawned via the mounted Docker socket land on Docker's default `bridge` network and can't reach it. Every job opting into `cache: 'pnpm'` ate ~2 min `ETIMEDOUT` on restore + another ~2 min on save — across the 5 jobs, ~20 min wasted per CI run for zero cache hits.

Drop `cache: 'pnpm'` everywhere. `pnpm install --frozen-lockfile` is fast on the warm store inside the job container, so removing the cache layer is a net gain today.

The proper fix (cross-container networking / fixed-port cache binding) is documented in `infra/README.md` → "Cache server (deferred)" so it can be picked up as an isolated infra spike later.

## Test plan
- [ ] CI run on this PR: every job's `Set up Node.js` step finishes in seconds (no ETIMEDOUT warning).
- [ ] `Complete job` step also finishes promptly (no `reserveCache failed` warning).
- [ ] Total wall-clock time for the run should drop by ~15-20 min vs. previous runs.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #8
2026-05-04 15:33:04 +02:00
julien 02bdd43fa1 fix(ci): pin act_runner job image to catthehacker/ubuntu:act-22.04 (#4)
CI / commits (push) Has been skipped
CI / check (push) Failing after 3s
CI / scan (push) Failing after 9s
CI / a11y (push) Failing after 4s
CI / perf (push) Failing after 4s
## Summary

- Pin `act_runner`'s job container image to `catthehacker/ubuntu:act-22.04` via the `<label>:docker://<image>` format on the runner registration labels.
- Update `infra/ci-runners.compose.yml`'s `GITEA_RUNNER_LABELS` for all three runner services, with an inline comment explaining the format requirement.
- Amend ADR-0015 §"Runners" to specify the chosen image and explain the docker-suffix syntax trap.

## Motivation

The first real PR test of the CI pipeline failed at the very first step:

```
Run actions/checkout@v4
0s
Cannot find: node in PATH
 Failure - Main actions/checkout@v4
```

Root cause: `act_runner` registers labels (`self-hosted`, `on-prem`) without a `docker://` image suffix. Without that suffix, act spawns jobs in a minimal default container that has no Node. Every JavaScript action (`actions/checkout`, `actions/setup-node`, `nrwl/nx-set-shas`, the Trivy/gitleaks actions) crashes during the action's launch step. None of the five CI gates can run.

`catthehacker/ubuntu:act-22.04` is the de facto image used by `act` upstream and the standard recommendation for self-hosted Gitea Actions runners. It bundles Node, Python, git, common build tools, and the Docker CLI — exactly the assumed environment for GitHub Actions-compatible workflows.

## Implementation notes

- The fix lives in `GITEA_RUNNER_LABELS` because that's what `act_runner` reads at registration time. The label-to-image mapping is then persisted in the runner's `data/runner-N/.runner` credential.
- For runners **already registered** (i.e. the three currently-running `apf-portal-runner-N`), the persisted credential ignores the new env var. Their labels must be updated through Gitea's UI (Site Administration → Actions → Runners → each runner → edit `Labels`). This is documented in the commit message and is an operational follow-up to merging this PR.
- The compose-file change applies the next time a runner is re-registered (e.g. when `data/runner-N/` is wiped or a new fourth runner is added).
- ADR-0015 amendment is in-place, status remains `accepted`. The runner-image choice is an implementation detail under the existing decision; no new ADR.

## Verification

- [ ] `pnpm ci:check` — n/a, this PR only changes infra and ADR docs, no code paths.
- [ ] **Manual:** after merge + Gitea label updates, the next PR's `actions/checkout@v4` step runs without `Cannot find: node in PATH`.

## Related

- [ADR-0015 — CI/CD pipeline](docs/decisions/0015-cicd-gitea-actions.md). Amended in §"Runners".
- [`infra/README.md`](infra/README.md) — operational doc for the runners; mentions the registration workflow but predates this format requirement. A subsequent docs touch could mirror the new label format there too; deferred to keep this PR scoped to the actual fix.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #4
2026-05-04 11:06:56 +02:00
Julien Gautier 644d3603d2 chore: add docker compose for self-hosted Gitea act_runners
CI / check (pull_request) Failing after 6s
CI / commits (pull_request) Failing after 2s
CI / scan (pull_request) Failing after 6s
CI / a11y (pull_request) Failing after 2s
CI / perf (pull_request) Failing after 3s
Set up the infrastructure-as-code recipe to bring up the three
self-hosted Gitea Actions runners required by ADR-0015. The compose
file launches three act_runner instances pinned to 0.2.13, registered
with the project's Gitea organisation, labelled self-hosted + on-prem
to match the runs-on selector in every job under .gitea/workflows/*.

Layout:
- infra/                         new top-level folder for IaC
- infra/README.md                explains the folder, registration
                                 flow, security implications, future
                                 placeholders (local/, prod/, runbooks/)
- infra/ci-runners.compose.yml   three act_runner services, networked
                                 together, persisting credentials to
                                 ./data/runner-N
- infra/.env.example             GITEA_INSTANCE_URL +
                                 GITEA_RUNNER_REGISTRATION_TOKEN; .env
                                 itself stays git-ignored (root rule)
- infra/data/.gitignore          tracks the dir, ignores runtime state

Security posture (documented in infra/README.md): mounting
/var/run/docker.sock gives the runner root-equivalent access to the
host Docker daemon. Mitigations rely on (a) repo-scope of the runner
in Gitea, (b) running the runner host outside the production trust
boundary, (c) no extra host filesystem mounts. Future hardening
(rootless Docker, DinD sidecar) is flagged as deferred.

The compose pins the runner image (0.2.13). Bumps go through a
dedicated chore(deps) PR per the convention; image upgrades roll one
runner at a time so CI is never starved (procedure documented in the
README).

Doc indexing (docs/README.md) deliberately not touched here to avoid
a conflict with the pending docs/architecture-diagrams branch which
also modifies that file. A small follow-up PR will add an index entry
once that branch is merged.
2026-05-01 00:00:28 +02:00