From e694011302b4712ec94a9061ebca39925dfc73e5 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Fri, 18 Sep 2026 23:56:45 +0000 Subject: [PATCH] =?UTF-8?q?ci:=20e2e=20=E2=80=94=20static=20in-network=20I?= =?UTF-8?q?P=20for=20the=20app=20(bypass=20DNS)=20+=20post-deploy=20chromi?= =?UTF-8?q?um=20diagnostics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/ci.yml | 35 ++++++++++++++++++----------------- CHANGELOG.md | 1 + docker-compose.ci.yml | 5 ++++- e2e/ci-diagnostics.ts | 36 ++++++++++++++++++++++++++++++++++++ playwright.config.ts | 1 + 5 files changed, 60 insertions(+), 18 deletions(-) create mode 100644 e2e/ci-diagnostics.ts diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 2e290eb..9cef9df 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -17,19 +17,30 @@ jobs: # The runner provides a working docker CLI out of the box (full-dind) — no endpoint # resolution needed. Job and app containers share the daemon, so put them on one - # user-defined network and reach the app by service DNS (the job's netns has no - # default route here, so host-IP URLs are unreliable). The alias `app` is declared - # in docker-compose.ci.yml. + # user-defined network with a pinned subnet; the app gets a STATIC IP (172.39.0.10, + # declared in docker-compose.ci.yml) because Chromium got ERR_SSL_PROTOCOL_ERROR via + # the DNS alias while node-side HTTP to the same name worked — bypassing DNS removes + # that variable entirely. A stale network with a different subnet is recreated. - name: Prepare e2e network run: | set -u - docker network inspect flight-ci-e2e >/dev/null 2>&1 || docker network create flight-ci-e2e + SUBNET=172.39.0.0/24 + if docker network inspect flight-ci-e2e >/dev/null 2>&1; then + cur=$(docker network inspect flight-ci-e2e --format '{{(index .IPAM.Config 0).Subnet}}') + if [ "$cur" != "$SUBNET" ]; then + echo "stale flight-ci-e2e with subnet $cur — recreating with $SUBNET" + docker network rm flight-ci-e2e || true + docker network create --subnet "$SUBNET" flight-ci-e2e + fi + else + docker network create --subnet "$SUBNET" flight-ci-e2e + fi if ! docker network connect flight-ci-e2e "$(hostname)"; then echo "::error::failed to attach job container '$(hostname)' to flight-ci-e2e — the job must run in a container on the shared daemon (check runner config)" >&2 exit 1 fi docker network inspect flight-ci-e2e --format 'containers on flight-ci-e2e: {{range .Containers}}{{.Name}} {{end}}' - echo "E2E_BASE_URL=http://app:3000" >> "$GITHUB_ENV" + echo "E2E_BASE_URL=http://172.39.0.10:3000" >> "$GITHUB_ENV" - name: Setup Node uses: actions/setup-node@v4 @@ -132,18 +143,8 @@ jobs: # in-network app host out of any proxy path. NO_PROXY: app,localhost,127.0.0.1,.kkhost.pl no_proxy: app,localhost,127.0.0.1,.kkhost.pl - run: | - # --- e2e connectivity diagnostics (non-fatal; evidence for ERR_SSL_PROTOCOL_ERROR hunt) --- - echo "=== proxy env ===" - env | grep -iE '^(http_proxy|https_proxy|all_proxy|no_proxy)=' || echo "(none set)" - echo "=== /etc/hosts ===" - cat /etc/hosts - echo "=== DNS for app ===" - getent hosts app || echo "getent: no result" - echo "=== curl http://app:3000/api/version (node-side) ===" - timeout 15 curl -sv http://app:3000/api/version 2>&1 | head -40 || true - # --- end diagnostics --- - npx playwright test + # Connectivity diagnostics now run post-deploy in e2e/ci-diagnostics.ts (globalSetup). + run: npx playwright test # The daemon is shared with production — never leave our stack or network behind. # -v drops the named DB volume so every run seeds fresh (no schema drift between PRs). diff --git a/CHANGELOG.md b/CHANGELOG.md index eef082b..f72228a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - **E2e image build needs no outbound network from dind build containers** (DNSResolveFailed in `RUN bun install` across runs #10/#12/#15): node_modules is now installed in the CI job / dev host and copied into the build context (.dockerignore no longer excludes it; Dockerfile COPYs it instead of running any install inside the image); base images switched alpine → node:22-slim (glibc) for native-module ABI compatibility with the job-installed tree (@next/swc-*, sharp are prebuilt per platform — CI job and prod host are x86_64 glibc linux); a "Pull e2e base image" step fails fast if the daemon can't fetch node:22-slim - **CI e2e deploy command stays alive** (`E2E_DEPLOY_CMD` now ends with `docker compose … logs -f`): Playwright's webServer requires a long-running process — `up -d` returns immediately and triggered "Process from config.webServer exited early" (run #16); `logs -f` also streams app logs into CI output for debugging - **CI e2e step bypasses proxies for the in-network app host** (`NO_PROXY`/`no_proxy=app,localhost,127.0.0.1,.kkhost.pl`) and prints proxy/DNS/curl diagnostics before the suite — run #18 showed Chromium getting `ERR_SSL_PROTOCOL_ERROR` on http://app:3000 while node-side HTTP worked (9 API-only tests passed), pointing at a TLS-terminating proxy env var that Chromium honors but Node's HTTP client ignores +- **CI e2e targets the app at a static in-network IP** (`172.39.0.10`, pinned subnet `172.39.0.0/24` created by "Prepare e2e network"; stale networks with other subnets are recreated) instead of the DNS alias — Chromium got `ERR_SSL_PROTOCOL_ERROR` via the hostname while node-side HTTP worked, so DNS is removed from the equation; post-deploy connectivity diagnostics added as a CI-gated globalSetup (`e2e/ci-diagnostics.ts`, no-op locally) comparing node resolution/HTTP against Chromium navigation on both hostname and static IP - Verified: unit suite 234 green locally; e2e not run locally by design (would deploy to production) — first real pass happens in the PR's CI run ## v0.12.2 — 2026-09-18 14:06 diff --git a/docker-compose.ci.yml b/docker-compose.ci.yml index 1724adf..12615ab 100644 --- a/docker-compose.ci.yml +++ b/docker-compose.ci.yml @@ -1,7 +1,9 @@ # Isolated e2e stack for CI (.gitea/workflows/ci.yml) — runs on the runner's shared dind # daemon under project name flight-ci (distinct from production). Joins the external # network flight-ci-e2e that the workflow attaches the job container to; the app is reached -# via service DNS as http://app:3000 (alias declared below) — no host port mapping needed. +# at its STATIC IP http://172.39.0.10:3000 — DNS bypassed for the browser because Chromium +# got ERR_SSL_PROTOCOL_ERROR via the hostname while node-side HTTP worked (the `app` alias +# stays as a convenience). Single deterministic address; survives `docker compose restart flight`. # The build context is tarred by the compose client in the job and sent over DOCKER_HOST, # so nothing here needs to be visible from inside dind; game state lives in a named volume # (fresh per run — CI cleanup removes it — survives `docker compose restart flight` for @@ -18,6 +20,7 @@ services: flight-ci-e2e: aliases: - app + ipv4_address: 172.39.0.10 networks: flight-ci-e2e: diff --git a/e2e/ci-diagnostics.ts b/e2e/ci-diagnostics.ts new file mode 100644 index 0000000..1ae1c99 --- /dev/null +++ b/e2e/ci-diagnostics.ts @@ -0,0 +1,36 @@ +// CI-only connectivity diagnostics (globalSetup runs after the e2e stack is ready). +// Evidence for the Chromium ERR_SSL_PROTOCOL_ERROR hunt: compares node-side resolution/HTTP +// against Chromium's own navigation, hostname vs static IP. Never fails the run. +import { promises as dnsPromises } from 'node:dns'; +import { chromium } from '@playwright/test'; + +export default async function ciDiagnostics() { + if (process.env.PW_REMOTE_BROWSER !== '0') return; // local runs: no-op + const base = process.env.E2E_BASE_URL ?? ''; + try { + console.log('[diag] node resolve4(app):', JSON.stringify(await dnsPromises.resolve4('app').catch((e) => `failed: ${e.code}`))); + try { + const r = await fetch(`${base}/api/version`); + console.log('[diag] node fetch', base, '->', r.status, JSON.stringify(await r.json().catch(() => null))); + } catch (e) { + console.log('[diag] node fetch failed:', String(e)); + } + const browser = await chromium.launch(); + try { + for (const url of ['http://app:3000/', base]) { + const page = await (await browser.newContext()).newPage(); + try { + await page.goto(url, { timeout: 15_000 }); + console.log('[diag] chromium goto', url, '-> OK, title:', await page.title().catch(() => '?')); + } catch (e) { + console.log('[diag] chromium goto', url, '-> FAILED:', String(e).split('\n')[0]); + } + await page.context().close(); + } + } finally { + await browser.close(); + } + } catch (e) { + console.log('[diag] diagnostics error (non-fatal):', String(e)); + } +} diff --git a/playwright.config.ts b/playwright.config.ts index a823770..5086e7d 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -17,6 +17,7 @@ const baseUrl = process.env.E2E_BASE_URL ?? 'https://flight.dev.kkhost.pl'; export default defineConfig({ testDir: './e2e', + globalSetup: './e2e/ci-diagnostics.ts', // Serial execution: e2e/state.spec.ts restarts the compose container mid-run, // which would break any map test running concurrently in another worker. workers: 1,