Files
curriculum-project-hub/hub/deploy/install_service.sh
T

324 lines
13 KiB
Bash
Executable File

#!/usr/bin/env bash
# Provision and install the cph-hub systemd service on a supported Linux host.
# The script is idempotent: existing identities/directories are validated and
# never silently rewritten. An absent environment file is seeded completely,
# then the script exits EX_CONFIG so no unit is installed with placeholders.
#
# Usage:
# sudo BASE=/srv/curriculum-project-hub \
# HUB_DIR=/srv/curriculum-project-hub/hub \
# bash deploy/install_service.sh
set -euo pipefail
BASE="${BASE:-/srv/curriculum-project-hub}"
HUB_DIR="${HUB_DIR:-$BASE/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/$INSTANCE_ID/home}"
STATE_DIR="${STATE_DIR:-/var/lib/cph-hub/$INSTANCE_ID/state}"
CACHE_DIR="${CACHE_DIR:-/var/cache/cph-hub/$INSTANCE_ID}"
SKILL_STORE_ROOT="${SKILL_STORE_ROOT:-$STATE_DIR/skills}"
WORKSPACE_ROOT="${WORKSPACE_ROOT:?WORKSPACE_ROOT required (use a short per-Silo path such as /w/997)}"
HOST="${HOST:-127.0.0.1}"
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)}"
SOCAT_BIN="${SOCAT_BIN:-$(command -v socat || true)}"
PG_ISREADY_BIN="${PG_ISREADY_BIN:-$(command -v pg_isready || true)}"
RUNUSER_BIN="${RUNUSER_BIN:-$(command -v runuser || true)}"
SETPRIV_BIN="${SETPRIV_BIN:-$(command -v setpriv || true)}"
CPH_BIN_DEFAULT="${CPH_BIN_DEFAULT:-/usr/local/bin/cph}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PREFLIGHT_JS="$HUB_DIR/dist/deployment/preflight-cli.js"
RUNTIME_PATH="$(dirname "$NODE_BIN"):$(dirname "$BWRAP_BIN"):$(dirname "$SOCAT_BIN"):$(dirname "$SETPRIV_BIN"):/usr/local/bin:/usr/bin:/bin"
fail() {
echo "[install] error: $*" >&2
exit 1
}
require_command() {
command -v "$1" >/dev/null 2>&1 || fail "required command is missing: $1"
}
require_safe_unit_value() {
local label="$1"
local value="$2"
if [[ ! "$value" =~ ^[A-Za-z0-9_./:@+-]+$ ]]; then
fail "$label contains characters that cannot be rendered safely into the systemd unit: $value"
fi
}
if [ "$(id -u)" -ne 0 ]; then
fail "must run as root (use sudo)"
fi
if [[ ! "$SERVICE_USER" =~ ^[a-z_][a-z0-9_-]*[$]?$ ]]; then
fail "invalid SERVICE_USER: $SERVICE_USER"
fi
if [[ ! "$SERVICE_GROUP" =~ ^[a-z_][a-z0-9_-]*[$]?$ ]]; then
fail "invalid SERVICE_GROUP: $SERVICE_GROUP"
fi
if [ "$SERVICE_USER" = "root" ] || [ "$SERVICE_GROUP" = "root" ]; then
fail "the Hub service identity must not be root"
fi
for command in getent groupadd useradd install stat systemctl id openssl date mktemp; do
require_command "$command"
done
for pair in \
"NODE_BIN:$NODE_BIN" \
"BWRAP_BIN:$BWRAP_BIN" \
"SOCAT_BIN:$SOCAT_BIN" \
"PG_ISREADY_BIN:$PG_ISREADY_BIN"; do
label="${pair%%:*}"
value="${pair#*:}"
[ -n "$value" ] || fail "$label is required and its executable was not found on PATH"
[ -x "$value" ] || fail "$label is not executable: $value"
done
[ -n "$RUNUSER_BIN" ] || fail "RUNUSER_BIN is required and runuser was not found on PATH"
[ -x "$RUNUSER_BIN" ] || fail "RUNUSER_BIN is not executable: $RUNUSER_BIN"
[ -n "$SETPRIV_BIN" ] || fail "SETPRIV_BIN is required and setpriv was not found on PATH"
[ -x "$SETPRIV_BIN" ] || fail "SETPRIV_BIN is not executable: $SETPRIV_BIN"
[ -d "$HUB_DIR" ] || fail "HUB_DIR not found: $HUB_DIR"
[ -f "$HUB_DIR/dist/server.js" ] || fail "Hub build missing: $HUB_DIR/dist/server.js"
[ -f "$PREFLIGHT_JS" ] || fail "deployment preflight missing from Hub build: $PREFLIGHT_JS"
[ -f "$HUB_DIR/dist/deployment/service-runtime-probe.js" ] || fail "service runtime probe missing from Hub build"
[ -f "$HUB_DIR/node_modules/prisma/build/index.js" ] || fail "production dependencies missing: run npm ci in $HUB_DIR"
[ -f "$HUB_DIR/prisma/schema.prisma" ] || fail "Prisma schema missing: $HUB_DIR/prisma/schema.prisma"
[ -f "$SCRIPT_DIR/cph-hub.service" ] || fail "service template missing: $SCRIPT_DIR/cph-hub.service"
for pair in \
"BASE:$BASE" \
"HUB_DIR:$HUB_DIR" \
"SERVICE_HOME:$SERVICE_HOME" \
"STATE_DIR:$STATE_DIR" \
"CACHE_DIR:$CACHE_DIR" \
"SKILL_STORE_ROOT:$SKILL_STORE_ROOT" \
"WORKSPACE_ROOT:$WORKSPACE_ROOT" \
"ENV_FILE:$ENV_FILE" \
"KEYRING_FILE:$KEYRING_FILE" \
"SYSTEMD_UNIT_DIR:$SYSTEMD_UNIT_DIR" \
"NODE_BIN:$NODE_BIN" \
"BWRAP_BIN:$BWRAP_BIN" \
"SOCAT_BIN:$SOCAT_BIN" \
"RUNUSER_BIN:$RUNUSER_BIN" \
"SETPRIV_BIN:$SETPRIV_BIN" \
"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"
[ "${#WORKSPACE_ROOT}" -le 16 ] || fail "WORKSPACE_ROOT must be at most 16 bytes for Agent sandbox sockets: $WORKSPACE_ROOT"
KEYRING_CREATED=false
if [ -L "$KEYRING_FILE" ]; then
fail "local secret keyring must not be a symlink: $KEYRING_FILE"
fi
if [ ! -e "$KEYRING_FILE" ]; then
install -d -o root -g root -m 0700 "$(dirname "$KEYRING_FILE")"
key_id="local-$(date -u +%Y%m%dT%H%M%SZ)"
key_value="$(openssl rand -base64 32)"
tmp_keyring="$(mktemp "$(dirname "$KEYRING_FILE")/.secret-keyring.XXXXXX")"
trap 'rm -f "${tmp_keyring:-}"' EXIT
chmod 0600 "$tmp_keyring"
printf '{"version":1,"activeKeyId":"%s","keys":{"%s":"%s"}}\n' \
"$key_id" "$key_id" "$key_value" >"$tmp_keyring"
install -o root -g root -m 0600 "$tmp_keyring" "$KEYRING_FILE"
rm -f "$tmp_keyring"
tmp_keyring=""
trap - EXIT
unset key_value
KEYRING_CREATED=true
echo "[install] generated local secret keyring: $KEYRING_FILE" >&2
fi
[ -f "$KEYRING_FILE" ] || fail "local secret keyring is not a regular file: $KEYRING_FILE"
keyring_metadata="$(stat -c '%u:%g:%a' -- "$KEYRING_FILE")"
[ "$keyring_metadata" = "0:0:600" ] || \
fail "local secret keyring must be owned by root:root with mode 0600; found $keyring_metadata"
if [ -L "$ENV_FILE" ]; then
fail "environment file must not be a symlink: $ENV_FILE"
fi
if [ ! -e "$ENV_FILE" ]; then
install -d -o root -g root -m 0700 "$(dirname "$ENV_FILE")"
umask 077
cat >"$ENV_FILE" <<EOF
# cph-hub production configuration. Required blanks must be filled before
# rerunning install_service.sh; failed preflight never installs the unit.
NODE_ENV=production
DATABASE_URL=
HUB_SILO_ORGANIZATION_ID=
HUB_SYSTEMD_UNIT=$SERVICE_UNIT
CPH_BIN=$CPH_BIN_DEFAULT
HOST=$HOST
PORT=$PORT
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
chmod 0600 "$ENV_FILE"
echo "[install] seeded complete production configuration: $ENV_FILE" >&2
echo "[install] copy $KEYRING_FILE to the separately protected keyring backup before continuing" >&2
echo "[install] fill every required blank, install cph at CPH_BIN, then rerun" >&2
exit 78
fi
[ -f "$ENV_FILE" ] || fail "environment file is not a regular file: $ENV_FILE"
if [ "$KEYRING_CREATED" = true ]; then
echo "[install] copy $KEYRING_FILE to the separately protected keyring backup, then rerun" >&2
exit 78
fi
PREFLIGHT_ARGS=(
"$NODE_BIN" "$PREFLIGHT_JS"
--env-file "$ENV_FILE"
--keyring-file "$KEYRING_FILE"
--node-bin "$NODE_BIN"
--runuser-bin "$RUNUSER_BIN"
--setpriv-bin "$SETPRIV_BIN"
--service-user "$SERVICE_USER"
--base-dir "$BASE"
--hub-dir "$HUB_DIR"
--service-home "$SERVICE_HOME"
--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"
)
# Required configuration, path isolation, binaries and PostgreSQL must pass
# before identity/directory provisioning or unit installation begins.
"${PREFLIGHT_ARGS[@]}"
NOLOGIN_SHELL=""
for candidate in /usr/sbin/nologin /sbin/nologin /usr/bin/false /bin/false; do
if [ -x "$candidate" ]; then
NOLOGIN_SHELL="$candidate"
break
fi
done
[ -n "$NOLOGIN_SHELL" ] || fail "no non-login shell found"
group_entry="$(getent group "$SERVICE_GROUP" || true)"
if [ -z "$group_entry" ]; then
groupadd --system "$SERVICE_GROUP"
group_entry="$(getent group "$SERVICE_GROUP" || true)"
[ -n "$group_entry" ] || fail "created service group cannot be resolved: $SERVICE_GROUP"
fi
IFS=: read -r resolved_group _ SERVICE_GID _ <<<"$group_entry"
[ "$resolved_group" = "$SERVICE_GROUP" ] || fail "service group resolved to unexpected name: $resolved_group"
[[ "$SERVICE_GID" =~ ^[0-9]+$ ]] || fail "service group has invalid gid: $SERVICE_GID"
[ "$SERVICE_GID" -ne 0 ] || fail "service group must not have gid 0"
passwd_entry="$(getent passwd "$SERVICE_USER" || true)"
if [ -z "$passwd_entry" ]; then
useradd \
--system \
--gid "$SERVICE_GROUP" \
--home-dir "$SERVICE_HOME" \
--shell "$NOLOGIN_SHELL" \
--no-create-home \
"$SERVICE_USER"
passwd_entry="$(getent passwd "$SERVICE_USER" || true)"
[ -n "$passwd_entry" ] || fail "created service user cannot be resolved: $SERVICE_USER"
fi
IFS=: read -r resolved_user _ SERVICE_UID user_gid _ user_home user_shell <<<"$passwd_entry"
[ "$resolved_user" = "$SERVICE_USER" ] || fail "service user resolved to unexpected name: $resolved_user"
[[ "$SERVICE_UID" =~ ^[0-9]+$ ]] || fail "service user has invalid uid: $SERVICE_UID"
[ "$SERVICE_UID" -ne 0 ] || fail "service user must not have uid 0"
[ "$user_gid" = "$SERVICE_GID" ] || fail "service user primary gid $user_gid does not match $SERVICE_GROUP ($SERVICE_GID)"
[ "$user_home" = "$SERVICE_HOME" ] || fail "service user home is $user_home; expected $SERVICE_HOME"
case "$user_shell" in
/usr/sbin/nologin | /sbin/nologin | /usr/bin/false | /bin/false) ;;
*) fail "service user must have a non-login shell; found $user_shell" ;;
esac
service_gids="$(id -G "$SERVICE_USER")" || fail "cannot resolve groups for service user $SERVICE_USER"
for service_gid in $service_gids; do
if [ "$service_gid" != "$SERVICE_GID" ]; then
fail "service user $SERVICE_USER must not have supplementary groups; found gid $service_gid in: $service_gids"
fi
done
provision_directory() {
local path="$1"
if [ -L "$path" ]; then
fail "service directory must not be a symlink: $path"
fi
if [ -e "$path" ]; then
[ -d "$path" ] || fail "service path exists but is not a directory: $path"
actual="$(stat -c '%u:%g:%a' -- "$path")"
expected="$SERVICE_UID:$SERVICE_GID:750"
[ "$actual" = "$expected" ] || fail "service directory $path has $actual; expected $expected"
return
fi
install -d -o "$SERVICE_UID" -g "$SERVICE_GID" -m 0750 "$path"
}
provision_directory "$SERVICE_HOME"
provision_directory "$STATE_DIR"
provision_directory "$CACHE_DIR"
provision_directory "$SKILL_STORE_ROOT"
provision_directory "$WORKSPACE_ROOT"
# Resolve every provisioned path again and verify uid/gid/mode before writing
# 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/$SERVICE_UNIT"
install -d -o root -g root -m 0755 "$SYSTEMD_UNIT_DIR"
TMP_UNIT="$(mktemp)"
trap 'rm -f "$TMP_UNIT"' EXIT
sed \
-e "s|__SERVICE_USER__|$SERVICE_USER|g" \
-e "s|__SERVICE_GROUP__|$SERVICE_GROUP|g" \
-e "s|__SERVICE_HOME__|$SERVICE_HOME|g" \
-e "s|__STATE_DIR__|$STATE_DIR|g" \
-e "s|__CACHE_DIR__|$CACHE_DIR|g" \
-e "s|__SKILL_STORE_ROOT__|$SKILL_STORE_ROOT|g" \
-e "s|__WORKSPACE_ROOT__|$WORKSPACE_ROOT|g" \
-e "s|__HUB_DIR__|$HUB_DIR|g" \
-e "s|__ENV_FILE__|$ENV_FILE|g" \
-e "s|__KEYRING_FILE__|$KEYRING_FILE|g" \
-e "s|__NODE_BIN__|$NODE_BIN|g" \
-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 "$SERVICE_UNIT"
echo "[install] installed $SERVICE_UNIT for $SERVICE_USER:$SERVICE_GROUP"
echo "[install] home=$SERVICE_HOME state=$STATE_DIR cache=$CACHE_DIR skills=$SKILL_STORE_ROOT workspaces=$WORKSPACE_ROOT"
echo "[install] start with: systemctl start $SERVICE_UNIT"