diff --git a/.gitea/workflows/deploy-admin.yml b/.gitea/workflows/deploy-admin.yml new file mode 100644 index 0000000..d86cf5d --- /dev/null +++ b/.gitea/workflows/deploy-admin.yml @@ -0,0 +1,126 @@ +name: deploy admin (hub fleet) + +# Rolls Hub releases that include the org-admin SPA (`admin-web` → registerStaticSpa) +# onto the managed Alpha host. Two fleets share one machine and are separated by +# the middle DNS label of each Silo's public URL: +# +# dev → https://.educraft-dev.paradigm-edu.net (every push + PR) +# prod → https://.educraft.paradigm-edu.net (main + tags) +# +# Example prod tenant: +# https://para-26071100.educraft.paradigm-edu.net/auth/feishu/para-26071100 +# +# Required Gitea secret: +# DEPLOY_SSH_KEY — private key for root@39.107.254.4 +# +# Optional secrets / vars (defaults match NEW_SILO_RUNBOOK): +# DEPLOY_HOST, DEPLOY_USER, DEPLOY_SSH_PORT, DEPLOY_BASE +# vars.ALLOW_EMPTY_FLEET=1 — green when the fleet has no silos yet + +on: + push: + paths: + - "hub/**" + - ".gitea/workflows/deploy-admin.yml" + pull_request: + paths: + - "hub/**" + - ".gitea/workflows/deploy-admin.yml" + workflow_dispatch: + inputs: + fleet: + description: "Which fleet to deploy (dev | prod | both)" + required: true + default: both + allow_empty_fleet: + description: "Succeed if no silo matches (1/0)" + required: false + default: "0" + +concurrency: + group: deploy-admin-host + cancel-in-progress: false + +jobs: + deploy-dev: + name: deploy fleet dev (educraft-dev) + runs-on: ubuntu-latest + if: > + github.event_name == 'pull_request' || + (github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')) || + (github.event_name == 'workflow_dispatch' && + (github.event.inputs.fleet == 'dev' || github.event.inputs.fleet == 'both')) + steps: + - uses: actions/checkout@v5 + + - name: Install rsync + ssh + run: sudo apt-get update && sudo apt-get install -y rsync openssh-client + + - name: Deploy Hub + admin SPA to educraft-dev fleet + env: + CPH_FLEET: dev + PLATFORM_DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} + PLATFORM_DEPLOY_USER: ${{ secrets.DEPLOY_USER }} + PLATFORM_DEPLOY_PORT: ${{ secrets.DEPLOY_SSH_PORT }} + PLATFORM_DEPLOY_BASE: ${{ secrets.DEPLOY_BASE }} + PLATFORM_DEPLOY_RELEASE: ${{ github.sha }} + ALLOW_EMPTY_FLEET: ${{ github.event.inputs.allow_empty_fleet || vars.ALLOW_EMPTY_FLEET || '0' }} + DEPLOY_SSH_KEY_BODY: ${{ secrets.DEPLOY_SSH_KEY }} + run: | + set -euo pipefail + if [ -z "${DEPLOY_SSH_KEY_BODY:-}" ]; then + echo "missing secret DEPLOY_SSH_KEY" >&2 + exit 1 + fi + key="$(mktemp)" + trap 'rm -f "$key"' EXIT + printf '%s\n' "$DEPLOY_SSH_KEY_BODY" >"$key" + chmod 600 "$key" + export PLATFORM_DEPLOY_SSH_KEY="$key" + # Apply managed-host defaults when secrets are unset. + export PLATFORM_DEPLOY_HOST="${PLATFORM_DEPLOY_HOST:-39.107.254.4}" + export PLATFORM_DEPLOY_USER="${PLATFORM_DEPLOY_USER:-root}" + export PLATFORM_DEPLOY_PORT="${PLATFORM_DEPLOY_PORT:-22}" + export PLATFORM_DEPLOY_BASE="${PLATFORM_DEPLOY_BASE:-/srv/curriculum-project-hub}" + bash hub/deploy/deploy_fleet_release.sh + + deploy-prod: + name: deploy fleet prod (educraft) + runs-on: ubuntu-latest + if: > + (github.event_name == 'push' && github.ref == 'refs/heads/main') || + (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')) || + (github.event_name == 'workflow_dispatch' && + (github.event.inputs.fleet == 'prod' || github.event.inputs.fleet == 'both')) + steps: + - uses: actions/checkout@v5 + + - name: Install rsync + ssh + run: sudo apt-get update && sudo apt-get install -y rsync openssh-client + + - name: Deploy Hub + admin SPA to educraft fleet + env: + CPH_FLEET: prod + PLATFORM_DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} + PLATFORM_DEPLOY_USER: ${{ secrets.DEPLOY_USER }} + PLATFORM_DEPLOY_PORT: ${{ secrets.DEPLOY_SSH_PORT }} + PLATFORM_DEPLOY_BASE: ${{ secrets.DEPLOY_BASE }} + PLATFORM_DEPLOY_RELEASE: ${{ github.sha }} + ALLOW_EMPTY_FLEET: ${{ github.event.inputs.allow_empty_fleet || vars.ALLOW_EMPTY_FLEET || '0' }} + DEPLOY_SSH_KEY_BODY: ${{ secrets.DEPLOY_SSH_KEY }} + run: | + set -euo pipefail + if [ -z "${DEPLOY_SSH_KEY_BODY:-}" ]; then + echo "missing secret DEPLOY_SSH_KEY" >&2 + exit 1 + fi + key="$(mktemp)" + trap 'rm -f "$key"' EXIT + printf '%s\n' "$DEPLOY_SSH_KEY_BODY" >"$key" + chmod 600 "$key" + export PLATFORM_DEPLOY_SSH_KEY="$key" + export PLATFORM_DEPLOY_HOST="${PLATFORM_DEPLOY_HOST:-39.107.254.4}" + export PLATFORM_DEPLOY_USER="${PLATFORM_DEPLOY_USER:-root}" + export PLATFORM_DEPLOY_PORT="${PLATFORM_DEPLOY_PORT:-22}" + export PLATFORM_DEPLOY_BASE="${PLATFORM_DEPLOY_BASE:-/srv/curriculum-project-hub}" + bash hub/deploy/deploy_fleet_release.sh diff --git a/hub/deploy/README.md b/hub/deploy/README.md index 4e766e9..9bffc37 100644 --- a/hub/deploy/README.md +++ b/hub/deploy/README.md @@ -11,6 +11,32 @@ See [NEW_SILO_RUNBOOK.md](NEW_SILO_RUNBOOK.md). The remainder of this document describes the individual installer and maintenance primitives used by the generated bundle. +## CI: roll Hub + admin SPA by fleet (`educraft` / `educraft-dev`) + +`.gitea/workflows/deploy-admin.yml` deploys Hub releases (including the org-admin +SPA) to the managed Alpha host via `deploy_fleet_release.sh`. Fleets share the +host and are selected by the middle DNS label of `HUB_PUBLIC_BASE_URL`: + +| Fleet | Domain | CI trigger | +|-------|--------|------------| +| **dev** | `https://.educraft-dev.paradigm-edu.net` | every push + PR (paths under `hub/`) | +| **prod** | `https://.educraft.paradigm-edu.net` | push to `main` + tags | + +Example prod tenant: +`https://para-26071100.educraft.paradigm-edu.net/auth/feishu/para-26071100`. + +Required Gitea secret: `DEPLOY_SSH_KEY` (private key for `root@39.107.254.4`). +Optional: `DEPLOY_HOST` / `DEPLOY_USER` / `DEPLOY_SSH_PORT` / `DEPLOY_BASE`. +Set repository var `ALLOW_EMPTY_FLEET=1` until the first silo exists for a fleet. + +Local dry-run against the host: + +```sh +CPH_FLEET=dev \ + PLATFORM_DEPLOY_SSH_KEY=$HOME/.ssh/id_ed25519 \ + bash hub/deploy/deploy_fleet_release.sh +``` + The supervised alpha runs one Organization per named Silo. The supported host has systemd, PostgreSQL, Node.js 24+, `pg_isready`, `runuser`, `setpriv`, bubblewrap, `socat`, `pg_dump`, `tar`, `sha256sum`, and a compatible `cph`. diff --git a/hub/deploy/deploy_fleet_release.sh b/hub/deploy/deploy_fleet_release.sh new file mode 100755 index 0000000..1174cc2 --- /dev/null +++ b/hub/deploy/deploy_fleet_release.sh @@ -0,0 +1,213 @@ +#!/usr/bin/env bash +# Publish one immutable Hub release (tsc + admin-web SPA) to the managed host, +# then repoint every Silo in the chosen fleet to that release and restart it. +# +# Fleet is selected by the middle DNS label of each Silo's HUB_PUBLIC_BASE_URL: +# +# prod → https://.educraft.paradigm-edu.net +# dev → https://.educraft-dev.paradigm-edu.net +# +# Example tenant (prod): +# https://para-26071100.educraft.paradigm-edu.net/auth/feishu/para-26071100 +# +# Configure (CI secrets / env): +# CPH_FLEET required: dev | prod +# PLATFORM_DEPLOY_HOST default 39.107.254.4 +# PLATFORM_DEPLOY_USER default root +# PLATFORM_DEPLOY_SSH_KEY required (path to private key) +# PLATFORM_DEPLOY_PORT default 22 +# PLATFORM_DEPLOY_BASE default /srv/curriculum-project-hub +# PLATFORM_DEPLOY_RELEASE default git HEAD (must be safe for paths) +# ALLOW_EMPTY_FLEET optional: if 1, succeed when no silo matches +# +# This is a code-roll for existing silos. It does not create databases, domains, +# or Feishu apps — use new_silo.sh / apply_new_silo.sh for that. +set -euo pipefail + +FLEET="${CPH_FLEET:?CPH_FLEET required (dev|prod)}" +case "$FLEET" in + dev) FLEET_DOMAIN_SUFFIX="educraft-dev.paradigm-edu.net" ;; + prod) FLEET_DOMAIN_SUFFIX="educraft.paradigm-edu.net" ;; + *) + echo "CPH_FLEET must be dev or prod, got: $FLEET" >&2 + exit 1 + ;; +esac + +HOST="${PLATFORM_DEPLOY_HOST:-39.107.254.4}" +DEPLOY_USER="${PLATFORM_DEPLOY_USER:-root}" +PORT="${PLATFORM_DEPLOY_PORT:-22}" +SSH_KEY="${PLATFORM_DEPLOY_SSH_KEY:?PLATFORM_DEPLOY_SSH_KEY required}" +BASE="${PLATFORM_DEPLOY_BASE:-/srv/curriculum-project-hub}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +RELEASE_ID="${PLATFORM_DEPLOY_RELEASE:-$(git -C "$REPO_ROOT" rev-parse --verify HEAD)}" +[[ "$RELEASE_ID" =~ ^[A-Za-z0-9._-]+$ ]] || { + echo "invalid PLATFORM_DEPLOY_RELEASE: $RELEASE_ID" >&2 + exit 1 +} +ALLOW_EMPTY_FLEET="${ALLOW_EMPTY_FLEET:-0}" + +HUB_DIR="$BASE/releases/$RELEASE_ID/hub" +RELEASE_DIR="$BASE/releases/$RELEASE_ID" +SSH_OPTS=(-i "$SSH_KEY" -p "$PORT" -o BatchMode=yes -o StrictHostKeyChecking=accept-new) + +echo "[fleet] fleet=$FLEET domain=*.$FLEET_DOMAIN_SUFFIX host=$DEPLOY_USER@$HOST release=$RELEASE_ID" + +# --- 1. Publish immutable release (shared; flock so parallel fleet jobs do not race) --- +release_ready=false +if ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" "test -f '$RELEASE_DIR/.complete'"; then + release_ready=true + echo "[fleet] release already complete: $RELEASE_DIR" +fi + +if [ "$release_ready" = false ]; then + echo "[fleet] rsync hub → $HUB_DIR" + ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" \ + "flock /var/lock/cph-hub-release-publish mkdir -p '$HUB_DIR'" + + rsync -az --delete \ + --exclude node_modules --exclude dist --exclude .env \ + --exclude admin-web/node_modules --exclude admin-web/build --exclude admin-web/.svelte-kit \ + -e "ssh ${SSH_OPTS[*]}" \ + "$REPO_ROOT/hub/" "$DEPLOY_USER@$HOST:$HUB_DIR/" + + echo "[fleet] npm ci + build (tsc + admin-web SPA)" + ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" bash -s <&2 + exit 1 + fi + cd "$HUB_DIR" + npm ci + npm ci --prefix admin-web + npm run audit:production + npm run build + test -f admin-web/build/index.html + touch "$RELEASE_DIR/.complete" +' +REMOTE +fi + +# --- 2. Discover silos in this fleet and roll them onto the release --- +ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" \ + env BASE="$BASE" HUB_DIR="$HUB_DIR" FLEET="$FLEET" \ + FLEET_DOMAIN_SUFFIX="$FLEET_DOMAIN_SUFFIX" ALLOW_EMPTY_FLEET="$ALLOW_EMPTY_FLEET" \ + bash -s <<'REMOTE' +set -euo pipefail + +fleet_match() { + local public_url="$1" + local host="${public_url#https://}" + host="${host#http://}" + host="${host%%/*}" + host="${host%%:*}" + case "$FLEET" in + prod) + [[ "$host" == *."$FLEET_DOMAIN_SUFFIX" || "$host" == "$FLEET_DOMAIN_SUFFIX" ]] || return 1 + # Avoid matching educraft-dev when suffix is educraft.… + [[ "$host" != *.educraft-dev.paradigm-edu.net && "$host" != educraft-dev.paradigm-edu.net ]] || return 1 + return 0 + ;; + dev) + [[ "$host" == *."$FLEET_DOMAIN_SUFFIX" || "$host" == "$FLEET_DOMAIN_SUFFIX" ]] + return + ;; + *) + return 1 + ;; + esac +} + +env_value() { + local file="$1" + local key="$2" + # Do not source secrets — only read the KEY=value line. + local line + line="$(grep -E "^${key}=" "$file" | tail -n1 || true)" + printf '%s' "${line#"${key}="}" +} + +matched=0 +shopt -s nullglob +for env_file in "$BASE"/.secrets/*/platform.env; do + instance_id="$(basename "$(dirname "$env_file")")" + public_url="$(env_value "$env_file" HUB_PUBLIC_BASE_URL)" + if [ -z "$public_url" ]; then + echo "[fleet] skip $instance_id: HUB_PUBLIC_BASE_URL empty" + continue + fi + if ! fleet_match "$public_url"; then + echo "[fleet] skip $instance_id: $public_url (not $FLEET)" + continue + fi + + port="$(env_value "$env_file" PORT)" + workspace="$(env_value "$env_file" HUB_PROJECT_WORKSPACE_ROOT)" + unit="cph-hub-${instance_id}.service" + if [ ! -f "/etc/systemd/system/$unit" ]; then + echo "[fleet] skip $instance_id: unit missing ($unit)" >&2 + continue + fi + + # Prefer unit file ceilings (MemoryMax=16G) over systemctl's byte form. + memory_max="$(sed -n 's/^MemoryMax=//p' "/etc/systemd/system/$unit" | tail -n1)" + cpu_quota="$(sed -n 's/^CPUQuota=//p' "/etc/systemd/system/$unit" | tail -n1)" + tasks_max="$(sed -n 's/^TasksMax=//p' "/etc/systemd/system/$unit" | tail -n1)" + + if [ -z "$port" ] || [ -z "$workspace" ] || [ -z "$memory_max" ] || [ -z "$cpu_quota" ] || [ -z "$tasks_max" ]; then + echo "[fleet] error: $instance_id missing PORT/workspace/ceilings (port=$port workspace=$workspace memory=$memory_max cpu=$cpu_quota tasks=$tasks_max)" >&2 + exit 1 + fi + + echo "[fleet] roll $instance_id → $HUB_DIR ($public_url port=$port)" + BASE="$BASE" \ + HUB_DIR="$HUB_DIR" \ + INSTANCE_ID="$instance_id" \ + WORKSPACE_ROOT="$workspace" \ + PORT="$port" \ + MEMORY_MAX="$memory_max" \ + CPU_QUOTA="$cpu_quota" \ + TASKS_MAX="$tasks_max" \ + bash "$HUB_DIR/deploy/install_service.sh" + + systemctl restart "$unit" + systemctl is-active --quiet "$unit" + + health="http://127.0.0.1:${port}/api/healthz" + ok=false + for _ in $(seq 1 45); do + if curl --fail --silent "$health" >/dev/null 2>&1; then + ok=true + break + fi + sleep 1 + done + if [ "$ok" != true ]; then + curl --fail --silent --show-error "$health" >/dev/null + fi + echo "[fleet] healthy $instance_id ($health)" + matched=$((matched + 1)) +done + +if [ "$matched" -eq 0 ]; then + msg="[fleet] no silos matched *.$FLEET_DOMAIN_SUFFIX under $BASE/.secrets" + if [ "$ALLOW_EMPTY_FLEET" = "1" ]; then + echo "$msg (ALLOW_EMPTY_FLEET=1)" + exit 0 + fi + echo "$msg" >&2 + exit 1 +fi + +echo "[fleet] rolled $matched silo(s) on $FLEET" +REMOTE + +echo "[fleet] done fleet=$FLEET release=$RELEASE_ID hub=$HUB_DIR"