forked from EduCraft/curriculum-project-hub
250 lines
9.4 KiB
TypeScript
250 lines
9.4 KiB
TypeScript
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 ?? "" };
|
|
}
|
|
}
|