feat: automate managed silo provisioning

This commit is contained in:
2026-07-11 14:53:03 +08:00
parent e5e923dd34
commit 19d942e812
5 changed files with 239 additions and 63 deletions
+25 -5
View File
@@ -11,6 +11,11 @@ The repeatable entry point is:
bash hub/deploy/new_silo.sh 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 It gathers values and writes a private deployment bundle below
`~/.cph-silo-plans/<instance-id>/`. The directory and all generated files are `~/.cph-silo-plans/<instance-id>/`. The directory and all generated files are
mode `0700`/`0600`. Never commit, paste into chat, or copy that directory into mode `0700`/`0600`. Never commit, paste into chat, or copy that directory into
@@ -19,20 +24,35 @@ from another Organization.
## Inputs to collect ## Inputs to collect
The platform operator chooses the host, release, unique instance id, short The wizard derives the instance/Organization id from the slug, uses the current
workspace path, unique loopback port, database name/role, resource ceilings and release and managed Alpha defaults, connects to the managed host (currently
public domain. The Organization administrator supplies: `39.107.254.4`), derives
`https://<organization-slug>.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; - 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; - 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 The Feishu app is scoped to this Silo. OAuth users authenticated by that app are
automatically admitted to this Organization; OWNER remains the initial automatically admitted to this Organization; OWNER remains the initial
privileged membership used for controlled administration and bootstrap. An privileged membership used for controlled administration and bootstrap. An
empty initial team list does not block the Alpha. 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 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 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 redirect URL shown by the generated `OPERATE.md`; it is required for first-time
+73
View File
@@ -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"
+110 -58
View File
@@ -226,16 +226,64 @@ capture_secret() {
write_env "$key" "${!key}" 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" 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." say "One run creates one Organization's private deployment bundle."
ask INSTANCE_ID "Unique instance id (lowercase, max 24 chars; e.g. school-a):" ask ORGANIZATION_SLUG "Organization slug (lowercase, max 24 chars; e.g. school-a):"
require_value INSTANCE_ID "$INSTANCE_ID" require_value ORGANIZATION_SLUG "$ORGANIZATION_SLUG"
[[ "$INSTANCE_ID" =~ ^[a-z0-9]([a-z0-9-]{0,22}[a-z0-9])?$ ]] || { [[ "$ORGANIZATION_SLUG" =~ ^[a-z0-9]([a-z0-9-]{0,22}[a-z0-9])?$ ]] || {
warn "invalid instance id" warn "invalid Organization slug"
exit 1 exit 1
} }
INSTANCE_ID="$ORGANIZATION_SLUG"
ORGANIZATION_ID="$ORGANIZATION_SLUG"
OUTPUT_DIR="$PLAN_ROOT/$INSTANCE_ID" OUTPUT_DIR="$PLAN_ROOT/$INSTANCE_ID"
mkdir -p "$OUTPUT_DIR" mkdir -p "$OUTPUT_DIR"
chmod 0700 "$OUTPUT_DIR" chmod 0700 "$OUTPUT_DIR"
@@ -243,34 +291,42 @@ ENV_FILE="$OUTPUT_DIR/answers.env"
touch "$ENV_FILE" touch "$ENV_FILE"
chmod 0600 "$ENV_FILE" chmod 0600 "$ENV_FILE"
write_env INSTANCE_ID "$INSTANCE_ID" write_env INSTANCE_ID "$INSTANCE_ID"
seed_default ORGANIZATION_ID "$INSTANCE_ID" write_env ORGANIZATION_ID "$ORGANIZATION_ID"
seed_default ORGANIZATION_SLUG "$INSTANCE_ID" write_env ORGANIZATION_SLUG "$ORGANIZATION_SLUG"
capture ORGANIZATION_ID "Organization id:"
capture ORGANIZATION_SLUG "Organization slug:"
capture ORGANIZATION_NAME "Organization display name:" capture ORGANIZATION_NAME "Organization display name:"
stage "Host, release, and isolation" 5 stage "Host, release, and isolation" 5
say "Choose values that are unique on the shared host. The service binds loopback only." say "The Alpha host is platform-managed. Port and short workspace path are allocated from live host state."
seed_default DEPLOY_USER "root" DEPLOY_HOST="${CPH_ALPHA_HOST:-39.107.254.4}"
seed_default DEPLOY_SSH_PORT "22" 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 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 MEMORY_MAX "16G"
seed_default CPU_QUOTA "400%" seed_default CPU_QUOTA "400%"
seed_default TASKS_MAX "512" seed_default TASKS_MAX "512"
seed_default CPH_BIN "/usr/local/bin/cph" seed_default CPH_BIN "/usr/local/bin/cph"
capture DEPLOY_HOST "Server IP or SSH host:" note "Managed Alpha host: $DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_SSH_PORT"
capture DEPLOY_USER "SSH deploy user:" DEPLOY_SSH_KEY="$(_existing DEPLOY_SSH_KEY)"
capture DEPLOY_SSH_PORT "SSH port:" MEMORY_MAX="$(_existing MEMORY_MAX)"
capture DEPLOY_SSH_KEY "Absolute path to SSH private key:" CPU_QUOTA="$(_existing CPU_QUOTA)"
capture DEPLOY_BASE "Remote release base:" TASKS_MAX="$(_existing TASKS_MAX)"
capture RELEASE_ID "Immutable release id/tag:" [ -f "$DEPLOY_SSH_KEY" ] || { warn "managed SSH key is missing: $DEPLOY_SSH_KEY"; exit 1; }
capture HUB_PORT "Unique loopback Hub port:" if [[ "$(_existing HUB_PORT || true)" =~ ^(878[8-9]|87[9][0-9]|8[89][0-9]{2})$ ]] && \
capture WORKSPACE_ROOT "Unique short workspace path (at most 16 bytes; e.g. /w/102):" [ "$(_existing WORKSPACE_ROOT || true)" = "/w/$(_existing HUB_PORT)" ]; then
capture MEMORY_MAX "systemd MemoryMax:" HUB_PORT="$(_existing HUB_PORT)"
capture CPU_QUOTA "systemd CPUQuota:" WORKSPACE_ROOT="$(_existing WORKSPACE_ROOT)"
capture TASKS_MAX "systemd TasksMax:" note "Keeping allocated host slot: port $HUB_PORT, workspace $WORKSPACE_ROOT"
capture CPH_BIN "Remote cph binary path:" 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 stage "Dedicated PostgreSQL database" 4
say "A PostgreSQL server may be shared, but this Silo gets a distinct login role and database." 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_PORT "5432"
seed_default DATABASE_NAME "cph_${INSTANCE_ID//-/_}" seed_default DATABASE_NAME "cph_${INSTANCE_ID//-/_}"
seed_default DATABASE_USER "cph_${INSTANCE_ID//-/_}" seed_default DATABASE_USER "cph_${INSTANCE_ID//-/_}"
capture DATABASE_HOST "Database host as seen by the Hub service:" DATABASE_NAME="$(_existing DATABASE_NAME)"
capture DATABASE_PORT "Database port:" if ! _existing DATABASE_PASSWORD >/dev/null 2>&1; then
capture DATABASE_NAME "Dedicated database name:" DATABASE_PASSWORD="$(openssl rand -base64 36 | tr -d '\n')"
capture DATABASE_USER "Dedicated database login role:" write_env DATABASE_PASSWORD "$DATABASE_PASSWORD"
capture_secret DATABASE_PASSWORD "New database password:" fi
note "The generated OPERATE.md uses an interactive/protected SQL path; the password is never put in a command argument." 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 stage "Public URL and Feishu app" 9
open_url "https://open.feishu.cn/app" open_url "https://open.feishu.cn/app"
say "Create or open the Organization's own app. Copy credentials from Credentials & Basic Info." 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 FEISHU_APP_ID "Feishu App ID:"
capture_secret FEISHU_APP_SECRET "Feishu App Secret:" 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" 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." 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_OPEN_ID "OWNER Open ID (ou_...):"
capture OWNER_DISPLAY_NAME "OWNER display name:" capture OWNER_DISPLAY_NAME "OWNER display name:"
ask OWNER_UNION_ID "OWNER Union ID (optional; Enter to skip):" write_env OWNER_UNION_ID ""
write_env OWNER_UNION_ID "$OWNER_UNION_ID"
say "The exact redirect URL and acceptance steps will be written to OPERATE.md." say "The exact redirect URL and acceptance steps will be written to OPERATE.md."
stage "Provider and Alpha limits" 5 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 PROVIDER_BASE_URL "https://openrouter.ai/api"
seed_default DEFAULT_MODEL "anthropic/claude-sonnet-5" seed_default DEFAULT_MODEL "anthropic/claude-sonnet-5"
seed_default DEFAULT_ROLE_ID "draft" seed_default DEFAULT_ROLE_ID "draft"
seed_default DEFAULT_ROLE_LABEL "草稿" seed_default DEFAULT_ROLE_LABEL "智能助手"
seed_default MAX_TURNS "25" seed_default MAX_TURNS "25"
seed_default MAX_CONCURRENT_RUNS "4" seed_default MAX_CONCURRENT_RUNS "4"
seed_default MAX_RUN_SECONDS "900" 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 MAX_FILE_BYTES "26214400"
seed_default HTTP_REQUESTS_PER_MINUTE "120" seed_default HTTP_REQUESTS_PER_MINUTE "120"
seed_default FEISHU_EVENTS_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_secret PROVIDER_AUTH_TOKEN "Provider auth token:"
capture DEFAULT_MODEL "Default model id exposed by this provider:" for key in PROVIDER_ID PROVIDER_BASE_URL DEFAULT_MODEL DEFAULT_ROLE_ID DEFAULT_ROLE_LABEL \
capture DEFAULT_ROLE_ID "Default role id:" MAX_TURNS MAX_CONCURRENT_RUNS MAX_RUN_SECONDS HTTP_BODY_LIMIT_BYTES \
capture DEFAULT_ROLE_LABEL "Default role label:" MAX_FILES_PER_MESSAGE MAX_FILE_BYTES HTTP_REQUESTS_PER_MINUTE FEISHU_EVENTS_PER_MINUTE; do
ask APPROVED_SKILLS "Approved installed skill names, comma-separated (optional):" printf -v "$key" '%s' "$(_existing "$key")"
write_env APPROVED_SKILLS "$APPROVED_SKILLS" done
capture MAX_TURNS "Maximum turns per run:" write_env APPROVED_SKILLS "outline,lesson-project,data-processing-spec,typst"
capture MAX_CONCURRENT_RUNS "Organization concurrent runs:" note "Platform runtime: OpenRouter, concurrency 4, default education role, curated skills."
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:"
if ! _existing HUB_SESSION_SECRET >/dev/null 2>&1; then if ! _existing HUB_SESSION_SECRET >/dev/null 2>&1; then
command -v openssl >/dev/null 2>&1 || { warn "openssl is required"; exit 1; } command -v openssl >/dev/null 2>&1 || { warn "openssl is required"; exit 1; }
HUB_SESSION_SECRET="$(openssl rand -hex 32)" 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." warn "It contains database, Feishu, provider and session secrets. Never commit or paste it."
stage "Operator handoff and gates" 7 stage "Operator handoff and gates" 7
say "Open OPERATE.md and execute its stages in order. Nothing has changed on the server yet." say "The deployment package is an internal retry/audit checkpoint; you do not operate it manually."
step "Verify DNS, Feishu redirect/permissions/events and the host proxy." step "Target: $PUBLIC_BASE_URL$DEPLOY_HOST:$HUB_PORT"
step "Create the dedicated database role/database and publish the release." step "Resources: database $DATABASE_NAME, workspace $WORKSPACE_ROOT, service cph-hub-$INSTANCE_ID"
step "Install root-only secrets, back up the keyring, migrate and bootstrap." warn "Confirmation will create server, database, TLS, and runtime state."
step "Install only reviewed runtime skills and the default role configuration." if confirm "Deploy this Organization now?"; then
step "Validate Nginx, start the service, run health/Feishu/session/Typst acceptance, then back up." bash "$SCRIPT_DIR/apply_new_silo.sh" "$OUTPUT_DIR"
if confirm "Print the bundle filenames now?"; then else
find "$OUTPUT_DIR" -maxdepth 1 -type f -exec basename {} \; | sort warn "deployment skipped; rerun the wizard later and keep existing answers"
fi fi
finish finish
+4
View File
@@ -113,6 +113,10 @@ const platformEnv = [
envLine("HUB_HTTP_REQUESTS_PER_MINUTE", required(answers, "HTTP_REQUESTS_PER_MINUTE")), 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_EVENTS_PER_MINUTE", required(answers, "FEISHU_EVENTS_PER_MINUTE")),
envLine("HUB_FEISHU_LISTENER_ENABLED", "true"), 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}`), envLine("CPH_SANDBOX_EXTRA_DENY_READ", `${envPath}:${keyringPath}`),
"", "",
].join("\n"); ].join("\n");
+27
View File
@@ -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);