forked from bai/curriculum-project-hub
159 lines
6.5 KiB
JavaScript
159 lines
6.5 KiB
JavaScript
#!/usr/bin/env node
|
|
import { lstat, readFile } from "node:fs/promises";
|
|
import { execFile } from "node:child_process";
|
|
import { promisify } from "node:util";
|
|
import { PrismaClient } from "@prisma/client";
|
|
import { bootstrapAlphaSilo, type AlphaSiloBootstrapInput } from "./bootstrap-silo.js";
|
|
import { loadLocalSecretKeyringFile, LocalSecretEnvelope } from "../security/secretEnvelope.js";
|
|
import { readSiloOrganizationId } from "./silo.js";
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
async function main(): Promise<void> {
|
|
if (process.getuid?.() !== 0) throw new Error("Alpha Silo bootstrap must run as root");
|
|
await requireStoppedSilo();
|
|
const { configFile, keyringFile } = parseArgs(process.argv.slice(2));
|
|
await requireRootPrivateFile(configFile, "bootstrap config");
|
|
await requireRootPrivateFile(keyringFile, "secret keyring");
|
|
const databaseUrl = process.env["DATABASE_URL"]?.trim();
|
|
if (databaseUrl === undefined || databaseUrl === "") throw new Error("DATABASE_URL is required");
|
|
const input = parseConfig(await readFile(configFile, "utf8"));
|
|
const configuredOrganizationId = readSiloOrganizationId();
|
|
if (input.organization.id !== configuredOrganizationId) {
|
|
throw new Error(
|
|
`bootstrap Organization ${input.organization.id} does not match HUB_SILO_ORGANIZATION_ID ${configuredOrganizationId}`,
|
|
);
|
|
}
|
|
const secrets = new LocalSecretEnvelope(await loadLocalSecretKeyringFile(keyringFile));
|
|
const prisma = new PrismaClient({ datasources: { db: { url: databaseUrl } }, log: [] });
|
|
try {
|
|
const result = await bootstrapAlphaSilo(prisma, secrets, input);
|
|
console.log(JSON.stringify(result));
|
|
} finally {
|
|
await prisma.$disconnect();
|
|
}
|
|
}
|
|
|
|
async function requireStoppedSilo(): Promise<void> {
|
|
const unit = process.env["HUB_SYSTEMD_UNIT"]?.trim();
|
|
if (unit === undefined || !/^cph-hub-[a-z0-9][a-z0-9-]*\.service$/.test(unit)) {
|
|
throw new Error("HUB_SYSTEMD_UNIT must name the Silo being bootstrapped");
|
|
}
|
|
let state: string;
|
|
try {
|
|
const result = await execFileAsync("systemctl", ["show", "--property=ActiveState", "--value", unit], {
|
|
timeout: 10_000,
|
|
});
|
|
state = result.stdout.trim();
|
|
} catch (error) {
|
|
throw new Error(`cannot verify that ${unit} is stopped`, { cause: error });
|
|
}
|
|
if (state !== "inactive" && state !== "failed") {
|
|
throw new Error(`${unit} must be stopped before bootstrap; state=${state || "unknown"}`);
|
|
}
|
|
}
|
|
|
|
function parseArgs(argv: readonly string[]): { readonly configFile: string; readonly keyringFile: string } {
|
|
if (argv.length !== 4 || argv[0] !== "--config-file" || argv[2] !== "--keyring-file" ||
|
|
argv[1] === undefined || argv[1] === "" || argv[3] === undefined || argv[3] === "") {
|
|
throw new Error("usage: bootstrap-silo --config-file /root/bootstrap.json --keyring-file /root/keyring.json");
|
|
}
|
|
return { configFile: argv[1], keyringFile: argv[3] };
|
|
}
|
|
|
|
async function requireRootPrivateFile(path: string, label: string): Promise<void> {
|
|
const metadata = await lstat(path);
|
|
if (metadata.isSymbolicLink() || !metadata.isFile()) throw new Error(`${label} is not a regular file: ${path}`);
|
|
if (metadata.uid !== 0 || (metadata.mode & 0o077) !== 0) {
|
|
throw new Error(`${label} must be root-owned and inaccessible by group/others: ${path}`);
|
|
}
|
|
}
|
|
|
|
function parseConfig(source: string): AlphaSiloBootstrapInput {
|
|
let value: unknown;
|
|
try {
|
|
value = JSON.parse(source);
|
|
} catch (error) {
|
|
throw new Error("bootstrap config is not valid JSON", { cause: error });
|
|
}
|
|
if (!isRecord(value) || !isRecord(value["organization"]) || !isRecord(value["owner"]) ||
|
|
!isRecord(value["feishu"]) || !isRecord(value["provider"])) {
|
|
throw new Error("bootstrap config must contain organization, owner, feishu, and provider objects");
|
|
}
|
|
const organization = value["organization"];
|
|
const owner = value["owner"];
|
|
const feishu = value["feishu"];
|
|
const provider = value["provider"];
|
|
const teams = value["teams"];
|
|
if (teams !== undefined && !Array.isArray(teams)) throw new Error("bootstrap teams must be an array");
|
|
return {
|
|
organization: {
|
|
id: stringField(organization, "id"),
|
|
slug: stringField(organization, "slug"),
|
|
name: stringField(organization, "name"),
|
|
},
|
|
owner: {
|
|
openId: stringField(owner, "openId"),
|
|
displayName: stringField(owner, "displayName"),
|
|
...(optionalStringField(owner, "unionId") !== undefined
|
|
? { unionId: optionalStringField(owner, "unionId")! }
|
|
: {}),
|
|
},
|
|
feishu: {
|
|
appId: stringField(feishu, "appId"),
|
|
appSecret: stringField(feishu, "appSecret"),
|
|
botOpenId: stringField(feishu, "botOpenId"),
|
|
...(optionalStringField(feishu, "verificationToken") !== undefined
|
|
? { verificationToken: optionalStringField(feishu, "verificationToken")! }
|
|
: {}),
|
|
...(optionalStringField(feishu, "encryptKey") !== undefined
|
|
? { encryptKey: optionalStringField(feishu, "encryptKey")! }
|
|
: {}),
|
|
},
|
|
provider: {
|
|
providerId: stringField(provider, "providerId"),
|
|
baseUrl: stringField(provider, "baseUrl"),
|
|
authToken: stringField(provider, "authToken"),
|
|
...(optionalStringField(provider, "anthropicApiKey") !== undefined
|
|
? { anthropicApiKey: optionalStringField(provider, "anthropicApiKey")! }
|
|
: {}),
|
|
},
|
|
...(Array.isArray(teams)
|
|
? {
|
|
teams: teams.map((team, index) => {
|
|
if (!isRecord(team)) throw new Error(`bootstrap teams[${index}] must be an object`);
|
|
return {
|
|
slug: stringField(team, "slug"),
|
|
name: stringField(team, "name"),
|
|
...(optionalStringField(team, "description") !== undefined
|
|
? { description: optionalStringField(team, "description")! }
|
|
: {}),
|
|
};
|
|
}),
|
|
}
|
|
: {}),
|
|
};
|
|
}
|
|
|
|
function stringField(value: Record<string, unknown>, name: string): string {
|
|
const field = value[name];
|
|
if (typeof field !== "string" || field.trim() === "") throw new Error(`bootstrap ${name} is required`);
|
|
return field;
|
|
}
|
|
|
|
function optionalStringField(value: Record<string, unknown>, name: string): string | undefined {
|
|
const field = value[name];
|
|
if (field === undefined) return undefined;
|
|
if (typeof field !== "string") throw new Error(`bootstrap ${name} must be a string`);
|
|
return field;
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
main().catch((error: unknown) => {
|
|
console.error(`[bootstrap-silo] failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
process.exitCode = 1;
|
|
});
|