forked from bai/curriculum-project-hub
feat: provision non-root Hub service
This commit is contained in:
@@ -0,0 +1,480 @@
|
||||
#!/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 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(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(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.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.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",
|
||||
"--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"),
|
||||
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;
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
import { isAbsolute, normalize, relative } from "node:path";
|
||||
import { readServerBinding, type ServerBinding, type ServerEnv } from "../settings/server.js";
|
||||
|
||||
export type DeploymentEnv = ServerEnv;
|
||||
|
||||
export interface DeploymentPreflightInput {
|
||||
readonly baseDir: string;
|
||||
readonly hubDir: string;
|
||||
readonly serviceHome: string;
|
||||
readonly stateDir: string;
|
||||
readonly cacheDir: string;
|
||||
readonly workspaceRoot: string;
|
||||
readonly env: DeploymentEnv;
|
||||
}
|
||||
|
||||
export interface DeploymentPreflightResult {
|
||||
readonly bind: ServerBinding;
|
||||
readonly databaseUrl: string;
|
||||
readonly cphBin: string;
|
||||
}
|
||||
|
||||
export class DeploymentPreflightError extends Error {
|
||||
readonly problems: readonly string[];
|
||||
|
||||
constructor(problems: readonly string[]) {
|
||||
super(`deployment preflight failed:\n${problems.map((problem) => `- ${problem}`).join("\n")}`);
|
||||
this.name = "DeploymentPreflightError";
|
||||
this.problems = problems;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the production values shared by the installer and the Hub runtime.
|
||||
* Inputs are required to be absolute normalized paths so callers can first
|
||||
* resolve existing paths physically and make symlink traversal observable.
|
||||
*/
|
||||
export function validateDeploymentPreflight(input: DeploymentPreflightInput): DeploymentPreflightResult {
|
||||
const problems: string[] = [];
|
||||
const paths = [
|
||||
["deployment root", input.baseDir],
|
||||
["Hub directory", input.hubDir],
|
||||
["service home", input.serviceHome],
|
||||
["state directory", input.stateDir],
|
||||
["cache directory", input.cacheDir],
|
||||
["workspace root", input.workspaceRoot],
|
||||
] as const;
|
||||
|
||||
for (const [label, value] of paths) {
|
||||
if (!isAbsoluteNormalized(value)) {
|
||||
problems.push(`${label} must be an absolute normalized path`);
|
||||
}
|
||||
}
|
||||
|
||||
if (isAbsoluteNormalized(input.baseDir) && isAbsoluteNormalized(input.hubDir)) {
|
||||
if (!isStrictDescendant(input.baseDir, input.hubDir)) {
|
||||
problems.push("Hub directory must be inside deployment root");
|
||||
}
|
||||
}
|
||||
|
||||
if (isAbsoluteNormalized(input.baseDir)) {
|
||||
for (const [label, value] of paths.slice(2)) {
|
||||
if (isAbsoluteNormalized(value) && pathsOverlap(input.baseDir, value)) {
|
||||
problems.push(`${label} must not overlap deployment root`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const envWorkspaceRoot = readRequired(input.env, "HUB_PROJECT_WORKSPACE_ROOT", problems);
|
||||
if (envWorkspaceRoot !== undefined) {
|
||||
if (!isAbsoluteNormalized(envWorkspaceRoot)) {
|
||||
problems.push("HUB_PROJECT_WORKSPACE_ROOT must be an absolute normalized path");
|
||||
} else if (isAbsoluteNormalized(input.workspaceRoot) && envWorkspaceRoot !== input.workspaceRoot) {
|
||||
problems.push("HUB_PROJECT_WORKSPACE_ROOT must equal provisioned workspace root");
|
||||
}
|
||||
}
|
||||
|
||||
if (input.env["NODE_ENV"] !== "production") {
|
||||
problems.push("NODE_ENV must equal production");
|
||||
}
|
||||
|
||||
const databaseUrl = readRequired(input.env, "DATABASE_URL", problems);
|
||||
if (databaseUrl !== undefined && !isPostgresUrl(databaseUrl)) {
|
||||
problems.push("DATABASE_URL must be a valid postgresql:// or postgres:// URL");
|
||||
}
|
||||
|
||||
const providerBaseUrl = readRequired(input.env, "ANTHROPIC_BASE_URL", problems);
|
||||
if (providerBaseUrl !== undefined && !isHttpUrl(providerBaseUrl)) {
|
||||
problems.push("ANTHROPIC_BASE_URL must be a valid http(s) URL");
|
||||
}
|
||||
readRequired(input.env, "ANTHROPIC_AUTH_TOKEN", problems);
|
||||
requirePresentAndEmpty(input.env, "ANTHROPIC_API_KEY", problems);
|
||||
|
||||
readRequired(input.env, "FEISHU_APP_ID", problems);
|
||||
readRequired(input.env, "FEISHU_APP_SECRET", problems);
|
||||
readRequired(input.env, "FEISHU_BOT_OPEN_ID", problems);
|
||||
|
||||
const cphBin = readRequired(input.env, "CPH_BIN", problems);
|
||||
if (cphBin !== undefined && !isAbsoluteNormalized(cphBin)) {
|
||||
problems.push("CPH_BIN must be an absolute normalized path");
|
||||
}
|
||||
|
||||
let bind: ServerBinding | undefined;
|
||||
try {
|
||||
bind = readServerBinding(input.env);
|
||||
} catch (error) {
|
||||
problems.push(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
|
||||
const publicBaseUrl = readRequired(input.env, "HUB_PUBLIC_BASE_URL", problems);
|
||||
if (publicBaseUrl !== undefined && !isHttpsUrl(publicBaseUrl)) {
|
||||
problems.push("HUB_PUBLIC_BASE_URL must use https");
|
||||
}
|
||||
|
||||
const sessionSecret = readRequired(input.env, "HUB_SESSION_SECRET", problems);
|
||||
if (sessionSecret !== undefined && sessionSecret.length < 32) {
|
||||
problems.push("HUB_SESSION_SECRET must contain at least 32 characters");
|
||||
}
|
||||
|
||||
validateOptionalPositiveInteger(input.env, "HUB_AGENT_MAX_TURNS", problems);
|
||||
validateOptionalBoolean(input.env, "HUB_FEISHU_LISTENER_ENABLED", problems);
|
||||
|
||||
if (problems.length > 0) {
|
||||
throw new DeploymentPreflightError(problems);
|
||||
}
|
||||
|
||||
return {
|
||||
bind: bind!,
|
||||
databaseUrl: databaseUrl!,
|
||||
cphBin: cphBin!,
|
||||
};
|
||||
}
|
||||
|
||||
function readRequired(env: DeploymentEnv, name: string, problems: string[]): string | undefined {
|
||||
const value = env[name]?.trim();
|
||||
if (value === undefined || value === "") {
|
||||
problems.push(`${name} is required`);
|
||||
return undefined;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function requirePresentAndEmpty(env: DeploymentEnv, name: string, problems: string[]): void {
|
||||
if (!Object.hasOwn(env, name) || env[name] === undefined || env[name]?.trim() !== "") {
|
||||
problems.push(`${name} must be present and empty`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateOptionalPositiveInteger(env: DeploymentEnv, name: string, problems: string[]): void {
|
||||
const raw = env[name]?.trim();
|
||||
if (raw === undefined || raw === "") return;
|
||||
const value = Number(raw);
|
||||
if (!Number.isInteger(value) || value <= 0) {
|
||||
problems.push(`${name} must be a positive integer`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateOptionalBoolean(env: DeploymentEnv, name: string, problems: string[]): void {
|
||||
const raw = env[name]?.trim().toLowerCase();
|
||||
if (raw === undefined || raw === "") return;
|
||||
if (!["1", "0", "true", "false", "yes", "no", "on", "off"].includes(raw)) {
|
||||
problems.push(`${name} must be a boolean value`);
|
||||
}
|
||||
}
|
||||
|
||||
function isAbsoluteNormalized(value: string): boolean {
|
||||
return isAbsolute(value) && normalize(value) === value;
|
||||
}
|
||||
|
||||
function pathsOverlap(left: string, right: string): boolean {
|
||||
return left === right || isStrictDescendant(left, right) || isStrictDescendant(right, left);
|
||||
}
|
||||
|
||||
function isStrictDescendant(parent: string, child: string): boolean {
|
||||
const candidate = relative(parent, child);
|
||||
return candidate !== "" && candidate !== ".." && !candidate.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) && !isAbsolute(candidate);
|
||||
}
|
||||
|
||||
function isPostgresUrl(value: string): boolean {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return (url.protocol === "postgresql:" || url.protocol === "postgres:") && url.hostname !== "";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isHttpUrl(value: string): boolean {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return (url.protocol === "https:" || url.protocol === "http:") && url.hostname !== "";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isHttpsUrl(value: string): boolean {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === "https:" && url.hostname !== "" && url.username === "" && url.password === "";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env node
|
||||
import "dotenv/config";
|
||||
import "../hub.js";
|
||||
import { prisma } from "../db.js";
|
||||
|
||||
async function main(): Promise<void> {
|
||||
let queryFailure: unknown;
|
||||
try {
|
||||
await prisma.$queryRawUnsafe("SELECT 1");
|
||||
} catch (error) {
|
||||
queryFailure = error;
|
||||
}
|
||||
|
||||
let disconnectFailure: unknown;
|
||||
try {
|
||||
await prisma.$disconnect();
|
||||
} catch (error) {
|
||||
disconnectFailure = error;
|
||||
}
|
||||
|
||||
const failures = [queryFailure, disconnectFailure].filter((failure) => failure !== undefined);
|
||||
if (failures.length > 0) {
|
||||
throw new AggregateError(failures, "Hub dependency load or authenticated database query failed");
|
||||
}
|
||||
console.log("[service-runtime-probe] Hub dependencies and PostgreSQL query succeeded");
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
console.error("[service-runtime-probe] failed:", error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import Fastify from "fastify";
|
||||
import { registerAdminPlugin } from "./admin/plugin.js";
|
||||
import { prisma } from "./db.js";
|
||||
import { createLarkClient, startFeishuListenerWithClient } from "./feishu/client.js";
|
||||
import { makeTriggerHandler } from "./feishu/trigger.js";
|
||||
import { triggerQueue } from "./feishu/triggerQueue.js";
|
||||
import { createEnvRuntimeSettings } from "./settings/runtime.js";
|
||||
import { readServerBinding } from "./settings/server.js";
|
||||
|
||||
function requireEnv(name: string): string {
|
||||
const value = process.env[name];
|
||||
if (value === undefined || value === "") {
|
||||
throw new Error(`missing required env: ${name}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function booleanEnv(name: string, fallback: boolean): boolean {
|
||||
const raw = process.env[name];
|
||||
if (raw === undefined || raw === "") return fallback;
|
||||
return !["0", "false", "no", "off"].includes(raw.trim().toLowerCase());
|
||||
}
|
||||
|
||||
/** Start the Hub after every import and runtime dependency has loaded. */
|
||||
export async function startHub(): Promise<void> {
|
||||
requireEnv("DATABASE_URL");
|
||||
|
||||
const runtimeSettings = createEnvRuntimeSettings();
|
||||
await runtimeSettings.provider("openrouter");
|
||||
|
||||
const feishuAppId = requireEnv("FEISHU_APP_ID");
|
||||
const feishuAppSecret = requireEnv("FEISHU_APP_SECRET");
|
||||
const feishuBotOpenId = requireEnv("FEISHU_BOT_OPEN_ID");
|
||||
const projectWorkspaceRoot = requireEnv("HUB_PROJECT_WORKSPACE_ROOT");
|
||||
const sessionSecret = requireEnv("HUB_SESSION_SECRET");
|
||||
const publicBaseUrl = process.env["HUB_PUBLIC_BASE_URL"] ?? "http://127.0.0.1:8788";
|
||||
const bind = readServerBinding();
|
||||
|
||||
const app = Fastify({ logger: true });
|
||||
|
||||
// Startup reset: clear stale locks + mark dead runs as FAILED.
|
||||
await prisma.projectAgentLock.deleteMany({});
|
||||
await prisma.agentRun.updateMany({
|
||||
where: { status: "ACTIVE" },
|
||||
data: { status: "FAILED", error: "process restart", finishedAt: new Date() },
|
||||
});
|
||||
app.log.info("startup: cleared stale locks + dead runs");
|
||||
|
||||
app.get("/api/healthz", async () => ({ ok: true, ts: Date.now() }));
|
||||
|
||||
await registerAdminPlugin(app, {
|
||||
prisma,
|
||||
sessionSecret,
|
||||
publicBaseUrl,
|
||||
feishuAppId,
|
||||
feishuAppSecret,
|
||||
projectWorkspaceRoot,
|
||||
});
|
||||
|
||||
const address = await app.listen(bind);
|
||||
app.log.info({ address }, "hub listening");
|
||||
|
||||
const feishuListenerEnabled = booleanEnv("HUB_FEISHU_LISTENER_ENABLED", true);
|
||||
if (feishuListenerEnabled) {
|
||||
const feishuConfig = { appId: feishuAppId, appSecret: feishuAppSecret, botOpenId: feishuBotOpenId };
|
||||
const larkClient = createLarkClient(feishuConfig);
|
||||
const triggerQueuePurgeTimer = setInterval(() => {
|
||||
const removed = triggerQueue.purgeExpired();
|
||||
if (removed > 0) {
|
||||
app.log.info({ removed }, "feishu trigger queue: purged expired triggers");
|
||||
}
|
||||
}, 60_000);
|
||||
triggerQueuePurgeTimer.unref();
|
||||
const trigger = makeTriggerHandler({ prisma, settings: runtimeSettings, logger: app.log, projectWorkspaceRoot });
|
||||
startFeishuListenerWithClient(feishuConfig, larkClient, app.log, trigger, trigger.onCardAction);
|
||||
} else {
|
||||
app.log.info("feishu listener disabled by HUB_FEISHU_LISTENER_ENABLED");
|
||||
}
|
||||
}
|
||||
+4
-88
@@ -14,93 +14,9 @@
|
||||
* HUB_PROJECT_WORKSPACE_ROOT, HUB_SESSION_SECRET, HUB_PUBLIC_BASE_URL
|
||||
*/
|
||||
import "dotenv/config";
|
||||
import Fastify from "fastify";
|
||||
import { registerAdminPlugin } from "./admin/plugin.js";
|
||||
import { prisma } from "./db.js";
|
||||
import { createEnvRuntimeSettings } from "./settings/runtime.js";
|
||||
import { createLarkClient, startFeishuListenerWithClient } from "./feishu/client.js";
|
||||
import { makeTriggerHandler } from "./feishu/trigger.js";
|
||||
import { triggerQueue } from "./feishu/triggerQueue.js";
|
||||
import { startHub } from "./hub.js";
|
||||
|
||||
function requireEnv(name: string): string {
|
||||
const v = process.env[name];
|
||||
if (v === undefined || v === "") {
|
||||
console.error(`[hub] missing required env: ${name}`);
|
||||
process.exit(1);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
function booleanEnv(name: string, fallback: boolean): boolean {
|
||||
const raw = process.env[name];
|
||||
if (raw === undefined || raw === "") return fallback;
|
||||
return !["0", "false", "no", "off"].includes(raw.trim().toLowerCase());
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const databaseUrl = requireEnv("DATABASE_URL");
|
||||
void databaseUrl;
|
||||
|
||||
const runtimeSettings = createEnvRuntimeSettings();
|
||||
await runtimeSettings.provider("openrouter");
|
||||
|
||||
const feishuAppId = requireEnv("FEISHU_APP_ID");
|
||||
const feishuAppSecret = requireEnv("FEISHU_APP_SECRET");
|
||||
const feishuBotOpenId = requireEnv("FEISHU_BOT_OPEN_ID");
|
||||
const projectWorkspaceRoot = requireEnv("HUB_PROJECT_WORKSPACE_ROOT");
|
||||
const sessionSecret = requireEnv("HUB_SESSION_SECRET");
|
||||
const publicBaseUrl = process.env["HUB_PUBLIC_BASE_URL"] ?? "http://127.0.0.1:8788";
|
||||
const port = Number(process.env["PORT"] ?? "8788");
|
||||
|
||||
const app = Fastify({ logger: true });
|
||||
|
||||
// Startup reset: clear stale locks + mark dead runs as FAILED.
|
||||
await prisma.projectAgentLock.deleteMany({});
|
||||
await prisma.agentRun.updateMany({
|
||||
where: { status: "ACTIVE" },
|
||||
data: { status: "FAILED", error: "process restart", finishedAt: new Date() },
|
||||
});
|
||||
app.log.info("startup: cleared stale locks + dead runs");
|
||||
|
||||
app.get("/api/healthz", async () => ({ ok: true, ts: Date.now() }));
|
||||
|
||||
await registerAdminPlugin(app, {
|
||||
prisma,
|
||||
sessionSecret,
|
||||
publicBaseUrl,
|
||||
feishuAppId,
|
||||
feishuAppSecret,
|
||||
projectWorkspaceRoot,
|
||||
});
|
||||
|
||||
// --- Feishu listener ---
|
||||
const feishuListenerEnabled = booleanEnv("HUB_FEISHU_LISTENER_ENABLED", true);
|
||||
if (feishuListenerEnabled) {
|
||||
const feishuConfig = { appId: feishuAppId, appSecret: feishuAppSecret, botOpenId: feishuBotOpenId };
|
||||
const larkClient = createLarkClient(feishuConfig);
|
||||
const triggerQueuePurgeTimer = setInterval(() => {
|
||||
const removed = triggerQueue.purgeExpired();
|
||||
if (removed > 0) {
|
||||
app.log.info({ removed }, "feishu trigger queue: purged expired triggers");
|
||||
}
|
||||
}, 60_000);
|
||||
triggerQueuePurgeTimer.unref();
|
||||
const trigger = makeTriggerHandler({ prisma, settings: runtimeSettings, logger: app.log, projectWorkspaceRoot });
|
||||
startFeishuListenerWithClient(feishuConfig, larkClient, app.log, trigger, trigger.onCardAction);
|
||||
} else {
|
||||
app.log.info("feishu listener disabled by HUB_FEISHU_LISTENER_ENABLED");
|
||||
}
|
||||
|
||||
app.listen({ port, host: "0.0.0.0" }, (err, address) => {
|
||||
if (err !== null) {
|
||||
app.log.error({ err }, "listen failed");
|
||||
process.exit(1);
|
||||
}
|
||||
app.log.info({ address }, "hub listening");
|
||||
});
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error("[hub] fatal:", e);
|
||||
process.exit(1);
|
||||
startHub().catch((error: unknown) => {
|
||||
console.error("[hub] fatal:", error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { isIP } from "node:net";
|
||||
|
||||
export type ServerEnv = Readonly<Record<string, string | undefined>>;
|
||||
|
||||
export interface ServerBinding {
|
||||
readonly host: string;
|
||||
readonly port: number;
|
||||
}
|
||||
|
||||
const HOSTNAME = /^(?=.{1,253}$)(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)(?:\.(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?))*\.?$/;
|
||||
|
||||
/** Read and validate the address passed directly to Fastify/Node listen(). */
|
||||
export function readServerBinding(env: ServerEnv = process.env): ServerBinding {
|
||||
const host = env["HOST"]?.trim() || "127.0.0.1";
|
||||
if (isIP(host) === 0 && !HOSTNAME.test(host)) {
|
||||
throw new Error("HOST must be a hostname or IP address");
|
||||
}
|
||||
|
||||
const rawPort = env["PORT"]?.trim() || "8788";
|
||||
const port = Number(rawPort);
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65_535) {
|
||||
throw new Error("PORT must be an integer between 1 and 65535");
|
||||
}
|
||||
|
||||
return { host, port };
|
||||
}
|
||||
Reference in New Issue
Block a user