forked from EduCraft/curriculum-project-hub
feat: add deployable alpha silo
This commit is contained in:
+126
-76
@@ -1,101 +1,151 @@
|
||||
# Hub service installation
|
||||
# Alpha Silo service installation
|
||||
|
||||
The initial production target is a supported Linux host with systemd,
|
||||
PostgreSQL, Node.js 20 or newer, `pg_isready`, `runuser`, `setpriv`, bubblewrap,
|
||||
`socat` and `cph`. The Hub runs as the dedicated non-login `cph-hub` user; do not
|
||||
create or run the unit as root.
|
||||
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`.
|
||||
|
||||
Build or deploy the Hub under `/srv/curriculum-project-hub`, then run:
|
||||
Each Silo needs its own logical database/role, instance id, service user,
|
||||
environment, keyring, workspace, port, domain, and host filesystem quota.
|
||||
Application code lives in immutable versioned directories under
|
||||
`/srv/curriculum-project-hub/releases/`; releases contain no customer state.
|
||||
|
||||
## Install an instance
|
||||
|
||||
```sh
|
||||
sudo BASE=/srv/curriculum-project-hub \
|
||||
HUB_DIR=/srv/curriculum-project-hub/hub \
|
||||
bash /srv/curriculum-project-hub/hub/deploy/install_service.sh
|
||||
HUB_DIR=/srv/curriculum-project-hub/releases/<release-id>/hub \
|
||||
INSTANCE_ID=org-a \
|
||||
PORT=8788 \
|
||||
MEMORY_MAX=16G CPU_QUOTA=400% TASKS_MAX=512 \
|
||||
bash /srv/curriculum-project-hub/releases/<release-id>/hub/deploy/install_service.sh
|
||||
```
|
||||
|
||||
On a clean host the first invocation creates
|
||||
`/srv/curriculum-project-hub/.secrets/platform.env` and a random root-owned
|
||||
`secret-keyring.json`, then exits with status 78. Copy the keyring to a
|
||||
separately protected recovery location before continuing; it must not live only
|
||||
on this host or in the ordinary database/workspace backup. Fill every required
|
||||
environment blank, set `CPH_BIN` to an executable compatible `cph`, keep both
|
||||
source files root-only at mode `0600`, and run the same command again. No
|
||||
systemd unit is installed until configuration,
|
||||
paths, Node, bubblewrap, `socat`, `cph --version`, PostgreSQL connectivity and directory
|
||||
ownership all pass preflight.
|
||||
|
||||
The service account cannot read the keyring source. The installed unit uses
|
||||
systemd `LoadCredential` to materialize a read-only `cph-secret-keyring` for the
|
||||
running service. Hub refuses production startup if this credential is missing,
|
||||
malformed, or does not contain its declared active key. Provider credentials
|
||||
are configured through the Organization admin connection API, never through
|
||||
process-global `ANTHROPIC_*` variables; preflight rejects those legacy settings.
|
||||
|
||||
Rotate the local KEK only from the controlled host console. The command first
|
||||
atomically adds a new active key while retaining every previous key, then
|
||||
authenticates and rewraps every stored Provider and Feishu Application DEK,
|
||||
writes redacted org audit events, and verifies the complete set again:
|
||||
The first invocation creates root-owned `0600` files below
|
||||
`/srv/curriculum-project-hub/.secrets/org-a/` and exits 78. Copy the keyring to
|
||||
a separately protected recovery destination and fill every blank in
|
||||
`platform.env`. Before rerunning the installer, migrate the empty Silo database:
|
||||
|
||||
```sh
|
||||
sudo bash -c '
|
||||
set -euo pipefail
|
||||
systemctl stop cph-hub.service
|
||||
set -a
|
||||
. /srv/curriculum-project-hub/.secrets/platform.env
|
||||
set +a
|
||||
node /srv/curriculum-project-hub/hub/dist/deployment/rotate-secret-kek.js \
|
||||
--keyring-file /srv/curriculum-project-hub/.secrets/secret-keyring.json
|
||||
systemctl start cph-hub.service
|
||||
set -a; . /srv/curriculum-project-hub/.secrets/org-a/platform.env; set +a
|
||||
node /srv/curriculum-project-hub/releases/<release-id>/hub/node_modules/prisma/build/index.js \
|
||||
migrate deploy \
|
||||
--schema /srv/curriculum-project-hub/releases/<release-id>/hub/prisma/schema.prisma
|
||||
'
|
||||
```
|
||||
|
||||
The CLI refuses to rotate while `cph-hub.service` is active; stopping the only
|
||||
writer prevents a concurrent BYOK version from being appended under the old
|
||||
in-memory keyring. A PostgreSQL transaction advisory lock excludes a second
|
||||
rotation and is released automatically if the CLI or host crashes. The
|
||||
operation is therefore rerunnable after interruption: old and new keys remain
|
||||
in the atomically replaced keyring, so partially rewrapped rows stay
|
||||
decryptable. It also refuses success while any envelope still names a
|
||||
non-active KEK. Back up the updated keyring to the separate recovery location
|
||||
and complete a restore preflight before considering old-key retirement. The
|
||||
pilot deliberately does not auto-delete previous keys.
|
||||
Then rerun the installer so it can complete preflight and install the stopped
|
||||
unit. Resource numbers above are examples, not defaults; the operator must set
|
||||
measured ceilings explicitly.
|
||||
|
||||
The deployment path also runs `npm run audit:production` from the locked clean
|
||||
install before building or restarting the service. A current high or critical
|
||||
production advisory therefore blocks deployment instead of becoming a warning
|
||||
buried in CI output.
|
||||
The installer refuses overlapping release/persistent paths, root service users,
|
||||
missing Node/cph/PostgreSQL/bubblewrap prerequisites, invalid credentials, and
|
||||
unreachable database roles. The installed unit is `cph-hub-org-a.service` and
|
||||
uses systemd `LoadCredential`; the service user cannot read the source keyring.
|
||||
|
||||
An existing service account is accepted only when its primary group, home and
|
||||
non-login shell match the requested configuration and it has no supplementary
|
||||
groups. After provisioning, the installer executes path-access, built Hub,
|
||||
Prisma, an authenticated database query, `cph`, `socat` and bubblewrap namespace probes
|
||||
as that account with `no_new_privs` set. Inaccessible parents, UID-specific
|
||||
database/network policy, or a host that forbids unprivileged user namespaces
|
||||
therefore fail before unit installation.
|
||||
|
||||
The default persistent layout is deliberately outside every source/release
|
||||
tree:
|
||||
Default state paths are:
|
||||
|
||||
```text
|
||||
/var/lib/cph-hub/home
|
||||
/var/lib/cph-hub/state
|
||||
/var/lib/cph-hub/workspaces
|
||||
/var/cache/cph-hub
|
||||
/var/lib/cph-hub/org-a/home
|
||||
/var/lib/cph-hub/org-a/state
|
||||
/var/lib/cph-hub/org-a/workspaces
|
||||
/var/cache/cph-hub/org-a
|
||||
```
|
||||
|
||||
Custom paths are supported with `SERVICE_HOME`, `STATE_DIR`, `CACHE_DIR` and
|
||||
`WORKSPACE_ROOT`, but all must be absolute. The installer rejects any persistent
|
||||
path that is equal to, above, or below `BASE`; this keeps rsync, release
|
||||
switching, rollback and pruning unable to traverse customer workspaces. Set the
|
||||
same custom workspace in `HUB_PROJECT_WORKSPACE_ROOT` inside `platform.env`.
|
||||
## Bootstrap the only Organization
|
||||
|
||||
After a successful installation:
|
||||
Prepare a root-owned `0600` JSON file containing
|
||||
`organization`, `owner`, `feishu`, `provider`, and optionally `teams`. Secrets
|
||||
never appear in command-line arguments.
|
||||
|
||||
```json
|
||||
{
|
||||
"organization": { "id": "org_a", "slug": "org-a", "name": "Org A" },
|
||||
"owner": { "openId": "ou_owner", "displayName": "Owner" },
|
||||
"feishu": {
|
||||
"appId": "cli_xxx",
|
||||
"appSecret": "...",
|
||||
"botOpenId": "ou_bot"
|
||||
},
|
||||
"provider": {
|
||||
"providerId": "openrouter",
|
||||
"baseUrl": "https://openrouter.ai/api",
|
||||
"authToken": "..."
|
||||
},
|
||||
"teams": [{ "slug": "teachers", "name": "Teachers" }]
|
||||
}
|
||||
```
|
||||
|
||||
```sh
|
||||
sudo systemctl start cph-hub.service
|
||||
sudo systemctl status cph-hub.service
|
||||
sudo bash -c '
|
||||
set -euo pipefail
|
||||
set -a; . /srv/curriculum-project-hub/.secrets/org-a/platform.env; set +a
|
||||
node /srv/curriculum-project-hub/releases/<release-id>/hub/dist/deployment/bootstrap-silo-cli.js \
|
||||
--config-file /root/org-a-bootstrap.json \
|
||||
--keyring-file /srv/curriculum-project-hub/.secrets/org-a/secret-keyring.json
|
||||
'
|
||||
```
|
||||
|
||||
`HOST` and `PORT` in `platform.env` are passed to the real HTTP listener. The
|
||||
default `127.0.0.1:8788` expects a local TLS reverse proxy; the public OAuth URL
|
||||
must be an HTTPS `HUB_PUBLIC_BASE_URL`.
|
||||
Bootstrap encrypts and activates both connection records and is rerunnable when
|
||||
provider configuration fails after Organization creation. Hub startup refuses
|
||||
to become healthy unless the sole Organization, active provider, active Feishu
|
||||
application, and Feishu listener are ready. Delete the bootstrap input after
|
||||
the recovery copy is current. Production has no process-global Feishu/provider
|
||||
credential fallback.
|
||||
|
||||
## Start and rotate
|
||||
|
||||
```sh
|
||||
sudo systemctl start cph-hub-org-a.service
|
||||
sudo systemctl status cph-hub-org-a.service
|
||||
```
|
||||
|
||||
KEK rotation requires the named unit to be stopped. Source its instance env so
|
||||
`HUB_SYSTEMD_UNIT` and `DATABASE_URL` identify the same Silo:
|
||||
|
||||
```sh
|
||||
sudo bash -c '
|
||||
set -euo pipefail
|
||||
systemctl stop cph-hub-org-a.service
|
||||
set -a; . /srv/curriculum-project-hub/.secrets/org-a/platform.env; set +a
|
||||
node /srv/curriculum-project-hub/hub/dist/deployment/rotate-secret-kek.js \
|
||||
--keyring-file /srv/curriculum-project-hub/.secrets/org-a/secret-keyring.json
|
||||
systemctl start cph-hub-org-a.service
|
||||
'
|
||||
```
|
||||
|
||||
## Backup and restore drill (recommended after the demo is running)
|
||||
|
||||
Stop the unit and run `backup_silo.sh` with distinct root-owned destinations:
|
||||
|
||||
```sh
|
||||
sudo INSTANCE_ID=org-a \
|
||||
ENV_FILE=/srv/curriculum-project-hub/.secrets/org-a/platform.env \
|
||||
KEYRING_FILE=/srv/curriculum-project-hub/.secrets/org-a/secret-keyring.json \
|
||||
BUSINESS_BACKUP_DIR=/backup/business \
|
||||
RECOVERY_BACKUP_DIR=/separate-recovery \
|
||||
bash hub/deploy/backup_silo.sh
|
||||
```
|
||||
|
||||
The business set contains the PostgreSQL custom dump and workspace archive. The
|
||||
separate recovery set contains the keyring and environment. Both include
|
||||
checksums; neither destination may be the live host's only disk.
|
||||
|
||||
Restore into a separate drill database/workspace, verify checksums, then run:
|
||||
|
||||
```sh
|
||||
set -a; . /path/to/restored/platform.env; set +a
|
||||
node hub/dist/deployment/restore-preflight.js \
|
||||
--keyring-file /path/to/restored/secret-keyring.json
|
||||
```
|
||||
|
||||
Traffic stays disabled until the sole Organization and every Feishu/provider
|
||||
envelope authenticate, the workspace exists, and an end-to-end test run passes.
|
||||
|
||||
For the first supervised demo, install → bootstrap → start → health check is the
|
||||
deployment gate. A completed off-host restore drill is an Alpha hardening item,
|
||||
not a prerequisite for bringing up that first controlled Silo.
|
||||
|
||||
The default bind remains loopback; expose it only through a TLS reverse proxy.
|
||||
The platform admin surface is not part of the alpha and must not be exposed.
|
||||
|
||||
Executable
+81
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env bash
|
||||
# Create a stopped-instance backup. Business data and recovery credentials are
|
||||
# deliberately written to different root-owned destinations.
|
||||
set -euo pipefail
|
||||
|
||||
INSTANCE_ID="${INSTANCE_ID:?INSTANCE_ID required}"
|
||||
ENV_FILE="${ENV_FILE:?ENV_FILE required}"
|
||||
KEYRING_FILE="${KEYRING_FILE:?KEYRING_FILE required}"
|
||||
BUSINESS_BACKUP_DIR="${BUSINESS_BACKUP_DIR:?BUSINESS_BACKUP_DIR required}"
|
||||
RECOVERY_BACKUP_DIR="${RECOVERY_BACKUP_DIR:?RECOVERY_BACKUP_DIR required}"
|
||||
SERVICE_UNIT="cph-hub-$INSTANCE_ID.service"
|
||||
|
||||
[ "$(id -u)" -eq 0 ] || { echo "backup must run as root" >&2; exit 1; }
|
||||
umask 077
|
||||
|
||||
validate_root_secret_file() {
|
||||
local label="$1" path="$2" metadata
|
||||
[[ "$path" = /* ]] || { echo "$label must be an absolute path" >&2; exit 1; }
|
||||
[ ! -L "$path" ] || { echo "$label must not be a symlink: $path" >&2; exit 1; }
|
||||
[ -f "$path" ] || { echo "$label must be a regular file: $path" >&2; exit 1; }
|
||||
metadata="$(stat -c '%u:%g:%a' -- "$path")"
|
||||
[ "$metadata" = "0:0:600" ] || {
|
||||
echo "$label must be root:root mode 0600; found $metadata" >&2
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
validate_root_secret_file "ENV_FILE" "$ENV_FILE"
|
||||
validate_root_secret_file "KEYRING_FILE" "$KEYRING_FILE"
|
||||
state="$(systemctl show --property=ActiveState --value "$SERVICE_UNIT")"
|
||||
case "$state" in inactive|failed) ;; *) echo "$SERVICE_UNIT must be stopped; state=$state" >&2; exit 1;; esac
|
||||
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
. "$ENV_FILE"
|
||||
set +a
|
||||
: "${DATABASE_URL:?DATABASE_URL missing from ENV_FILE}"
|
||||
: "${HUB_PROJECT_WORKSPACE_ROOT:?HUB_PROJECT_WORKSPACE_ROOT missing from ENV_FILE}"
|
||||
[ -d "$HUB_PROJECT_WORKSPACE_ROOT" ] || { echo "workspace root missing" >&2; exit 1; }
|
||||
|
||||
install -d -o root -g root -m 0700 "$BUSINESS_BACKUP_DIR" "$RECOVERY_BACKUP_DIR"
|
||||
business_root="$(realpath -m "$BUSINESS_BACKUP_DIR")"
|
||||
recovery_root="$(realpath -m "$RECOVERY_BACKUP_DIR")"
|
||||
workspace_root="$(realpath -m "$HUB_PROJECT_WORKSPACE_ROOT")"
|
||||
secret_root="$(realpath -m "$(dirname "$KEYRING_FILE")")"
|
||||
paths_overlap() {
|
||||
local left="$1" right="$2"
|
||||
[ "$left" = "$right" ] || [[ "$left/" == "$right/"* ]] || [[ "$right/" == "$left/"* ]]
|
||||
}
|
||||
for pair in \
|
||||
"$business_root|$recovery_root" \
|
||||
"$business_root|$workspace_root" \
|
||||
"$recovery_root|$workspace_root" \
|
||||
"$recovery_root|$secret_root"; do
|
||||
left="${pair%%|*}"; right="${pair#*|}"
|
||||
if paths_overlap "$left" "$right"; then
|
||||
echo "backup destinations must not overlap each other or live state: $left <> $right" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
stamp="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
business="$business_root/$INSTANCE_ID-$stamp"
|
||||
recovery="$recovery_root/$INSTANCE_ID-$stamp"
|
||||
install -d -o root -g root -m 0700 "$business" "$recovery"
|
||||
|
||||
pg_dump --format=custom --file="$business/database.dump" "$DATABASE_URL"
|
||||
tar --create --file="$business/workspaces.tar" --directory="$HUB_PROJECT_WORKSPACE_ROOT" .
|
||||
cp --preserve=mode,ownership,timestamps "$KEYRING_FILE" "$recovery/secret-keyring.json"
|
||||
cp --preserve=mode,ownership,timestamps "$ENV_FILE" "$recovery/platform.env"
|
||||
chmod 0600 "$recovery/secret-keyring.json" "$recovery/platform.env"
|
||||
|
||||
(
|
||||
cd "$business"
|
||||
sha256sum database.dump workspaces.tar > SHA256SUMS
|
||||
)
|
||||
(
|
||||
cd "$recovery"
|
||||
sha256sum secret-keyring.json platform.env > SHA256SUMS
|
||||
)
|
||||
echo "business backup: $business"
|
||||
echo "separate recovery backup: $recovery"
|
||||
@@ -1,5 +1,5 @@
|
||||
[Unit]
|
||||
Description=Curriculum Project Hub (Feishu agent + cph)
|
||||
Description=Curriculum Project Hub Silo (__INSTANCE_ID__)
|
||||
After=network-online.target postgresql.service
|
||||
Wants=network-online.target
|
||||
# ADR-0018: refuse to start when the configured OS sandbox binary disappears
|
||||
@@ -28,6 +28,10 @@ ExecStartPre=__NODE_BIN__ __HUB_DIR__/node_modules/prisma/build/index.js migrate
|
||||
ExecStart=__NODE_BIN__ __HUB_DIR__/dist/server.js
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
MemoryMax=__MEMORY_MAX__
|
||||
CPUQuota=__CPU_QUOTA__
|
||||
TasksMax=__TASKS_MAX__
|
||||
OOMPolicy=stop
|
||||
# Graceful shutdown: lark ws close + in-flight runs.
|
||||
KillSignal=SIGTERM
|
||||
TimeoutStopSec=30
|
||||
|
||||
@@ -5,51 +5,75 @@
|
||||
# PLATFORM_DEPLOY_HOST required
|
||||
# PLATFORM_DEPLOY_SSH_KEY required (private key for the deploy user)
|
||||
# PLATFORM_DEPLOY_USER optional, defaults to deploy
|
||||
# PLATFORM_DEPLOY_PORT optional, defaults to 22
|
||||
# PLATFORM_DEPLOY_PORT optional SSH port, defaults to 22
|
||||
# PLATFORM_DEPLOY_HUB_PORT required Silo listener port
|
||||
# PLATFORM_DEPLOY_BASE optional, defaults to /srv/curriculum-project-hub
|
||||
# PLATFORM_DEPLOY_RELEASE optional immutable release id, defaults to git HEAD
|
||||
# PLATFORM_DEPLOY_INSTANCE required, Silo instance id
|
||||
# PLATFORM_DEPLOY_MEMORY_MAX / CPU_QUOTA / TASKS_MAX required ceilings
|
||||
# PLATFORM_DEPLOY_HEALTH_URL optional, defaults to http://127.0.0.1:8788/api/healthz
|
||||
# The target host must have Node.js 20+, npm, rsync, PostgreSQL client tools
|
||||
# The target host must have Node.js 24+, npm, rsync, PostgreSQL client tools
|
||||
# (`pg_isready`), systemd, bubblewrap (`bwrap`) and `socat` (for the ADR-0018 agent sandbox),
|
||||
# and a compatible `cph` binary named by platform.env. The service installer
|
||||
# provisions the dedicated non-root identity and persistent directories.
|
||||
# Use a dedicated `deploy` user that has passwordless sudo for systemctl and
|
||||
# writing /etc/systemd/system/cph-hub.service through deploy/install_service.sh.
|
||||
# writing the named /etc/systemd/system/cph-hub-<instance>.service through the installer.
|
||||
set -euo pipefail
|
||||
|
||||
HOST="${PLATFORM_DEPLOY_HOST:?PLATFORM_DEPLOY_HOST required}"
|
||||
SSH_KEY="${PLATFORM_DEPLOY_SSH_KEY:?PLATFORM_DEPLOY_SSH_KEY required}"
|
||||
DEPLOY_USER="${PLATFORM_DEPLOY_USER:-deploy}"
|
||||
PORT="${PLATFORM_DEPLOY_PORT:-22}"
|
||||
HUB_PORT="${PLATFORM_DEPLOY_HUB_PORT:?PLATFORM_DEPLOY_HUB_PORT required}"
|
||||
BASE="${PLATFORM_DEPLOY_BASE:-/srv/curriculum-project-hub}"
|
||||
HEALTH_URL="${PLATFORM_DEPLOY_HEALTH_URL:-http://127.0.0.1:8788/api/healthz}"
|
||||
HUB_DIR="$BASE/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" >&2; exit 1; }
|
||||
INSTANCE_ID="${PLATFORM_DEPLOY_INSTANCE:?PLATFORM_DEPLOY_INSTANCE required}"
|
||||
SERVICE_UNIT="cph-hub-$INSTANCE_ID.service"
|
||||
MEMORY_MAX="${PLATFORM_DEPLOY_MEMORY_MAX:?PLATFORM_DEPLOY_MEMORY_MAX required}"
|
||||
CPU_QUOTA="${PLATFORM_DEPLOY_CPU_QUOTA:?PLATFORM_DEPLOY_CPU_QUOTA required}"
|
||||
TASKS_MAX="${PLATFORM_DEPLOY_TASKS_MAX:?PLATFORM_DEPLOY_TASKS_MAX required}"
|
||||
HEALTH_URL="${PLATFORM_DEPLOY_HEALTH_URL:-http://127.0.0.1:$HUB_PORT/api/healthz}"
|
||||
HUB_DIR="$BASE/releases/$RELEASE_ID/hub"
|
||||
RELEASE_DIR="$BASE/releases/$RELEASE_ID"
|
||||
|
||||
SSH_OPTS=(-i "$SSH_KEY" -p "$PORT" -o StrictHostKeyChecking=accept-new)
|
||||
|
||||
ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" "mkdir -p '$HUB_DIR'"
|
||||
release_ready=false
|
||||
if ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" "test -f '$RELEASE_DIR/.complete'"; then
|
||||
release_ready=true
|
||||
elif ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" "test -e '$RELEASE_DIR'"; then
|
||||
echo "incomplete release already exists: $RELEASE_DIR" >&2
|
||||
exit 1
|
||||
else
|
||||
ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" "mkdir -p '$HUB_DIR'"
|
||||
fi
|
||||
|
||||
# 1. Rsync the hub/ directory (exclude node_modules/dist, they rebuild on host).
|
||||
rsync -az --delete \
|
||||
--exclude node_modules --exclude dist --exclude .env \
|
||||
-e "ssh ${SSH_OPTS[*]}" \
|
||||
"$REPO_ROOT/hub/" "$DEPLOY_USER@$HOST:$HUB_DIR/"
|
||||
if [ "$release_ready" = false ]; then
|
||||
# 1. Populate a new release directory. A completed release is never mutated.
|
||||
rsync -az --delete \
|
||||
--exclude node_modules --exclude dist --exclude .env \
|
||||
-e "ssh ${SSH_OPTS[*]}" \
|
||||
"$REPO_ROOT/hub/" "$DEPLOY_USER@$HOST:$HUB_DIR/"
|
||||
|
||||
# 2. Install deps, prove the production dependency tree, then build on the host.
|
||||
ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" "cd '$HUB_DIR' && npm ci && npm run audit:production && npm run build"
|
||||
# 2. Install deps, audit and build, then atomically mark the release complete.
|
||||
ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" \
|
||||
"cd '$HUB_DIR' && npm ci && npm run audit:production && npm run build && touch '$RELEASE_DIR/.complete'"
|
||||
fi
|
||||
|
||||
# 3. Ensure the service is installed (idempotent), then restart.
|
||||
ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" "
|
||||
set -euo pipefail
|
||||
if [ \"\$(id -u)\" = \"0\" ]; then
|
||||
BASE='$BASE' HUB_DIR='$HUB_DIR' bash '$HUB_DIR/deploy/install_service.sh'
|
||||
systemctl restart cph-hub.service
|
||||
systemctl is-active --quiet cph-hub.service
|
||||
BASE='$BASE' HUB_DIR='$HUB_DIR' INSTANCE_ID='$INSTANCE_ID' PORT='$HUB_PORT' MEMORY_MAX='$MEMORY_MAX' CPU_QUOTA='$CPU_QUOTA' TASKS_MAX='$TASKS_MAX' bash '$HUB_DIR/deploy/install_service.sh'
|
||||
systemctl restart '$SERVICE_UNIT'
|
||||
systemctl is-active --quiet '$SERVICE_UNIT'
|
||||
else
|
||||
sudo -n BASE='$BASE' HUB_DIR='$HUB_DIR' bash '$HUB_DIR/deploy/install_service.sh'
|
||||
sudo -n systemctl restart cph-hub.service
|
||||
sudo -n systemctl is-active --quiet cph-hub.service
|
||||
sudo -n BASE='$BASE' HUB_DIR='$HUB_DIR' INSTANCE_ID='$INSTANCE_ID' PORT='$HUB_PORT' MEMORY_MAX='$MEMORY_MAX' CPU_QUOTA='$CPU_QUOTA' TASKS_MAX='$TASKS_MAX' bash '$HUB_DIR/deploy/install_service.sh'
|
||||
sudo -n systemctl restart '$SERVICE_UNIT'
|
||||
sudo -n systemctl is-active --quiet '$SERVICE_UNIT'
|
||||
fi
|
||||
for attempt in \$(seq 1 30); do
|
||||
if curl --fail --silent '$HEALTH_URL' >/dev/null 2>&1; then
|
||||
|
||||
@@ -12,16 +12,25 @@ set -euo pipefail
|
||||
|
||||
BASE="${BASE:-/srv/curriculum-project-hub}"
|
||||
HUB_DIR="${HUB_DIR:-$BASE/hub}"
|
||||
SERVICE_USER="${SERVICE_USER:-cph-hub}"
|
||||
INSTANCE_ID="${INSTANCE_ID:?INSTANCE_ID is required (lowercase letters, digits, hyphens)}"
|
||||
if [[ ! "$INSTANCE_ID" =~ ^[a-z0-9]([a-z0-9-]{0,22}[a-z0-9])?$ ]]; then
|
||||
echo "[install] error: invalid INSTANCE_ID: $INSTANCE_ID" >&2
|
||||
exit 1
|
||||
fi
|
||||
SERVICE_UNIT="cph-hub-$INSTANCE_ID.service"
|
||||
SERVICE_USER="${SERVICE_USER:-cph-$INSTANCE_ID}"
|
||||
SERVICE_GROUP="${SERVICE_GROUP:-$SERVICE_USER}"
|
||||
SERVICE_HOME="${SERVICE_HOME:-/var/lib/cph-hub/home}"
|
||||
STATE_DIR="${STATE_DIR:-/var/lib/cph-hub/state}"
|
||||
CACHE_DIR="${CACHE_DIR:-/var/cache/cph-hub}"
|
||||
WORKSPACE_ROOT="${WORKSPACE_ROOT:-/var/lib/cph-hub/workspaces}"
|
||||
SERVICE_HOME="${SERVICE_HOME:-/var/lib/cph-hub/$INSTANCE_ID/home}"
|
||||
STATE_DIR="${STATE_DIR:-/var/lib/cph-hub/$INSTANCE_ID/state}"
|
||||
CACHE_DIR="${CACHE_DIR:-/var/cache/cph-hub/$INSTANCE_ID}"
|
||||
WORKSPACE_ROOT="${WORKSPACE_ROOT:-/var/lib/cph-hub/$INSTANCE_ID/workspaces}"
|
||||
HOST="${HOST:-127.0.0.1}"
|
||||
PORT="${PORT:-8788}"
|
||||
ENV_FILE="${ENV_FILE:-$BASE/.secrets/platform.env}"
|
||||
KEYRING_FILE="${KEYRING_FILE:-$BASE/.secrets/secret-keyring.json}"
|
||||
PORT="${PORT:?PORT is required and must be unique on the host}"
|
||||
ENV_FILE="${ENV_FILE:-$BASE/.secrets/$INSTANCE_ID/platform.env}"
|
||||
KEYRING_FILE="${KEYRING_FILE:-$BASE/.secrets/$INSTANCE_ID/secret-keyring.json}"
|
||||
MEMORY_MAX="${MEMORY_MAX:?MEMORY_MAX is required, for example 16G}"
|
||||
CPU_QUOTA="${CPU_QUOTA:?CPU_QUOTA is required, for example 400%}"
|
||||
TASKS_MAX="${TASKS_MAX:?TASKS_MAX is required, for example 512}"
|
||||
SYSTEMD_UNIT_DIR="${SYSTEMD_UNIT_DIR:-/etc/systemd/system}"
|
||||
NODE_BIN="${NODE_BIN:-$(command -v node || true)}"
|
||||
BWRAP_BIN="${BWRAP_BIN:-$(command -v bwrap || true)}"
|
||||
@@ -108,6 +117,9 @@ for pair in \
|
||||
"RUNTIME_PATH:$RUNTIME_PATH"; do
|
||||
require_safe_unit_value "${pair%%:*}" "${pair#*:}"
|
||||
done
|
||||
[[ "$MEMORY_MAX" =~ ^[1-9][0-9]*[KMGT]$ ]] || fail "MEMORY_MAX must be a systemd byte size such as 16G"
|
||||
[[ "$CPU_QUOTA" =~ ^[1-9][0-9]*%$ ]] || fail "CPU_QUOTA must be a positive percentage such as 400%"
|
||||
[[ "$TASKS_MAX" =~ ^[1-9][0-9]*$ ]] || fail "TASKS_MAX must be a positive integer"
|
||||
|
||||
KEYRING_CREATED=false
|
||||
if [ -L "$KEYRING_FILE" ]; then
|
||||
@@ -146,9 +158,8 @@ if [ ! -e "$ENV_FILE" ]; then
|
||||
# rerunning install_service.sh; failed preflight never installs the unit.
|
||||
NODE_ENV=production
|
||||
DATABASE_URL=
|
||||
FEISHU_APP_ID=
|
||||
FEISHU_APP_SECRET=
|
||||
FEISHU_BOT_OPEN_ID=
|
||||
HUB_SILO_ORGANIZATION_ID=
|
||||
HUB_SYSTEMD_UNIT=$SERVICE_UNIT
|
||||
CPH_BIN=$CPH_BIN_DEFAULT
|
||||
HOST=$HOST
|
||||
PORT=$PORT
|
||||
@@ -156,6 +167,13 @@ HUB_PROJECT_WORKSPACE_ROOT=$WORKSPACE_ROOT
|
||||
HUB_PUBLIC_BASE_URL=
|
||||
HUB_SESSION_SECRET=
|
||||
HUB_AGENT_MAX_TURNS=25
|
||||
HUB_AGENT_MAX_CONCURRENT_RUNS=
|
||||
HUB_AGENT_MAX_RUN_SECONDS=
|
||||
HUB_HTTP_BODY_LIMIT_BYTES=
|
||||
HUB_MAX_FILES_PER_MESSAGE=
|
||||
HUB_MAX_FILE_BYTES=
|
||||
HUB_HTTP_REQUESTS_PER_MINUTE=
|
||||
HUB_FEISHU_EVENTS_PER_MINUTE=
|
||||
HUB_FEISHU_LISTENER_ENABLED=true
|
||||
CPH_SANDBOX_EXTRA_DENY_READ=$ENV_FILE:$KEYRING_FILE
|
||||
EOF
|
||||
@@ -185,6 +203,7 @@ PREFLIGHT_ARGS=(
|
||||
--state-dir "$STATE_DIR"
|
||||
--cache-dir "$CACHE_DIR"
|
||||
--workspace-root "$WORKSPACE_ROOT"
|
||||
--service-unit "$SERVICE_UNIT"
|
||||
--bwrap-bin "$BWRAP_BIN"
|
||||
--socat-bin "$SOCAT_BIN"
|
||||
--pg-isready-bin "$PG_ISREADY_BIN"
|
||||
@@ -267,7 +286,7 @@ provision_directory "$WORKSPACE_ROOT"
|
||||
# the unit. This catches symlink or ownership changes between the two phases.
|
||||
"${PREFLIGHT_ARGS[@]}" --service-uid "$SERVICE_UID" --service-gid "$SERVICE_GID"
|
||||
|
||||
UNIT="$SYSTEMD_UNIT_DIR/cph-hub.service"
|
||||
UNIT="$SYSTEMD_UNIT_DIR/$SERVICE_UNIT"
|
||||
install -d -o root -g root -m 0755 "$SYSTEMD_UNIT_DIR"
|
||||
TMP_UNIT="$(mktemp)"
|
||||
trap 'rm -f "$TMP_UNIT"' EXIT
|
||||
@@ -285,11 +304,15 @@ sed \
|
||||
-e "s|__BWRAP_BIN__|$BWRAP_BIN|g" \
|
||||
-e "s|__SOCAT_BIN__|$SOCAT_BIN|g" \
|
||||
-e "s|__RUNTIME_PATH__|$RUNTIME_PATH|g" \
|
||||
-e "s|__INSTANCE_ID__|$INSTANCE_ID|g" \
|
||||
-e "s|__MEMORY_MAX__|$MEMORY_MAX|g" \
|
||||
-e "s|__CPU_QUOTA__|$CPU_QUOTA|g" \
|
||||
-e "s|__TASKS_MAX__|$TASKS_MAX|g" \
|
||||
"$SCRIPT_DIR/cph-hub.service" >"$TMP_UNIT"
|
||||
install -o root -g root -m 0644 "$TMP_UNIT" "$UNIT"
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable cph-hub.service
|
||||
echo "[install] installed cph-hub.service for $SERVICE_USER:$SERVICE_GROUP"
|
||||
systemctl enable "$SERVICE_UNIT"
|
||||
echo "[install] installed $SERVICE_UNIT for $SERVICE_USER:$SERVICE_GROUP"
|
||||
echo "[install] home=$SERVICE_HOME state=$STATE_DIR cache=$CACHE_DIR workspaces=$WORKSPACE_ROOT"
|
||||
echo "[install] start with: systemctl start cph-hub.service"
|
||||
echo "[install] start with: systemctl start $SERVICE_UNIT"
|
||||
|
||||
Reference in New Issue
Block a user