forked from EduCraft/curriculum-project-hub
feat: automate managed silo provisioning
This commit is contained in:
+110
-58
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user