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
+110 -58
View File
@@ -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