#!/usr/bin/env node import { execFile } from "node:child_process"; import { constants } from "node:fs"; import { access, readFile, realpath, stat } from "node:fs/promises"; import { basename, dirname, join, resolve } from "node:path"; import { promisify } from "node:util"; import { PrismaClient } from "@prisma/client"; import { parse } from "dotenv"; import { DeploymentPreflightError, validateDeploymentPreflight, type DeploymentEnv, type DeploymentPreflightInput, type DeploymentPreflightResult, } from "./preflight.js"; const execFileAsync = promisify(execFile); interface Options { readonly envFile: string; readonly nodeBin: string; readonly runuserBin: string; readonly setprivBin: string; readonly serviceUser: string; readonly baseDir: string; readonly hubDir: string; readonly serviceHome: string; readonly stateDir: string; readonly cacheDir: string; readonly workspaceRoot: string; readonly bwrapBin: string; readonly socatBin: string; readonly pgIsReadyBin: string; readonly serviceUid?: number; readonly serviceGid?: number; } async function main(): Promise { const options = parseArgs(process.argv.slice(2)); const envFileContents = await readFile(options.envFile); const env = parse(envFileContents) satisfies DeploymentEnv; const rawInput = deploymentInput(options, env); // Validate lexical paths and values before touching any external dependency. validateDeploymentPreflight(rawInput); const canonicalInput = await canonicalizeInput(rawInput); const result = validateDeploymentPreflight(canonicalInput); await validateSecretFile(options.envFile); await validateNodeVersion(options.nodeBin); await validateExecutable(options.runuserBin, "runuser"); await validateExecutable(options.setprivBin, "setpriv"); await validateExecutable(options.bwrapBin, "bubblewrap"); await validateExecutable(options.socatBin, "socat"); await validateExecutable(result.cphBin, "cph"); await validateExecutable(options.pgIsReadyBin, "pg_isready"); await validateCph(result.cphBin); await validatePostgres(options.pgIsReadyBin, result); await validatePostgresQuery(result.databaseUrl); if (options.serviceUid !== undefined && options.serviceGid !== undefined) { await validateProvisionedDirectories(canonicalInput, options.serviceUid, options.serviceGid); await validateServiceRuntime(options, rawInput, canonicalInput, result); } console.log("[preflight] configuration and prerequisites valid"); } function deploymentInput(options: Options, env: DeploymentEnv): DeploymentPreflightInput { return { baseDir: options.baseDir, hubDir: options.hubDir, serviceHome: options.serviceHome, stateDir: options.stateDir, cacheDir: options.cacheDir, workspaceRoot: options.workspaceRoot, env, }; } async function canonicalizeInput(input: DeploymentPreflightInput): Promise { const baseDir = await canonicalizeProspectivePath(input.baseDir); const hubDir = await canonicalizeProspectivePath(input.hubDir); const serviceHome = await canonicalizeProspectivePath(input.serviceHome); const stateDir = await canonicalizeProspectivePath(input.stateDir); const cacheDir = await canonicalizeProspectivePath(input.cacheDir); const workspaceRoot = await canonicalizeProspectivePath(input.workspaceRoot); return { baseDir, hubDir, serviceHome, stateDir, cacheDir, workspaceRoot, env: { ...input.env, HUB_PROJECT_WORKSPACE_ROOT: workspaceRoot }, }; } async function canonicalizeProspectivePath(value: string): Promise { const absolute = resolve(value); const missingComponents: string[] = []; let candidate = absolute; while (true) { try { const existing = await realpath(candidate); return resolve(existing, ...missingComponents.reverse()); } catch (error) { if (!isNodeError(error) || error.code !== "ENOENT") throw error; const parent = dirname(candidate); if (parent === candidate) throw error; missingComponents.push(basename(candidate)); candidate = parent; } } } async function validateSecretFile(path: string): Promise { const metadata = await stat(path); if (!metadata.isFile()) { throw new Error(`environment file is not a regular file: ${path}`); } if ((metadata.mode & 0o077) !== 0) { throw new Error(`environment file must not be accessible by group or others: ${path}`); } } async function validateNodeVersion(nodeBin: string): Promise { let stdout: string; try { ({ stdout } = await execFileAsync(nodeBin, ["--version"], { timeout: 10_000 })); } catch (error) { throw new Error(`Node.js version check failed: ${nodeBin} (${formatFailure(error)})`, { cause: error }); } const version = stdout.trim().replace(/^v/, ""); const major = Number(version.split(".")[0]); if (!Number.isInteger(major) || major < 20) { throw new Error(`Node.js 20 or newer is required; found ${version || "unknown"}`); } console.log(`[preflight] Node.js: ${version}`); } async function validateExecutable(path: string, label: string): Promise { try { await access(path, constants.X_OK); } catch (error) { throw new Error( `${label} executable is missing or not executable: ${path} (${formatFailure(error)})`, { cause: error }, ); } } async function validateCph(cphBin: string): Promise { let stdout: string; try { ({ stdout } = await execFileAsync(cphBin, ["--version"], { timeout: 10_000 })); } catch (error) { throw new Error(`cph --version failed: ${cphBin} (${formatFailure(error)})`, { cause: error }); } if (!/^cph \d+\.\d+\.\d+(?:\s|$)/m.test(stdout)) { throw new Error(`cph --version returned an unexpected value: ${cphBin}`); } console.log(`[preflight] cph: ${stdout.trim()}`); } async function validatePostgres(pgIsReadyBin: string, result: DeploymentPreflightResult): Promise { const url = new URL(result.databaseUrl); const postgresEnv: NodeJS.ProcessEnv = { ...process.env, PGCONNECT_TIMEOUT: "5", PGHOST: url.hostname, PGPORT: url.port || "5432", PGUSER: decodeURIComponent(url.username), PGPASSWORD: decodeURIComponent(url.password), PGDATABASE: decodeURIComponent(url.pathname.replace(/^\//, "")), }; const sslMode = url.searchParams.get("sslmode"); if (sslMode !== null) postgresEnv["PGSSLMODE"] = sslMode; try { await execFileAsync(pgIsReadyBin, ["--quiet"], { env: postgresEnv, timeout: 10_000 }); } catch (error) { throw new Error( `PostgreSQL is not accepting connections for DATABASE_URL (${formatFailure(error)})`, { cause: error }, ); } console.log("[preflight] PostgreSQL is accepting connections"); } async function validatePostgresQuery(databaseUrl: string): Promise { const client = new PrismaClient({ datasources: { db: { url: databaseUrl } }, log: [], }); let queryFailure: unknown; try { await client.$queryRawUnsafe("SELECT 1"); } catch (error) { queryFailure = error; } let disconnectFailure: unknown; try { await client.$disconnect(); } catch (error) { disconnectFailure = error; } if (queryFailure !== undefined) { const disconnectDetail = disconnectFailure === undefined ? "" : `; disconnect also failed (${formatFailure(disconnectFailure)})`; throw new Error( `PostgreSQL authenticated query failed for DATABASE_URL (${formatFailure(queryFailure)})${disconnectDetail}`, { cause: queryFailure }, ); } if (disconnectFailure !== undefined) { throw new Error( `PostgreSQL preflight disconnect failed (${formatFailure(disconnectFailure)})`, { cause: disconnectFailure }, ); } console.log("[preflight] PostgreSQL authenticated query succeeded"); } async function validateProvisionedDirectories( input: DeploymentPreflightInput, serviceUid: number, serviceGid: number, ): Promise { const directories = [input.serviceHome, input.stateDir, input.cacheDir, input.workspaceRoot]; for (const directory of directories) { const metadata = await stat(directory); if (!metadata.isDirectory()) { throw new Error(`provisioned path is not a directory: ${directory}`); } if (metadata.uid !== serviceUid || metadata.gid !== serviceGid) { throw new Error(`provisioned directory has unexpected ownership: ${directory}`); } if ((metadata.mode & 0o777) !== 0o750) { throw new Error(`provisioned directory must have mode 0750: ${directory}`); } } console.log(`[preflight] service directories owned by ${serviceUid}:${serviceGid} with mode 0750`); } async function validateServiceRuntime( options: Options, configuredInput: DeploymentPreflightInput, canonicalInput: DeploymentPreflightInput, result: DeploymentPreflightResult, ): Promise { const serviceEnv: NodeJS.ProcessEnv = { ...configuredInput.env, NODE_ENV: "production", DATABASE_URL: result.databaseUrl, HOME: configuredInput.serviceHome, XDG_STATE_HOME: configuredInput.stateDir, XDG_CACHE_HOME: configuredInput.cacheDir, PATH: [dirname(options.nodeBin), dirname(options.bwrapBin), dirname(options.socatBin), dirname(result.cphBin), "/usr/bin", "/bin"].join(":"), }; const pathAccessRequirements = (input: DeploymentPreflightInput) => [ [input.hubDir, constants.R_OK | constants.X_OK], [join(input.hubDir, "dist"), constants.R_OK | constants.X_OK], [join(input.hubDir, "node_modules"), constants.R_OK | constants.X_OK], [join(input.hubDir, "prisma"), constants.R_OK | constants.X_OK], [input.serviceHome, constants.R_OK | constants.W_OK | constants.X_OK], [input.stateDir, constants.R_OK | constants.W_OK | constants.X_OK], [input.cacheDir, constants.R_OK | constants.W_OK | constants.X_OK], [input.workspaceRoot, constants.R_OK | constants.W_OK | constants.X_OK], [join(input.hubDir, "dist", "server.js"), constants.R_OK], [join(input.hubDir, "dist", "deployment", "service-runtime-probe.js"), constants.R_OK], [join(input.hubDir, "node_modules", "prisma", "build", "index.js"), constants.R_OK], [join(input.hubDir, "prisma", "schema.prisma"), constants.R_OK], ] as const; const accessRequirements = [ ...pathAccessRequirements(configuredInput), ...pathAccessRequirements(canonicalInput), [options.nodeBin, constants.X_OK], [options.bwrapBin, constants.X_OK], [options.socatBin, constants.X_OK], [options.setprivBin, constants.X_OK], [result.cphBin, constants.X_OK], ] as const; const accessProbe = [ 'const { accessSync } = require("node:fs");', "for (const [path, mode] of JSON.parse(process.argv[1])) accessSync(path, mode);", ].join("\n"); await runAsService( options, serviceEnv, options.nodeBin, ["-e", accessProbe, JSON.stringify(accessRequirements)], "release and persistent path access probe", ); await runAsService( options, serviceEnv, options.nodeBin, ["--check", join(configuredInput.hubDir, "dist", "server.js")], "built Hub syntax probe", ); await runAsService( options, serviceEnv, options.nodeBin, [join(configuredInput.hubDir, "node_modules", "prisma", "build", "index.js"), "--version"], "Prisma runtime probe", ); await runAsService( options, serviceEnv, options.nodeBin, [join(configuredInput.hubDir, "dist", "deployment", "service-runtime-probe.js")], "Hub dependency and authenticated database probe", ); await runAsService(options, serviceEnv, result.cphBin, ["--version"], "cph runtime probe"); await runAsService(options, serviceEnv, options.socatBin, ["-V"], "socat runtime probe"); await runAsService( options, serviceEnv, options.bwrapBin, [ "--unshare-all", "--die-with-parent", "--new-session", "--ro-bind", "/", "/", "--proc", "/proc", "--dev", "/dev", "/bin/true", ], "bubblewrap user-namespace probe", ); console.log(`[preflight] runtime prerequisites execute as service user ${options.serviceUser}`); } async function runAsService( options: Options, env: NodeJS.ProcessEnv, command: string, args: readonly string[], label: string, ): Promise { try { await execFileAsync( options.runuserBin, [ "-u", options.serviceUser, "--", options.setprivBin, "--no-new-privs", command, ...args, ], { cwd: options.hubDir, env, timeout: 15_000, maxBuffer: 2 * 1024 * 1024 }, ); } catch (error) { throw new Error(`${label} failed for service user ${options.serviceUser} (${formatFailure(error)})`, { cause: error, }); } } function parseArgs(argv: readonly string[]): Options { const values = new Map(); for (let index = 0; index < argv.length; index += 2) { const key = argv[index]; const value = argv[index + 1]; if (key === undefined || value === undefined || !key.startsWith("--")) { throw new Error("preflight arguments must be --name value pairs"); } if (values.has(key)) throw new Error(`duplicate preflight argument: ${key}`); values.set(key, value); } const allowed = new Set([ "--env-file", "--node-bin", "--runuser-bin", "--setpriv-bin", "--service-user", "--base-dir", "--hub-dir", "--service-home", "--state-dir", "--cache-dir", "--workspace-root", "--bwrap-bin", "--socat-bin", "--pg-isready-bin", "--service-uid", "--service-gid", ]); for (const key of values.keys()) { if (!allowed.has(key)) throw new Error(`unknown preflight argument: ${key}`); } const serviceUid = optionalId(values, "--service-uid"); const serviceGid = optionalId(values, "--service-gid"); if ((serviceUid === undefined) !== (serviceGid === undefined)) { throw new Error("--service-uid and --service-gid must be supplied together"); } return { envFile: required(values, "--env-file"), nodeBin: required(values, "--node-bin"), runuserBin: required(values, "--runuser-bin"), setprivBin: required(values, "--setpriv-bin"), serviceUser: required(values, "--service-user"), baseDir: required(values, "--base-dir"), hubDir: required(values, "--hub-dir"), serviceHome: required(values, "--service-home"), stateDir: required(values, "--state-dir"), cacheDir: required(values, "--cache-dir"), workspaceRoot: required(values, "--workspace-root"), bwrapBin: required(values, "--bwrap-bin"), socatBin: required(values, "--socat-bin"), pgIsReadyBin: required(values, "--pg-isready-bin"), ...(serviceUid === undefined ? {} : { serviceUid, serviceGid: serviceGid! }), }; } function required(values: ReadonlyMap, key: string): string { const value = values.get(key); if (value === undefined || value === "") throw new Error(`missing required preflight argument: ${key}`); return value; } function optionalId(values: ReadonlyMap, key: string): number | undefined { const raw = values.get(key); if (raw === undefined) return undefined; const value = Number(raw); if (!Number.isInteger(value) || value < 0) throw new Error(`${key} must be a non-negative integer`); return value; } function isNodeError(error: unknown): error is NodeJS.ErrnoException { return error instanceof Error && "code" in error; } function formatFailure(error: unknown): string { if (!(error instanceof Error)) return String(error); const details: string[] = []; const processError = error as NodeJS.ErrnoException & { readonly signal?: NodeJS.Signals | null; readonly stderr?: string | Buffer; }; if (processError.code !== undefined) details.push(`exit=${String(processError.code)}`); if (processError.signal !== undefined && processError.signal !== null) { details.push(`signal=${processError.signal}`); } const stderr = processError.stderr?.toString().trim(); if (stderr !== undefined && stderr !== "") details.push(`stderr=${boundedDiagnostic(redactDiagnostic(stderr))}`); if (details.length === 0) details.push(boundedDiagnostic(redactDiagnostic(error.message))); return details.join(", "); } function boundedDiagnostic(value: string): string { const limit = 2_000; if (value.length <= limit) return value; return `${value.slice(0, limit)}…[truncated ${value.length - limit} chars]`; } function redactDiagnostic(value: string): string { return value .replace(/\b(postgres(?:ql)?):\/\/([^:\s/@]+):([^@\s]+)@/gi, "$1://$2:[REDACTED]@") .replace(/\b(password\s*[:=]\s*)\S+/gi, "$1[REDACTED]"); } main().catch((error: unknown) => { if (error instanceof DeploymentPreflightError) { console.error(`[preflight] ${error.message}`); } else { console.error(`[preflight] failed: ${error instanceof Error ? error.message : String(error)}`); } process.exitCode = 1; });