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

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;
});