forked from EduCraft/curriculum-project-hub
feat: add repeatable alpha silo setup
This commit is contained in:
Executable
+211
@@ -0,0 +1,211 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const [answersPath, outputPath] = process.argv.slice(2);
|
||||
if (!answersPath || !outputPath) {
|
||||
throw new Error("usage: render_new_silo_bundle.mjs ANSWERS_ENV OUTPUT_DIR");
|
||||
}
|
||||
|
||||
function parseAnswers(source) {
|
||||
const values = new Map();
|
||||
for (const [index, line] of source.split("\n").entries()) {
|
||||
if (!line || line.startsWith("#")) continue;
|
||||
const separator = line.indexOf("=");
|
||||
if (separator < 1) throw new Error(`invalid answers line ${index + 1}`);
|
||||
const key = line.slice(0, separator);
|
||||
const value = line.slice(separator + 1);
|
||||
if (!/^[A-Z][A-Z0-9_]*$/.test(key)) {
|
||||
throw new Error(`invalid answers key on line ${index + 1}: ${key}`);
|
||||
}
|
||||
if (value.includes("\r") || value.includes("\n")) {
|
||||
throw new Error(`multiline value is not supported: ${key}`);
|
||||
}
|
||||
values.set(key, value);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function required(values, key) {
|
||||
const value = values.get(key);
|
||||
if (!value) throw new Error(`missing required answer: ${key}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function optional(values, key, fallback = "") {
|
||||
return values.get(key) || fallback;
|
||||
}
|
||||
|
||||
function assertMatch(label, value, pattern) {
|
||||
if (!pattern.test(value)) throw new Error(`invalid ${label}: ${value}`);
|
||||
}
|
||||
|
||||
function envLine(key, value) {
|
||||
if (/[\r\n]/.test(value)) throw new Error(`unsafe newline in ${key}`);
|
||||
return `${key}=${value}`;
|
||||
}
|
||||
|
||||
const answers = parseAnswers(await readFile(resolve(answersPath), "utf8"));
|
||||
const instanceId = required(answers, "INSTANCE_ID");
|
||||
const orgId = required(answers, "ORGANIZATION_ID");
|
||||
const orgSlug = required(answers, "ORGANIZATION_SLUG");
|
||||
const port = required(answers, "HUB_PORT");
|
||||
const workspaceRoot = required(answers, "WORKSPACE_ROOT");
|
||||
const publicBaseUrl = required(answers, "PUBLIC_BASE_URL").replace(/\/$/, "");
|
||||
const domain = new URL(publicBaseUrl).hostname;
|
||||
const databasePassword = required(answers, "DATABASE_PASSWORD");
|
||||
const databaseUrl = `postgresql://${encodeURIComponent(required(answers, "DATABASE_USER"))}:${encodeURIComponent(databasePassword)}@${required(answers, "DATABASE_HOST")}:${required(answers, "DATABASE_PORT")}/${encodeURIComponent(required(answers, "DATABASE_NAME"))}`;
|
||||
|
||||
assertMatch("INSTANCE_ID", instanceId, /^[a-z0-9](?:[a-z0-9-]{0,22}[a-z0-9])?$/);
|
||||
assertMatch("organization slug", orgSlug, /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/);
|
||||
assertMatch("port", port, /^[1-9][0-9]{1,4}$/);
|
||||
assertMatch("database name", required(answers, "DATABASE_NAME"), /^[a-z_][a-z0-9_]*$/);
|
||||
assertMatch("database user", required(answers, "DATABASE_USER"), /^[a-z_][a-z0-9_]*$/);
|
||||
assertMatch("default role id", required(answers, "DEFAULT_ROLE_ID"), /^[a-z][a-z0-9-]*$/);
|
||||
assertMatch("release id", required(answers, "RELEASE_ID"), /^[A-Za-z0-9._-]+$/);
|
||||
if (!publicBaseUrl.startsWith("https://")) throw new Error("PUBLIC_BASE_URL must use https");
|
||||
for (const [label, value] of [
|
||||
["WORKSPACE_ROOT", workspaceRoot],
|
||||
["DEPLOY_BASE", required(answers, "DEPLOY_BASE")],
|
||||
["DEPLOY_SSH_KEY", required(answers, "DEPLOY_SSH_KEY")],
|
||||
["CPH_BIN", required(answers, "CPH_BIN")],
|
||||
]) {
|
||||
if (!value.startsWith("/") || /\s/.test(value)) {
|
||||
throw new Error(`${label} must be an absolute path without whitespace: ${value}`);
|
||||
}
|
||||
}
|
||||
if (Number(port) > 65535) throw new Error(`invalid port: ${port}`);
|
||||
if (Buffer.byteLength(workspaceRoot) > 16) {
|
||||
throw new Error(`WORKSPACE_ROOT exceeds the 16-byte sandbox socket limit: ${workspaceRoot}`);
|
||||
}
|
||||
|
||||
const outputDir = resolve(outputPath);
|
||||
await mkdir(outputDir, { recursive: true, mode: 0o700 });
|
||||
await chmod(outputDir, 0o700);
|
||||
|
||||
const base = required(answers, "DEPLOY_BASE");
|
||||
const release = required(answers, "RELEASE_ID");
|
||||
const releaseHub = `${base}/releases/${release}/hub`;
|
||||
const secretDir = `${base}/.secrets/${instanceId}`;
|
||||
const envPath = `${secretDir}/platform.env`;
|
||||
const keyringPath = `${secretDir}/secret-keyring.json`;
|
||||
const unit = `cph-hub-${instanceId}.service`;
|
||||
|
||||
const platformEnv = [
|
||||
"# Generated by hub/deploy/new_silo.sh. Install root:root mode 0600.",
|
||||
envLine("NODE_ENV", "production"),
|
||||
envLine("DATABASE_URL", databaseUrl),
|
||||
envLine("HUB_SILO_ORGANIZATION_ID", orgId),
|
||||
envLine("HUB_SYSTEMD_UNIT", unit),
|
||||
envLine("CPH_BIN", required(answers, "CPH_BIN")),
|
||||
envLine("HOST", "127.0.0.1"),
|
||||
envLine("PORT", port),
|
||||
envLine("HUB_PROJECT_WORKSPACE_ROOT", workspaceRoot),
|
||||
envLine("HUB_PUBLIC_BASE_URL", publicBaseUrl),
|
||||
envLine("HUB_SESSION_SECRET", required(answers, "HUB_SESSION_SECRET")),
|
||||
envLine("HUB_AGENT_MAX_TURNS", required(answers, "MAX_TURNS")),
|
||||
envLine("HUB_AGENT_MAX_CONCURRENT_RUNS", required(answers, "MAX_CONCURRENT_RUNS")),
|
||||
envLine("HUB_AGENT_MAX_RUN_SECONDS", required(answers, "MAX_RUN_SECONDS")),
|
||||
envLine("HUB_HTTP_BODY_LIMIT_BYTES", required(answers, "HTTP_BODY_LIMIT_BYTES")),
|
||||
envLine("HUB_MAX_FILES_PER_MESSAGE", required(answers, "MAX_FILES_PER_MESSAGE")),
|
||||
envLine("HUB_MAX_FILE_BYTES", required(answers, "MAX_FILE_BYTES")),
|
||||
envLine("HUB_HTTP_REQUESTS_PER_MINUTE", required(answers, "HTTP_REQUESTS_PER_MINUTE")),
|
||||
envLine("HUB_FEISHU_EVENTS_PER_MINUTE", required(answers, "FEISHU_EVENTS_PER_MINUTE")),
|
||||
envLine("HUB_FEISHU_LISTENER_ENABLED", "true"),
|
||||
envLine("CPH_SANDBOX_EXTRA_DENY_READ", `${envPath}:${keyringPath}`),
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
const bootstrap = {
|
||||
organization: {
|
||||
id: orgId,
|
||||
slug: orgSlug,
|
||||
name: required(answers, "ORGANIZATION_NAME"),
|
||||
},
|
||||
owner: {
|
||||
openId: required(answers, "OWNER_OPEN_ID"),
|
||||
displayName: required(answers, "OWNER_DISPLAY_NAME"),
|
||||
},
|
||||
feishu: {
|
||||
appId: required(answers, "FEISHU_APP_ID"),
|
||||
appSecret: required(answers, "FEISHU_APP_SECRET"),
|
||||
botOpenId: required(answers, "FEISHU_BOT_OPEN_ID"),
|
||||
},
|
||||
provider: {
|
||||
providerId: required(answers, "PROVIDER_ID"),
|
||||
baseUrl: required(answers, "PROVIDER_BASE_URL"),
|
||||
authToken: required(answers, "PROVIDER_AUTH_TOKEN"),
|
||||
},
|
||||
teams: [],
|
||||
};
|
||||
const ownerUnionId = optional(answers, "OWNER_UNION_ID");
|
||||
if (ownerUnionId) bootstrap.owner.unionId = ownerUnionId;
|
||||
|
||||
const deployEnv = [
|
||||
"# Source this file locally before deploy_platform.sh (contains no app/provider secrets).",
|
||||
envLine("PLATFORM_DEPLOY_HOST", required(answers, "DEPLOY_HOST")),
|
||||
envLine("PLATFORM_DEPLOY_SSH_KEY", required(answers, "DEPLOY_SSH_KEY")),
|
||||
envLine("PLATFORM_DEPLOY_USER", required(answers, "DEPLOY_USER")),
|
||||
envLine("PLATFORM_DEPLOY_PORT", required(answers, "DEPLOY_SSH_PORT")),
|
||||
envLine("PLATFORM_DEPLOY_HUB_PORT", port),
|
||||
envLine("PLATFORM_DEPLOY_BASE", base),
|
||||
envLine("PLATFORM_DEPLOY_RELEASE", release),
|
||||
envLine("PLATFORM_DEPLOY_INSTANCE", instanceId),
|
||||
envLine("PLATFORM_DEPLOY_WORKSPACE_ROOT", workspaceRoot),
|
||||
envLine("PLATFORM_DEPLOY_MEMORY_MAX", required(answers, "MEMORY_MAX")),
|
||||
envLine("PLATFORM_DEPLOY_CPU_QUOTA", required(answers, "CPU_QUOTA")),
|
||||
envLine("PLATFORM_DEPLOY_TASKS_MAX", required(answers, "TASKS_MAX")),
|
||||
envLine("PLATFORM_DEPLOY_HEALTH_URL", `http://127.0.0.1:${port}/api/healthz`),
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
const nginx = `# Install as /etc/nginx/conf.d/${instanceId}.conf after obtaining TLS certificates.\nserver {\n listen 80;\n server_name ${domain};\n return 301 https://$host$request_uri;\n}\n\nserver {\n listen 443 ssl http2;\n server_name ${domain};\n\n ssl_certificate /etc/letsencrypt/live/${domain}/fullchain.pem;\n ssl_certificate_key /etc/letsencrypt/live/${domain}/privkey.pem;\n\n location / {\n proxy_pass http://127.0.0.1:${port};\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n proxy_set_header X-Forwarded-Proto https;\n }\n}\n`;
|
||||
|
||||
const defaultRolePrompt = `你是一位教育机构智能助手,服务于学校和教培机构的日常管理与协作。
|
||||
## 核心能力
|
||||
**教务管理**:熟悉排课调课、班级管理、课时统计、师资调度等常见管理流程。
|
||||
**教研支持**:理解课程设计、教案编写、例题/变式题、定理证明、随堂练习、阶段性测验、知识点拆解、大纲对标等教学业务概念。
|
||||
**数字化操作**:熟练使用shell命令行进行文件管理、批量处理、脚本编写、文本处理(grep/sed/awk等)、自动化任务;能协助处理Markdown、Typst等文档、表格数据、格式转换等。
|
||||
## 行为准则
|
||||
- **实事求是**:不确定时明确说明,能力边界外的任务如实告知,不为完成目标而编造信息或勉强输出
|
||||
- **简洁优先**:直接给出答案或方案,省略铺垫和客套
|
||||
- **按需展开**:仅在问题复杂或用户明确要求时提供详细说明
|
||||
- **结构清晰**:多步骤内容使用编号,便于飞书阅读
|
||||
- **可操作性**:涉及操作时给出具体命令或步骤
|
||||
保持专业、高效,像一位熟悉业务的同事。
|
||||
`;
|
||||
|
||||
const operate = `# ${instanceId} deployment commands\n\nThis file contains no application/provider secrets. Run commands deliberately; do not source answers.env.\n\n## 1. DNS and Feishu\n\n- Point \`${domain}\` to \`${required(answers, "DEPLOY_HOST")}\`.\n- In the Feishu app, configure redirect URL: \`${publicBaseUrl}/auth/feishu/callback\`.\n- Enable the required bot/message/contact permissions and event subscription described in the customer setup document.\n\n## 2. Database (run as the PostgreSQL administrator)\n\nCreate one role and one database for this Silo. The password is in \`answers.env\`; use an interactive client or a protected SQL file, never a command-line argument.\n\n\`\`\`sql\nCREATE ROLE ${required(answers, "DATABASE_USER")} LOGIN PASSWORD '<copy DATABASE_PASSWORD from answers.env>';\nCREATE DATABASE ${required(answers, "DATABASE_NAME")} OWNER ${required(answers, "DATABASE_USER")};\n\`\`\`\n\n## 3. Publish the immutable release\n\nThe normal deploy script seeds the first-instance secrets and exits 78. That exit is expected only on this first pass.\n\n\`\`\`sh\nset -a; . ./deploy.env; set +a\nbash hub/deploy/deploy_platform.sh\n\`\`\`\n\n## 4. Install the generated secrets\n\nCopy \`platform.env\` and \`bootstrap.json\` to the server through a protected channel. On the server:\n\n\`\`\`sh\ninstall -d -o root -g root -m 0700 '${secretDir}'\ninstall -o root -g root -m 0600 platform.env '${envPath}'\ninstall -o root -g root -m 0600 bootstrap.json '/root/${instanceId}-bootstrap.json'\n# Copy ${keyringPath} to separate off-host recovery storage before continuing.\n\`\`\`\n\n## 5. Migrate, install the stopped service, and bootstrap\n\n\`\`\`sh\nset -a; . '${envPath}'; set +a\nnode '${releaseHub}/node_modules/prisma/build/index.js' migrate deploy --schema '${releaseHub}/prisma/schema.prisma'\nBASE='${base}' HUB_DIR='${releaseHub}' INSTANCE_ID='${instanceId}' WORKSPACE_ROOT='${workspaceRoot}' PORT='${port}' MEMORY_MAX='${required(answers, "MEMORY_MAX")}' CPU_QUOTA='${required(answers, "CPU_QUOTA")}' TASKS_MAX='${required(answers, "TASKS_MAX")}' bash '${releaseHub}/deploy/install_service.sh'\nnode '${releaseHub}/dist/deployment/bootstrap-silo-cli.js' --config-file '/root/${instanceId}-bootstrap.json' --keyring-file '${keyringPath}'\nrm -f '/root/${instanceId}-bootstrap.json'\n\`\`\`\n\n## 6. Runtime role and skills\n\nRuntime role/skill configuration is intentionally separate from the release. Follow NEW_SILO_RUNBOOK.md, staging each approved skill directory outside the immutable release, then use \`agent_config.sh\`. Do not silently copy a local personal skill collection.\n\n## 7. Nginx, start, and acceptance\n\nInstall \`nginx.conf\`, run \`nginx -t\`, reload Nginx, then:\n\n\`\`\`sh\nsystemctl start '${unit}'\nsystemctl is-active '${unit}'\ncurl --fail --silent --show-error 'http://127.0.0.1:${port}/api/healthz'\ncurl --fail --silent --show-error '${publicBaseUrl}/api/healthz'\njournalctl -u '${unit}' --since '-10 min' --no-pager\n\`\`\`\n\nAcceptance requires: Feishu OAuth completes, OWNER and a non-OWNER can @bot, a second group creates a separate project/session, Typst works when that skill is enabled, and restart preserves session cursor. Then take the first off-host backup.\n\n## 8. Later releases\n\nAfter the instance exists, source \`deploy.env\` with the new release id and run \`deploy_platform.sh\`. Never reuse another org's database, secret directory, workspace root, port, service identity, domain, Feishu app, or provider credential.\n`;
|
||||
|
||||
const selectedSkills = optional(answers, "APPROVED_SKILLS");
|
||||
const roleInstructions = `
|
||||
## Appendix: default runtime role
|
||||
|
||||
Copy \`default-role-prompt.md\` to \`/root/${instanceId}-default-role-prompt.md\`.
|
||||
After installing each reviewed skill with \`agent_config.sh install-skill\`, run:
|
||||
|
||||
\`\`\`sh
|
||||
INSTANCE_ID=${JSON.stringify(instanceId)} ENV_FILE=${JSON.stringify(envPath)} HUB_DIR=${JSON.stringify(releaseHub)} bash ${JSON.stringify(`${releaseHub}/deploy/agent_config.sh`)} upsert-role --organization ${JSON.stringify(orgId)} --role ${JSON.stringify(required(answers, "DEFAULT_ROLE_ID"))} --label ${JSON.stringify(required(answers, "DEFAULT_ROLE_LABEL"))} --model ${JSON.stringify(required(answers, "DEFAULT_MODEL"))} --system-prompt-file ${JSON.stringify(`/root/${instanceId}-default-role-prompt.md`)} --tools-json null
|
||||
${selectedSkills ? `INSTANCE_ID=${JSON.stringify(instanceId)} ENV_FILE=${JSON.stringify(envPath)} HUB_DIR=${JSON.stringify(releaseHub)} bash ${JSON.stringify(`${releaseHub}/deploy/agent_config.sh`)} set-role-skills --organization ${JSON.stringify(orgId)} --role ${JSON.stringify(required(answers, "DEFAULT_ROLE_ID"))} --skills ${JSON.stringify(selectedSkills)}
|
||||
` : "# No skills selected in the wizard; set-role-skills remains an explicit operator step.\n"}INSTANCE_ID=${JSON.stringify(instanceId)} ENV_FILE=${JSON.stringify(envPath)} HUB_DIR=${JSON.stringify(releaseHub)} bash ${JSON.stringify(`${releaseHub}/deploy/agent_config.sh`)} list --organization ${JSON.stringify(orgId)}
|
||||
\`\`\`
|
||||
|
||||
Changing a role's model, system prompt, tools or skill selection archives that
|
||||
role's existing sessions by design. Finish this setup before inviting Alpha users.
|
||||
`;
|
||||
|
||||
async function privateWrite(name, contents) {
|
||||
const path = resolve(outputDir, name);
|
||||
await writeFile(path, contents, { encoding: "utf8", mode: 0o600 });
|
||||
await chmod(path, 0o600);
|
||||
}
|
||||
|
||||
await privateWrite("platform.env", platformEnv);
|
||||
await privateWrite("bootstrap.json", `${JSON.stringify(bootstrap, null, 2)}\n`);
|
||||
await privateWrite("deploy.env", deployEnv);
|
||||
await privateWrite("nginx.conf", nginx);
|
||||
await privateWrite("default-role-prompt.md", defaultRolePrompt);
|
||||
await privateWrite("OPERATE.md", `${operate}${roleInstructions}`);
|
||||
|
||||
console.log(`Rendered private Silo bundle: ${outputDir}`);
|
||||
Reference in New Issue
Block a user