c080d1ad89
## 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
156 lines
4.5 KiB
Bash
Executable File
156 lines
4.5 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Convenience wrapper around infra/local/dev.compose.yml. Documented
|
|
# in infra/README.md → "Local-dev stack" → "Convenience script".
|
|
#
|
|
# Hides the Compose-profile gotcha: `docker compose down` only acts
|
|
# on services whose profile is currently active, so anything brought
|
|
# up with `--profile X` keeps running unless the same flag is on
|
|
# `down`. Every teardown / status / log command in this script
|
|
# always includes every profile in scope, so profile-gated services
|
|
# are visible and stoppable.
|
|
|
|
set -euo pipefail
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
COMPOSE_FILE="${SCRIPT_DIR}/dev.compose.yml"
|
|
|
|
# Profiles defined in dev.compose.yml. Keep in sync if a new profile
|
|
# is added.
|
|
ALL_PROFILES=(dbtools observability serve-static apps)
|
|
|
|
# Build "--profile p1 --profile p2 …" as separate arguments.
|
|
build_all_profile_flags() {
|
|
local p
|
|
ALL_PROFILE_FLAGS=()
|
|
for p in "${ALL_PROFILES[@]}"; do
|
|
ALL_PROFILE_FLAGS+=(--profile "$p")
|
|
done
|
|
}
|
|
|
|
# Run docker compose with every profile flagged in.
|
|
dc_all() {
|
|
build_all_profile_flags
|
|
docker compose -f "$COMPOSE_FILE" "${ALL_PROFILE_FLAGS[@]}" "$@"
|
|
}
|
|
|
|
# Run docker compose with no profiles (core services only).
|
|
dc_core() {
|
|
docker compose -f "$COMPOSE_FILE" "$@"
|
|
}
|
|
|
|
# Run `up -d` with a custom set of profiles.
|
|
up_with_profiles() {
|
|
local p
|
|
local -a flags=()
|
|
for p in "$@"; do
|
|
flags+=(--profile "$p")
|
|
done
|
|
docker compose -f "$COMPOSE_FILE" "${flags[@]}" up -d
|
|
}
|
|
|
|
usage() {
|
|
cat <<'USAGE'
|
|
Usage: ./infra/local/dev.sh <command> [args]
|
|
|
|
Commands:
|
|
up [target...] Bring up the stack.
|
|
Targets:
|
|
(none) core only (postgres + redis + otel-collector)
|
|
all core + all profiles
|
|
dbtools core + pgweb
|
|
observability core + jaeger
|
|
serve-static core + caddy (production-build reverse proxy)
|
|
apps core + the three Nx dev servers in
|
|
Docker — no native Node/pnpm needed
|
|
(ADR-0030). portal-bff:3000,
|
|
portal-shell:4200, portal-admin:4300.
|
|
Multiple targets allowed (e.g. `up dbtools observability`).
|
|
|
|
down [-v] Tear the stack down. Always runs with every
|
|
profile in scope, so profile-gated services
|
|
(pgweb, jaeger) are caught too. -v also wipes
|
|
the named Docker volumes.
|
|
|
|
stop <service> Stop a single service (containers stay around).
|
|
Use `up <service>` (or restart) to bring it back.
|
|
|
|
restart <service> Restart a single service.
|
|
|
|
status docker compose ps, with every profile in scope.
|
|
|
|
logs [service] Follow logs (one service or all of them).
|
|
|
|
exec <service> <cmd> Run a command inside a running service container.
|
|
|
|
help Show this help.
|
|
|
|
<other> [args...] Anything else is passed through to
|
|
`docker compose -f dev.compose.yml ...` with
|
|
every profile in scope.
|
|
|
|
Examples:
|
|
./infra/local/dev.sh up
|
|
./infra/local/dev.sh up all
|
|
./infra/local/dev.sh up apps
|
|
./infra/local/dev.sh up observability
|
|
./infra/local/dev.sh down -v
|
|
./infra/local/dev.sh stop pgweb
|
|
./infra/local/dev.sh logs otel-collector
|
|
./infra/local/dev.sh exec postgres psql -U "$POSTGRES_USER" -d "$POSTGRES_DB"
|
|
USAGE
|
|
}
|
|
|
|
require_args() {
|
|
local verb="$1"
|
|
local n="$2"
|
|
local hint="$3"
|
|
if [[ $# -lt $((3 + n)) ]]; then
|
|
echo "Error: '$verb' requires $hint." >&2
|
|
echo "Try: ./infra/local/dev.sh help" >&2
|
|
exit 64
|
|
fi
|
|
}
|
|
|
|
cmd="${1:-help}"
|
|
[[ $# -gt 0 ]] && shift
|
|
|
|
case "$cmd" in
|
|
up | start)
|
|
if [[ $# -eq 0 ]]; then
|
|
dc_core up -d
|
|
elif [[ "$1" == "all" ]]; then
|
|
dc_all up -d
|
|
else
|
|
up_with_profiles "$@"
|
|
fi
|
|
;;
|
|
down)
|
|
dc_all down "$@"
|
|
;;
|
|
stop)
|
|
require_args stop 1 "a service name (use 'down' to tear down the whole stack)" "$@"
|
|
dc_all stop "$@"
|
|
;;
|
|
restart)
|
|
require_args restart 1 "a service name" "$@"
|
|
dc_all restart "$@"
|
|
;;
|
|
status | ps)
|
|
dc_all ps "$@"
|
|
;;
|
|
logs)
|
|
dc_all logs -f "$@"
|
|
;;
|
|
exec)
|
|
require_args exec 2 "a service name and a command" "$@"
|
|
dc_all exec "$@"
|
|
;;
|
|
help | -h | --help | "")
|
|
usage
|
|
;;
|
|
*)
|
|
# Pass-through to docker compose, with profiles in scope.
|
|
dc_all "$cmd" "$@"
|
|
;;
|
|
esac
|