feat: provision non-root Hub service

This commit is contained in:
2026-07-10 15:59:35 +08:00
parent 5f791c5ce4
commit 73fa28a178
19 changed files with 1608 additions and 134 deletions
@@ -0,0 +1,249 @@
import { execFile } from "node:child_process";
import { chmod, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { promisify } from "node:util";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
const execFileAsync = promisify(execFile);
const TEST_DATABASE_URL = "postgresql://paradigm:paradigm@127.0.0.1:5432/cph_hub_test";
describe("deployment preflight CLI", () => {
let root: string;
let baseDir: string;
let hubDir: string;
let persistentDir: string;
let marker: string;
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), "cph-deployment-preflight-"));
baseDir = join(root, "deploy");
hubDir = join(baseDir, "current", "hub");
persistentDir = join(root, "persistent");
marker = join(root, "external-check-ran");
await Promise.all([
mkdir(hubDir, { recursive: true }),
mkdir(join(persistentDir, "home"), { recursive: true }),
mkdir(join(persistentDir, "state"), { recursive: true }),
mkdir(join(persistentDir, "cache"), { recursive: true }),
mkdir(join(persistentDir, "workspaces"), { recursive: true }),
]);
await Promise.all([
chmod(join(persistentDir, "home"), 0o750),
chmod(join(persistentDir, "state"), 0o750),
chmod(join(persistentDir, "cache"), 0o750),
chmod(join(persistentDir, "workspaces"), 0o750),
]);
});
afterEach(async () => {
await rm(root, { recursive: true, force: true });
});
it("exits before prerequisite checks when a persistent path overlaps the deployment tree", async () => {
const workspaceRoot = join(hubDir, "data", "workspaces");
await mkdir(workspaceRoot, { recursive: true });
const fixture = await createFixture({ workspaceRoot, marker });
const result = await runCli(fixture.args);
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain("workspace root must not overlap deployment root");
await expect(readFile(marker, "utf8")).rejects.toMatchObject({ code: "ENOENT" });
});
it("returns success only after configuration and external prerequisites pass", async () => {
const workspaceRoot = join(persistentDir, "workspaces");
const fixture = await createFixture({ workspaceRoot, marker });
const result = await runCli(fixture.args);
expect(result).toMatchObject({ exitCode: 0, stderr: "" });
expect(result.stdout).toContain("[preflight] configuration and prerequisites valid");
expect(await readFile(marker, "utf8")).toContain("cph\npg_isready\n");
});
it("preserves the failed prerequisite exit code and stderr", async () => {
const workspaceRoot = join(persistentDir, "workspaces");
const fixture = await createFixture({
workspaceRoot,
marker,
pgIsReadyScript: "#!/bin/sh\nprintf 'socket permission denied\\n' >&2\nexit 23\n",
});
const result = await runCli(fixture.args);
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain("PostgreSQL is not accepting connections for DATABASE_URL");
expect(result.stderr).toContain("exit=23");
expect(result.stderr).toContain("socket permission denied");
});
it("rejects a database URL that accepts TCP connections but cannot execute a query", async () => {
const workspaceRoot = join(persistentDir, "workspaces");
const fixture = await createFixture({
workspaceRoot,
marker,
databaseUrl: "postgresql://paradigm:paradigm@127.0.0.1:5432/cph_hub_preflight_missing",
});
const result = await runCli(fixture.args);
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain("PostgreSQL authenticated query failed for DATABASE_URL");
});
it("loads the actual Hub dependency graph and queries PostgreSQL in the runtime probe", async () => {
const result = await execFileAsync(
resolve("node_modules/.bin/tsx"),
[resolve("src/deployment/service-runtime-probe.ts")],
{
env: { ...process.env, DATABASE_URL: TEST_DATABASE_URL, NODE_ENV: "production" },
},
);
expect(result.stderr).toBe("");
expect(result.stdout).toContain("Hub dependencies and PostgreSQL query succeeded");
});
it("probes the built runtime and bubblewrap as the service identity", async () => {
const configuredPersistentParent = join(root, "persistent-link");
await symlink(persistentDir, configuredPersistentParent);
const workspaceRoot = join(configuredPersistentParent, "workspaces");
const fixture = await createFixture({ workspaceRoot, marker });
const uid = process.getuid?.();
const gid = process.getgid?.();
if (uid === undefined || gid === undefined) throw new Error("service identity probe requires POSIX uid/gid");
const result = await runCli([
...fixture.args,
"--service-uid",
String(uid),
"--service-gid",
String(gid),
]);
expect(result).toMatchObject({ exitCode: 0, stderr: "" });
const probeLog = await readFile(marker, "utf8");
expect(probeLog).toContain(workspaceRoot);
expect(probeLog).toContain("service-setpriv\n");
expect(probeLog).toContain("service-bwrap\n");
expect(probeLog).toContain("service-prisma\n");
expect(probeLog).toContain("service-database\n");
});
async function createFixture(options: {
workspaceRoot: string;
marker: string;
pgIsReadyScript?: string;
databaseUrl?: string;
}): Promise<{ args: string[] }> {
const binDir = join(root, "bin");
const cphBin = join(binDir, "cph");
const bwrapBin = join(binDir, "bwrap");
const pgIsReadyBin = join(binDir, "pg_isready");
const runuserBin = join(binDir, "runuser");
const setprivBin = join(binDir, "setpriv");
const envFile = join(root, "platform.env");
await Promise.all([
mkdir(binDir, { recursive: true }),
mkdir(join(hubDir, "dist"), { recursive: true }),
mkdir(join(hubDir, "dist", "deployment"), { recursive: true }),
mkdir(join(hubDir, "node_modules", "prisma", "build"), { recursive: true }),
mkdir(join(hubDir, "prisma"), { recursive: true }),
]);
await Promise.all([
executable(cphBin, `#!/bin/sh\nprintf 'cph\\n' >> '${options.marker}'\nprintf 'cph 0.0.2\\n'\n`),
executable(bwrapBin, `#!/bin/sh\nprintf 'service-bwrap\\n' >> '${options.marker}'\nexit 0\n`),
executable(
runuserBin,
`#!/bin/sh\nprintf '%s\\n' "$*" >> '${options.marker}'\n[ "$1" = "-u" ] || exit 91\nshift 2\n[ "$1" = "--" ] || exit 92\nshift\nexec "$@"\n`,
),
executable(
setprivBin,
`#!/bin/sh\n[ "$1" = "--no-new-privs" ] || exit 93\nshift\nprintf 'service-setpriv\\n' >> '${options.marker}'\nexec "$@"\n`,
),
executable(
pgIsReadyBin,
options.pgIsReadyScript ?? `#!/bin/sh\nprintf 'pg_isready\\n' >> '${options.marker}'\nexit 0\n`,
),
writeFile(join(hubDir, "dist", "server.js"), "export {};\n"),
writeFile(
join(hubDir, "dist", "deployment", "service-runtime-probe.js"),
`const { appendFileSync } = require("node:fs");\nappendFileSync(${JSON.stringify(options.marker)}, "service-database\\n");\n`,
),
writeFile(
join(hubDir, "node_modules", "prisma", "build", "index.js"),
`const { appendFileSync } = require("node:fs");\nappendFileSync(${JSON.stringify(options.marker)}, "service-prisma\\n");\n`,
),
writeFile(join(hubDir, "prisma", "schema.prisma"), "// fixture\n"),
]);
await writeFile(
envFile,
[
"NODE_ENV=production",
`DATABASE_URL=${options.databaseUrl ?? TEST_DATABASE_URL}`,
"ANTHROPIC_BASE_URL=https://openrouter.ai/api",
"ANTHROPIC_AUTH_TOKEN=provider-token",
"ANTHROPIC_API_KEY=",
"FEISHU_APP_ID=cli_app_id",
"FEISHU_APP_SECRET=feishu-app-secret",
"FEISHU_BOT_OPEN_ID=ou_bot",
`CPH_BIN=${cphBin}`,
"HOST=127.0.0.1",
"PORT=8788",
`HUB_PROJECT_WORKSPACE_ROOT=${options.workspaceRoot}`,
"HUB_PUBLIC_BASE_URL=https://hub.example.com",
"HUB_SESSION_SECRET=a-production-session-secret-with-32-bytes",
"",
].join("\n"),
{ mode: 0o600 },
);
return {
args: [
"--env-file",
envFile,
"--node-bin",
process.execPath,
"--runuser-bin",
runuserBin,
"--setpriv-bin",
setprivBin,
"--service-user",
process.env["USER"] ?? "test-user",
"--base-dir",
baseDir,
"--hub-dir",
hubDir,
"--service-home",
join(persistentDir, "home"),
"--state-dir",
join(persistentDir, "state"),
"--cache-dir",
join(persistentDir, "cache"),
"--workspace-root",
options.workspaceRoot,
"--bwrap-bin",
bwrapBin,
"--pg-isready-bin",
pgIsReadyBin,
],
};
}
});
async function executable(path: string, contents: string): Promise<void> {
await writeFile(path, contents);
await chmod(path, 0o755);
}
async function runCli(args: string[]): Promise<{ exitCode: number; stdout: string; stderr: string }> {
try {
const result = await execFileAsync(resolve("node_modules/.bin/tsx"), [resolve("src/deployment/preflight-cli.ts"), ...args]);
return { exitCode: 0, stdout: result.stdout, stderr: result.stderr };
} catch (error) {
const failure = error as { code?: number; stdout?: string; stderr?: string };
return { exitCode: failure.code ?? 1, stdout: failure.stdout ?? "", stderr: failure.stderr ?? "" };
}
}
+61
View File
@@ -0,0 +1,61 @@
import { createServer } from "node:net";
import { once } from "node:events";
import { afterEach, describe, expect, it } from "vitest";
import { startHub } from "../../src/hub.js";
import { TEST_DATABASE_URL } from "./helpers.js";
const ENV_KEYS = [
"DATABASE_URL",
"ANTHROPIC_AUTH_TOKEN",
"ANTHROPIC_API_KEY",
"FEISHU_APP_ID",
"FEISHU_APP_SECRET",
"FEISHU_BOT_OPEN_ID",
"HUB_PROJECT_WORKSPACE_ROOT",
"HUB_SESSION_SECRET",
"HUB_PUBLIC_BASE_URL",
"HUB_FEISHU_LISTENER_ENABLED",
"HOST",
"PORT",
] as const;
describe("Hub startup", () => {
const previousEnv = new Map(ENV_KEYS.map((key) => [key, process.env[key]]));
afterEach(() => {
for (const [key, value] of previousEnv) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
});
it("rejects startup before the Feishu listener when the HTTP bind fails", async () => {
const blocker = createServer();
blocker.listen(0, "127.0.0.1");
await once(blocker, "listening");
const address = blocker.address();
if (address === null || typeof address === "string") throw new Error("expected an INET test listener");
Object.assign(process.env, {
DATABASE_URL: TEST_DATABASE_URL,
ANTHROPIC_AUTH_TOKEN: "test-provider-token",
ANTHROPIC_API_KEY: "",
FEISHU_APP_ID: "test-app",
FEISHU_APP_SECRET: "test-secret",
FEISHU_BOT_OPEN_ID: "ou_test_bot",
HUB_PROJECT_WORKSPACE_ROOT: "/tmp/cph-hub-server-start-test",
HUB_SESSION_SECRET: "integration-session-secret-with-32-bytes",
HUB_PUBLIC_BASE_URL: "https://hub.example.test",
HUB_FEISHU_LISTENER_ENABLED: "false",
HOST: "127.0.0.1",
PORT: String(address.port),
});
try {
await expect(startHub()).rejects.toMatchObject({ code: "EADDRINUSE" });
} finally {
blocker.close();
await once(blocker, "close");
}
});
});
+117
View File
@@ -0,0 +1,117 @@
import { describe, expect, it } from "vitest";
import {
DeploymentPreflightError,
validateDeploymentPreflight,
type DeploymentPreflightInput,
} from "../../src/deployment/preflight.js";
const VALID_ENV = {
NODE_ENV: "production",
DATABASE_URL: "postgresql://hub:secret@127.0.0.1:5432/hub",
ANTHROPIC_BASE_URL: "https://openrouter.ai/api",
ANTHROPIC_AUTH_TOKEN: "provider-token",
ANTHROPIC_API_KEY: "",
FEISHU_APP_ID: "cli_app_id",
FEISHU_APP_SECRET: "feishu-app-secret",
FEISHU_BOT_OPEN_ID: "ou_bot",
CPH_BIN: "/srv/curriculum-project-hub/current/bin/cph",
HOST: "127.0.0.1",
PORT: "8788",
HUB_PROJECT_WORKSPACE_ROOT: "/var/lib/cph-hub/workspaces",
HUB_PUBLIC_BASE_URL: "https://hub.example.com",
HUB_SESSION_SECRET: "a-production-session-secret-with-32-bytes",
} as const;
function input(overrides: Partial<DeploymentPreflightInput> = {}): DeploymentPreflightInput {
return {
baseDir: "/srv/curriculum-project-hub",
hubDir: "/srv/curriculum-project-hub/current/hub",
serviceHome: "/var/lib/cph-hub/home",
stateDir: "/var/lib/cph-hub/state",
cacheDir: "/var/cache/cph-hub",
workspaceRoot: "/var/lib/cph-hub/workspaces",
env: VALID_ENV,
...overrides,
};
}
describe("validateDeploymentPreflight", () => {
it("accepts a complete production configuration with persistent storage outside the deployment tree", () => {
expect(validateDeploymentPreflight(input())).toEqual({
bind: { host: "127.0.0.1", port: 8788 },
databaseUrl: "postgresql://hub:secret@127.0.0.1:5432/hub",
cphBin: "/srv/curriculum-project-hub/current/bin/cph",
});
});
it.each([
["inside the live Hub", "/srv/curriculum-project-hub/current/hub/data/workspaces"],
["inside future releases", "/srv/curriculum-project-hub/releases/workspaces"],
["an ancestor of the deployment root", "/srv"],
])("rejects a workspace %s", (_description, workspaceRoot) => {
expect(() =>
validateDeploymentPreflight(
input({
workspaceRoot,
env: { ...VALID_ENV, HUB_PROJECT_WORKSPACE_ROOT: workspaceRoot },
}),
),
).toThrow("must not overlap deployment root");
});
it("rejects a runtime workspace that differs from the provisioned path", () => {
expect(() =>
validateDeploymentPreflight(
input({ env: { ...VALID_ENV, HUB_PROJECT_WORKSPACE_ROOT: "/var/lib/cph-hub/other" } }),
),
).toThrow("HUB_PROJECT_WORKSPACE_ROOT must equal provisioned workspace root");
});
it("rejects a relative runtime workspace even if it resolves to the provisioned path", () => {
expect(() =>
validateDeploymentPreflight(
input({ env: { ...VALID_ENV, HUB_PROJECT_WORKSPACE_ROOT: "./workspaces" } }),
),
).toThrow("HUB_PROJECT_WORKSPACE_ROOT must be an absolute normalized path");
});
it("reports all invalid required production values in one failed preflight", () => {
expect.assertions(2);
try {
validateDeploymentPreflight(
input({
env: {
...VALID_ENV,
ANTHROPIC_AUTH_TOKEN: "",
FEISHU_APP_SECRET: "",
HUB_PUBLIC_BASE_URL: "http://hub.example.com",
HUB_SESSION_SECRET: "short",
},
}),
);
} catch (error) {
expect(error).toBeInstanceOf(DeploymentPreflightError);
expect((error as Error).message).toMatchInlineSnapshot(`
"deployment preflight failed:
- ANTHROPIC_AUTH_TOKEN is required
- FEISHU_APP_SECRET is required
- HUB_PUBLIC_BASE_URL must use https
- HUB_SESSION_SECRET must contain at least 32 characters"
`);
}
});
it("rejects relative operational paths", () => {
expect(() => validateDeploymentPreflight(input({ cacheDir: "./cache" }))).toThrow(
"cache directory must be an absolute normalized path",
);
});
it("rejects a non-empty ANTHROPIC_API_KEY that would conflict with auth-token mode", () => {
expect(() =>
validateDeploymentPreflight(
input({ env: { ...VALID_ENV, ANTHROPIC_API_KEY: "conflicting-api-key" } }),
),
).toThrow("ANTHROPIC_API_KEY must be present and empty");
});
});
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { readServerBinding } from "../../src/settings/server.js";
describe("readServerBinding", () => {
it("honors the configured bind host and port", () => {
expect(readServerBinding({ HOST: "127.0.0.2", PORT: "9100" })).toEqual({
host: "127.0.0.2",
port: 9100,
});
});
it.each(["0", "65536", "1.5", "not-a-port"])('rejects invalid PORT="%s"', (port) => {
expect(() => readServerBinding({ HOST: "127.0.0.1", PORT: port })).toThrow(
"PORT must be an integer between 1 and 65535",
);
});
it("rejects a bind URL in place of a host", () => {
expect(() => readServerBinding({ HOST: "https://127.0.0.1", PORT: "8788" })).toThrow(
"HOST must be a hostname or IP address",
);
});
});