feat: support split Dashboard and API origins (ADR 0045)
CI / verify (pull_request) Successful in 50s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Failing after 25s
CI / image-build (pull_request) Successful in 18s
CI / deploy-smoke (same-origin) (pull_request) Successful in 49s
CI / deploy-smoke (split) (pull_request) Successful in 1m17s
CI / verify (pull_request) Successful in 50s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Failing after 25s
CI / image-build (pull_request) Successful in 18s
CI / deploy-smoke (same-origin) (pull_request) Successful in 49s
CI / deploy-smoke (split) (pull_request) Successful in 1m17s
An installation can now serve the Dashboard and the API on sibling hostnames (dash./api.) beside the default same-origin topology, from one image and with configuration alone. - The Dashboard learns its API origin at run time: the built page carries one empty <meta name="ekklesia-api-origin">, which the API fills with its identity base URL's origin at startup, before computing the page's ETag. The shell accepts only a canonical http(s) origin and otherwise fails closed without making a request. The build-time EKKLESIA_API_BASE_URL is gone from the bundle; the development server injects the same element. - The display host stays on the API's origin, so its device cookies, requests, and offline cache are unchanged. - The API trusts its own origin (trusted origins plus the identity base URL), as Better Auth already does, so the display page's mutations pass the origin check without operators listing it. - Doctor validates EKKLESIA_DASHBOARD_ORIGIN exactly as the API's startup does (it previously skipped it), reports the topology without printing hostnames, and warns when split hostnames are not siblings. - The module UI contract states the CORS header limit (safelisted headers plus Content-Type, safelisted response headers only). - Reference deployment: Caddyfile.split.example (Dashboard hostname serves / and its script; API hostname serves /api/*, /display/*, and the display script; each refuses the other's paths) and EKKLESIA_DASHBOARD_HOSTNAME in the proxy overlay and env examples. - The deploy smoke test runs either topology (CI runs both). The split run installs the module template as a test-only module, checks per-hostname routing and exact CORS, then drives a digest-pinned headless Chromium over the DevTools protocol, with no added dependency: sign-in on the Dashboard hostname, the session cookie host-only on the API hostname, the module UI imported cross-origin and its credentialed request succeeding, the display and its service worker on the API hostname, and sign-out. ADR 0045 is marked Accepted. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
2323467aef
commit
a4966dd0dd
@@ -0,0 +1,287 @@
|
||||
// Real-browser assertions for scripts/smoke-deploy.sh in the split topology (ADR 0045). Runs inside a
|
||||
// disposable container on the deployment's `edge` network and drives a headless Chromium, itself a
|
||||
// disposable container on the same network, over the Chrome DevTools Protocol, with Node.js's own
|
||||
// fetch and WebSocket and no other dependency.
|
||||
//
|
||||
// node deploy-smoke-browser.mjs
|
||||
//
|
||||
// What only a browser can prove: that a person signing in on the Dashboard's hostname gets a session
|
||||
// cookie on the API's hostname alone, that the Dashboard then imports a module's UI from the API's
|
||||
// origin and the module's credentialed requests succeed under CORS, that the module's display page
|
||||
// mounts on the API's origin with that same session, and that sign-out ends it. Output is one line
|
||||
// per check; nothing printed carries a credential, cookie value, or response body.
|
||||
|
||||
function required(name) {
|
||||
const value = process.env[name];
|
||||
if (!value) {
|
||||
throw new Error(`${name} is required.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
const apiOrigin = `https://${required("SMOKE_HOSTNAME")}`;
|
||||
const dashboardOrigin = `https://${required("SMOKE_DASHBOARD_HOSTNAME")}`;
|
||||
const apiHostname = new URL(apiOrigin).hostname;
|
||||
const devtools = required("SMOKE_CDP_ADDRESS");
|
||||
const timeoutMs = 30_000;
|
||||
|
||||
let failures = 0;
|
||||
function check(condition, description) {
|
||||
console.log(`${condition ? "ok " : "FAIL"} ${description}`);
|
||||
if (!condition) {
|
||||
failures += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/** The smallest Chrome DevTools Protocol client: numbered commands, flat sessions, and events. */
|
||||
async function connect() {
|
||||
// The browser container was started moments ago; give it time to listen.
|
||||
// One overall deadline, with every attempt bounded, so a browser that never comes up fails the
|
||||
// run in seconds rather than waiting out TCP timeouts.
|
||||
const deadline = Date.now() + 30_000;
|
||||
let version;
|
||||
while (version === undefined) {
|
||||
try {
|
||||
const response = await fetch(`http://${devtools}/json/version`, {
|
||||
signal: AbortSignal.timeout(2_000),
|
||||
});
|
||||
version = await response.json();
|
||||
} catch {
|
||||
if (Date.now() > deadline) {
|
||||
throw new Error("The headless browser never answered.");
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
}
|
||||
// Chromium names its WebSocket after the Host it was asked on; ask on the same address.
|
||||
const socketUrl = version.webSocketDebuggerUrl.replace(/^ws:\/\/[^/]+/u, `ws://${devtools}`);
|
||||
const socket = new WebSocket(socketUrl);
|
||||
await new Promise((resolve, reject) => {
|
||||
socket.addEventListener("open", resolve, { once: true });
|
||||
socket.addEventListener("error", () => reject(new Error("DevTools connection failed.")), {
|
||||
once: true,
|
||||
});
|
||||
});
|
||||
let nextId = 1;
|
||||
const pending = new Map();
|
||||
const listeners = [];
|
||||
socket.addEventListener("message", (event) => {
|
||||
const message = JSON.parse(String(event.data));
|
||||
if (message.id !== undefined) {
|
||||
const waiter = pending.get(message.id);
|
||||
pending.delete(message.id);
|
||||
if (message.error) {
|
||||
waiter?.reject(new Error(`DevTools ${waiter.method} failed.`));
|
||||
} else {
|
||||
waiter?.resolve(message.result);
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (const listener of listeners) {
|
||||
listener(message);
|
||||
}
|
||||
});
|
||||
return {
|
||||
close: () => socket.close(),
|
||||
on: (listener) => listeners.push(listener),
|
||||
send(method, params = {}, sessionId = undefined) {
|
||||
const id = nextId;
|
||||
nextId += 1;
|
||||
socket.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }));
|
||||
return new Promise((resolve, reject) => pending.set(id, { method, reject, resolve }));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const cdp = await connect();
|
||||
const { targetId } = await cdp.send("Target.createTarget", { url: "about:blank" });
|
||||
const { sessionId } = await cdp.send("Target.attachToTarget", { flatten: true, targetId });
|
||||
const send = (method, params) => cdp.send(method, params, sessionId);
|
||||
|
||||
/** Every response and failure the page sees, for the API's origin. A CORS refusal is a
|
||||
* `loadingFailed` here, never a response. */
|
||||
const responses = [];
|
||||
const failed = [];
|
||||
cdp.on((message) => {
|
||||
if (message.sessionId !== sessionId) {
|
||||
return;
|
||||
}
|
||||
if (message.method === "Network.responseReceived") {
|
||||
const { response, type } = message.params;
|
||||
responses.push({ status: response.status, type, url: response.url });
|
||||
} else if (message.method === "Network.loadingFailed") {
|
||||
failed.push(message.params);
|
||||
}
|
||||
});
|
||||
await send("Network.enable");
|
||||
await send("Page.enable");
|
||||
await send("Runtime.enable");
|
||||
// A desktop-sized window: a module may reasonably decline to render its management UI on a small one.
|
||||
await send("Emulation.setDeviceMetricsOverride", {
|
||||
deviceScaleFactor: 1,
|
||||
height: 1080,
|
||||
mobile: false,
|
||||
width: 1920,
|
||||
});
|
||||
|
||||
async function evaluate(expression) {
|
||||
const result = await send("Runtime.evaluate", {
|
||||
awaitPromise: true,
|
||||
expression,
|
||||
returnByValue: true,
|
||||
});
|
||||
if (result.exceptionDetails) {
|
||||
throw new Error("A page expression threw.");
|
||||
}
|
||||
return result.result.value;
|
||||
}
|
||||
|
||||
async function waitFor(expression, description) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (await evaluate(`Boolean(${expression})`).catch(() => false)) {
|
||||
return true;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
console.log(`FAIL timed out waiting for ${description}`);
|
||||
failures += 1;
|
||||
return false;
|
||||
}
|
||||
|
||||
async function navigate(url) {
|
||||
await send("Page.navigate", { url });
|
||||
}
|
||||
|
||||
const selector = (testId) => `document.querySelector('[data-testid="${testId}"]')`;
|
||||
|
||||
async function cookiesFor(origin) {
|
||||
const { cookies } = await send("Network.getCookies", { urls: [`${origin}/`] });
|
||||
return cookies;
|
||||
}
|
||||
|
||||
// --- Sign in on the Dashboard's hostname -------------------------------------------------------
|
||||
await navigate(`${dashboardOrigin}/`);
|
||||
await waitFor(selector("login-email"), "the sign-in form on the Dashboard hostname");
|
||||
await evaluate(`(() => {
|
||||
// React tracks input values through the native setter; assigning .value alone is invisible to it.
|
||||
const setValue = (element, value) => {
|
||||
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value").set.call(element, value);
|
||||
element.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
};
|
||||
setValue(${selector("login-email")}, ${JSON.stringify(required("SMOKE_OWNER_EMAIL"))});
|
||||
setValue(${selector("login-password")}, ${JSON.stringify(required("SMOKE_OWNER_PASSWORD"))});
|
||||
${selector("login-email")}.form.requestSubmit();
|
||||
})()`);
|
||||
check(
|
||||
await waitFor(selector("host-shell"), "the signed-in Dashboard"),
|
||||
"a person signs in through the real form on the Dashboard hostname",
|
||||
);
|
||||
|
||||
const apiCookies = await cookiesFor(apiOrigin);
|
||||
const dashboardCookies = await cookiesFor(dashboardOrigin);
|
||||
check(
|
||||
apiCookies.some((cookie) => cookie.httpOnly && cookie.secure && cookie.domain === apiHostname) &&
|
||||
dashboardCookies.length === 0,
|
||||
"the session cookie is host-only on the API hostname, and the Dashboard hostname holds none",
|
||||
);
|
||||
|
||||
// --- A module UI, imported from the API's origin -------------------------------------------------
|
||||
const moduleButton = "document.querySelector('[data-testid^=\"select-\"]')";
|
||||
const hasModule = await waitFor(moduleButton, "a module in the Dashboard navigation");
|
||||
if (hasModule) {
|
||||
const moduleId = await evaluate(`${moduleButton}.dataset.testid.slice("select-".length)`);
|
||||
await evaluate(`${moduleButton}.click()`);
|
||||
const mounted = await waitFor(
|
||||
`${selector("module-container")}?.childElementCount > 0 && !${selector("module-loading")} && !${selector("module-error")}`,
|
||||
"the module UI to mount",
|
||||
);
|
||||
check(
|
||||
mounted && !(await evaluate(`Boolean(${selector("module-error")})`)),
|
||||
`the ${moduleId} module UI mounts`,
|
||||
);
|
||||
// Give the module's first requests a moment to settle before judging them.
|
||||
await new Promise((resolve) => setTimeout(resolve, 1_000));
|
||||
check(
|
||||
responses.some(
|
||||
(response) =>
|
||||
response.url.startsWith(`${apiOrigin}/api/v1/module-ui/${moduleId}/`) &&
|
||||
response.status === 200,
|
||||
),
|
||||
"its UI artifact was imported from the API's origin",
|
||||
);
|
||||
check(
|
||||
responses.some(
|
||||
(response) =>
|
||||
response.url.startsWith(`${apiOrigin}/api/v1/modules/${moduleId}`) &&
|
||||
response.status >= 200 &&
|
||||
response.status < 300,
|
||||
),
|
||||
"its own credentialed API request to the API's origin succeeded under CORS",
|
||||
);
|
||||
const streams = responses.filter((response) => response.type === "EventSource");
|
||||
check(
|
||||
streams.every((response) => response.status === 200),
|
||||
`every realtime stream it opened was accepted (${streams.length} opened)`,
|
||||
);
|
||||
}
|
||||
// A CORS refusal or a blocked request is always a failure. A request the page itself cancelled (a
|
||||
// module unmounting, a navigation) is not.
|
||||
const refused = failed.filter(
|
||||
(failure) =>
|
||||
failure.corsErrorStatus !== undefined ||
|
||||
failure.blockedReason !== undefined ||
|
||||
!(failure.canceled || failure.errorText === "net::ERR_ABORTED"),
|
||||
);
|
||||
check(
|
||||
refused.length === 0,
|
||||
`no request from the Dashboard failed or was refused by CORS (${refused.length} refused)`,
|
||||
);
|
||||
|
||||
// --- The display host, on the API's origin, with the same session --------------------------------
|
||||
const displayPath =
|
||||
await evaluate(`fetch(${JSON.stringify(`${apiOrigin}/api/v1/dashboard/navigation`)}, {
|
||||
credentials: "include",
|
||||
}).then((response) => response.json()).then((navigation) => {
|
||||
const entries = [...navigation.ungrouped, ...navigation.groups.flatMap((group) => group.modules)];
|
||||
return entries.find((entry) => entry.module.display !== null)?.module.display.url ?? null;
|
||||
})`);
|
||||
if (displayPath === null) {
|
||||
console.log("skip no installed module publishes a display");
|
||||
} else {
|
||||
await navigate(`${apiOrigin}${displayPath}`);
|
||||
check(
|
||||
await waitFor(
|
||||
`${selector("display-module-container")}?.childElementCount > 0 && !${selector("display-error")}`,
|
||||
"the display to mount",
|
||||
),
|
||||
"the module's display page mounts on the API hostname with the same session",
|
||||
);
|
||||
check(
|
||||
await waitFor(
|
||||
"navigator.serviceWorker.getRegistrations().then((registrations) => registrations.length > 0)",
|
||||
"the display's service worker",
|
||||
),
|
||||
"the display's offline service worker registers on the API's origin",
|
||||
);
|
||||
}
|
||||
|
||||
// --- Sign out on the Dashboard -------------------------------------------------------------------
|
||||
await navigate(`${dashboardOrigin}/`);
|
||||
await waitFor(selector("host-logout"), "the sign-out control");
|
||||
await evaluate(`${selector("host-logout")}.click()`);
|
||||
check(
|
||||
await waitFor(selector("login-email"), "the sign-in form after sign-out"),
|
||||
"sign-out returns the Dashboard to the sign-in form",
|
||||
);
|
||||
const principal = await evaluate(`fetch(${JSON.stringify(`${apiOrigin}/api/v1/principal`)}, {
|
||||
credentials: "include",
|
||||
}).then((response) => response.json()).then((body) => body.kind)`);
|
||||
check(principal === "anonymous", "after sign-out, the API answers anonymous to the same browser");
|
||||
|
||||
await cdp.send("Target.closeTarget", { targetId });
|
||||
cdp.close();
|
||||
if (failures > 0) {
|
||||
console.error(`${failures} check(s) failed`);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -7,12 +7,20 @@
|
||||
// node deploy-smoke-client.mjs tunnel the Cloudflare Tunnel connector's fixed address
|
||||
// node deploy-smoke-client.mjs session the initial owner signing in and out through the proxy
|
||||
//
|
||||
// Either topology (ADR 0045): SMOKE_HOSTNAME is the API's hostname, and SMOKE_DASHBOARD_HOSTNAME,
|
||||
// when set, the Dashboard's. Unset means the same-origin topology, where both are one hostname. Every
|
||||
// request goes to the API's origin and carries the Dashboard's origin as `Origin`, as a browser would.
|
||||
//
|
||||
// Output is one line per check; any failed check exits non-zero. Nothing printed carries a
|
||||
// credential, cookie, signed URL, or response body beyond the fixed shapes asserted here.
|
||||
|
||||
const mode = process.argv[2];
|
||||
const hostname = required("SMOKE_HOSTNAME");
|
||||
const origin = `https://${hostname}`;
|
||||
const apiOrigin = `https://${required("SMOKE_HOSTNAME")}`;
|
||||
const dashboardOrigin = process.env.SMOKE_DASHBOARD_HOSTNAME
|
||||
? `https://${process.env.SMOKE_DASHBOARD_HOSTNAME}`
|
||||
: apiOrigin;
|
||||
const split = dashboardOrigin !== apiOrigin;
|
||||
const untrustedOrigin = "https://untrusted.smoke.test";
|
||||
|
||||
function required(name) {
|
||||
const value = process.env[name];
|
||||
@@ -34,9 +42,9 @@ function check(condition, description) {
|
||||
/** A sign-in attempt that can only fail authentication; what matters is whether the API's
|
||||
* per-client limiter (3 attempts per 10 seconds) counts it against the expected client. */
|
||||
async function signInAttempt(headers = {}) {
|
||||
const response = await fetch(`${origin}/api/auth/sign-in/email`, {
|
||||
const response = await fetch(`${apiOrigin}/api/auth/sign-in/email`, {
|
||||
body: JSON.stringify({ email: "[email protected]", password: "definitely-not-the-password" }),
|
||||
headers: { "content-type": "application/json", origin, ...headers },
|
||||
headers: { "content-type": "application/json", origin: dashboardOrigin, ...headers },
|
||||
method: "POST",
|
||||
});
|
||||
await response.arrayBuffer();
|
||||
@@ -44,13 +52,13 @@ async function signInAttempt(headers = {}) {
|
||||
}
|
||||
|
||||
async function edgeChecks() {
|
||||
const ready = await fetch(`${origin}/api/v1/health/ready`);
|
||||
const ready = await fetch(`${apiOrigin}/api/v1/health/ready`);
|
||||
check(
|
||||
ready.status === 200 && (await ready.text()) === '{"status":"ready"}',
|
||||
"GET /api/v1/health/ready through the proxy is 200 ready",
|
||||
);
|
||||
|
||||
const page = await fetch(`${origin}/`);
|
||||
const page = await fetch(`${dashboardOrigin}/`);
|
||||
const html = await page.text();
|
||||
check(page.status === 200, "GET / through the proxy is 200");
|
||||
check(
|
||||
@@ -66,22 +74,32 @@ async function edgeChecks() {
|
||||
"the Dashboard page keeps its security headers through the proxy",
|
||||
);
|
||||
|
||||
const script = await fetch(`${origin}/assets/ekklesia-dashboard/app.js`);
|
||||
check(
|
||||
html.includes(`<meta content="${apiOrigin}" name="ekklesia-api-origin" />`),
|
||||
"the Dashboard page names the API's origin, rendered at run time (ADR 0045)",
|
||||
);
|
||||
|
||||
const script = await fetch(`${dashboardOrigin}/assets/ekklesia-dashboard/app.js`);
|
||||
await script.arrayBuffer();
|
||||
check(script.status === 200, "GET /assets/ekklesia-dashboard/app.js is 200");
|
||||
|
||||
const outside = await fetch(`${origin}/favicon.ico`);
|
||||
const outside = await fetch(`${dashboardOrigin}/favicon.ico`);
|
||||
check(
|
||||
outside.status === 404 && !(outside.headers.get("content-type") ?? "").includes("json"),
|
||||
"a path the API does not serve is refused by the proxy, never forwarded",
|
||||
);
|
||||
|
||||
const unknownApi = await fetch(`${origin}/api/v1/does-not-exist`);
|
||||
const unknownApi = await fetch(`${apiOrigin}/api/v1/does-not-exist`);
|
||||
check(
|
||||
unknownApi.status === 404 && (await unknownApi.text()) === '{"code":"NOT_FOUND"}',
|
||||
"an unknown /api/* path reaches the API and gets its sanitized 404",
|
||||
);
|
||||
|
||||
if (split) {
|
||||
await splitRoutingChecks();
|
||||
await splitCorsChecks();
|
||||
}
|
||||
|
||||
await storageDeliveryCheck();
|
||||
|
||||
// Every caller-supplied forwarding header rotates on every attempt. If any of them reached the
|
||||
@@ -106,6 +124,74 @@ async function edgeChecks() {
|
||||
);
|
||||
}
|
||||
|
||||
/** A proxy refusal: Caddy's own empty `404`, never the API's JSON one, so the request was not
|
||||
* forwarded at all. */
|
||||
async function refusedByProxy(url) {
|
||||
const response = await fetch(url);
|
||||
const body = await response.text();
|
||||
return response.status === 404 && body === "";
|
||||
}
|
||||
|
||||
/** Each hostname serves exactly its own paths (ADR 0045, "Routing per hostname"). */
|
||||
async function splitRoutingChecks() {
|
||||
check(
|
||||
await refusedByProxy(`${dashboardOrigin}/api/v1/health/ready`),
|
||||
"the Dashboard hostname refuses /api/*, so the session cookie has one home",
|
||||
);
|
||||
check(
|
||||
await refusedByProxy(`${dashboardOrigin}/display/example`),
|
||||
"the Dashboard hostname refuses /display/*",
|
||||
);
|
||||
check(await refusedByProxy(`${apiOrigin}/`), "the API hostname serves nothing at /");
|
||||
check(
|
||||
await refusedByProxy(`${apiOrigin}/assets/ekklesia-dashboard/app.js`),
|
||||
"the API hostname refuses the Dashboard's script",
|
||||
);
|
||||
const display = await fetch(`${apiOrigin}/display/does-not-exist`);
|
||||
check(
|
||||
display.status === 404 && (await display.text()) === '{"code":"NOT_FOUND"}',
|
||||
"the API hostname forwards /display/* to the API (the display host's origin)",
|
||||
);
|
||||
}
|
||||
|
||||
/** Credentialed CORS for exactly the Dashboard's origin, and for nothing else. */
|
||||
async function splitCorsChecks() {
|
||||
const preflight = await fetch(`${apiOrigin}/api/auth/sign-in/email`, {
|
||||
headers: {
|
||||
"access-control-request-headers": "content-type",
|
||||
"access-control-request-method": "POST",
|
||||
origin: dashboardOrigin,
|
||||
},
|
||||
method: "OPTIONS",
|
||||
});
|
||||
await preflight.arrayBuffer();
|
||||
check(
|
||||
preflight.status === 204 &&
|
||||
preflight.headers.get("access-control-allow-origin") === dashboardOrigin &&
|
||||
preflight.headers.get("access-control-allow-credentials") === "true",
|
||||
"a preflight from the Dashboard's origin is allowed, with credentials",
|
||||
);
|
||||
const foreignPreflight = await fetch(`${apiOrigin}/api/auth/sign-in/email`, {
|
||||
headers: { "access-control-request-method": "POST", origin: untrustedOrigin },
|
||||
method: "OPTIONS",
|
||||
});
|
||||
await foreignPreflight.arrayBuffer();
|
||||
check(
|
||||
foreignPreflight.headers.get("access-control-allow-origin") === null,
|
||||
"a preflight from any other origin gets no CORS permission",
|
||||
);
|
||||
const principal = await fetch(`${apiOrigin}/api/v1/principal`, {
|
||||
headers: { origin: dashboardOrigin },
|
||||
});
|
||||
await principal.arrayBuffer();
|
||||
check(
|
||||
principal.headers.get("access-control-allow-origin") === dashboardOrigin &&
|
||||
principal.headers.get("access-control-allow-credentials") === "true" &&
|
||||
(principal.headers.get("vary") ?? "").includes("Origin"),
|
||||
"an API response to the Dashboard's origin reflects exactly that origin, with credentials",
|
||||
);
|
||||
}
|
||||
|
||||
async function storageDeliveryCheck() {
|
||||
// Core's own provider, from the image, exactly as the API composes it: it reaches object-storage
|
||||
// over HTTPS on the private network and signs read URLs for the storage hostname.
|
||||
@@ -184,9 +270,9 @@ async function sessionChecks() {
|
||||
"/api/auth/update-session",
|
||||
"/api/auth/change-password",
|
||||
]) {
|
||||
const response = await fetch(`${origin}${path}`, {
|
||||
const response = await fetch(`${apiOrigin}${path}`, {
|
||||
method: path.endsWith("get-session") || path.endsWith("list-sessions") ? "GET" : "POST",
|
||||
headers: { origin },
|
||||
headers: { origin: dashboardOrigin },
|
||||
});
|
||||
check(
|
||||
response.status === 404 && (await response.text()) === '{"code":"NOT_FOUND"}',
|
||||
@@ -194,12 +280,12 @@ async function sessionChecks() {
|
||||
);
|
||||
}
|
||||
|
||||
const signIn = await fetch(`${origin}/api/auth/sign-in/email`, {
|
||||
const signIn = await fetch(`${apiOrigin}/api/auth/sign-in/email`, {
|
||||
body: JSON.stringify({
|
||||
email: required("SMOKE_OWNER_EMAIL"),
|
||||
password: required("SMOKE_OWNER_PASSWORD"),
|
||||
}),
|
||||
headers: { "content-type": "application/json", origin },
|
||||
headers: { "content-type": "application/json", origin: dashboardOrigin },
|
||||
method: "POST",
|
||||
});
|
||||
const signInBody = await signIn.text();
|
||||
@@ -218,20 +304,24 @@ async function sessionChecks() {
|
||||
);
|
||||
const cookie = setCookie.split(";")[0] ?? "";
|
||||
|
||||
const principal = await fetch(`${origin}/api/v1/principal`, { headers: { cookie, origin } });
|
||||
const principal = await fetch(`${apiOrigin}/api/v1/principal`, {
|
||||
headers: { cookie, origin: dashboardOrigin },
|
||||
});
|
||||
const principalBody = await principal.json();
|
||||
check(
|
||||
principal.status === 200 && principalBody.kind === "authenticated",
|
||||
"the session resolves an authenticated principal through the proxy",
|
||||
);
|
||||
|
||||
const signOut = await fetch(`${origin}/api/auth/sign-out`, {
|
||||
const signOut = await fetch(`${apiOrigin}/api/auth/sign-out`, {
|
||||
body: "{}",
|
||||
headers: { "content-type": "application/json", cookie, origin },
|
||||
headers: { "content-type": "application/json", cookie, origin: dashboardOrigin },
|
||||
method: "POST",
|
||||
});
|
||||
await signOut.arrayBuffer();
|
||||
const after = await fetch(`${origin}/api/v1/principal`, { headers: { cookie, origin } });
|
||||
const after = await fetch(`${apiOrigin}/api/v1/principal`, {
|
||||
headers: { cookie, origin: dashboardOrigin },
|
||||
});
|
||||
check(
|
||||
signOut.status === 200 && (await after.json()).kind === "anonymous",
|
||||
"sign-out ends the session",
|
||||
|
||||
+100
-6
@@ -5,6 +5,15 @@
|
||||
#
|
||||
# bash scripts/smoke-deploy.sh builds the image first
|
||||
# EKKLESIA_SMOKE_IMAGE=<image> bash scripts/smoke-deploy.sh
|
||||
# EKKLESIA_SMOKE_TOPOLOGY=split bash scripts/smoke-deploy.sh
|
||||
#
|
||||
# Either topology (ADR 0045). `same-origin` (the default) serves the Dashboard, the API, and displays
|
||||
# from one hostname with Caddyfile.example and a platform-only image. `split` serves the Dashboard on
|
||||
# its own hostname with Caddyfile.split.example, builds an image with the repository's module
|
||||
# template installed as a test-only module (so a module UI, its API routes, and a display exist), and
|
||||
# then drives a real headless Chromium through the Dashboard, the module, the display, and sign-out.
|
||||
# With EKKLESIA_SMOKE_IMAGE, the given image is used as is in either topology; a split run then
|
||||
# exercises whichever of its modules publishes UI and a display.
|
||||
#
|
||||
# TEST-ONLY: the proxy runs with Caddy's internal CA (`local_certs`) instead of ACME, because a test
|
||||
# run has no public hostname. Never use that option in an installation.
|
||||
@@ -36,14 +45,36 @@ docker compose version >/dev/null 2>&1 || {
|
||||
# The pinned PostgreSQL image doubles as a root shell for file ownership, so the run needs neither
|
||||
# root nor sudo on the host.
|
||||
postgres_image="postgres:18.4-alpine3.24@sha256:9a8afca54e7861fd90fab5fdf4c42477a6b1cb7d293595148e674e0a3181de15"
|
||||
# The split topology's real browser: headless Chromium, pinned by its multi-architecture index digest.
|
||||
browser_image="chromedp/headless-shell:151.0.7922.109@sha256:2d349b544a1ea6b5b5fd7c0fe99215ff662339c57407ee2e8c0a11af93516b04"
|
||||
|
||||
topology="${EKKLESIA_SMOKE_TOPOLOGY:-same-origin}"
|
||||
case "$topology" in
|
||||
same-origin | split) ;;
|
||||
*)
|
||||
echo "smoke-deploy: EKKLESIA_SMOKE_TOPOLOGY must be same-origin or split." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
run_id="$(openssl rand -hex 4)"
|
||||
project="ekklesia-deploy-smoke-${run_id}"
|
||||
work="$(mktemp -d)"
|
||||
image="${EKKLESIA_SMOKE_IMAGE:-}"
|
||||
built_image=""
|
||||
hostname="ekklesia.smoke.test"
|
||||
# `hostname` is always the API's (and the display host's); the Dashboard has its own only when split.
|
||||
if [[ "$topology" == split ]]; then
|
||||
hostname="api.smoke.test"
|
||||
dashboard_hostname="dash.smoke.test"
|
||||
caddyfile_example="deploy/Caddyfile.split.example"
|
||||
else
|
||||
hostname="ekklesia.smoke.test"
|
||||
dashboard_hostname=""
|
||||
caddyfile_example="deploy/Caddyfile.example"
|
||||
fi
|
||||
dashboard_origin="https://${dashboard_hostname:-$hostname}"
|
||||
storage_hostname="storage.smoke.test"
|
||||
browser_container=""
|
||||
|
||||
# A per-run edge subnet, so concurrent runs and the host's own networks do not collide. Fixed
|
||||
# addresses come from the upper half, outside Docker's dynamic range, exactly as documented.
|
||||
@@ -54,6 +85,8 @@ proxy_address="10.249.${octet}.254"
|
||||
tunnel_address="10.249.${octet}.253"
|
||||
# A test-only client address, also outside the dynamic range, so its limiter bucket is its own.
|
||||
session_client_address="10.249.${octet}.252"
|
||||
# The headless browser's fixed address, likewise (split topology only).
|
||||
browser_address="10.249.${octet}.251"
|
||||
|
||||
# daemon_work: where the Docker daemon sees this run's mounted files. The same directory as `work`
|
||||
# on a workstation; a staged copy on the Docker host when this script runs in a job container.
|
||||
@@ -83,6 +116,9 @@ compose=(
|
||||
cleanup() {
|
||||
local status=$?
|
||||
trap - EXIT INT TERM
|
||||
if [[ -n "$browser_container" ]]; then
|
||||
docker rm --force "$browser_container" >/dev/null 2>&1 || true
|
||||
fi
|
||||
if [[ -f "$work/compose.env" ]]; then
|
||||
"${compose[@]}" --profile ops down --volumes --remove-orphans --timeout 10 >/dev/null 2>&1 || status=1
|
||||
fi
|
||||
@@ -113,11 +149,32 @@ fail() {
|
||||
exit 1
|
||||
}
|
||||
|
||||
if [[ -z "$image" ]]; then
|
||||
if [[ -z "$image" && "$topology" == same-origin ]]; then
|
||||
step "Building the platform-only image"
|
||||
built_image="ekklesia-deploy-smoke:${run_id}"
|
||||
DOCKER_BUILDKIT=1 docker build --quiet --file deploy/Dockerfile --tag "$built_image" . >/dev/null
|
||||
image="$built_image"
|
||||
elif [[ -z "$image" ]]; then
|
||||
# TEST-ONLY: the repository's module template, installed as the module `example`, in a copy of the
|
||||
# working tree. Its lockfile importer moves with it; the depth is the same, so every relative link
|
||||
# in that importer stays correct and the install stays frozen.
|
||||
step "Building an image with the module template installed as a test-only module"
|
||||
command -v git >/dev/null || fail "git is required to build the split topology's test image."
|
||||
context="$work/build-context"
|
||||
mkdir -p "$context"
|
||||
git ls-files -z --cached --others --exclude-standard |
|
||||
tar --null --files-from=- --create --file=- |
|
||||
tar -C "$context" -xf -
|
||||
mkdir -p "$context/modules"
|
||||
mv "$context/templates/module" "$context/modules/example"
|
||||
[[ "$(grep -c '^ templates/module:$' "$context/pnpm-lock.yaml")" == 1 ]] ||
|
||||
fail "the lockfile has no single templates/module importer to move."
|
||||
sed -i 's|^ templates/module:$| modules/example:|' "$context/pnpm-lock.yaml"
|
||||
built_image="ekklesia-deploy-smoke:${run_id}"
|
||||
DOCKER_BUILDKIT=1 docker build --quiet --file "$context/deploy/Dockerfile" --tag "$built_image" \
|
||||
"$context" >/dev/null || fail "the test-module image did not build."
|
||||
rm -rf "$context"
|
||||
image="$built_image"
|
||||
fi
|
||||
|
||||
# The compose project directory: its relative bind mount (postgres/pg_hba.conf) resolves here.
|
||||
@@ -173,7 +230,10 @@ ekklesia_env="$work/env/ekklesia.env"
|
||||
set_env "$ekklesia_env" DATABASE_URL "postgresql://ekklesia:${postgres_password}@postgres:5432/ekklesia"
|
||||
set_env "$ekklesia_env" EKKLESIA_DATABASE_CA "${database_ca_line#EKKLESIA_DATABASE_CA=}"
|
||||
set_env "$ekklesia_env" EKKLESIA_IDENTITY_BASE_URL "https://${hostname}"
|
||||
set_env "$ekklesia_env" EKKLESIA_IDENTITY_TRUSTED_ORIGINS "https://${hostname}"
|
||||
set_env "$ekklesia_env" EKKLESIA_IDENTITY_TRUSTED_ORIGINS "$dashboard_origin"
|
||||
if [[ "$topology" == split ]]; then
|
||||
set_env "$ekklesia_env" EKKLESIA_DASHBOARD_ORIGIN "$dashboard_origin"
|
||||
fi
|
||||
set_env "$ekklesia_env" EKKLESIA_IDENTITY_SECRET "$identity_secret"
|
||||
set_env "$ekklesia_env" S3_DELIVERY_ENDPOINT "https://${storage_hostname}"
|
||||
set_env "$ekklesia_env" S3_ACCESS_KEY "$s3_access_key"
|
||||
@@ -185,7 +245,7 @@ chmod 600 "$work"/env/*.env
|
||||
|
||||
# TEST-ONLY: Caddy's internal CA instead of ACME. Inserted as the first global option.
|
||||
awk '!done && $0 == "{" { print; print "\tlocal_certs"; done = 1; next } { print }' \
|
||||
deploy/Caddyfile.example >"$work/Caddyfile"
|
||||
"$caddyfile_example" >"$work/Caddyfile"
|
||||
grep -q $'^\tlocal_certs$' "$work/Caddyfile" || fail "could not enable local_certs for the test proxy."
|
||||
stage Caddyfile
|
||||
|
||||
@@ -194,6 +254,7 @@ EKKLESIA_IMAGE=${image}
|
||||
EKKLESIA_ENV_DIR=${work}/env
|
||||
EKKLESIA_TLS_DIR=${daemon_work}/tls
|
||||
EKKLESIA_HOSTNAME=${hostname}
|
||||
EKKLESIA_DASHBOARD_HOSTNAME=${dashboard_hostname}
|
||||
EKKLESIA_STORAGE_HOSTNAME=${storage_hostname}
|
||||
[email protected]
|
||||
EKKLESIA_CADDYFILE=${daemon_work}/Caddyfile
|
||||
@@ -235,13 +296,17 @@ stage client-ca.pem
|
||||
stage client.mjs
|
||||
|
||||
edge_network="${project}_edge"
|
||||
host_mappings=(--add-host "${hostname}:${proxy_address}" --add-host "${storage_hostname}:${proxy_address}")
|
||||
if [[ -n "$dashboard_hostname" ]]; then
|
||||
host_mappings+=(--add-host "${dashboard_hostname}:${proxy_address}")
|
||||
fi
|
||||
client() {
|
||||
local mode="$1"
|
||||
shift
|
||||
docker run --rm --read-only --cap-drop ALL --security-opt no-new-privileges \
|
||||
--network "$edge_network" "$@" \
|
||||
--add-host "${hostname}:${proxy_address}" --add-host "${storage_hostname}:${proxy_address}" \
|
||||
--network "$edge_network" "$@" "${host_mappings[@]}" \
|
||||
-e NODE_EXTRA_CA_CERTS=/smoke/client-ca.pem -e SMOKE_HOSTNAME="$hostname" \
|
||||
-e SMOKE_DASHBOARD_HOSTNAME="$dashboard_hostname" \
|
||||
-e SMOKE_STORAGE_HOSTNAME="$storage_hostname" \
|
||||
-e S3_ACCESS_KEY="$s3_access_key" -e S3_SECRET_KEY="$s3_secret_key" \
|
||||
-v "$daemon_work/client.mjs:/smoke/client.mjs:ro" \
|
||||
@@ -266,6 +331,35 @@ client session --ip "$session_client_address" \
|
||||
-e SMOKE_OWNER_EMAIL="$owner_email" -e SMOKE_OWNER_PASSWORD="$owner_password" ||
|
||||
fail "session checks failed."
|
||||
|
||||
if [[ "$topology" == split ]]; then
|
||||
step "Driving a real browser through the split Dashboard, a module, its display, and sign-out"
|
||||
# Chromium trusts this run's proxy through the public-key hash of Caddy's internal intermediate CA,
|
||||
# which signs every certificate the test proxy serves: no CA is installed anywhere, and nothing but
|
||||
# this run's certificates is accepted.
|
||||
"${compose[@]}" exec -T proxy cat /data/caddy/pki/authorities/local/intermediate.crt \
|
||||
>"$work/caddy-intermediate.crt" || fail "the test proxy's intermediate CA is unavailable."
|
||||
spki="$(openssl x509 -in "$work/caddy-intermediate.crt" -pubkey -noout |
|
||||
openssl pkey -pubin -outform der | openssl dgst -sha256 -binary | openssl base64 -A)"
|
||||
browser_container="${project}-browser"
|
||||
# The image's entry script passes its arguments on unquoted, so none may contain a space: the
|
||||
# hostnames reach Chromium through /etc/hosts, which it reads, rather than a resolver rule.
|
||||
docker run --detach --rm --name "$browser_container" \
|
||||
--cap-drop ALL --security-opt no-new-privileges --tmpfs /tmp \
|
||||
--network "$edge_network" --ip "$browser_address" "${host_mappings[@]}" \
|
||||
"$browser_image" \
|
||||
"--ignore-certificate-errors-spki-list=${spki}" \
|
||||
--user-data-dir=/tmp/profile >/dev/null || fail "the headless browser did not start."
|
||||
cp scripts/lib/deploy-smoke-browser.mjs "$work/browser.mjs"
|
||||
stage browser.mjs
|
||||
docker run --rm --read-only --cap-drop ALL --security-opt no-new-privileges \
|
||||
--network "$edge_network" \
|
||||
-e SMOKE_CDP_ADDRESS="${browser_address}:9222" \
|
||||
-e SMOKE_HOSTNAME="$hostname" -e SMOKE_DASHBOARD_HOSTNAME="$dashboard_hostname" \
|
||||
-e SMOKE_OWNER_EMAIL="$owner_email" -e SMOKE_OWNER_PASSWORD="$owner_password" \
|
||||
-v "$daemon_work/browser.mjs:/smoke/browser.mjs:ro" \
|
||||
--entrypoint node "$image" /smoke/browser.mjs || fail "browser checks failed."
|
||||
fi
|
||||
|
||||
step "Proving the API's database connection is certificate-verified TLS"
|
||||
api_container="$("${compose[@]}" ps -q api)"
|
||||
api_data_address="$(
|
||||
|
||||
Reference in New Issue
Block a user