From 288627243bd50efcf2a7edc5cfb45c7b64fa7f46 Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Fri, 24 Apr 2026 20:27:44 +0200 Subject: [PATCH] Resolve edge E2E host routing in CI --- .github/docker-compose.ci.yml | 14 ++++++ .github/workflows/tests.yml | 7 +-- scripts/edge-gateway-e2e.mjs | 85 ++++++++++++++++++++++++++++++----- 3 files changed, 88 insertions(+), 18 deletions(-) diff --git a/.github/docker-compose.ci.yml b/.github/docker-compose.ci.yml index b607519f..c23789aa 100644 --- a/.github/docker-compose.ci.yml +++ b/.github/docker-compose.ci.yml @@ -1,5 +1,19 @@ services: + edge-broker: + labels: + - "traefik.http.routers.edge-broker-local-ci.rule=PathPrefix(`/api/edge-broker`)" + - "traefik.http.routers.edge-broker-local-ci.entrypoints=web" + - "traefik.http.routers.edge-broker-local-ci.middlewares=secure-headers@file,edge-broker-strip-local" + - "traefik.http.routers.edge-broker-local-ci.priority=190" + - "traefik.http.routers.edge-broker-local-ci.service=edge-broker" + caddy: + labels: + - "traefik.http.routers.local-api-ci.rule=PathPrefix(`/api`)" + - "traefik.http.routers.local-api-ci.entrypoints=web" + - "traefik.http.routers.local-api-ci.middlewares=strip-api-prefix@file,secure-headers@file" + - "traefik.http.routers.local-api-ci.priority=90" + - "traefik.http.routers.local-api-ci.service=caddy" volumes: - ci_php_app:/var/www/html diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d85a4597..5a262d3e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -219,12 +219,7 @@ jobs: vendor/bin/pest tests/Integration/EdgeGateway --colors=always" - name: Run edge gateway E2E smoke - run: | - docker_host="$(ip route show default 2>/dev/null | awk '/default/ { print $3; exit }')" - if [ -z "${docker_host}" ]; then - docker_host="localhost" - fi - EDGE_GATEWAY_E2E_BASE_URL="http://${docker_host}:18080/api" node scripts/edge-gateway-e2e.mjs + run: node scripts/edge-gateway-e2e.mjs - name: Tear down local stack if: always() diff --git a/scripts/edge-gateway-e2e.mjs b/scripts/edge-gateway-e2e.mjs index 1cadd071..4ff3ff53 100644 --- a/scripts/edge-gateway-e2e.mjs +++ b/scripts/edge-gateway-e2e.mjs @@ -75,6 +75,63 @@ function normalizeBaseUrl(url) { return String(url || "").replace(/\/+$/, ""); } +function baseUrlWithHost(baseUrl, host) { + const url = new URL(normalizeBaseUrl(baseUrl)); + url.hostname = host; + return normalizeBaseUrl(url.toString()); +} + +async function readDefaultGatewayHost() { + if (process.platform === "win32") { + return null; + } + + try { + const routeTable = await fs.readFile("/proc/net/route", "utf8"); + const route = routeTable + .split(/\r?\n/) + .map((line) => line.trim().split(/\s+/)) + .find((fields) => fields[1] === "00000000" && /^[0-9A-Fa-f]{8}$/.test(fields[2] || "")); + + if (!route) { + return null; + } + + const gateway = route[2]; + const octets = [ + gateway.slice(6, 8), + gateway.slice(4, 6), + gateway.slice(2, 4), + gateway.slice(0, 2), + ].map((octet) => Number.parseInt(octet, 16)); + + if (octets.some((octet) => !Number.isInteger(octet)) || octets.every((octet) => octet === 0)) { + return null; + } + + return octets.join("."); + } catch { + return null; + } +} + +async function candidateApiBaseUrls(baseUrl) { + const normalized = normalizeBaseUrl(baseUrl); + const candidates = [normalized]; + const url = new URL(normalized); + + if (["localhost", "127.0.0.1", "::1"].includes(url.hostname)) { + const gatewayHost = await readDefaultGatewayHost(); + if (gatewayHost) { + candidates.push(baseUrlWithHost(normalized, gatewayHost)); + } + + candidates.push(baseUrlWithHost(normalized, "host.docker.internal")); + } + + return [...new Set(candidates)]; +} + async function waitForCondition(predicate, { timeoutMs = 30_000, intervalMs = 500, message = "Timed out" } = {}) { const deadline = Date.now() + timeoutMs; @@ -97,25 +154,29 @@ async function ensureComposeServices(rootDir, composeProject) { } async function waitForApiReady(baseUrl, attempts = 60) { - const root = normalizeBaseUrl(baseUrl); + const candidates = await candidateApiBaseUrls(baseUrl); let lastError = "API never responded"; for (let attempt = 0; attempt < attempts; attempt += 1) { - try { - const response = await fetch(`${root}/ping`); - if (response.ok) { - return; - } + for (const root of candidates) { + try { + const response = await fetch(`${root}/ping`, { + signal: AbortSignal.timeout(1000), + }); + if (response.ok) { + return root; + } - lastError = `Unexpected ping status ${response.status}`; - } catch (error) { - lastError = error instanceof Error ? error.message : String(error); + lastError = `${root}/ping returned HTTP ${response.status}`; + } catch (error) { + lastError = `${root}/ping failed: ${error instanceof Error ? error.message : String(error)}`; + } } await new Promise((resolve) => setTimeout(resolve, 1000)); } - throw new Error(`API did not become ready at ${root}/ping: ${lastError}`); + throw new Error(`API did not become ready at ${candidates.map((candidate) => `${candidate}/ping`).join(", ")}: ${lastError}`); } function parseLastJsonLine(output) { @@ -345,7 +406,7 @@ async function main() { const containerName = `truckwash-edge-e2e-${runId}`; const configDir = path.join(rootDir, ".tmp", "edge-gateway-e2e", runId); const configFilePath = path.join(configDir, DEFAULT_CONFIG_FILE_NAME); - const baseUrl = process.env.EDGE_GATEWAY_E2E_BASE_URL || DEFAULT_HOST_API_URL; + let baseUrl = process.env.EDGE_GATEWAY_E2E_BASE_URL || DEFAULT_HOST_API_URL; const composeProject = process.env.EDGE_GATEWAY_E2E_COMPOSE_PROJECT || path.basename(rootDir); @@ -357,7 +418,7 @@ async function main() { try { await ensureComposeServices(rootDir, composeProject); - await waitForApiReady(baseUrl); + baseUrl = await waitForApiReady(baseUrl); fixture = await runPhpFixture(rootDir, composeProject, "create"); const authToken = String(fixture.auth_token || "");