422 lines
15 KiB
JavaScript
422 lines
15 KiB
JavaScript
#!/usr/bin/env node
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const WEB_HEALTHCHECK_PATH = "apps/web/app/routes/healthcheck.tsx";
|
|
const WEB_HEALTHZ_PATH = "apps/web/app/routes/healthz.tsx";
|
|
const WEB_READYZ_PATH = "apps/web/app/routes/readyz.tsx";
|
|
const API_APP_PATH = "apps/api/src/app.ts";
|
|
const API_SYSTEM_ROUTES_PATH = "apps/api/src/routes/system-routes.ts";
|
|
const WORKER_HEALTH_PATH = "apps/worker/src/health.ts";
|
|
const WORKER_MANIFEST_PATH = "apps/worker/package.json";
|
|
const DOCKERFILE_PATH = "Dockerfile";
|
|
|
|
function manifestPath(relativePath) {
|
|
return relativePath.split(path.sep).join("/");
|
|
}
|
|
|
|
function readTextFile(rootDir, relativePath) {
|
|
return fs.readFileSync(path.join(rootDir, relativePath), "utf8");
|
|
}
|
|
|
|
function getDockerfileTargetBody(dockerfile, targetName) {
|
|
const targetPattern = new RegExp(`^FROM\\s+\\S+(?:\\s+AS\\s+${targetName})\\s*$`, "im");
|
|
const targetMatch = targetPattern.exec(dockerfile);
|
|
|
|
if (!targetMatch) {
|
|
return null;
|
|
}
|
|
|
|
const bodyStart = targetMatch.index + targetMatch[0].length;
|
|
const nextTargetMatch = /^FROM\s+/gim.exec(dockerfile.slice(bodyStart));
|
|
const bodyEnd = nextTargetMatch ? bodyStart + nextTargetMatch.index : dockerfile.length;
|
|
return dockerfile.slice(bodyStart, bodyEnd);
|
|
}
|
|
|
|
function checkFileExists(rootDir, relativePath) {
|
|
return fs.existsSync(path.join(rootDir, relativePath));
|
|
}
|
|
|
|
function getApiGetRouteSource(source, routePath) {
|
|
const escapedRoutePath = routePath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
const routeStartMatch = new RegExp(`app\\.get\\(\\s*["']${escapedRoutePath}["']\\s*,`).exec(source);
|
|
if (!routeStartMatch) {
|
|
return null;
|
|
}
|
|
|
|
const callSource = readCallExpression(source, routeStartMatch.index);
|
|
if (!callSource) {
|
|
return null;
|
|
}
|
|
|
|
const handlerReferenceMatch = callSource.match(/app\.get\(\s*["'][^"']+["']\s*,\s*([A-Za-z_$][\w$]*)\s*\);?\s*$/);
|
|
if (handlerReferenceMatch) {
|
|
const handlerName = handlerReferenceMatch[1];
|
|
const handlerSourceMatch = new RegExp(`function\\s+${handlerName}\\s*\\([^)]*\\)\\s*{[\\s\\S]*?\\n}`).exec(source);
|
|
return [callSource, handlerSourceMatch?.[0] ?? ""].filter(Boolean).join("\n");
|
|
}
|
|
|
|
return callSource;
|
|
}
|
|
|
|
function readCallExpression(source, startIndex) {
|
|
let depth = 0;
|
|
let quote = null;
|
|
let escaped = false;
|
|
for (let index = startIndex; index < source.length; index += 1) {
|
|
const char = source[index];
|
|
if (quote) {
|
|
if (escaped) {
|
|
escaped = false;
|
|
} else if (char === "\\") {
|
|
escaped = true;
|
|
} else if (char === quote) {
|
|
quote = null;
|
|
}
|
|
continue;
|
|
}
|
|
if (char === '"' || char === "'" || char === "`") {
|
|
quote = char;
|
|
continue;
|
|
}
|
|
if (char === "(") {
|
|
depth += 1;
|
|
continue;
|
|
}
|
|
if (char === ")") {
|
|
depth -= 1;
|
|
if (depth === 0) {
|
|
const semicolonOffset = source.slice(index).match(/^\)\s*;?/)?.[0]?.length ?? 1;
|
|
return source.slice(startIndex, index + semicolonOffset);
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function checkWebHealthcheck(rootDir) {
|
|
const relativePath = WEB_HEALTHCHECK_PATH;
|
|
const passes = [];
|
|
const failures = [];
|
|
const observations = [];
|
|
|
|
if (!checkFileExists(rootDir, relativePath)) {
|
|
failures.push(`${relativePath}: missing existing web healthcheck route`);
|
|
} else {
|
|
const source = readTextFile(rootDir, relativePath);
|
|
passes.push(`${relativePath}: exists`);
|
|
|
|
if (!/\bdb\b|utils\/db\.server|@toolctrl\/db/.test(source) && !/\bfetch\s*\(/.test(source)) {
|
|
passes.push(`${relativePath}: legacy liveness healthcheck avoids DB and self-fetch`);
|
|
} else {
|
|
failures.push(`${relativePath}: legacy healthcheck must not import/check DB or self-fetch the app`);
|
|
}
|
|
}
|
|
|
|
if (checkFileExists(rootDir, WEB_HEALTHZ_PATH)) {
|
|
const healthzSource = readTextFile(rootDir, WEB_HEALTHZ_PATH);
|
|
passes.push(`${WEB_HEALTHZ_PATH}: defines lightweight /healthz liveness route`);
|
|
|
|
if (!/\bdb\b|utils\/db\.server|@toolctrl\/db/.test(healthzSource)) {
|
|
passes.push(`${WEB_HEALTHZ_PATH}: liveness route does not require DB`);
|
|
} else {
|
|
failures.push(`${WEB_HEALTHZ_PATH}: liveness route must not import or check db`);
|
|
}
|
|
|
|
if (/revision\s*:\s*process\.env\.APP_REVISION\s*\|\|\s*["']unknown["']/.test(healthzSource)) {
|
|
passes.push(`${WEB_HEALTHZ_PATH}: liveness route reports APP_REVISION`);
|
|
} else {
|
|
failures.push(`${WEB_HEALTHZ_PATH}: liveness route must report APP_REVISION as revision`);
|
|
}
|
|
|
|
if (/status\s*:\s*["']ok["']/.test(healthzSource) && /service\s*:\s*["']web["']/.test(healthzSource)) {
|
|
passes.push(`${WEB_HEALTHZ_PATH}: liveness route reports ok web service identity`);
|
|
} else {
|
|
failures.push(`${WEB_HEALTHZ_PATH}: liveness route must report status ok and service web`);
|
|
}
|
|
} else {
|
|
failures.push(`${WEB_HEALTHZ_PATH}: missing web /healthz liveness route`);
|
|
}
|
|
|
|
if (checkFileExists(rootDir, WEB_READYZ_PATH)) {
|
|
const readyzSource = readTextFile(rootDir, WEB_READYZ_PATH);
|
|
passes.push(`${WEB_READYZ_PATH}: defines database-backed /readyz readiness route`);
|
|
|
|
if (/import\s+\{\s*db\s*\}\s+from\s+["']~\/utils\/db\.server["']/.test(readyzSource) && /db\.[\w$]+\.(?:count|findFirst|findMany)|db\.\$queryRaw|db\.\$executeRaw/.test(readyzSource)) {
|
|
passes.push(`${WEB_READYZ_PATH}: readiness imports and checks db`);
|
|
} else {
|
|
failures.push(`${WEB_READYZ_PATH}: readiness route must import and check db`);
|
|
}
|
|
} else {
|
|
failures.push(`${WEB_READYZ_PATH}: missing web /readyz readiness route`);
|
|
}
|
|
|
|
return { passes, failures, observations };
|
|
}
|
|
|
|
export function checkApiHealthContract(rootDir) {
|
|
const relativePath = API_APP_PATH;
|
|
const passes = [];
|
|
const failures = [];
|
|
const observations = [];
|
|
|
|
if (!checkFileExists(rootDir, relativePath)) {
|
|
failures.push(`${relativePath}: missing API app source with health routes`);
|
|
return { passes, failures, observations };
|
|
}
|
|
|
|
const source = [
|
|
readTextFile(rootDir, relativePath),
|
|
checkFileExists(rootDir, API_SYSTEM_ROUTES_PATH) ? readTextFile(rootDir, API_SYSTEM_ROUTES_PATH) : "",
|
|
].join("\n");
|
|
const v1HealthRouteSource = getApiGetRouteSource(source, "/v1/health");
|
|
|
|
if (v1HealthRouteSource) {
|
|
passes.push(`${relativePath}: defines GET /v1/health`);
|
|
const routeSource = v1HealthRouteSource;
|
|
|
|
if (/service\s*:\s*["']api["']/.test(routeSource)) {
|
|
passes.push(`${relativePath}: /v1/health reports service "api"`);
|
|
} else {
|
|
failures.push(`${relativePath}: /v1/health must report service "api"`);
|
|
}
|
|
|
|
if (/requestId\s*:\s*c\.get\(\s*["']requestId["']\s*\)/.test(routeSource)) {
|
|
passes.push(`${relativePath}: /v1/health includes requestId`);
|
|
} else {
|
|
failures.push(`${relativePath}: /v1/health must include requestId`);
|
|
}
|
|
|
|
if (
|
|
/function\s+apiHealthResponse[\s\S]*?revision\s*:\s*getApiRuntimeConfig\(\)\.revision/.test(source) ||
|
|
/revision\s*:\s*getApiRuntimeConfig\(\)\.revision/.test(routeSource)
|
|
) {
|
|
passes.push(`${relativePath}: /v1/health reports configured APP_REVISION`);
|
|
} else {
|
|
failures.push(`${relativePath}: /v1/health must report configured APP_REVISION as revision`);
|
|
}
|
|
} else {
|
|
failures.push(`${relativePath}: missing GET /v1/health route`);
|
|
}
|
|
|
|
const healthzRouteSource = getApiGetRouteSource(source, "/healthz");
|
|
if (healthzRouteSource) {
|
|
passes.push(`${relativePath}: defines GET /healthz`);
|
|
const routeSource = healthzRouteSource;
|
|
|
|
if (/service\s*:\s*["']api["']/.test(routeSource)) {
|
|
passes.push(`${relativePath}: /healthz reports service "api"`);
|
|
} else {
|
|
failures.push(`${relativePath}: /healthz must report service "api"`);
|
|
}
|
|
|
|
if (/requestId\s*:\s*c\.get\(\s*["']requestId["']\s*\)/.test(routeSource)) {
|
|
passes.push(`${relativePath}: /healthz includes requestId`);
|
|
} else {
|
|
failures.push(`${relativePath}: /healthz must include requestId`);
|
|
}
|
|
|
|
if (
|
|
/function\s+apiHealthResponse[\s\S]*?revision\s*:\s*getApiRuntimeConfig\(\)\.revision/.test(source) ||
|
|
/revision\s*:\s*getApiRuntimeConfig\(\)\.revision/.test(routeSource)
|
|
) {
|
|
passes.push(`${relativePath}: /healthz reports configured APP_REVISION`);
|
|
} else {
|
|
failures.push(`${relativePath}: /healthz must report configured APP_REVISION as revision`);
|
|
}
|
|
} else {
|
|
failures.push(`${relativePath}: missing GET /healthz route`);
|
|
}
|
|
|
|
const readyzRouteSource = getApiGetRouteSource(source, "/readyz");
|
|
if (readyzRouteSource) {
|
|
passes.push(`${relativePath}: defines GET /readyz`);
|
|
const routeSource = readyzRouteSource;
|
|
|
|
if (/service\s*:\s*["']api["']/.test(routeSource)) {
|
|
passes.push(`${relativePath}: /readyz reports service "api"`);
|
|
} else {
|
|
failures.push(`${relativePath}: /readyz must report service "api"`);
|
|
}
|
|
|
|
if (/requestId\s*:\s*c\.get\(\s*["']requestId["']\s*\)/.test(routeSource)) {
|
|
passes.push(`${relativePath}: /readyz includes requestId`);
|
|
} else {
|
|
failures.push(`${relativePath}: /readyz must include requestId`);
|
|
}
|
|
|
|
if (/status\s*:\s*["']not_ready["']/.test(routeSource) && /\b503\b/.test(routeSource) && /code\s*:\s*["']dependency_unavailable["']/.test(routeSource)) {
|
|
passes.push(`${relativePath}: /readyz returns 503 not-ready dependency diagnostics`);
|
|
} else {
|
|
failures.push(`${relativePath}: /readyz must return 503 not_ready dependency diagnostics`);
|
|
}
|
|
} else {
|
|
failures.push(`${relativePath}: missing GET /readyz route`);
|
|
}
|
|
|
|
return { passes, failures, observations };
|
|
}
|
|
|
|
export function checkWorkerManifest(rootDir) {
|
|
const relativePath = WORKER_MANIFEST_PATH;
|
|
const passes = [];
|
|
const failures = [];
|
|
|
|
if (!checkFileExists(rootDir, relativePath)) {
|
|
return { passes, failures: [`${relativePath}: missing worker manifest with health script`], observations: [] };
|
|
}
|
|
|
|
try {
|
|
const manifest = JSON.parse(readTextFile(rootDir, relativePath));
|
|
if (manifest.scripts?.health === "node dist/health.js") {
|
|
passes.push(`${relativePath}: script "health" is node dist/health.js`);
|
|
} else {
|
|
failures.push(`${relativePath}: script "health" must be exactly node dist/health.js`);
|
|
}
|
|
} catch (error) {
|
|
failures.push(`${manifestPath(relativePath)}: failed to read manifest JSON: ${error.message}`);
|
|
}
|
|
|
|
return { passes, failures, observations: [] };
|
|
}
|
|
|
|
export function checkWorkerHealthReport(rootDir) {
|
|
const relativePath = WORKER_HEALTH_PATH;
|
|
const passes = [];
|
|
const failures = [];
|
|
const observations = [];
|
|
|
|
if (!checkFileExists(rootDir, relativePath)) {
|
|
failures.push(`${relativePath}: missing worker health source`);
|
|
return { passes, failures, observations };
|
|
}
|
|
|
|
const source = readTextFile(rootDir, relativePath);
|
|
|
|
if (/JSON\.stringify\(report\)/.test(source)) {
|
|
passes.push(`${relativePath}: CLI emits JSON health report`);
|
|
} else {
|
|
failures.push(`${relativePath}: CLI must emit JSON health report`);
|
|
}
|
|
|
|
const reportReturn = source.match(/return\s+{[\s\S]*?};/)?.[0] ?? "";
|
|
if (/redisSource\s*:/.test(reportReturn) && /redisParseStatus\s*:/.test(reportReturn) && !/redisUrl\s*:|redisPassword\s*:|password\s*:/.test(reportReturn)) {
|
|
passes.push(`${relativePath}: health JSON omits Redis URL and password values`);
|
|
} else {
|
|
failures.push(`${relativePath}: health JSON must not expose Redis URL or password values`);
|
|
}
|
|
|
|
if (/from\s+["']@toolctrl\/db\/client\.server["']/.test(source) || /\.\$queryRaw|\.\$executeRaw|\.appConfiguration\.count\(/.test(source)) {
|
|
passes.push(`${relativePath}: worker readiness checks DB`);
|
|
} else {
|
|
failures.push(`${relativePath}: worker readiness must check DB`);
|
|
}
|
|
|
|
if (/revision\s*:\s*env\.APP_REVISION\s*\|\|\s*["']unknown["']/.test(source)) {
|
|
passes.push(`${relativePath}: health report includes APP_REVISION`);
|
|
} else {
|
|
failures.push(`${relativePath}: health report must include APP_REVISION as revision`);
|
|
}
|
|
|
|
return { passes, failures, observations };
|
|
}
|
|
|
|
export function checkDockerfileWorkerHealthcheck(rootDir) {
|
|
const relativePath = DOCKERFILE_PATH;
|
|
const passes = [];
|
|
const failures = [];
|
|
|
|
if (!checkFileExists(rootDir, relativePath)) {
|
|
return { passes, failures: [`${relativePath}: missing Dockerfile with worker healthcheck`], observations: [] };
|
|
}
|
|
|
|
const dockerfile = readTextFile(rootDir, relativePath);
|
|
const workerBody = getDockerfileTargetBody(dockerfile, "worker");
|
|
|
|
if (!workerBody) {
|
|
failures.push(`${relativePath}: missing worker target`);
|
|
} else if (/HEALTHCHECK[\s\S]*?\bnode\s+apps\/worker\/dist\/health\.js\b/.test(workerBody)) {
|
|
passes.push(`${relativePath}: worker target contains HEALTHCHECK using compiled worker health`);
|
|
} else {
|
|
failures.push(`${relativePath}: worker target HEALTHCHECK must invoke node apps/worker/dist/health.js`);
|
|
}
|
|
|
|
return { passes, failures, observations: [] };
|
|
}
|
|
|
|
export function checkDockerfileWebApiHealthchecks(rootDir) {
|
|
const relativePath = DOCKERFILE_PATH;
|
|
const passes = [];
|
|
const failures = [];
|
|
|
|
if (!checkFileExists(rootDir, relativePath)) {
|
|
return { passes, failures: [`${relativePath}: missing Dockerfile with web/API healthchecks`], observations: [] };
|
|
}
|
|
|
|
const dockerfile = readTextFile(rootDir, relativePath);
|
|
for (const target of ["web", "api"]) {
|
|
const body = getDockerfileTargetBody(dockerfile, target);
|
|
const expectedPattern =
|
|
target === "api"
|
|
? /HEALTHCHECK[\s\S]*?\bcurl\b[\s\S]*?127\.0\.0\.1:\$\{API_PORT:-\$\{PORT\}\}\/healthz/
|
|
: /HEALTHCHECK[\s\S]*?\bcurl\b[\s\S]*?127\.0\.0\.1:\$\{PORT\}\/healthz/;
|
|
if (!body) {
|
|
failures.push(`${relativePath}: missing ${target} target`);
|
|
} else if (expectedPattern.test(body)) {
|
|
passes.push(`${relativePath}: ${target} target contains HEALTHCHECK using /healthz`);
|
|
} else {
|
|
const expectedPort = target === "api" ? "\\${API_PORT:-\\${PORT}}" : "\\${PORT}";
|
|
failures.push(`${relativePath}: ${target} target HEALTHCHECK must probe http://127.0.0.1:${expectedPort}/healthz`);
|
|
}
|
|
}
|
|
|
|
return { passes, failures, observations: [] };
|
|
}
|
|
|
|
export function verifyHealthBaseline(rootDir = process.cwd()) {
|
|
const checks = [
|
|
checkWebHealthcheck(rootDir),
|
|
checkApiHealthContract(rootDir),
|
|
checkWorkerManifest(rootDir),
|
|
checkWorkerHealthReport(rootDir),
|
|
checkDockerfileWorkerHealthcheck(rootDir),
|
|
checkDockerfileWebApiHealthchecks(rootDir),
|
|
];
|
|
|
|
return checks.reduce(
|
|
(result, check) => ({
|
|
passes: [...result.passes, ...check.passes],
|
|
failures: [...result.failures, ...check.failures],
|
|
observations: [...result.observations, ...check.observations],
|
|
}),
|
|
{ passes: [], failures: [], observations: [] },
|
|
);
|
|
}
|
|
|
|
export function formatHealthBaselineResult(result) {
|
|
const status = result.failures.length > 0 ? "FAIL" : "PASS";
|
|
return [
|
|
`${status} Health baseline verification`,
|
|
...result.passes.map((pass) => `PASS ${pass}`),
|
|
...result.observations.map((observation) => `OBSERVE ${observation}`),
|
|
...result.failures.map((failure) => `FAIL ${failure}`),
|
|
`Summary: ${result.passes.length} passed, ${result.observations.length} observed, ${result.failures.length} failed`,
|
|
"",
|
|
].join("\n");
|
|
}
|
|
|
|
const isCli = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);
|
|
|
|
if (isCli) {
|
|
const result = verifyHealthBaseline(process.cwd());
|
|
const output = formatHealthBaselineResult(result);
|
|
|
|
if (result.failures.length > 0) {
|
|
process.stderr.write(output);
|
|
process.exitCode = 1;
|
|
} else {
|
|
process.stdout.write(output);
|
|
}
|
|
}
|