Skip to content

Commit 5593353

Browse files
committed
chore(compose): add repro smoke gate and GHCR prod-like docs
1 parent f4995d9 commit 5593353

7 files changed

Lines changed: 342 additions & 0 deletions

README.md

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,12 +128,58 @@ This uses:
128128
- `docker-compose.yml` + `docker-compose.prod.yml`
129129
- published images from GHCR (`pull_policy` defaults to `missing`)
130130

131+
GHCR tag policy (from publish workflows):
132+
133+
- `master` branch -> `ghcr.io/cenit-io/cenit:latest` and `ghcr.io/cenit-io/ui:latest`
134+
- `develop` branch -> `ghcr.io/cenit-io/cenit:develop` and `ghcr.io/cenit-io/ui:develop`
135+
- release tags `v*.*.*` -> semver tags
136+
- every publish -> immutable `sha-<gitsha>` tag
137+
138+
Run prod-like using `develop` images:
139+
140+
```bash
141+
CENIT_SERVER_IMAGE=ghcr.io/cenit-io/cenit:develop \
142+
CENIT_UI_IMAGE=ghcr.io/cenit-io/ui:develop \
143+
scripts/compose-prod.sh up -d
144+
```
145+
146+
Run prod-like pinned to immutable SHA tags:
147+
148+
```bash
149+
CENIT_SERVER_IMAGE=ghcr.io/cenit-io/cenit:sha-<server_sha> \
150+
CENIT_UI_IMAGE=ghcr.io/cenit-io/ui:sha-<ui_sha> \
151+
scripts/compose-prod.sh up -d
152+
```
153+
131154
For strict refresh from registry each run:
132155

133156
```bash
134157
CENIT_PULL_POLICY=always scripts/compose-prod.sh up -d
135158
```
136159

160+
### 2.3) Repro mode with non-default host ports (redirect/debug)
161+
162+
Use this to reproduce host/port redirect issues (for example, verify UI does not fall back to `localhost:3000`).
163+
164+
```bash
165+
cd /path/to/cenit
166+
REPRO_SERVER_PORT=13000 REPRO_UI_PORT=13002 scripts/compose-repro.sh up -d
167+
REPRO_SERVER_PORT=13000 REPRO_UI_PORT=13002 scripts/smoke/repro_runtime_ports.sh
168+
```
169+
170+
Default repro port mapping:
171+
172+
- Backend host port: `13000 -> container 8080`
173+
- UI host port: `13002 -> container 80`
174+
175+
Optional public URL overrides (if not using localhost):
176+
177+
```bash
178+
REPRO_SERVER_PUBLIC_URL=http://127.0.0.1:13000 \
179+
REPRO_UI_PUBLIC_URL=http://127.0.0.1:13002 \
180+
scripts/smoke/repro_runtime_ports.sh
181+
```
182+
137183
### 3) Verify services
138184

139185
```bash
@@ -183,11 +229,16 @@ Important environment knobs used by local scripts:
183229
- `CENIT_BASE_COMPOSE_FILE` (helper override for base file)
184230
- `CENIT_DEV_COMPOSE_FILE` (helper override for dev file)
185231
- `CENIT_PROD_COMPOSE_FILE` (helper override for prod-like file)
232+
- `CENIT_REPRO_COMPOSE_FILE` (helper override for repro file)
186233
- `CENIT_E2E_AUTOSTART` (`1` to auto-start stack in E2E scripts)
187234
- `CENIT_E2E_RESET_STACK` (`1` to reset containers/volumes before E2E)
188235
- `CENIT_E2E_BUILD_STACK` (`1` to rebuild images before E2E, default `0`)
189236
- `CENIT_E2E_HEADED` (`1` for headed browser runs)
190237

238+
Image labels:
239+
240+
- GHCR images are published with OCI metadata labels (source/revision/created) via `docker/metadata-action`.
241+
191242
## Testing and quality checks
192243

193244
### Login E2E smoke
@@ -196,6 +247,26 @@ Important environment knobs used by local scripts:
196247
scripts/e2e/cenit_ui_login.sh
197248
```
198249

