Files
curriculum-project-hub/hub/src/deployment/preflight-cli.ts
T

487 lines
17 KiB
JavaScript

#!/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<void> {
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<DeploymentPreflightInput> {
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<string> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<string, string>();
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<string, string>, 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<string, string>, 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;
});