ci: e2e — static in-network IP for the app (bypass DNS) + post-deploy chromium diagnostics

This commit is contained in:
2026-09-19 07:53:22 +00:00
parent 0bfbd2a2e8
commit e694011302
5 changed files with 60 additions and 18 deletions
+18 -17
View File
@@ -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).
+1
View File
@@ -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
+4 -1
View File
@@ -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:
+36
View File
@@ -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));
}
}
+1
View File
@@ -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,