250+
### Browser smoke: no localhost redirect during auth bootstrap
251+
252+
```bash
253+
# Default URL
254+
scripts/smoke/cenit_ui_no_localhost_redirect.sh
255+
256+
# Repro stack URL
257+
CENIT_UI_URL=http://localhost:13002 scripts/smoke/cenit_ui_no_localhost_redirect.sh
258+
```
259+
260+
This smoke fails if any browser request hits `http://localhost:3000` during initial auth flow.
261+
262+
### Pre-apply repro gate (runtime + browser checks)
263+
264+
```bash
265+
REPRO_SERVER_PORT=13000 REPRO_UI_PORT=13002 scripts/smoke/repro_preapply_gate.sh
266+
```
267+
268+
Use this as the required gate before Terraform apply or other deploy steps when validating the localhost redirect fix path.
269+
199270
### Contact data type + records E2E
200271

201272
```bash

docker-compose.repro.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
services:
2+
ui:
3+
environment:
4+
- REACT_APP_USE_ENVIRONMENT_CONFIG=true
5+
- REACT_APP_TIMEOUT_SPAN=300000
6+
- REACT_APP_APP_ID=admin
7+
- REACT_APP_LOCALHOST=${REPRO_UI_PUBLIC_URL:-http://localhost:${REPRO_UI_PORT:-13002}}
8+
- REACT_APP_CENIT_HOST=${REPRO_SERVER_PUBLIC_URL:-http://localhost:${REPRO_SERVER_PORT:-13000}}
9+
10+
server:
11+
environment:
12+
- HOMEPAGE=${REPRO_SERVER_PUBLIC_URL:-http://localhost:${REPRO_SERVER_PORT:-13000}}
13+
- CENIT_UI=${REPRO_UI_PUBLIC_URL:-http://localhost:${REPRO_UI_PORT:-13002}}

scripts/compose-repro.sh

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
5+
BASE_FILE="${CENIT_BASE_COMPOSE_FILE:-$ROOT_DIR/docker-compose.yml}"
6+
DEV_FILE="${CENIT_DEV_COMPOSE_FILE:-$ROOT_DIR/docker-compose.dev.yml}"
7+
REPRO_FILE="${CENIT_REPRO_COMPOSE_FILE:-$ROOT_DIR/docker-compose.repro.yml}"
8+
9+
REPRO_SERVER_PORT="${REPRO_SERVER_PORT:-13000}"
10+
REPRO_UI_PORT="${REPRO_UI_PORT:-13002}"
11+
12+
# Force base compose interpolation to repro ports, avoiding accidental 3000/3002 publishes.
13+
export SERVER_PORT="${SERVER_PORT:-$REPRO_SERVER_PORT}"
14+
export UI_PORT="${UI_PORT:-$REPRO_UI_PORT}"
15+
16+
if [[ $# -eq 0 ]]; then
17+
echo "Usage: scripts/compose-repro.sh <docker compose args...>" >&2
18+
echo "Example: REPRO_SERVER_PORT=13000 REPRO_UI_PORT=13002 scripts/compose-repro.sh up -d" >&2
19+
exit 1
20+
fi
21+
22+
exec docker compose -f "$BASE_FILE" -f "$DEV_FILE" -f "$REPRO_FILE" "$@"
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
5+
6+
if ! command -v node >/dev/null 2>&1; then
7+
echo "Node.js is required to run this smoke test." >&2
8+
exit 1
9+
fi
10+
11+
if ! node -e "require.resolve('playwright')" >/dev/null 2>&1; then
12+
echo "The 'playwright' package is required for this smoke test." >&2
13+
echo "Install it with: npm install --no-save playwright@1.52.0" >&2
14+
exit 1
15+
fi
16+
17+
node "$ROOT_DIR/scripts/smoke/cenit_ui_no_localhost_redirect_playwright.mjs"
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
#!/usr/bin/env node
2+
import fs from "node:fs";
3+
import path from "node:path";
4+
import { chromium } from "playwright";
5+
6+
const uiUrl = process.env.CENIT_UI_URL || process.env.REPRO_UI_PUBLIC_URL || "http://localhost:3002";
7+
const forbiddenBase = process.env.CENIT_FORBIDDEN_BASE_URL || "http://localhost:3000";
8+
const outputDir = process.env.CENIT_E2E_OUTPUT_DIR || path.resolve(process.cwd(), "output/playwright");
9+
const stamp = process.env.CENIT_E2E_TIMESTAMP || new Date().toISOString().replace(/[-:.TZ]/g, "").slice(0, 14);
10+
11+
fs.mkdirSync(outputDir, { recursive: true });
12+
13+
const screenshotPath = path.join(outputDir, `cenit-ui-no-localhost-redirect-${stamp}.png`);
14+
const htmlPath = path.join(outputDir, `cenit-ui-no-localhost-redirect-${stamp}.html`);
15+
const requestsPath = path.join(outputDir, `cenit-ui-no-localhost-redirect-requests-${stamp}.json`);
16+
17+
const forbiddenRequests = [];
18+
19+
const browser = await chromium.launch({ headless: true });
20+
const context = await browser.newContext();
21+
const page = await context.newPage();
22+
23+
page.on("request", (request) => {
24+
const url = request.url();
25+
if (url.startsWith(forbiddenBase)) {
26+
forbiddenRequests.push({
27+
method: request.method(),
28+
url,
29+
resourceType: request.resourceType(),
30+
});
31+
}
32+
});
33+
34+
let exitCode = 0;
35+
let failureReason = "";
36+
37+
try {
38+
await page.goto(uiUrl, { waitUntil: "domcontentloaded", timeout: 120000 });
39+
await page.waitForLoadState("networkidle", { timeout: 30000 }).catch(() => {});
40+
await page.waitForTimeout(5000);
41+
42+
const finalUrl = page.url();
43+
const finalForbidden = finalUrl.startsWith(forbiddenBase);
44+
fs.writeFileSync(
45+
requestsPath,
46+
JSON.stringify(
47+
{
48+
uiUrl,
49+
forbiddenBase,
50+
finalUrl,
51+
forbiddenRequests,
52+
},
53+
null,
54+
2
55+
)
56+
);
57+
58+
if (finalForbidden || forbiddenRequests.length > 0) {
59+
exitCode = 1;
60+
failureReason = finalForbidden
61+
? `Final URL redirected to forbidden base: ${finalUrl}`
62+
: `Forbidden request(s) detected to ${forbiddenBase}`;
63+
await page.screenshot({ path: screenshotPath, fullPage: true });
64+
fs.writeFileSync(htmlPath, await page.content());
65+
}
66+
67+
if (exitCode === 0) {
68+
console.log(`PASS: no requests to ${forbiddenBase} during initial auth flow.`);
69+
console.log(`Final URL: ${finalUrl}`);
70+
console.log(`Requests artifact: ${requestsPath}`);
71+
} else {
72+
console.error(`FAIL: ${failureReason}`);
73+
console.error(`Final URL: ${finalUrl}`);
74+
console.error(`Forbidden requests: ${forbiddenRequests.length}`);
75+
console.error(`Screenshot: ${screenshotPath}`);
76+
console.error(`HTML: ${htmlPath}`);
77+
console.error(`Requests artifact: ${requestsPath}`);
78+
}
79+
} catch (error) {
80+
exitCode = 1;
81+
console.error(`FAIL: browser smoke execution error: ${error?.message || error}`);
82+
try {
83+
await page.screenshot({ path: screenshotPath, fullPage: true });
84+
fs.writeFileSync(htmlPath, await page.content());
85+
} catch (_) {}
86+
fs.writeFileSync(
87+
requestsPath,
88+
JSON.stringify(
89+
{
90+
uiUrl,
91+
forbiddenBase,
92+
error: String(error),
93+
forbiddenRequests,
94+
},
95+
null,
96+
2
97+
)
98+
);
99+
console.error(`Screenshot: ${screenshotPath}`);
100+
console.error(`HTML: ${htmlPath}`);
101+
console.error(`Requests artifact: ${requestsPath}`);
102+
} finally {
103+
await browser.close();
104+
}
105+
106+
process.exit(exitCode);
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
5+
6+
echo "Pre-apply repro gate"
7+
echo " REPRO_SERVER_PORT=${REPRO_SERVER_PORT:-13000}"
8+
echo " REPRO_UI_PORT=${REPRO_UI_PORT:-13002}"
9+
10+
"$ROOT_DIR/scripts/smoke/repro_runtime_ports.sh"
11+
12+
ui_url="${CENIT_UI_URL:-http://localhost:${REPRO_UI_PORT:-13002}}"
13+
CENIT_UI_URL="$ui_url" "$ROOT_DIR/scripts/smoke/cenit_ui_no_localhost_redirect.sh"
14+
15+
echo "PASS: pre-apply repro gate checks completed."
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
5+
6+
REPRO_SERVER_PORT="${REPRO_SERVER_PORT:-13000}"
7+
REPRO_UI_PORT="${REPRO_UI_PORT:-13002}"
8+
9+
UI_URL="${REPRO_UI_PUBLIC_URL:-http://localhost:${REPRO_UI_PORT}}"
10+
SERVER_URL="${REPRO_SERVER_PUBLIC_URL:-http://localhost:${REPRO_SERVER_PORT}}"
11+
12+
CONFIG_URL="${UI_URL}/config.js"
13+
CREDENTIALS_URL="${SERVER_URL}/app/admin/oauth2/client/credentials"
14+
SIGN_IN_URL="${SERVER_URL}/users/sign_in"
15+
16+
tmp_config="$(mktemp)"
17+
trap 'rm -f "$tmp_config"' EXIT
18+
19+
retry_curl() {
20+
local url="$1"
21+
local tries="${2:-40}"
22+
local sleep_seconds="${3:-2}"
23+
local i
24+
for ((i = 1; i <= tries; i += 1)); do
25+
if curl -fsS "$url" >/dev/null 2>&1; then
26+
return 0
27+
fi
28+
sleep "$sleep_seconds"
29+
done
30+
return 1
31+
}
32+
33+
assert_contains() {
34+
local file="$1"
35+
local pattern="$2"
36+
if ! rg -q "$pattern" "$file"; then
37+
echo "Expected pattern not found: $pattern" >&2
38+
return 1
39+
fi
40+
}
41+
42+
echo "Runtime repro smoke checks"
43+
echo " UI_URL=${UI_URL}"
44+
echo " SERVER_URL=${SERVER_URL}"
45+
46+
# Guardrail: repro stack must not still publish default ports 3000/3002.
47+
if command -v docker >/dev/null 2>&1; then
48+
server_ports="$(docker ps --format '{{.Names}} {{.Ports}}' | grep '^cenit-server-1 ' || true)"
49+
ui_ports="$(docker ps --format '{{.Names}} {{.Ports}}' | grep '^cenit-ui-1 ' || true)"
50+
if [[ "$server_ports" == *":3000->"* ]]; then
51+
echo "Repro stack is also publishing port 3000 (ambiguous host routing): $server_ports" >&2
52+
echo "Run: scripts/compose-repro.sh down && scripts/compose-repro.sh up -d --build --force-recreate" >&2
53+
exit 1
54+
fi
55+
if [[ "$ui_ports" == *":3002->"* ]]; then
56+
echo "Repro stack is also publishing port 3002 (ambiguous host routing): $ui_ports" >&2
57+
echo "Run: scripts/compose-repro.sh down && scripts/compose-repro.sh up -d --build --force-recreate" >&2
58+
exit 1
59+
fi
60+
fi
61+
62+
if ! retry_curl "${CONFIG_URL}" 30 2; then
63+
echo "UI config endpoint is unreachable: ${CONFIG_URL}" >&2
64+
echo "Start stack with: ${ROOT_DIR}/scripts/compose-repro.sh up -d" >&2
65+
exit 1
66+
fi
67+
68+
curl -fsS "${CONFIG_URL}" > "$tmp_config"
69+
70+
expected_server="REACT_APP_CENIT_HOST: \"${SERVER_URL}\""
71+
expected_ui="REACT_APP_LOCALHOST: \"${UI_URL}\""
72+
73+
assert_contains "$tmp_config" "$expected_server"
74+
assert_contains "$tmp_config" "$expected_ui"
75+
76+
if rg -q 'REACT_APP_CENIT_HOST: "http://localhost:3000"' "$tmp_config"; then
77+
echo "config.js still points to localhost:3000, repro override not applied." >&2
78+
exit 1
79+
fi
80+
81+
if ! retry_curl "${SIGN_IN_URL}" 30 2; then
82+
echo "Backend sign-in endpoint is unreachable: ${SIGN_IN_URL}" >&2
83+
exit 1
84+
fi
85+
86+
credentials_body="$(curl -fsS "${CREDENTIALS_URL}" || true)"
87+
if [[ -z "$credentials_body" ]]; then
88+
echo "Credentials endpoint returned empty response: ${CREDENTIALS_URL}" >&2
89+
exit 1
90+
fi
91+
92+
if ! rg -q '"client_id"|"client_token"' <<<"$credentials_body"; then
93+
echo "Credentials endpoint did not return expected client payload (client_id or client_token)." >&2
94+
echo "Response: $credentials_body" >&2
95+
exit 1
96+
fi
97+
98+
echo "PASS: runtime config and backend endpoints match repro ports."

0 commit comments

Comments
 (0)