forked from bai/curriculum-project-hub
feat: add deployable alpha silo
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
#!/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;
|
||||
});
|
||||
Reference in New Issue
Block a user