From 19d942e81244ea576ab006f6be5b70412488b0fe Mon Sep 17 00:00:00 2001 From: Hong Jiarong Date: Sat, 11 Jul 2026 14:53:03 +0800 Subject: [PATCH] feat: automate managed silo provisioning --- hub/deploy/NEW_SILO_RUNBOOK.md | 30 ++++- hub/deploy/apply_new_silo.sh | 73 +++++++++++ hub/deploy/new_silo.sh | 168 +++++++++++++++++--------- hub/deploy/render_new_silo_bundle.mjs | 4 + hub/deploy/resolve_feishu_bot.mjs | 27 +++++ 5 files changed, 239 insertions(+), 63 deletions(-) create mode 100755 hub/deploy/apply_new_silo.sh create mode 100755 hub/deploy/resolve_feishu_bot.mjs diff --git a/hub/deploy/NEW_SILO_RUNBOOK.md b/hub/deploy/NEW_SILO_RUNBOOK.md index fb53c47..defa327 100644 --- a/hub/deploy/NEW_SILO_RUNBOOK.md +++ b/hub/deploy/NEW_SILO_RUNBOOK.md @@ -11,6 +11,11 @@ The repeatable entry point is: bash hub/deploy/new_silo.sh ``` +After collecting the Organization inputs, the wizard shows the assigned +resources and asks once before applying them directly over SSH. The generated +bundle is only a root-secret-safe retry and audit checkpoint; the operator does +not execute it manually during the normal path. + It gathers values and writes a private deployment bundle below `~/.cph-silo-plans//`. The directory and all generated files are mode `0700`/`0600`. Never commit, paste into chat, or copy that directory into @@ -19,20 +24,35 @@ from another Organization. ## Inputs to collect -The platform operator chooses the host, release, unique instance id, short -workspace path, unique loopback port, database name/role, resource ceilings and -public domain. The Organization administrator supplies: +The wizard derives the instance/Organization id from the slug, uses the current +release and managed Alpha defaults, connects to the managed host (currently +`39.107.254.4`), derives +`https://.educraft.paradigm-edu.net` from the wildcard DNS, +and allocates an unused loopback port plus short workspace path by inspecting +existing Silo environments, listening sockets and workspace paths over +read-only SSH. The Organization administrator supplies: - Organization display name and slug; -- Feishu App ID, App Secret and bot Open ID; +- Feishu App ID and App Secret (the wizard resolves the bot Open ID); - the first OWNER's Open ID and display name; -- an Organization-exclusive provider token and provider base URL. +- an Organization-exclusive OpenRouter token. + +Everything else is platform-managed or derived: instance/Organization id, +server, SSH settings, release, resource ceilings, database coordinates and +generated password, domain, port, workspace, provider/base URL, model/role, +curated skills, concurrency, request/file limits and the managed Mihomo proxy +environment. The Feishu app is scoped to this Silo. OAuth users authenticated by that app are automatically admitted to this Organization; OWNER remains the initial privileged membership used for controlled administration and bootstrap. An empty initial team list does not block the Alpha. +To target a replacement platform-managed host, the platform operator may set +`CPH_ALPHA_HOST`, `CPH_ALPHA_DEPLOY_USER`, `CPH_ALPHA_SSH_PORT` and +`CPH_ALPHA_BASE_DOMAIN` before running the wizard. These are fleet controls, +not Organization setup questions. + Obtain a person's Open ID from the Feishu user-get documentation page by clicking the `user_id` value picker and selecting the person. Configure the redirect URL shown by the generated `OPERATE.md`; it is required for first-time diff --git a/hub/deploy/apply_new_silo.sh b/hub/deploy/apply_new_silo.sh new file mode 100755 index 0000000..f5287bb --- /dev/null +++ b/hub/deploy/apply_new_silo.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# Apply a bundle produced by new_silo.sh to the managed Alpha host. +set -euo pipefail + +BUNDLE="${1:?usage: apply_new_silo.sh BUNDLE_DIR}" +ANSWERS="$BUNDLE/answers.env" +[ -f "$ANSWERS" ] || { echo "missing $ANSWERS" >&2; exit 1; } + +value() { sed -n "s/^$1=//p" "$ANSWERS" | tail -n1; } +INSTANCE_ID="$(value INSTANCE_ID)" +ORG_ID="$(value ORGANIZATION_ID)" +HOST="$(value DEPLOY_HOST)" +SSH_USER="$(value DEPLOY_USER)" +SSH_PORT="$(value DEPLOY_SSH_PORT)" +SSH_KEY="$(value DEPLOY_SSH_KEY)" +BASE="$(value DEPLOY_BASE)" +RELEASE="$(value RELEASE_ID)" +HUB_PORT="$(value HUB_PORT)" +WORKSPACE="$(value WORKSPACE_ROOT)" +MEMORY="$(value MEMORY_MAX)" +CPU="$(value CPU_QUOTA)" +TASKS="$(value TASKS_MAX)" +DB_NAME="$(value DATABASE_NAME)" +DB_USER="$(value DATABASE_USER)" +DB_PASSWORD="$(value DATABASE_PASSWORD)" +PUBLIC_URL="$(value PUBLIC_BASE_URL)" +DOMAIN="${PUBLIC_URL#https://}" +HUB_DIR="$BASE/releases/$RELEASE/hub" +ENV_PATH="$BASE/.secrets/$INSTANCE_ID/platform.env" +KEYRING_PATH="$BASE/.secrets/$INSTANCE_ID/secret-keyring.json" +UNIT="cph-hub-$INSTANCE_ID.service" +SSH=(ssh -i "$SSH_KEY" -p "$SSH_PORT" -o BatchMode=yes "$SSH_USER@$HOST") +SCP=(scp -i "$SSH_KEY" -P "$SSH_PORT") + +for file in platform.env bootstrap.json default-role-prompt.md; do + [ -f "$BUNDLE/$file" ] || { echo "missing bundle file: $file" >&2; exit 1; } +done + +echo "[1/8] Verify immutable release" +"${SSH[@]}" "test -f '$BASE/releases/$RELEASE/.complete'" + +echo "[2/8] Create dedicated database" +if ! "${SSH[@]}" "sudo -u postgres psql -Atqc \"select 1 from pg_database where datname='$DB_NAME'\"" | grep -qx 1; then + printf "CREATE ROLE %s LOGIN PASSWORD '%s';\nCREATE DATABASE %s OWNER %s;\n" \ + "$DB_USER" "$DB_PASSWORD" "$DB_NAME" "$DB_USER" | "${SSH[@]}" sudo -u postgres psql -v ON_ERROR_STOP=1 +fi + +echo "[3/8] Seed instance keyring and service template" +set +e +"${SSH[@]}" "BASE='$BASE' HUB_DIR='$HUB_DIR' INSTANCE_ID='$INSTANCE_ID' WORKSPACE_ROOT='$WORKSPACE' PORT='$HUB_PORT' MEMORY_MAX='$MEMORY' CPU_QUOTA='$CPU' TASKS_MAX='$TASKS' bash '$HUB_DIR/deploy/install_service.sh'" +status=$? +set -e +[ "$status" -eq 0 ] || [ "$status" -eq 78 ] || exit "$status" + +echo "[4/8] Upload root-only configuration" +remote_stage="/root/.cph-bootstrap-$INSTANCE_ID" +"${SSH[@]}" "install -d -o root -g root -m 0700 '$remote_stage'" +"${SCP[@]}" "$BUNDLE/platform.env" "$BUNDLE/bootstrap.json" "$BUNDLE/default-role-prompt.md" "$SSH_USER@$HOST:$remote_stage/" +"${SSH[@]}" "install -o root -g root -m 0600 '$remote_stage/platform.env' '$ENV_PATH'; chmod 0600 '$remote_stage/bootstrap.json' '$remote_stage/default-role-prompt.md'" + +echo "[5/8] Migrate, install, and bootstrap" +"${SSH[@]}" "set -euo pipefail; set -a; . '$ENV_PATH'; set +a; node '$HUB_DIR/node_modules/prisma/build/index.js' migrate deploy --schema '$HUB_DIR/prisma/schema.prisma'; BASE='$BASE' HUB_DIR='$HUB_DIR' INSTANCE_ID='$INSTANCE_ID' WORKSPACE_ROOT='$WORKSPACE' PORT='$HUB_PORT' MEMORY_MAX='$MEMORY' CPU_QUOTA='$CPU' TASKS_MAX='$TASKS' bash '$HUB_DIR/deploy/install_service.sh'; node '$HUB_DIR/dist/deployment/bootstrap-silo-cli.js' --config-file '$remote_stage/bootstrap.json' --keyring-file '$KEYRING_PATH'" + +echo "[6/8] Copy curated skills and configure default role" +"${SSH[@]}" "set -euo pipefail; stage='/var/lib/cph-hub/$INSTANCE_ID/state/operator-staging'; install -d -o cph-$INSTANCE_ID -g cph-$INSTANCE_ID -m 0700 \"\$stage/skills\"; install -o cph-$INSTANCE_ID -g cph-$INSTANCE_ID -m 0600 '$remote_stage/default-role-prompt.md' \"\$stage/default-role-prompt.md\"; for source in /var/lib/cph-hub/para-26071100/state/skills/versions/*; do name=\$(sed -n 's/^name: *//p' \"\$source/SKILL.md\" | head -n1); case \"\$name\" in outline|lesson-project|data-processing-spec|typst) cp -a \"\$source\" \"\$stage/skills/\$name\"; chown -R cph-$INSTANCE_ID:cph-$INSTANCE_ID \"\$stage/skills/\$name\";; esac; done; for name in outline lesson-project data-processing-spec typst; do INSTANCE_ID='$INSTANCE_ID' ENV_FILE='$ENV_PATH' HUB_DIR='$HUB_DIR' bash '$HUB_DIR/deploy/agent_config.sh' install-skill --organization '$ORG_ID' --source \"\$stage/skills/\$name\" --version 1; done; INSTANCE_ID='$INSTANCE_ID' ENV_FILE='$ENV_PATH' HUB_DIR='$HUB_DIR' bash '$HUB_DIR/deploy/agent_config.sh' upsert-role --organization '$ORG_ID' --role draft --label '智能助手' --system-prompt-file \"\$stage/default-role-prompt.md\" --tools-json null; INSTANCE_ID='$INSTANCE_ID' ENV_FILE='$ENV_PATH' HUB_DIR='$HUB_DIR' bash '$HUB_DIR/deploy/agent_config.sh' set-role-skills --organization '$ORG_ID' --role draft --skills outline,lesson-project,data-processing-spec,typst; rm -rf \"\$stage\"" + +echo "[7/8] Configure Nginx and TLS" +printf 'server { listen 80; listen [::]:80; server_name %s; client_max_body_size 2m; location / { proxy_pass http://127.0.0.1:%s; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_read_timeout 3600s; proxy_send_timeout 3600s; proxy_buffering off; } }\n' "$DOMAIN" "$HUB_PORT" | "${SSH[@]}" "install -o root -g root -m 0644 /dev/stdin '/etc/nginx/sites-available/$INSTANCE_ID'; ln -sfn '/etc/nginx/sites-available/$INSTANCE_ID' '/etc/nginx/sites-enabled/$INSTANCE_ID'; nginx -t; systemctl reload nginx; certbot --nginx --non-interactive --agree-tos --redirect --register-unsafely-without-email -d '$DOMAIN'" + +echo "[8/8] Start and verify" +"${SSH[@]}" "systemctl enable --now '$UNIT'; systemctl is-active --quiet '$UNIT'; curl --fail --silent 'http://127.0.0.1:$HUB_PORT/api/healthz' >/dev/null; rm -f '$remote_stage/bootstrap.json'" +curl --fail --silent --show-error "$PUBLIC_URL/api/healthz" >/dev/null +echo "Deployed $INSTANCE_ID at $PUBLIC_URL" diff --git a/hub/deploy/new_silo.sh b/hub/deploy/new_silo.sh index 2266a22..25a640f 100755 --- a/hub/deploy/new_silo.sh +++ b/hub/deploy/new_silo.sh @@ -226,16 +226,64 @@ capture_secret() { write_env "$key" "${!key}" } +allocate_host_slot() { + local allocation local_reserved="" answers_file reserved_port + for answers_file in "$PLAN_ROOT"/*/answers.env; do + [ -f "$answers_file" ] || continue + reserved_port="$(sed -n 's/^HUB_PORT=//p' "$answers_file" | tail -n1)" + [[ "$reserved_port" =~ ^[0-9]+$ ]] || continue + local_reserved="${local_reserved:+$local_reserved,}$reserved_port" + done + allocation="$(ssh \ + -i "$DEPLOY_SSH_KEY" \ + -p "$DEPLOY_SSH_PORT" \ + -o BatchMode=yes \ + -o StrictHostKeyChecking=accept-new \ + "$DEPLOY_USER@$DEPLOY_HOST" \ + bash -s -- "$local_reserved" <<'REMOTE' + set -euo pipefail + local_reserved=",${1:-}," + for candidate in $(seq 8788 8999); do + reserved=false + if [[ "$local_reserved" == *",$candidate,"* ]]; then + continue + fi + for env_file in /srv/curriculum-project-hub/.secrets/*/platform.env; do + [ -f "$env_file" ] || continue + if [ "$(sed -n "s/^PORT=//p" "$env_file")" = "$candidate" ]; then + reserved=true + break + fi + done + workspace="/w/$candidate" + if [ "$reserved" = false ] && ! ss -H -ltn "sport = :$candidate" | grep -q . && [ ! -e "$workspace" ]; then + printf "%s %s\n" "$candidate" "$workspace" + exit 0 + fi + done + echo "no free Alpha Silo slot in 8788..8999" >&2 + exit 1 +REMOTE + )" + read -r HUB_PORT WORKSPACE_ROOT <<<"$allocation" + require_value HUB_PORT "$HUB_PORT" + require_value WORKSPACE_ROOT "$WORKSPACE_ROOT" + write_env HUB_PORT "$HUB_PORT" + write_env WORKSPACE_ROOT "$WORKSPACE_ROOT" +} + banner "New Alpha Silo" -stage "Silo identity and private plan" 3 +stage "Organization identity and private plan" 3 say "One run creates one Organization's private deployment bundle." -ask INSTANCE_ID "Unique instance id (lowercase, max 24 chars; e.g. school-a):" -require_value INSTANCE_ID "$INSTANCE_ID" -[[ "$INSTANCE_ID" =~ ^[a-z0-9]([a-z0-9-]{0,22}[a-z0-9])?$ ]] || { - warn "invalid instance id" +ask ORGANIZATION_SLUG "Organization slug (lowercase, max 24 chars; e.g. school-a):" +require_value ORGANIZATION_SLUG "$ORGANIZATION_SLUG" +[[ "$ORGANIZATION_SLUG" =~ ^[a-z0-9]([a-z0-9-]{0,22}[a-z0-9])?$ ]] || { + warn "invalid Organization slug" exit 1 } +INSTANCE_ID="$ORGANIZATION_SLUG" +ORGANIZATION_ID="$ORGANIZATION_SLUG" OUTPUT_DIR="$PLAN_ROOT/$INSTANCE_ID" mkdir -p "$OUTPUT_DIR" chmod 0700 "$OUTPUT_DIR" @@ -243,34 +291,42 @@ ENV_FILE="$OUTPUT_DIR/answers.env" touch "$ENV_FILE" chmod 0600 "$ENV_FILE" write_env INSTANCE_ID "$INSTANCE_ID" -seed_default ORGANIZATION_ID "$INSTANCE_ID" -seed_default ORGANIZATION_SLUG "$INSTANCE_ID" -capture ORGANIZATION_ID "Organization id:" -capture ORGANIZATION_SLUG "Organization slug:" +write_env ORGANIZATION_ID "$ORGANIZATION_ID" +write_env ORGANIZATION_SLUG "$ORGANIZATION_SLUG" capture ORGANIZATION_NAME "Organization display name:" stage "Host, release, and isolation" 5 -say "Choose values that are unique on the shared host. The service binds loopback only." -seed_default DEPLOY_USER "root" -seed_default DEPLOY_SSH_PORT "22" +say "The Alpha host is platform-managed. Port and short workspace path are allocated from live host state." +DEPLOY_HOST="${CPH_ALPHA_HOST:-39.107.254.4}" +DEPLOY_USER="${CPH_ALPHA_DEPLOY_USER:-root}" +DEPLOY_SSH_PORT="${CPH_ALPHA_SSH_PORT:-22}" +write_env DEPLOY_HOST "$DEPLOY_HOST" +write_env DEPLOY_USER "$DEPLOY_USER" +write_env DEPLOY_SSH_PORT "$DEPLOY_SSH_PORT" seed_default DEPLOY_BASE "/srv/curriculum-project-hub" -seed_default RELEASE_ID "$(git -C "$REPO_ROOT" describe --tags --exact-match 2>/dev/null || git -C "$REPO_ROOT" rev-parse --short HEAD)" +seed_default DEPLOY_SSH_KEY "$HOME/.ssh/id_ed25519" +write_env RELEASE_ID "v$(node -p 'require(process.argv[1]).version' "$REPO_ROOT/hub/package.json")" seed_default MEMORY_MAX "16G" seed_default CPU_QUOTA "400%" seed_default TASKS_MAX "512" seed_default CPH_BIN "/usr/local/bin/cph" -capture DEPLOY_HOST "Server IP or SSH host:" -capture DEPLOY_USER "SSH deploy user:" -capture DEPLOY_SSH_PORT "SSH port:" -capture DEPLOY_SSH_KEY "Absolute path to SSH private key:" -capture DEPLOY_BASE "Remote release base:" -capture RELEASE_ID "Immutable release id/tag:" -capture HUB_PORT "Unique loopback Hub port:" -capture WORKSPACE_ROOT "Unique short workspace path (at most 16 bytes; e.g. /w/102):" -capture MEMORY_MAX "systemd MemoryMax:" -capture CPU_QUOTA "systemd CPUQuota:" -capture TASKS_MAX "systemd TasksMax:" -capture CPH_BIN "Remote cph binary path:" +note "Managed Alpha host: $DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_SSH_PORT" +DEPLOY_SSH_KEY="$(_existing DEPLOY_SSH_KEY)" +MEMORY_MAX="$(_existing MEMORY_MAX)" +CPU_QUOTA="$(_existing CPU_QUOTA)" +TASKS_MAX="$(_existing TASKS_MAX)" +[ -f "$DEPLOY_SSH_KEY" ] || { warn "managed SSH key is missing: $DEPLOY_SSH_KEY"; exit 1; } +if [[ "$(_existing HUB_PORT || true)" =~ ^(878[8-9]|87[9][0-9]|8[89][0-9]{2})$ ]] && \ + [ "$(_existing WORKSPACE_ROOT || true)" = "/w/$(_existing HUB_PORT)" ]; then + HUB_PORT="$(_existing HUB_PORT)" + WORKSPACE_ROOT="$(_existing WORKSPACE_ROOT)" + note "Keeping allocated host slot: port $HUB_PORT, workspace $WORKSPACE_ROOT" +else + say "Checking existing Silo environments, listening sockets, and workspace paths..." + allocate_host_slot + note "Allocated host slot: port $HUB_PORT, workspace $WORKSPACE_ROOT" +fi +note "Platform ceilings: MemoryMax=$MEMORY_MAX, CPUQuota=$CPU_QUOTA, TasksMax=$TASKS_MAX" stage "Dedicated PostgreSQL database" 4 say "A PostgreSQL server may be shared, but this Silo gets a distinct login role and database." @@ -278,26 +334,30 @@ seed_default DATABASE_HOST "127.0.0.1" seed_default DATABASE_PORT "5432" seed_default DATABASE_NAME "cph_${INSTANCE_ID//-/_}" seed_default DATABASE_USER "cph_${INSTANCE_ID//-/_}" -capture DATABASE_HOST "Database host as seen by the Hub service:" -capture DATABASE_PORT "Database port:" -capture DATABASE_NAME "Dedicated database name:" -capture DATABASE_USER "Dedicated database login role:" -capture_secret DATABASE_PASSWORD "New database password:" +DATABASE_NAME="$(_existing DATABASE_NAME)" +if ! _existing DATABASE_PASSWORD >/dev/null 2>&1; then + DATABASE_PASSWORD="$(openssl rand -base64 36 | tr -d '\n')" + write_env DATABASE_PASSWORD "$DATABASE_PASSWORD" +fi note "The generated OPERATE.md uses an interactive/protected SQL path; the password is never put in a command argument." stage "Public URL and Feishu app" 9 open_url "https://open.feishu.cn/app" say "Create or open the Organization's own app. Copy credentials from Credentials & Basic Info." -capture PUBLIC_BASE_URL "Public base URL including https:// (e.g. https://school-a.example.com):" +PUBLIC_BASE_URL="https://${ORGANIZATION_SLUG}.${CPH_ALPHA_BASE_DOMAIN:-educraft.paradigm-edu.net}" +write_env PUBLIC_BASE_URL "$PUBLIC_BASE_URL" +note "Platform-assigned public URL: $PUBLIC_BASE_URL" capture FEISHU_APP_ID "Feishu App ID:" capture_secret FEISHU_APP_SECRET "Feishu App Secret:" -capture FEISHU_BOT_OPEN_ID "Bot Open ID:" +say "Resolving the bot Open ID from Feishu..." +FEISHU_BOT_OPEN_ID="$(printf '%s\0%s\0' "$FEISHU_APP_ID" "$FEISHU_APP_SECRET" | node "$SCRIPT_DIR/resolve_feishu_bot.mjs")" +write_env FEISHU_BOT_OPEN_ID "$FEISHU_BOT_OPEN_ID" +note "Resolved bot identity: $FEISHU_BOT_OPEN_ID" open_url "https://open.feishu.cn/document/server-docs/contact-v3/user/get" step "In the user/get page, click the user_id value picker, select the first OWNER, and copy the returned open_id." capture OWNER_OPEN_ID "OWNER Open ID (ou_...):" capture OWNER_DISPLAY_NAME "OWNER display name:" -ask OWNER_UNION_ID "OWNER Union ID (optional; Enter to skip):" -write_env OWNER_UNION_ID "$OWNER_UNION_ID" +write_env OWNER_UNION_ID "" say "The exact redirect URL and acceptance steps will be written to OPERATE.md." stage "Provider and Alpha limits" 5 @@ -306,7 +366,7 @@ seed_default PROVIDER_ID "openrouter" seed_default PROVIDER_BASE_URL "https://openrouter.ai/api" seed_default DEFAULT_MODEL "anthropic/claude-sonnet-5" seed_default DEFAULT_ROLE_ID "draft" -seed_default DEFAULT_ROLE_LABEL "草稿" +seed_default DEFAULT_ROLE_LABEL "智能助手" seed_default MAX_TURNS "25" seed_default MAX_CONCURRENT_RUNS "4" seed_default MAX_RUN_SECONDS "900" @@ -315,22 +375,14 @@ seed_default MAX_FILES_PER_MESSAGE "8" seed_default MAX_FILE_BYTES "26214400" seed_default HTTP_REQUESTS_PER_MINUTE "120" seed_default FEISHU_EVENTS_PER_MINUTE "120" -capture PROVIDER_ID "Provider id:" -capture PROVIDER_BASE_URL "Provider base URL:" capture_secret PROVIDER_AUTH_TOKEN "Provider auth token:" -capture DEFAULT_MODEL "Default model id exposed by this provider:" -capture DEFAULT_ROLE_ID "Default role id:" -capture DEFAULT_ROLE_LABEL "Default role label:" -ask APPROVED_SKILLS "Approved installed skill names, comma-separated (optional):" -write_env APPROVED_SKILLS "$APPROVED_SKILLS" -capture MAX_TURNS "Maximum turns per run:" -capture MAX_CONCURRENT_RUNS "Organization concurrent runs:" -capture MAX_RUN_SECONDS "Maximum run seconds:" -capture HTTP_BODY_LIMIT_BYTES "HTTP body limit bytes:" -capture MAX_FILES_PER_MESSAGE "Maximum files per message:" -capture MAX_FILE_BYTES "Maximum bytes per file:" -capture HTTP_REQUESTS_PER_MINUTE "HTTP requests per minute:" -capture FEISHU_EVENTS_PER_MINUTE "Feishu events per minute:" +for key in PROVIDER_ID PROVIDER_BASE_URL DEFAULT_MODEL DEFAULT_ROLE_ID DEFAULT_ROLE_LABEL \ + MAX_TURNS MAX_CONCURRENT_RUNS MAX_RUN_SECONDS HTTP_BODY_LIMIT_BYTES \ + MAX_FILES_PER_MESSAGE MAX_FILE_BYTES HTTP_REQUESTS_PER_MINUTE FEISHU_EVENTS_PER_MINUTE; do + printf -v "$key" '%s' "$(_existing "$key")" +done +write_env APPROVED_SKILLS "outline,lesson-project,data-processing-spec,typst" +note "Platform runtime: OpenRouter, concurrency 4, default education role, curated skills." if ! _existing HUB_SESSION_SECRET >/dev/null 2>&1; then command -v openssl >/dev/null 2>&1 || { warn "openssl is required"; exit 1; } HUB_SESSION_SECRET="$(openssl rand -hex 32)" @@ -347,14 +399,14 @@ say "Bundle: $OUTPUT_DIR" warn "It contains database, Feishu, provider and session secrets. Never commit or paste it." stage "Operator handoff and gates" 7 -say "Open OPERATE.md and execute its stages in order. Nothing has changed on the server yet." -step "Verify DNS, Feishu redirect/permissions/events and the host proxy." -step "Create the dedicated database role/database and publish the release." -step "Install root-only secrets, back up the keyring, migrate and bootstrap." -step "Install only reviewed runtime skills and the default role configuration." -step "Validate Nginx, start the service, run health/Feishu/session/Typst acceptance, then back up." -if confirm "Print the bundle filenames now?"; then - find "$OUTPUT_DIR" -maxdepth 1 -type f -exec basename {} \; | sort +say "The deployment package is an internal retry/audit checkpoint; you do not operate it manually." +step "Target: $PUBLIC_BASE_URL → $DEPLOY_HOST:$HUB_PORT" +step "Resources: database $DATABASE_NAME, workspace $WORKSPACE_ROOT, service cph-hub-$INSTANCE_ID" +warn "Confirmation will create server, database, TLS, and runtime state." +if confirm "Deploy this Organization now?"; then + bash "$SCRIPT_DIR/apply_new_silo.sh" "$OUTPUT_DIR" +else + warn "deployment skipped; rerun the wizard later and keep existing answers" fi finish diff --git a/hub/deploy/render_new_silo_bundle.mjs b/hub/deploy/render_new_silo_bundle.mjs index a58750a..68fbbcb 100755 --- a/hub/deploy/render_new_silo_bundle.mjs +++ b/hub/deploy/render_new_silo_bundle.mjs @@ -113,6 +113,10 @@ const platformEnv = [ 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("HTTP_PROXY", "http://127.0.0.1:7890"), + envLine("HTTPS_PROXY", "http://127.0.0.1:7890"), + envLine("ALL_PROXY", "socks5h://127.0.0.1:7890"), + envLine("NO_PROXY", "127.0.0.1,localhost,::1"), envLine("CPH_SANDBOX_EXTRA_DENY_READ", `${envPath}:${keyringPath}`), "", ].join("\n"); diff --git a/hub/deploy/resolve_feishu_bot.mjs b/hub/deploy/resolve_feishu_bot.mjs new file mode 100755 index 0000000..8cc2d3b --- /dev/null +++ b/hub/deploy/resolve_feishu_bot.mjs @@ -0,0 +1,27 @@ +#!/usr/bin/env node + +const chunks = []; +for await (const chunk of process.stdin) chunks.push(chunk); +const [appId, appSecret] = Buffer.concat(chunks).toString("utf8").split("\0"); +if (!appId || !appSecret) throw new Error("Feishu App ID and App Secret are required on stdin"); + +const tokenResponse = await fetch("https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal", { + method: "POST", + headers: { "content-type": "application/json; charset=utf-8" }, + body: JSON.stringify({ app_id: appId, app_secret: appSecret }), +}); +if (!tokenResponse.ok) throw new Error(`Feishu token request failed: HTTP ${tokenResponse.status}`); +const tokenPayload = await tokenResponse.json(); +if (tokenPayload.code !== 0 || typeof tokenPayload.tenant_access_token !== "string") { + throw new Error(`Feishu token request failed: ${JSON.stringify(tokenPayload)}`); +} + +const botResponse = await fetch("https://open.feishu.cn/open-apis/bot/v3/info", { + headers: { authorization: `Bearer ${tokenPayload.tenant_access_token}` }, +}); +if (!botResponse.ok) throw new Error(`Feishu bot info request failed: HTTP ${botResponse.status}`); +const botPayload = await botResponse.json(); +if (botPayload.code !== 0 || typeof botPayload.bot?.open_id !== "string" || !botPayload.bot.open_id) { + throw new Error(`Feishu bot info request failed: ${JSON.stringify(botPayload)}`); +} +process.stdout.write(botPayload.bot.open_id);