From f16e279f5856095b7947f909ea983b48cdd82190 Mon Sep 17 00:00:00 2001 From: myrmidex Date: Tue, 30 Jun 2026 21:07:45 +0200 Subject: [PATCH] 35 - Add prod image SPA serving, health endpoint, and Forgejo CI --- .forgejo/workflows/ci.yml | 256 ++++++++++++++++++++++++++++ .forgejo/workflows/image.yml | 46 +++++ Dockerfile | 25 ++- compose.override.yaml | 29 ++++ frankenphp/Caddyfile | 42 +++-- src/Controller/HealthController.php | 37 ++++ tests/Functional/HealthApiTest.php | 57 +++++++ 7 files changed, 477 insertions(+), 15 deletions(-) create mode 100644 .forgejo/workflows/ci.yml create mode 100644 .forgejo/workflows/image.yml create mode 100644 src/Controller/HealthController.php create mode 100644 tests/Functional/HealthApiTest.php diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml new file mode 100644 index 0000000..6917131 --- /dev/null +++ b/.forgejo/workflows/ci.yml @@ -0,0 +1,256 @@ +name: CI + +# Tests + static analysis + lint. Runs on every push to a release branch and on +# PRs targeting main. The production image is built/pushed separately and ONLY on +# a v* tag (see image.yml) — never here. +on: + push: + branches: ['release/*'] + pull_request: + branches: [main] + +jobs: + backend: + runs-on: docker + container: + image: catthehacker/ubuntu:act-latest + + services: + database: + image: postgres:16-alpine + env: + POSTGRES_DB: buckets + POSTGRES_USER: buckets + POSTGRES_PASSWORD: buckets + options: >- + --health-cmd "pg_isready -U buckets -d buckets" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + + env: + # Point the test suite at the CI postgres service (host = service name). + DATABASE_URL: postgresql://buckets:buckets@database:5432/buckets?serverVersion=16&charset=utf8 + + steps: + - uses: https://data.forgejo.org/actions/checkout@v4 + + - name: Set up PHP + uses: https://github.com/shivammathur/setup-php@v2 + with: + php-version: '8.4' + extensions: pdo_pgsql, mbstring, xml, dom, intl, zip + coverage: pcov + + - name: Cache Composer dependencies + uses: https://data.forgejo.org/actions/cache@v4 + with: + path: ~/.composer/cache + key: composer-${{ hashFiles('composer.lock') }} + restore-keys: composer- + + - name: Install Composer dependencies + run: composer install --no-interaction --prefer-dist + + - name: Lint (php-cs-fixer) + run: vendor/bin/php-cs-fixer check --diff + env: + PHP_CS_FIXER_IGNORE_ENV: '1' + + - name: Static analysis (PHPStan) + run: vendor/bin/phpstan analyse --memory-limit=1G -c phpstan.dist.neon + + - name: Create test database + run: php bin/console --env=test doctrine:database:create --if-not-exists + + - name: Run migrations + run: php bin/console --env=test doctrine:migrations:migrate --no-interaction + + - name: Tests with coverage + run: php bin/phpunit --coverage-clover coverage.xml + + # Backend sits at ~98.8% clover element coverage (a few framework-glue + # elements are @codeCoverageIgnore'd in the text report but still counted + # in the clover XML). Gate at a 95% floor: catches real backslide without + # demanding an unreachable 100% off the raw clover metric (mirrors the + # frontend's vite.config thresholds, which are floors, not 100). + - name: Enforce coverage threshold + run: | + MIN=95 + COVERAGE=$(php -r ' + $xml = @simplexml_load_file("coverage.xml"); + if ($xml === false || !isset($xml->project->metrics)) { echo "0"; exit; } + $m = $xml->project->metrics; + $el = (int) $m["elements"]; + $cov = (int) $m["coveredelements"]; + echo $el > 0 ? round(($cov / $el) * 100, 2) : 0; + ') + echo "Coverage: ${COVERAGE}% (minimum ${MIN}%)" + php -r "exit((float)\$argv[1] >= (float)\$argv[2] ? 0 : 1);" "$COVERAGE" "$MIN" \ + || { echo "::error::Coverage ${COVERAGE}% is below the ${MIN}% threshold"; exit 1; } + + frontend: + runs-on: docker + container: + image: catthehacker/ubuntu:act-latest + + defaults: + run: + working-directory: frontend + + steps: + - uses: https://data.forgejo.org/actions/checkout@v4 + + # Node is preinstalled in catthehacker/ubuntu:act-latest (no setup-node + # needed). npm ci is lockfile-driven, so the image's npm version is fine; + # the *production* frontend build pins NPM_VERSION inside the Dockerfile. + - name: Cache npm dependencies + uses: https://data.forgejo.org/actions/cache@v4 + with: + path: ~/.npm + key: npm-${{ hashFiles('frontend/package-lock.json') }} + restore-keys: npm- + + - name: Install dependencies + run: npm ci + + - name: Lint (oxlint) + run: npm run lint + + - name: Format check (Prettier) + run: npm run format:check + + - name: Type check (tsc) + run: npx tsc -b + + # Coverage thresholds are enforced inside vite.config.ts (test:coverage + # fails on backslide) — no separate gate step needed. + - name: Tests with coverage + run: npm run test:coverage + + # End-to-end smoke tests. Unlike local (where compose stands up dev services), + # CI builds the PRODUCTION image and runs Playwright against it — so e2e + # exercises the real shipped artifact: the SPA served by Caddy with same-origin + # /api/* routing (Phase A). The prod entrypoint waits for the DB and runs + # migrations itself, so no separate migrate step is needed here. + # + # NOTE: this BUILDS the prod image to test against; it never PUSHES it. Image + # publishing stays tag-gated (see image.yml). Building-to-test on release/* is + # deliberate and does not cross the publish lock. + e2e: + runs-on: docker + container: + image: catthehacker/ubuntu:act-latest + + needs: [backend, frontend] + + steps: + - uses: https://data.forgejo.org/actions/checkout@v4 + + - name: Set up Docker Buildx + uses: https://data.forgejo.org/docker/setup-buildx-action@v3 + + - name: Build production image + uses: https://data.forgejo.org/docker/build-push-action@v5 + with: + context: . + target: frankenphp_prod + build-args: | + NPM_VERSION=11.17.0 + push: false + load: true + tags: buckets-prod:e2e + + # Stand up postgres + the app as plain `docker run` containers on an + # explicitly-created network. We do NOT use a `services:` postgres + the + # `job.container.network` context here: that field is GitHub-specific and + # support in Forgejo's act_runner is unreliable, so an empty value would + # silently drop `app` onto the default bridge where it can't resolve the + # DB by hostname. Creating the network ourselves keeps name resolution + # under our control and portable across runner versions. + # + # Pre-clean first: if a prior run crashed mid-job on a shared Docker + # daemon, the leftover containers/network would make `create`/`run --name` + # fail on "name already in use". `|| true` keeps a clean first run quiet. + - name: Create the e2e network + run: | + docker rm -f app database 2>/dev/null || true + docker network rm e2e-net 2>/dev/null || true + docker network create e2e-net + + # Playwright runs INSIDE this job container and targets the app by hostname + # (E2E_BASE_URL=http://app:80), so the job container must share e2e-net to + # resolve `app`. With a `services:` block the runner wires this up for us; + # since we manage the network ourselves, connect the job container — its + # $HOSTNAME is the container id act_runner assigned us (Docker's default + # hostname). If a future runner overrides the hostname this step fails + # LOUDLY (network connect errors) rather than silently — acceptable since + # the whole e2e job is unverified on the real runner until the first + # release/* push. + - name: Join the job container to the e2e network + run: docker network connect e2e-net "$HOSTNAME" + + - name: Start the database + run: | + docker run -d --name database --network e2e-net \ + -e POSTGRES_DB=buckets \ + -e POSTGRES_USER=buckets \ + -e POSTGRES_PASSWORD=buckets \ + --health-cmd "pg_isready -U buckets -d buckets" \ + --health-interval 5s --health-timeout 5s --health-retries 10 \ + postgres:16-alpine + + - name: Wait for the database to be healthy + run: | + for i in $(seq 1 30); do + if [ "$(docker inspect -f '{{.State.Health.Status}}' database)" = "healthy" ]; then + echo "Database is up."; exit 0 + fi + echo "Waiting for database... ($i)"; sleep 2 + done + echo "Database did not become healthy — dumping logs:"; docker logs database; exit 1 + + - name: Start the app + run: | + docker run -d --name app --network e2e-net \ + -e SERVER_NAME=":80" \ + -e APP_SECRET=ci-e2e-secret \ + -e DATABASE_URL="postgresql://buckets:buckets@database:5432/buckets?serverVersion=16&charset=utf8" \ + -e MERCURE_PUBLISHER_JWT_KEY="!ChangeThisMercureHubJWTSecretKey!" \ + -e MERCURE_SUBSCRIBER_JWT_KEY="!ChangeThisMercureHubJWTSecretKey!" \ + buckets-prod:e2e + + # Wait for a FULLY healthy app, not just a reachable one: check the body + # for "db":"ok" (same precision as the compose healthcheck), so a 503 from + # an app that booted but can't reach the DB fails HERE with a clear "did + # not become healthy" message instead of confusingly mid-Playwright. + - name: Wait for the app to be healthy + run: | + for i in $(seq 1 60); do + if docker exec app php -r 'exit(str_contains((string) @file_get_contents("http://127.0.0.1:80/api/health"), "\"db\":\"ok\"") ? 0 : 1);' 2>/dev/null; then + echo "App is up."; exit 0 + fi + echo "Waiting for app... ($i)"; sleep 2 + done + echo "App did not become healthy — dumping logs:"; docker logs app; exit 1 + + # Node is preinstalled in the runner image (see frontend job note), but the + # Chromium binary is not — install it (with OS deps) before running. + - name: Install Playwright dependencies + working-directory: e2e + run: npm ci + + - name: Install Playwright browser + working-directory: e2e + run: npx playwright install --with-deps chromium + + - name: Run Playwright tests + working-directory: e2e + env: + # Target the app container by name on the shared job network. + E2E_BASE_URL: http://app:80 + run: npm test + + - name: Dump app logs on failure + if: failure() + run: docker logs app || true diff --git a/.forgejo/workflows/image.yml b/.forgejo/workflows/image.yml new file mode 100644 index 0000000..6c1c262 --- /dev/null +++ b/.forgejo/workflows/image.yml @@ -0,0 +1,46 @@ +name: Build and Push Production Image + +# Builds the production image and pushes it to the Forgejo registry. +# TAG-PUSH ONLY: triggers exclusively on a v* tag (e.g. v0.3.0) — never on a +# branch push or PR. This matches the release flow: merge to main → CI green → +# cut a v* tag → that tag (and only that tag) builds + publishes the image. +on: + push: + tags: ['v*'] + +jobs: + build: + runs-on: docker + container: + image: catthehacker/ubuntu:act-latest + + steps: + - uses: https://data.forgejo.org/actions/checkout@v4 + + - name: Set up Docker Buildx + uses: https://data.forgejo.org/docker/setup-buildx-action@v3 + + - name: Login to Forgejo Registry + uses: https://data.forgejo.org/docker/login-action@v3 + with: + registry: forge.lvl0.xyz + # Forgejo mirrors GitHub's context naming, so `github.actor` resolves to + # the user who pushed the tag (there is no `forgejo.actor`). + username: ${{ github.actor }} + password: ${{ secrets.REGISTRY_TOKEN }} + + - name: Build and push + uses: https://data.forgejo.org/docker/build-push-action@v5 + with: + context: . + # The repo Dockerfile is multi-target; the production stage is + # frankenphp_prod (compiles the frontend in frontend_builder + bundles + # the dist into the FrankenPHP image). + target: frankenphp_prod + build-args: | + NPM_VERSION=11.17.0 + platforms: linux/amd64 + push: true + tags: | + forge.lvl0.xyz/lvl0/buckets:${{ github.ref_name }} + forge.lvl0.xyz/lvl0/buckets:latest diff --git a/Dockerfile b/Dockerfile index 1ab492a..9a7aeca 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,6 +8,23 @@ FROM dunglas/frankenphp:1-php8.4 AS frankenphp_upstream # https://docs.docker.com/reference/compose-file/build/#target +# Frontend build: compile the React/Vite SPA to static assets (frontend/dist), +# copied into the prod image's web root below. npm pinned to match the project +# (NPM_VERSION — same single source as the dev images, passed as a build arg). +FROM node:22-alpine AS frontend_builder + +ARG NPM_VERSION +WORKDIR /frontend + +RUN npm install -g "npm@${NPM_VERSION}" + +# Install deps from the lockfile first (cache-friendly), then build. +COPY --link frontend/package.json frontend/package-lock.json ./ +RUN npm ci +COPY --link frontend/ ./ +RUN npm run build + + # Base FrankenPHP image FROM frankenphp_upstream AS frankenphp_base @@ -98,6 +115,9 @@ RUN composer install --no-cache --prefer-dist --no-dev --no-autoloader --no-scri # copy sources COPY --link --exclude=frankenphp/ . ./ +# Built SPA → a dedicated static root Caddy serves for all non-/api routes. +COPY --link --from=frontend_builder /frontend/dist ./frontend-dist + RUN <<-EOF mkdir -p var/cache var/log var/share composer dump-autoload --classmap-authoritative --no-dev @@ -117,8 +137,9 @@ RUN <<-'EOF' apt-get update apt-get install -y --no-install-recommends libtree mkdir -p /tmp/libs - BINARIES=(frankenphp php file) - for target in $(printf '%s\n' "${BINARIES[@]}" | xargs -I{} which {}) \ + # POSIX sh — podman runs heredocs under /bin/sh regardless of the SHELL + # directive, so no bash arrays here (space-separated list + word splitting). + for target in $(for b in frankenphp php file; do which "$b"; done) \ $(find "$(php -r 'echo ini_get("extension_dir");')" -maxdepth 2 -name "*.so"); do libtree -pv "$target" 2>/dev/null | grep -oP '(?:── )\K/\S+(?= \[)' | while IFS= read -r lib; do [ -f "$lib" ] && cp -n "$lib" /tmp/libs/ diff --git a/compose.override.yaml b/compose.override.yaml index 1dbe927..cab9053 100644 --- a/compose.override.yaml +++ b/compose.override.yaml @@ -30,6 +30,30 @@ services: extra_hosts: # Ensure that host.docker.internal is correctly defined on Linux - host.docker.internal:host-gateway + # Marks php healthy only once the backend can serve a request AND reach the + # DB (GET /api/health → {status:ok,db:ok}). Overrides the image's baked-in + # HEALTHCHECK (which only probes Caddy's :2019/metrics = "web server alive", + # no DB). This is observability only — nothing in the dev path *blocks* on it + # (the e2e race-prevention lives in the CI e2e job, which waits on + # /api/health before Playwright; gating `frontend` on it here only risks + # hanging `dev-up`). + # Use 127.0.0.1, NOT localhost: the container resolves localhost to ::1 + # (IPv6) first, but FrankenPHP binds :80 on IPv4 → an IPv6 probe is refused + # and the container never goes healthy (same gotcha as #52). The probe is a + # SINGLE `php -r` expression: podman feeds the test args straight to the + # binary and does NOT preserve a multi-statement script (newlines collapse → + # parse error), so keep it to one expression — `str_contains` the body for + # the healthy marker, exit 0/1. + healthcheck: + test: + - CMD + - php + - -r + - exit(str_contains((string) @file_get_contents("http://127.0.0.1:80/api/health"), "\"db\":\"ok\"") ? 0 : 1); + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s tty: true # Standalone React/Vite SPA (dev only — not in compose.prod.yaml; see #33/#35). @@ -50,6 +74,11 @@ services: - "${VITE_PORT:-5173}:5173" command: sh -c "npm install && npm run dev -- --host 0.0.0.0 --port 5173" depends_on: + # Start php first, but DON'T gate on its health — a not-yet-ready backend + # only means the Vite proxy 502s a few early /api calls until php boots, + # which self-heals. Gating on `service_healthy` here turned a flaky/slow + # php healthcheck into a `dev-up` hang. e2e (CI) does the real readiness + # wait on /api/health itself before driving the browser. - php environment: # Reliable file-watching under the bind-mount. diff --git a/frankenphp/Caddyfile b/frankenphp/Caddyfile index fef196e..47596b4 100644 --- a/frankenphp/Caddyfile +++ b/frankenphp/Caddyfile @@ -44,23 +44,39 @@ # Disable Topics tracking if not enabled explicitly: https://github.com/jkarlin/topics header ?Permissions-Policy "browsing-topics=()" - @phpRoute { - not path /.well-known/mercure* - not file {path} - } - rewrite @phpRoute index.php + # Backend (Symfony) owns /api/* and Mercure; everything else is the SPA. + # `route` preserves declaration order so the backend matchers win before the + # SPA catch-all. + @backend path /api/* /.well-known/mercure* - @frontController path index.php - php @frontController { - {$FRANKENPHP_SITE_CONFIG} + handle @backend { + @phpRoute { + not path /.well-known/mercure* + not file {path} + } + rewrite @phpRoute index.php - worker { - file ./public/index.php - {$FRANKENPHP_WORKER_CONFIG} + @frontController path index.php + php @frontController { + {$FRANKENPHP_SITE_CONFIG} + + worker { + file ./public/index.php + {$FRANKENPHP_WORKER_CONFIG} + } + } + + file_server { + hide *.php } } - file_server { - hide *.php + # Single-page app: serve built static assets, falling back to index.html so + # client-side routes (/login, /register, …) resolve. The dist is copied to + # /app/frontend-dist by the prod image build (see Dockerfile). + handle { + root * /app/frontend-dist + try_files {path} /index.html + file_server } } diff --git a/src/Controller/HealthController.php b/src/Controller/HealthController.php new file mode 100644 index 0000000..c890666 --- /dev/null +++ b/src/Controller/HealthController.php @@ -0,0 +1,37 @@ +executeQuery('SELECT 1'); + } catch (\Throwable $e) { + // The 503 body stays generic (no leak), but log the cause so a + // flapping health check is diagnosable from app logs, not just from + // the healthcheck's own retry count. + $logger->error('Health check failed: database unreachable', ['exception' => $e]); + + return $this->json( + ['status' => 'error', 'db' => 'unreachable'], + JsonResponse::HTTP_SERVICE_UNAVAILABLE, + ); + } + + return $this->json(['status' => 'ok', 'db' => 'ok']); + } +} diff --git a/tests/Functional/HealthApiTest.php b/tests/Functional/HealthApiTest.php new file mode 100644 index 0000000..e086ee3 --- /dev/null +++ b/tests/Functional/HealthApiTest.php @@ -0,0 +1,57 @@ +request('GET', '/api/health'); + + $response = $client->getResponse(); + + self::assertSame( + 200, + $response->getStatusCode(), + 'GET /api/health must return 200 and require no authentication.', + ); + + $body = json_decode((string) $response->getContent(), true); + + self::assertIsArray($body); + self::assertSame('ok', $body['status'] ?? null); + self::assertSame('ok', $body['db'] ?? null); + } + + public function testItReportsUnhealthyWhenTheDatabaseIsUnreachable(): void + { + $client = static::createClient(); + + // Swap the DBAL connection for a stub that fails the ping, simulating an + // unreachable database without touching the real one. Overriding the + // bare Connection::class id is what the controller actually receives: + // autowiring keys by FQCN, so the `Connection $connection` parameter + // resolves to this same service. + $connection = $this->createStub(Connection::class); + $connection->method('executeQuery') + ->willThrowException(new \RuntimeException('connection refused')); + static::getContainer()->set(Connection::class, $connection); + + $client->request('GET', '/api/health'); + + $response = $client->getResponse(); + + self::assertSame(503, $response->getStatusCode()); + + $body = json_decode((string) $response->getContent(), true); + + self::assertIsArray($body); + self::assertSame('error', $body['status'] ?? null); + self::assertSame('unreachable', $body['db'] ?? null); + } +}