From 73fa28a178473dfd89affacb0e3f6f851092c393 Mon Sep 17 00:00:00 2001 From: Hong Jiarong Date: Fri, 10 Jul 2026 15:59:35 +0800 Subject: [PATCH] feat: provision non-root Hub service --- .gitea/workflows/hub-check.yml | 4 + .../issues/10-provision-nonroot-service.md | 29 +- .scratch/saas-production-readiness/map.md | 1 + README.md | 3 +- hub/.env.example | 20 +- hub/deploy/README.md | 57 +++ hub/deploy/cph-hub.service | 13 +- hub/deploy/deploy_platform.sh | 6 +- hub/deploy/install_service.sh | 248 +++++++-- hub/src/deployment/preflight-cli.ts | 480 ++++++++++++++++++ hub/src/deployment/preflight.ts | 203 ++++++++ hub/src/deployment/service-runtime-probe.ts | 31 ++ hub/src/hub.ts | 79 +++ hub/src/server.ts | 92 +--- hub/src/settings/server.ts | 26 + .../deployment-preflight-cli.test.ts | 249 +++++++++ hub/test/integration/server-start.test.ts | 61 +++ hub/test/unit/deployment-preflight.test.ts | 117 +++++ hub/test/unit/server-config.test.ts | 23 + 19 files changed, 1608 insertions(+), 134 deletions(-) create mode 100644 hub/deploy/README.md create mode 100644 hub/src/deployment/preflight-cli.ts create mode 100644 hub/src/deployment/preflight.ts create mode 100644 hub/src/deployment/service-runtime-probe.ts create mode 100644 hub/src/hub.ts create mode 100644 hub/src/settings/server.ts create mode 100644 hub/test/integration/deployment-preflight-cli.test.ts create mode 100644 hub/test/integration/server-start.test.ts create mode 100644 hub/test/unit/deployment-preflight.test.ts create mode 100644 hub/test/unit/server-config.test.ts diff --git a/.gitea/workflows/hub-check.yml b/.gitea/workflows/hub-check.yml index d87ea05..df4eea7 100644 --- a/.gitea/workflows/hub-check.yml +++ b/.gitea/workflows/hub-check.yml @@ -106,6 +106,10 @@ jobs: FEISHU_APP_ID=cli_smoke \ FEISHU_APP_SECRET=smoke_secret \ FEISHU_BOT_OPEN_ID=ou_smoke \ + HOST=127.0.0.1 \ + HUB_PROJECT_WORKSPACE_ROOT=/tmp/cph-hub-smoke-workspaces \ + HUB_PUBLIC_BASE_URL=https://hub.example.test \ + HUB_SESSION_SECRET=smoke-session-secret-with-at-least-32-bytes \ HUB_FEISHU_LISTENER_ENABLED=false \ npm start & pid=$! diff --git a/.scratch/saas-production-readiness/issues/10-provision-nonroot-service.md b/.scratch/saas-production-readiness/issues/10-provision-nonroot-service.md index 3044206..7fdd8f5 100644 --- a/.scratch/saas-production-readiness/issues/10-provision-nonroot-service.md +++ b/.scratch/saas-production-readiness/issues/10-provision-nonroot-service.md @@ -1,7 +1,7 @@ # Provision a runnable non-root Hub service Type: task -Status: open +Status: resolved ## Question @@ -11,3 +11,30 @@ configuration, honor the configured bind host, and fail preflight before unit installation when any required value or prerequisite is invalid. The persistent workspace root must be outside all release trees, and preflight must reject any path that overlaps rsync, release switching, rollback, or release pruning. + +## Answer + +The installer now creates or strictly validates a dedicated non-root, +non-login service identity with no supplementary groups; provisions its +home/state/cache/workspace directories at mode `0750`; and refuses persistent +paths that overlap the deployment tree after both lexical and physical path +resolution. A missing production environment file is seeded completely and +exits with status 78; invalid configuration or prerequisites fail before the +unit is written or enabled. + +Preflight validates the configured bind host/port, authenticated PostgreSQL +access, Node/cph/Prisma, configured and canonical path traversal, and a real +bubblewrap namespace. The post-provision phase runs the built Hub dependency +graph, a database query, cph and bubblewrap as the service user under +`no_new_privs`, matching the systemd security context. HTTP binding is awaited +before the Feishu listener starts, so bind failure rejects startup instead of +leaving a listener-only process alive. + +Verification evidence: + +- `npm run check`, `npm run build`, and `npm run prisma:validate` +- 166 unit tests and 86 integration tests passed; one external-model test skipped +- `bash -n deploy/install_service.sh` and `shellcheck deploy/install_service.sh` +- `prisma migrate deploy`: 9 migrations found, none pending +- production-mode HTTP smoke test on `127.0.0.1:8878` returned `/api/healthz` +- final Standards and Spec reviews found no remaining Issue 10 gap diff --git a/.scratch/saas-production-readiness/map.md b/.scratch/saas-production-readiness/map.md index c79c2e7..1e85a7e 100644 --- a/.scratch/saas-production-readiness/map.md +++ b/.scratch/saas-production-readiness/map.md @@ -44,6 +44,7 @@ rollback and recovery, and no known critical security or data-integrity gaps. - [Audit the accepted product surface for production completeness](issues/07-audit-product-surface-completeness.md) — retain the guarded Organization APIs and single-app Feishu vertical slice, but require explicit Organization-scoped Feishu/provider connections and external-identity namespaces, private platform and Organization admin control planes, and the full ADR-0022 capacity surface before release. - [Decide the platform-administrator identity and audit boundary](issues/42-decide-platform-admin-identity-audit.md) — use a dedicated platform Feishu issuer, one peer administrator role, guarded bootstrap and identity-bound invitations, revocable server-side sessions, atomic append-only Platform Audit, and dual-factor offline recovery without a standing break-glass account. - [Restore the checker formatting gate](issues/09-restore-checker-format-gate.md) — pin local and CI Rust/rustfmt/clippy to 1.92.0, apply the canonical workspace format, and keep the full fmt + clippy + 53-test checker gate green. +- [Provision a runnable non-root Hub service](issues/10-provision-nonroot-service.md) — install a strictly validated service identity and persistent layout, reject release/workspace overlap, validate complete production configuration and real bind settings, and execute Hub/Prisma/cph/bubblewrap probes as the service user under `no_new_privs` before writing the unit. ## Fog diff --git a/README.md b/README.md index 75d60ee..62e6688 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,8 @@ crates/ ← 实现:rule-based checker(向 spec 对齐)。见 crates/R cph-check / cph-cli ← checker 本体 + `cph` 命令行 render/ ← typst 渲染包 cph-render(母本的渲染后端之一,ADR-0005) examples/ ← 样例工程文件(如 TH-141),流水线的真实输入 -(hub/ exporter/ …) ← 将来的其他部件,平级于 spec/。尚未创建 +hub/ ← SaaS Hub:飞书协作、org 管理、agent runtime 与生产部署 +(exporter/ …) ← 将来的其他部件,平级于 spec/ ``` `spec/` 与实现部件**物理分离、平级共存**:谁是上游、谁向谁对齐,一眼可见。 diff --git a/hub/.env.example b/hub/.env.example index c1da809..d324980 100644 --- a/hub/.env.example +++ b/hub/.env.example @@ -1,5 +1,8 @@ # Hub runtime configuration. Copy to .env and fill in. +# Production preflight requires the explicit production runtime mode. +NODE_ENV="production" + # PostgreSQL connection string. DATABASE_URL="postgresql://paradigm:paradigm@127.0.0.1:5432/paradigm" @@ -21,18 +24,23 @@ ANTHROPIC_API_KEY="" # Agent run policy (optional). Defaults to 25. # HUB_AGENT_MAX_TURNS=25 -# System-managed root for project workspaces created from org admin / Feishu onboarding. -HUB_PROJECT_WORKSPACE_ROOT="./data/project-workspaces" +# Persistent system-managed root for project workspaces. Production must use an +# absolute path outside the deployment/release tree; install_service.sh defaults +# to this path and rejects any overlap before installing the unit. +HUB_PROJECT_WORKSPACE_ROOT="/var/lib/cph-hub/workspaces" # Feishu (lark) bot credentials. FEISHU_APP_ID="" FEISHU_APP_SECRET="" FEISHU_BOT_OPEN_ID="" -# Path to the `cph` binary (ADR-0016). Defaults to `cph` on PATH. -# CPH_BIN="/usr/local/bin/cph" +# Absolute path to the `cph` binary (ADR-0016). Production preflight requires +# the file to be executable and `cph --version` to succeed. +CPH_BIN="/usr/local/bin/cph" -# Hub server port. Defaults to 8788. +# Hub bind address and port. Production defaults to loopback for a local TLS +# reverse proxy; both values are validated and honored by the HTTP server. +HOST="127.0.0.1" PORT=8788 # --- Org admin web (ADR-0021) --------------------------------------------- @@ -40,7 +48,7 @@ PORT=8788 # redirect_uri = ${HUB_PUBLIC_BASE_URL}/auth/feishu/callback # Configure the same callback URL in the Feishu developer console under # Security Settings → Redirect URLs. -HUB_PUBLIC_BASE_URL="http://127.0.0.1:8788" +HUB_PUBLIC_BASE_URL="https://hub.example.com" # HMAC secret for signed session + OAuth state cookies. Generate with: # openssl rand -base64 32 diff --git a/hub/deploy/README.md b/hub/deploy/README.md new file mode 100644 index 0000000..2458257 --- /dev/null +++ b/hub/deploy/README.md @@ -0,0 +1,57 @@ +# Hub service installation + +The initial production target is a supported Linux host with systemd, +PostgreSQL, Node.js 20 or newer, `pg_isready`, `runuser`, `setpriv`, bubblewrap +and `cph`. The Hub runs as the dedicated non-login `cph-hub` user; do not +create or run the unit as root. + +Build or deploy the Hub under `/srv/curriculum-project-hub`, then run: + +```sh +sudo BASE=/srv/curriculum-project-hub \ + HUB_DIR=/srv/curriculum-project-hub/hub \ + bash /srv/curriculum-project-hub/hub/deploy/install_service.sh +``` + +On a clean host the first invocation creates +`/srv/curriculum-project-hub/.secrets/platform.env` with every supported +production key and exits with status 78. Fill every required blank, set +`CPH_BIN` to an executable compatible `cph`, keep the file mode at `0600`, and +run the same command again. No systemd unit is installed until configuration, +paths, Node, bubblewrap, `cph --version`, PostgreSQL connectivity and directory +ownership all pass preflight. + +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` 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: + +```text +/var/lib/cph-hub/home +/var/lib/cph-hub/state +/var/lib/cph-hub/workspaces +/var/cache/cph-hub +``` + +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`. + +After a successful installation: + +```sh +sudo systemctl start cph-hub.service +sudo systemctl status cph-hub.service +``` + +`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`. diff --git a/hub/deploy/cph-hub.service b/hub/deploy/cph-hub.service index ba0a346..48b9ae2 100644 --- a/hub/deploy/cph-hub.service +++ b/hub/deploy/cph-hub.service @@ -2,12 +2,22 @@ Description=Curriculum Project Hub (Feishu agent + cph) After=network-online.target postgresql.service Wants=network-online.target +# ADR-0018: refuse to start when the configured OS sandbox binary disappears +# after installation. Deployment preflight validates the executable itself; +# this start-time assertion keeps later package/host changes fail-closed. +AssertPathExists=__BWRAP_BIN__ [Service] Type=simple User=__SERVICE_USER__ +Group=__SERVICE_GROUP__ WorkingDirectory=__HUB_DIR__ EnvironmentFile=__ENV_FILE__ +Environment=HOME=__SERVICE_HOME__ +Environment=XDG_STATE_HOME=__STATE_DIR__ +Environment=XDG_CACHE_HOME=__CACHE_DIR__ +Environment=PATH=__RUNTIME_PATH__ +UMask=0027 # Run prisma migrations before each start, then launch the Hub. ExecStartPre=__NODE_BIN__ __HUB_DIR__/node_modules/prisma/build/index.js migrate deploy --schema __HUB_DIR__/prisma/schema.prisma ExecStart=__NODE_BIN__ __HUB_DIR__/dist/server.js @@ -16,9 +26,6 @@ RestartSec=5 # Graceful shutdown: lark ws close + in-flight runs. KillSignal=SIGTERM TimeoutStopSec=30 -# ADR-0018: the agent sandbox (bubblewrap) must be available on the host — -# failIfUnavailable makes the Hub refuse to run unsandboxed. Install bwrap. -AssertPathExists=/usr/bin/bwrap # Hardening: no new privileges. ProtectSystem/ProtectHome are NOT set here — # they would break cph's render-cache extraction (~/.cache/cph/) and the # bwrap user-namespace setup. The OS-level sandbox (ADR-0018) is the hard diff --git a/hub/deploy/deploy_platform.sh b/hub/deploy/deploy_platform.sh index 90e6730..f18024f 100755 --- a/hub/deploy/deploy_platform.sh +++ b/hub/deploy/deploy_platform.sh @@ -8,8 +8,10 @@ # PLATFORM_DEPLOY_PORT optional, defaults to 22 # PLATFORM_DEPLOY_BASE optional, defaults to /srv/curriculum-project-hub # PLATFORM_DEPLOY_HEALTH_URL optional, defaults to http://127.0.0.1:8788/api/healthz -# The target host must have Node.js, npm, rsync, PostgreSQL, systemd, bubblewrap -# (`bwrap`, for the ADR-0018 agent sandbox), and a writable $PLATFORM_DEPLOY_BASE. +# The target host must have Node.js 20+, npm, rsync, PostgreSQL client tools +# (`pg_isready`), systemd, bubblewrap (`bwrap`, 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. set -euo pipefail diff --git a/hub/deploy/install_service.sh b/hub/deploy/install_service.sh index 93aeebb..b40256f 100755 --- a/hub/deploy/install_service.sh +++ b/hub/deploy/install_service.sh @@ -1,76 +1,258 @@ #!/usr/bin/env bash -# Install the cph-hub systemd service on a host. Idempotent. -# -# Prerequisites already on the host: Node.js, npm, PostgreSQL, systemd. -# The Hub repo is expected at HUB_DIR (default /srv/curriculum-project-hub/hub) -# with `npm ci` + `npm run build` already run by deploy_platform.sh. +# 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 bash deploy/install_service.sh +# 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}" SERVICE_USER="${SERVICE_USER:-cph-hub}" +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}" HOST="${HOST:-127.0.0.1}" PORT="${PORT:-8788}" ENV_FILE="${ENV_FILE:-$BASE/.secrets/platform.env}" +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)}" +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 "$SETPRIV_BIN"):/usr/local/bin:/usr/bin:/bin" -if [ -z "$NODE_BIN" ]; then - echo "node must be on PATH, or set NODE_BIN." >&2 +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 [ ! -d "$HUB_DIR" ]; then - echo "HUB_DIR not found: $HUB_DIR (set HUB_DIR or clone the repo there)." >&2 - exit 1 +if [[ ! "$SERVICE_USER" =~ ^[a-z_][a-z0-9_-]*[$]?$ ]]; then + fail "invalid SERVICE_USER: $SERVICE_USER" fi -if [ ! -f "$HUB_DIR/dist/server.js" ]; then - echo "build missing: run 'npm ci && npm run build' in $HUB_DIR first." >&2 - exit 1 +if [[ ! "$SERVICE_GROUP" =~ ^[a-z_][a-z0-9_-]*[$]?$ ]]; then + fail "invalid SERVICE_GROUP: $SERVICE_GROUP" fi -if [ ! -f "$SCRIPT_DIR/cph-hub.service" ]; then - echo "service template missing: $SCRIPT_DIR/cph-hub.service" >&2 - exit 1 +if [ "$SERVICE_USER" = "root" ] || [ "$SERVICE_GROUP" = "root" ]; then + fail "the Hub service identity must not be root" fi -# Seed the env file if absent. Secrets stay on the server, never in the repo. -if [ ! -f "$ENV_FILE" ]; then - install -d -m 0700 "$(dirname "$ENV_FILE")" +for command in getent groupadd useradd install stat systemctl id; do + require_command "$command" +done +for pair in \ + "NODE_BIN:$NODE_BIN" \ + "BWRAP_BIN:$BWRAP_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" \ + "WORKSPACE_ROOT:$WORKSPACE_ROOT" \ + "ENV_FILE:$ENV_FILE" \ + "SYSTEMD_UNIT_DIR:$SYSTEMD_UNIT_DIR" \ + "NODE_BIN:$NODE_BIN" \ + "BWRAP_BIN:$BWRAP_BIN" \ + "RUNUSER_BIN:$RUNUSER_BIN" \ + "SETPRIV_BIN:$SETPRIV_BIN" \ + "RUNTIME_PATH:$RUNTIME_PATH"; do + require_safe_unit_value "${pair%%:*}" "${pair#*:}" +done + +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" <&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" -# Render the unit with concrete paths. -UNIT=/etc/systemd/system/cph-hub.service +PREFLIGHT_ARGS=( + "$NODE_BIN" "$PREFLIGHT_JS" + --env-file "$ENV_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" + --bwrap-bin "$BWRAP_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 "$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/cph-hub.service" +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|__WORKSPACE_ROOT__|$WORKSPACE_ROOT|g" \ -e "s|__HUB_DIR__|$HUB_DIR|g" \ - -e "s|__BASE_DIR__|$BASE|g" \ -e "s|__ENV_FILE__|$ENV_FILE|g" \ -e "s|__NODE_BIN__|$NODE_BIN|g" \ + -e "s|__BWRAP_BIN__|$BWRAP_BIN|g" \ + -e "s|__RUNTIME_PATH__|$RUNTIME_PATH|g" \ "$SCRIPT_DIR/cph-hub.service" >"$TMP_UNIT" -install -m 0644 "$TMP_UNIT" "$UNIT" -rm -f "$TMP_UNIT" +install -o root -g root -m 0644 "$TMP_UNIT" "$UNIT" systemctl daemon-reload systemctl enable cph-hub.service -echo "Installed cph-hub.service. Start with: systemctl start cph-hub" -echo "Check health: curl http://127.0.0.1:$PORT/api/healthz" +echo "[install] installed cph-hub.service 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" diff --git a/hub/src/deployment/preflight-cli.ts b/hub/src/deployment/preflight-cli.ts new file mode 100644 index 0000000..721b313 --- /dev/null +++ b/hub/src/deployment/preflight-cli.ts @@ -0,0 +1,480 @@ +#!/usr/bin/env node +import { execFile } from "node:child_process"; +import { constants } from "node:fs"; +import { access, readFile, realpath, stat } from "node:fs/promises"; +import { basename, dirname, join, resolve } from "node:path"; +import { promisify } from "node:util"; +import { PrismaClient } from "@prisma/client"; +import { parse } from "dotenv"; +import { + DeploymentPreflightError, + validateDeploymentPreflight, + type DeploymentEnv, + type DeploymentPreflightInput, + type DeploymentPreflightResult, +} from "./preflight.js"; + +const execFileAsync = promisify(execFile); + +interface Options { + readonly envFile: string; + readonly nodeBin: string; + readonly runuserBin: string; + readonly setprivBin: string; + readonly serviceUser: string; + readonly baseDir: string; + readonly hubDir: string; + readonly serviceHome: string; + readonly stateDir: string; + readonly cacheDir: string; + readonly workspaceRoot: string; + readonly bwrapBin: string; + readonly pgIsReadyBin: string; + readonly serviceUid?: number; + readonly serviceGid?: number; +} + +async function main(): Promise { + const options = parseArgs(process.argv.slice(2)); + const envFileContents = await readFile(options.envFile); + const env = parse(envFileContents) satisfies DeploymentEnv; + const rawInput = deploymentInput(options, env); + + // Validate lexical paths and values before touching any external dependency. + validateDeploymentPreflight(rawInput); + + const canonicalInput = await canonicalizeInput(rawInput); + const result = validateDeploymentPreflight(canonicalInput); + await validateSecretFile(options.envFile); + await validateNodeVersion(options.nodeBin); + await validateExecutable(options.runuserBin, "runuser"); + await validateExecutable(options.setprivBin, "setpriv"); + await validateExecutable(options.bwrapBin, "bubblewrap"); + await validateExecutable(result.cphBin, "cph"); + await validateExecutable(options.pgIsReadyBin, "pg_isready"); + await validateCph(result.cphBin); + await validatePostgres(options.pgIsReadyBin, result); + await validatePostgresQuery(result.databaseUrl); + + if (options.serviceUid !== undefined && options.serviceGid !== undefined) { + await validateProvisionedDirectories(canonicalInput, options.serviceUid, options.serviceGid); + await validateServiceRuntime(options, rawInput, canonicalInput, result); + } + + console.log("[preflight] configuration and prerequisites valid"); +} + +function deploymentInput(options: Options, env: DeploymentEnv): DeploymentPreflightInput { + return { + baseDir: options.baseDir, + hubDir: options.hubDir, + serviceHome: options.serviceHome, + stateDir: options.stateDir, + cacheDir: options.cacheDir, + workspaceRoot: options.workspaceRoot, + env, + }; +} + +async function canonicalizeInput(input: DeploymentPreflightInput): Promise { + const baseDir = await canonicalizeProspectivePath(input.baseDir); + const hubDir = await canonicalizeProspectivePath(input.hubDir); + const serviceHome = await canonicalizeProspectivePath(input.serviceHome); + const stateDir = await canonicalizeProspectivePath(input.stateDir); + const cacheDir = await canonicalizeProspectivePath(input.cacheDir); + const workspaceRoot = await canonicalizeProspectivePath(input.workspaceRoot); + return { + baseDir, + hubDir, + serviceHome, + stateDir, + cacheDir, + workspaceRoot, + env: { ...input.env, HUB_PROJECT_WORKSPACE_ROOT: workspaceRoot }, + }; +} + +async function canonicalizeProspectivePath(value: string): Promise { + const absolute = resolve(value); + const missingComponents: string[] = []; + let candidate = absolute; + + while (true) { + try { + const existing = await realpath(candidate); + return resolve(existing, ...missingComponents.reverse()); + } catch (error) { + if (!isNodeError(error) || error.code !== "ENOENT") throw error; + const parent = dirname(candidate); + if (parent === candidate) throw error; + missingComponents.push(basename(candidate)); + candidate = parent; + } + } +} + +async function validateSecretFile(path: string): Promise { + const metadata = await stat(path); + if (!metadata.isFile()) { + throw new Error(`environment file is not a regular file: ${path}`); + } + if ((metadata.mode & 0o077) !== 0) { + throw new Error(`environment file must not be accessible by group or others: ${path}`); + } +} + +async function validateNodeVersion(nodeBin: string): Promise { + let stdout: string; + try { + ({ stdout } = await execFileAsync(nodeBin, ["--version"], { timeout: 10_000 })); + } catch (error) { + throw new Error(`Node.js version check failed: ${nodeBin} (${formatFailure(error)})`, { cause: error }); + } + const version = stdout.trim().replace(/^v/, ""); + const major = Number(version.split(".")[0]); + if (!Number.isInteger(major) || major < 20) { + throw new Error(`Node.js 20 or newer is required; found ${version || "unknown"}`); + } + console.log(`[preflight] Node.js: ${version}`); +} + +async function validateExecutable(path: string, label: string): Promise { + try { + await access(path, constants.X_OK); + } catch (error) { + throw new Error( + `${label} executable is missing or not executable: ${path} (${formatFailure(error)})`, + { cause: error }, + ); + } +} + +async function validateCph(cphBin: string): Promise { + let stdout: string; + try { + ({ stdout } = await execFileAsync(cphBin, ["--version"], { timeout: 10_000 })); + } catch (error) { + throw new Error(`cph --version failed: ${cphBin} (${formatFailure(error)})`, { cause: error }); + } + if (!/^cph \d+\.\d+\.\d+(?:\s|$)/m.test(stdout)) { + throw new Error(`cph --version returned an unexpected value: ${cphBin}`); + } + console.log(`[preflight] cph: ${stdout.trim()}`); +} + +async function validatePostgres(pgIsReadyBin: string, result: DeploymentPreflightResult): Promise { + const url = new URL(result.databaseUrl); + const postgresEnv: NodeJS.ProcessEnv = { + ...process.env, + PGCONNECT_TIMEOUT: "5", + PGHOST: url.hostname, + PGPORT: url.port || "5432", + PGUSER: decodeURIComponent(url.username), + PGPASSWORD: decodeURIComponent(url.password), + PGDATABASE: decodeURIComponent(url.pathname.replace(/^\//, "")), + }; + const sslMode = url.searchParams.get("sslmode"); + if (sslMode !== null) postgresEnv["PGSSLMODE"] = sslMode; + + try { + await execFileAsync(pgIsReadyBin, ["--quiet"], { env: postgresEnv, timeout: 10_000 }); + } catch (error) { + throw new Error( + `PostgreSQL is not accepting connections for DATABASE_URL (${formatFailure(error)})`, + { cause: error }, + ); + } + console.log("[preflight] PostgreSQL is accepting connections"); +} + +async function validatePostgresQuery(databaseUrl: string): Promise { + const client = new PrismaClient({ + datasources: { db: { url: databaseUrl } }, + log: [], + }); + let queryFailure: unknown; + try { + await client.$queryRawUnsafe("SELECT 1"); + } catch (error) { + queryFailure = error; + } + + let disconnectFailure: unknown; + try { + await client.$disconnect(); + } catch (error) { + disconnectFailure = error; + } + + if (queryFailure !== undefined) { + const disconnectDetail = + disconnectFailure === undefined ? "" : `; disconnect also failed (${formatFailure(disconnectFailure)})`; + throw new Error( + `PostgreSQL authenticated query failed for DATABASE_URL (${formatFailure(queryFailure)})${disconnectDetail}`, + { cause: queryFailure }, + ); + } + if (disconnectFailure !== undefined) { + throw new Error( + `PostgreSQL preflight disconnect failed (${formatFailure(disconnectFailure)})`, + { cause: disconnectFailure }, + ); + } + console.log("[preflight] PostgreSQL authenticated query succeeded"); +} + +async function validateProvisionedDirectories( + input: DeploymentPreflightInput, + serviceUid: number, + serviceGid: number, +): Promise { + const directories = [input.serviceHome, input.stateDir, input.cacheDir, input.workspaceRoot]; + for (const directory of directories) { + const metadata = await stat(directory); + if (!metadata.isDirectory()) { + throw new Error(`provisioned path is not a directory: ${directory}`); + } + if (metadata.uid !== serviceUid || metadata.gid !== serviceGid) { + throw new Error(`provisioned directory has unexpected ownership: ${directory}`); + } + if ((metadata.mode & 0o777) !== 0o750) { + throw new Error(`provisioned directory must have mode 0750: ${directory}`); + } + } + console.log(`[preflight] service directories owned by ${serviceUid}:${serviceGid} with mode 0750`); +} + +async function validateServiceRuntime( + options: Options, + configuredInput: DeploymentPreflightInput, + canonicalInput: DeploymentPreflightInput, + result: DeploymentPreflightResult, +): Promise { + const serviceEnv: NodeJS.ProcessEnv = { + ...configuredInput.env, + NODE_ENV: "production", + DATABASE_URL: result.databaseUrl, + HOME: configuredInput.serviceHome, + XDG_STATE_HOME: configuredInput.stateDir, + XDG_CACHE_HOME: configuredInput.cacheDir, + PATH: [dirname(options.nodeBin), dirname(options.bwrapBin), dirname(result.cphBin), "/usr/bin", "/bin"].join(":"), + }; + const pathAccessRequirements = (input: DeploymentPreflightInput) => [ + [input.hubDir, constants.R_OK | constants.X_OK], + [join(input.hubDir, "dist"), constants.R_OK | constants.X_OK], + [join(input.hubDir, "node_modules"), constants.R_OK | constants.X_OK], + [join(input.hubDir, "prisma"), constants.R_OK | constants.X_OK], + [input.serviceHome, constants.R_OK | constants.W_OK | constants.X_OK], + [input.stateDir, constants.R_OK | constants.W_OK | constants.X_OK], + [input.cacheDir, constants.R_OK | constants.W_OK | constants.X_OK], + [input.workspaceRoot, constants.R_OK | constants.W_OK | constants.X_OK], + [join(input.hubDir, "dist", "server.js"), constants.R_OK], + [join(input.hubDir, "dist", "deployment", "service-runtime-probe.js"), constants.R_OK], + [join(input.hubDir, "node_modules", "prisma", "build", "index.js"), constants.R_OK], + [join(input.hubDir, "prisma", "schema.prisma"), constants.R_OK], + ] as const; + const accessRequirements = [ + ...pathAccessRequirements(configuredInput), + ...pathAccessRequirements(canonicalInput), + [options.nodeBin, constants.X_OK], + [options.bwrapBin, constants.X_OK], + [options.setprivBin, constants.X_OK], + [result.cphBin, constants.X_OK], + ] as const; + const accessProbe = [ + 'const { accessSync } = require("node:fs");', + "for (const [path, mode] of JSON.parse(process.argv[1])) accessSync(path, mode);", + ].join("\n"); + + await runAsService( + options, + serviceEnv, + options.nodeBin, + ["-e", accessProbe, JSON.stringify(accessRequirements)], + "release and persistent path access probe", + ); + await runAsService( + options, + serviceEnv, + options.nodeBin, + ["--check", join(configuredInput.hubDir, "dist", "server.js")], + "built Hub syntax probe", + ); + await runAsService( + options, + serviceEnv, + options.nodeBin, + [join(configuredInput.hubDir, "node_modules", "prisma", "build", "index.js"), "--version"], + "Prisma runtime probe", + ); + await runAsService( + options, + serviceEnv, + options.nodeBin, + [join(configuredInput.hubDir, "dist", "deployment", "service-runtime-probe.js")], + "Hub dependency and authenticated database probe", + ); + await runAsService(options, serviceEnv, result.cphBin, ["--version"], "cph runtime probe"); + await runAsService( + options, + serviceEnv, + options.bwrapBin, + [ + "--unshare-all", + "--die-with-parent", + "--new-session", + "--ro-bind", + "/", + "/", + "--proc", + "/proc", + "--dev", + "/dev", + "/bin/true", + ], + "bubblewrap user-namespace probe", + ); + console.log(`[preflight] runtime prerequisites execute as service user ${options.serviceUser}`); +} + +async function runAsService( + options: Options, + env: NodeJS.ProcessEnv, + command: string, + args: readonly string[], + label: string, +): Promise { + try { + await execFileAsync( + options.runuserBin, + [ + "-u", + options.serviceUser, + "--", + options.setprivBin, + "--no-new-privs", + command, + ...args, + ], + { cwd: options.hubDir, env, timeout: 15_000, maxBuffer: 2 * 1024 * 1024 }, + ); + } catch (error) { + throw new Error(`${label} failed for service user ${options.serviceUser} (${formatFailure(error)})`, { + cause: error, + }); + } +} + +function parseArgs(argv: readonly string[]): Options { + const values = new Map(); + for (let index = 0; index < argv.length; index += 2) { + const key = argv[index]; + const value = argv[index + 1]; + if (key === undefined || value === undefined || !key.startsWith("--")) { + throw new Error("preflight arguments must be --name value pairs"); + } + if (values.has(key)) throw new Error(`duplicate preflight argument: ${key}`); + values.set(key, value); + } + + const allowed = new Set([ + "--env-file", + "--node-bin", + "--runuser-bin", + "--setpriv-bin", + "--service-user", + "--base-dir", + "--hub-dir", + "--service-home", + "--state-dir", + "--cache-dir", + "--workspace-root", + "--bwrap-bin", + "--pg-isready-bin", + "--service-uid", + "--service-gid", + ]); + for (const key of values.keys()) { + if (!allowed.has(key)) throw new Error(`unknown preflight argument: ${key}`); + } + + const serviceUid = optionalId(values, "--service-uid"); + const serviceGid = optionalId(values, "--service-gid"); + if ((serviceUid === undefined) !== (serviceGid === undefined)) { + throw new Error("--service-uid and --service-gid must be supplied together"); + } + + return { + envFile: required(values, "--env-file"), + nodeBin: required(values, "--node-bin"), + runuserBin: required(values, "--runuser-bin"), + setprivBin: required(values, "--setpriv-bin"), + serviceUser: required(values, "--service-user"), + baseDir: required(values, "--base-dir"), + hubDir: required(values, "--hub-dir"), + serviceHome: required(values, "--service-home"), + stateDir: required(values, "--state-dir"), + cacheDir: required(values, "--cache-dir"), + workspaceRoot: required(values, "--workspace-root"), + bwrapBin: required(values, "--bwrap-bin"), + pgIsReadyBin: required(values, "--pg-isready-bin"), + ...(serviceUid === undefined ? {} : { serviceUid, serviceGid: serviceGid! }), + }; +} + +function required(values: ReadonlyMap, key: string): string { + const value = values.get(key); + if (value === undefined || value === "") throw new Error(`missing required preflight argument: ${key}`); + return value; +} + +function optionalId(values: ReadonlyMap, key: string): number | undefined { + const raw = values.get(key); + if (raw === undefined) return undefined; + const value = Number(raw); + if (!Number.isInteger(value) || value < 0) throw new Error(`${key} must be a non-negative integer`); + return value; +} + +function isNodeError(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && "code" in error; +} + +function formatFailure(error: unknown): string { + if (!(error instanceof Error)) return String(error); + + const details: string[] = []; + const processError = error as NodeJS.ErrnoException & { + readonly signal?: NodeJS.Signals | null; + readonly stderr?: string | Buffer; + }; + if (processError.code !== undefined) details.push(`exit=${String(processError.code)}`); + if (processError.signal !== undefined && processError.signal !== null) { + details.push(`signal=${processError.signal}`); + } + const stderr = processError.stderr?.toString().trim(); + if (stderr !== undefined && stderr !== "") details.push(`stderr=${boundedDiagnostic(redactDiagnostic(stderr))}`); + if (details.length === 0) details.push(boundedDiagnostic(redactDiagnostic(error.message))); + return details.join(", "); +} + +function boundedDiagnostic(value: string): string { + const limit = 2_000; + if (value.length <= limit) return value; + return `${value.slice(0, limit)}…[truncated ${value.length - limit} chars]`; +} + +function redactDiagnostic(value: string): string { + return value + .replace(/\b(postgres(?:ql)?):\/\/([^:\s/@]+):([^@\s]+)@/gi, "$1://$2:[REDACTED]@") + .replace(/\b(password\s*[:=]\s*)\S+/gi, "$1[REDACTED]"); +} + +main().catch((error: unknown) => { + if (error instanceof DeploymentPreflightError) { + console.error(`[preflight] ${error.message}`); + } else { + console.error(`[preflight] failed: ${error instanceof Error ? error.message : String(error)}`); + } + process.exitCode = 1; +}); diff --git a/hub/src/deployment/preflight.ts b/hub/src/deployment/preflight.ts new file mode 100644 index 0000000..aa6170b --- /dev/null +++ b/hub/src/deployment/preflight.ts @@ -0,0 +1,203 @@ +import { isAbsolute, normalize, relative } from "node:path"; +import { readServerBinding, type ServerBinding, type ServerEnv } from "../settings/server.js"; + +export type DeploymentEnv = ServerEnv; + +export interface DeploymentPreflightInput { + readonly baseDir: string; + readonly hubDir: string; + readonly serviceHome: string; + readonly stateDir: string; + readonly cacheDir: string; + readonly workspaceRoot: string; + readonly env: DeploymentEnv; +} + +export interface DeploymentPreflightResult { + readonly bind: ServerBinding; + readonly databaseUrl: string; + readonly cphBin: string; +} + +export class DeploymentPreflightError extends Error { + readonly problems: readonly string[]; + + constructor(problems: readonly string[]) { + super(`deployment preflight failed:\n${problems.map((problem) => `- ${problem}`).join("\n")}`); + this.name = "DeploymentPreflightError"; + this.problems = problems; + } +} + +/** + * Validate the production values shared by the installer and the Hub runtime. + * Inputs are required to be absolute normalized paths so callers can first + * resolve existing paths physically and make symlink traversal observable. + */ +export function validateDeploymentPreflight(input: DeploymentPreflightInput): DeploymentPreflightResult { + const problems: string[] = []; + const paths = [ + ["deployment root", input.baseDir], + ["Hub directory", input.hubDir], + ["service home", input.serviceHome], + ["state directory", input.stateDir], + ["cache directory", input.cacheDir], + ["workspace root", input.workspaceRoot], + ] as const; + + for (const [label, value] of paths) { + if (!isAbsoluteNormalized(value)) { + problems.push(`${label} must be an absolute normalized path`); + } + } + + if (isAbsoluteNormalized(input.baseDir) && isAbsoluteNormalized(input.hubDir)) { + if (!isStrictDescendant(input.baseDir, input.hubDir)) { + problems.push("Hub directory must be inside deployment root"); + } + } + + if (isAbsoluteNormalized(input.baseDir)) { + for (const [label, value] of paths.slice(2)) { + if (isAbsoluteNormalized(value) && pathsOverlap(input.baseDir, value)) { + problems.push(`${label} must not overlap deployment root`); + } + } + } + + const envWorkspaceRoot = readRequired(input.env, "HUB_PROJECT_WORKSPACE_ROOT", problems); + if (envWorkspaceRoot !== undefined) { + if (!isAbsoluteNormalized(envWorkspaceRoot)) { + problems.push("HUB_PROJECT_WORKSPACE_ROOT must be an absolute normalized path"); + } else if (isAbsoluteNormalized(input.workspaceRoot) && envWorkspaceRoot !== input.workspaceRoot) { + problems.push("HUB_PROJECT_WORKSPACE_ROOT must equal provisioned workspace root"); + } + } + + if (input.env["NODE_ENV"] !== "production") { + problems.push("NODE_ENV must equal production"); + } + + const databaseUrl = readRequired(input.env, "DATABASE_URL", problems); + if (databaseUrl !== undefined && !isPostgresUrl(databaseUrl)) { + problems.push("DATABASE_URL must be a valid postgresql:// or postgres:// URL"); + } + + const providerBaseUrl = readRequired(input.env, "ANTHROPIC_BASE_URL", problems); + if (providerBaseUrl !== undefined && !isHttpUrl(providerBaseUrl)) { + problems.push("ANTHROPIC_BASE_URL must be a valid http(s) URL"); + } + readRequired(input.env, "ANTHROPIC_AUTH_TOKEN", problems); + requirePresentAndEmpty(input.env, "ANTHROPIC_API_KEY", problems); + + readRequired(input.env, "FEISHU_APP_ID", problems); + readRequired(input.env, "FEISHU_APP_SECRET", problems); + readRequired(input.env, "FEISHU_BOT_OPEN_ID", problems); + + const cphBin = readRequired(input.env, "CPH_BIN", problems); + if (cphBin !== undefined && !isAbsoluteNormalized(cphBin)) { + problems.push("CPH_BIN must be an absolute normalized path"); + } + + let bind: ServerBinding | undefined; + try { + bind = readServerBinding(input.env); + } catch (error) { + problems.push(error instanceof Error ? error.message : String(error)); + } + + const publicBaseUrl = readRequired(input.env, "HUB_PUBLIC_BASE_URL", problems); + if (publicBaseUrl !== undefined && !isHttpsUrl(publicBaseUrl)) { + problems.push("HUB_PUBLIC_BASE_URL must use https"); + } + + const sessionSecret = readRequired(input.env, "HUB_SESSION_SECRET", problems); + if (sessionSecret !== undefined && sessionSecret.length < 32) { + problems.push("HUB_SESSION_SECRET must contain at least 32 characters"); + } + + validateOptionalPositiveInteger(input.env, "HUB_AGENT_MAX_TURNS", problems); + validateOptionalBoolean(input.env, "HUB_FEISHU_LISTENER_ENABLED", problems); + + if (problems.length > 0) { + throw new DeploymentPreflightError(problems); + } + + return { + bind: bind!, + databaseUrl: databaseUrl!, + cphBin: cphBin!, + }; +} + +function readRequired(env: DeploymentEnv, name: string, problems: string[]): string | undefined { + const value = env[name]?.trim(); + if (value === undefined || value === "") { + problems.push(`${name} is required`); + return undefined; + } + return value; +} + +function requirePresentAndEmpty(env: DeploymentEnv, name: string, problems: string[]): void { + if (!Object.hasOwn(env, name) || env[name] === undefined || env[name]?.trim() !== "") { + problems.push(`${name} must be present and empty`); + } +} + +function validateOptionalPositiveInteger(env: DeploymentEnv, name: string, problems: string[]): void { + const raw = env[name]?.trim(); + if (raw === undefined || raw === "") return; + const value = Number(raw); + if (!Number.isInteger(value) || value <= 0) { + problems.push(`${name} must be a positive integer`); + } +} + +function validateOptionalBoolean(env: DeploymentEnv, name: string, problems: string[]): void { + const raw = env[name]?.trim().toLowerCase(); + if (raw === undefined || raw === "") return; + if (!["1", "0", "true", "false", "yes", "no", "on", "off"].includes(raw)) { + problems.push(`${name} must be a boolean value`); + } +} + +function isAbsoluteNormalized(value: string): boolean { + return isAbsolute(value) && normalize(value) === value; +} + +function pathsOverlap(left: string, right: string): boolean { + return left === right || isStrictDescendant(left, right) || isStrictDescendant(right, left); +} + +function isStrictDescendant(parent: string, child: string): boolean { + const candidate = relative(parent, child); + return candidate !== "" && candidate !== ".." && !candidate.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) && !isAbsolute(candidate); +} + +function isPostgresUrl(value: string): boolean { + try { + const url = new URL(value); + return (url.protocol === "postgresql:" || url.protocol === "postgres:") && url.hostname !== ""; + } catch { + return false; + } +} + +function isHttpUrl(value: string): boolean { + try { + const url = new URL(value); + return (url.protocol === "https:" || url.protocol === "http:") && url.hostname !== ""; + } catch { + return false; + } +} + +function isHttpsUrl(value: string): boolean { + try { + const url = new URL(value); + return url.protocol === "https:" && url.hostname !== "" && url.username === "" && url.password === ""; + } catch { + return false; + } +} diff --git a/hub/src/deployment/service-runtime-probe.ts b/hub/src/deployment/service-runtime-probe.ts new file mode 100644 index 0000000..7fcf421 --- /dev/null +++ b/hub/src/deployment/service-runtime-probe.ts @@ -0,0 +1,31 @@ +#!/usr/bin/env node +import "dotenv/config"; +import "../hub.js"; +import { prisma } from "../db.js"; + +async function main(): Promise { + let queryFailure: unknown; + try { + await prisma.$queryRawUnsafe("SELECT 1"); + } catch (error) { + queryFailure = error; + } + + let disconnectFailure: unknown; + try { + await prisma.$disconnect(); + } catch (error) { + disconnectFailure = error; + } + + const failures = [queryFailure, disconnectFailure].filter((failure) => failure !== undefined); + if (failures.length > 0) { + throw new AggregateError(failures, "Hub dependency load or authenticated database query failed"); + } + console.log("[service-runtime-probe] Hub dependencies and PostgreSQL query succeeded"); +} + +main().catch((error: unknown) => { + console.error("[service-runtime-probe] failed:", error); + process.exitCode = 1; +}); diff --git a/hub/src/hub.ts b/hub/src/hub.ts new file mode 100644 index 0000000..88b78d6 --- /dev/null +++ b/hub/src/hub.ts @@ -0,0 +1,79 @@ +import Fastify from "fastify"; +import { registerAdminPlugin } from "./admin/plugin.js"; +import { prisma } from "./db.js"; +import { createLarkClient, startFeishuListenerWithClient } from "./feishu/client.js"; +import { makeTriggerHandler } from "./feishu/trigger.js"; +import { triggerQueue } from "./feishu/triggerQueue.js"; +import { createEnvRuntimeSettings } from "./settings/runtime.js"; +import { readServerBinding } from "./settings/server.js"; + +function requireEnv(name: string): string { + const value = process.env[name]; + if (value === undefined || value === "") { + throw new Error(`missing required env: ${name}`); + } + return value; +} + +function booleanEnv(name: string, fallback: boolean): boolean { + const raw = process.env[name]; + if (raw === undefined || raw === "") return fallback; + return !["0", "false", "no", "off"].includes(raw.trim().toLowerCase()); +} + +/** Start the Hub after every import and runtime dependency has loaded. */ +export async function startHub(): Promise { + requireEnv("DATABASE_URL"); + + const runtimeSettings = createEnvRuntimeSettings(); + await runtimeSettings.provider("openrouter"); + + const feishuAppId = requireEnv("FEISHU_APP_ID"); + const feishuAppSecret = requireEnv("FEISHU_APP_SECRET"); + const feishuBotOpenId = requireEnv("FEISHU_BOT_OPEN_ID"); + const projectWorkspaceRoot = requireEnv("HUB_PROJECT_WORKSPACE_ROOT"); + const sessionSecret = requireEnv("HUB_SESSION_SECRET"); + const publicBaseUrl = process.env["HUB_PUBLIC_BASE_URL"] ?? "http://127.0.0.1:8788"; + const bind = readServerBinding(); + + const app = Fastify({ logger: true }); + + // Startup reset: clear stale locks + mark dead runs as FAILED. + await prisma.projectAgentLock.deleteMany({}); + await prisma.agentRun.updateMany({ + where: { status: "ACTIVE" }, + data: { status: "FAILED", error: "process restart", finishedAt: new Date() }, + }); + app.log.info("startup: cleared stale locks + dead runs"); + + app.get("/api/healthz", async () => ({ ok: true, ts: Date.now() })); + + await registerAdminPlugin(app, { + prisma, + sessionSecret, + publicBaseUrl, + feishuAppId, + feishuAppSecret, + projectWorkspaceRoot, + }); + + const address = await app.listen(bind); + app.log.info({ address }, "hub listening"); + + const feishuListenerEnabled = booleanEnv("HUB_FEISHU_LISTENER_ENABLED", true); + if (feishuListenerEnabled) { + const feishuConfig = { appId: feishuAppId, appSecret: feishuAppSecret, botOpenId: feishuBotOpenId }; + const larkClient = createLarkClient(feishuConfig); + const triggerQueuePurgeTimer = setInterval(() => { + const removed = triggerQueue.purgeExpired(); + if (removed > 0) { + app.log.info({ removed }, "feishu trigger queue: purged expired triggers"); + } + }, 60_000); + triggerQueuePurgeTimer.unref(); + const trigger = makeTriggerHandler({ prisma, settings: runtimeSettings, logger: app.log, projectWorkspaceRoot }); + startFeishuListenerWithClient(feishuConfig, larkClient, app.log, trigger, trigger.onCardAction); + } else { + app.log.info("feishu listener disabled by HUB_FEISHU_LISTENER_ENABLED"); + } +} diff --git a/hub/src/server.ts b/hub/src/server.ts index 8104d79..d7b4a15 100644 --- a/hub/src/server.ts +++ b/hub/src/server.ts @@ -14,93 +14,9 @@ * HUB_PROJECT_WORKSPACE_ROOT, HUB_SESSION_SECRET, HUB_PUBLIC_BASE_URL */ import "dotenv/config"; -import Fastify from "fastify"; -import { registerAdminPlugin } from "./admin/plugin.js"; -import { prisma } from "./db.js"; -import { createEnvRuntimeSettings } from "./settings/runtime.js"; -import { createLarkClient, startFeishuListenerWithClient } from "./feishu/client.js"; -import { makeTriggerHandler } from "./feishu/trigger.js"; -import { triggerQueue } from "./feishu/triggerQueue.js"; +import { startHub } from "./hub.js"; -function requireEnv(name: string): string { - const v = process.env[name]; - if (v === undefined || v === "") { - console.error(`[hub] missing required env: ${name}`); - process.exit(1); - } - return v; -} - -function booleanEnv(name: string, fallback: boolean): boolean { - const raw = process.env[name]; - if (raw === undefined || raw === "") return fallback; - return !["0", "false", "no", "off"].includes(raw.trim().toLowerCase()); -} - -async function main(): Promise { - const databaseUrl = requireEnv("DATABASE_URL"); - void databaseUrl; - - const runtimeSettings = createEnvRuntimeSettings(); - await runtimeSettings.provider("openrouter"); - - const feishuAppId = requireEnv("FEISHU_APP_ID"); - const feishuAppSecret = requireEnv("FEISHU_APP_SECRET"); - const feishuBotOpenId = requireEnv("FEISHU_BOT_OPEN_ID"); - const projectWorkspaceRoot = requireEnv("HUB_PROJECT_WORKSPACE_ROOT"); - const sessionSecret = requireEnv("HUB_SESSION_SECRET"); - const publicBaseUrl = process.env["HUB_PUBLIC_BASE_URL"] ?? "http://127.0.0.1:8788"; - const port = Number(process.env["PORT"] ?? "8788"); - - const app = Fastify({ logger: true }); - - // Startup reset: clear stale locks + mark dead runs as FAILED. - await prisma.projectAgentLock.deleteMany({}); - await prisma.agentRun.updateMany({ - where: { status: "ACTIVE" }, - data: { status: "FAILED", error: "process restart", finishedAt: new Date() }, - }); - app.log.info("startup: cleared stale locks + dead runs"); - - app.get("/api/healthz", async () => ({ ok: true, ts: Date.now() })); - - await registerAdminPlugin(app, { - prisma, - sessionSecret, - publicBaseUrl, - feishuAppId, - feishuAppSecret, - projectWorkspaceRoot, - }); - - // --- Feishu listener --- - const feishuListenerEnabled = booleanEnv("HUB_FEISHU_LISTENER_ENABLED", true); - if (feishuListenerEnabled) { - const feishuConfig = { appId: feishuAppId, appSecret: feishuAppSecret, botOpenId: feishuBotOpenId }; - const larkClient = createLarkClient(feishuConfig); - const triggerQueuePurgeTimer = setInterval(() => { - const removed = triggerQueue.purgeExpired(); - if (removed > 0) { - app.log.info({ removed }, "feishu trigger queue: purged expired triggers"); - } - }, 60_000); - triggerQueuePurgeTimer.unref(); - const trigger = makeTriggerHandler({ prisma, settings: runtimeSettings, logger: app.log, projectWorkspaceRoot }); - startFeishuListenerWithClient(feishuConfig, larkClient, app.log, trigger, trigger.onCardAction); - } else { - app.log.info("feishu listener disabled by HUB_FEISHU_LISTENER_ENABLED"); - } - - app.listen({ port, host: "0.0.0.0" }, (err, address) => { - if (err !== null) { - app.log.error({ err }, "listen failed"); - process.exit(1); - } - app.log.info({ address }, "hub listening"); - }); -} - -main().catch((e) => { - console.error("[hub] fatal:", e); - process.exit(1); +startHub().catch((error: unknown) => { + console.error("[hub] fatal:", error); + process.exitCode = 1; }); diff --git a/hub/src/settings/server.ts b/hub/src/settings/server.ts new file mode 100644 index 0000000..146f129 --- /dev/null +++ b/hub/src/settings/server.ts @@ -0,0 +1,26 @@ +import { isIP } from "node:net"; + +export type ServerEnv = Readonly>; + +export interface ServerBinding { + readonly host: string; + readonly port: number; +} + +const HOSTNAME = /^(?=.{1,253}$)(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)(?:\.(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?))*\.?$/; + +/** Read and validate the address passed directly to Fastify/Node listen(). */ +export function readServerBinding(env: ServerEnv = process.env): ServerBinding { + const host = env["HOST"]?.trim() || "127.0.0.1"; + if (isIP(host) === 0 && !HOSTNAME.test(host)) { + throw new Error("HOST must be a hostname or IP address"); + } + + const rawPort = env["PORT"]?.trim() || "8788"; + const port = Number(rawPort); + if (!Number.isInteger(port) || port < 1 || port > 65_535) { + throw new Error("PORT must be an integer between 1 and 65535"); + } + + return { host, port }; +} diff --git a/hub/test/integration/deployment-preflight-cli.test.ts b/hub/test/integration/deployment-preflight-cli.test.ts new file mode 100644 index 0000000..25caa71 --- /dev/null +++ b/hub/test/integration/deployment-preflight-cli.test.ts @@ -0,0 +1,249 @@ +import { execFile } from "node:child_process"; +import { chmod, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { promisify } from "node:util"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +const execFileAsync = promisify(execFile); +const TEST_DATABASE_URL = "postgresql://paradigm:paradigm@127.0.0.1:5432/cph_hub_test"; + +describe("deployment preflight CLI", () => { + let root: string; + let baseDir: string; + let hubDir: string; + let persistentDir: string; + let marker: string; + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "cph-deployment-preflight-")); + baseDir = join(root, "deploy"); + hubDir = join(baseDir, "current", "hub"); + persistentDir = join(root, "persistent"); + marker = join(root, "external-check-ran"); + await Promise.all([ + mkdir(hubDir, { recursive: true }), + mkdir(join(persistentDir, "home"), { recursive: true }), + mkdir(join(persistentDir, "state"), { recursive: true }), + mkdir(join(persistentDir, "cache"), { recursive: true }), + mkdir(join(persistentDir, "workspaces"), { recursive: true }), + ]); + await Promise.all([ + chmod(join(persistentDir, "home"), 0o750), + chmod(join(persistentDir, "state"), 0o750), + chmod(join(persistentDir, "cache"), 0o750), + chmod(join(persistentDir, "workspaces"), 0o750), + ]); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + + it("exits before prerequisite checks when a persistent path overlaps the deployment tree", async () => { + const workspaceRoot = join(hubDir, "data", "workspaces"); + await mkdir(workspaceRoot, { recursive: true }); + const fixture = await createFixture({ workspaceRoot, marker }); + + const result = await runCli(fixture.args); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("workspace root must not overlap deployment root"); + await expect(readFile(marker, "utf8")).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("returns success only after configuration and external prerequisites pass", async () => { + const workspaceRoot = join(persistentDir, "workspaces"); + const fixture = await createFixture({ workspaceRoot, marker }); + + const result = await runCli(fixture.args); + + expect(result).toMatchObject({ exitCode: 0, stderr: "" }); + expect(result.stdout).toContain("[preflight] configuration and prerequisites valid"); + expect(await readFile(marker, "utf8")).toContain("cph\npg_isready\n"); + }); + + it("preserves the failed prerequisite exit code and stderr", async () => { + const workspaceRoot = join(persistentDir, "workspaces"); + const fixture = await createFixture({ + workspaceRoot, + marker, + pgIsReadyScript: "#!/bin/sh\nprintf 'socket permission denied\\n' >&2\nexit 23\n", + }); + + const result = await runCli(fixture.args); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("PostgreSQL is not accepting connections for DATABASE_URL"); + expect(result.stderr).toContain("exit=23"); + expect(result.stderr).toContain("socket permission denied"); + }); + + it("rejects a database URL that accepts TCP connections but cannot execute a query", async () => { + const workspaceRoot = join(persistentDir, "workspaces"); + const fixture = await createFixture({ + workspaceRoot, + marker, + databaseUrl: "postgresql://paradigm:paradigm@127.0.0.1:5432/cph_hub_preflight_missing", + }); + + const result = await runCli(fixture.args); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("PostgreSQL authenticated query failed for DATABASE_URL"); + }); + + it("loads the actual Hub dependency graph and queries PostgreSQL in the runtime probe", async () => { + const result = await execFileAsync( + resolve("node_modules/.bin/tsx"), + [resolve("src/deployment/service-runtime-probe.ts")], + { + env: { ...process.env, DATABASE_URL: TEST_DATABASE_URL, NODE_ENV: "production" }, + }, + ); + + expect(result.stderr).toBe(""); + expect(result.stdout).toContain("Hub dependencies and PostgreSQL query succeeded"); + }); + + it("probes the built runtime and bubblewrap as the service identity", async () => { + const configuredPersistentParent = join(root, "persistent-link"); + await symlink(persistentDir, configuredPersistentParent); + const workspaceRoot = join(configuredPersistentParent, "workspaces"); + const fixture = await createFixture({ workspaceRoot, marker }); + const uid = process.getuid?.(); + const gid = process.getgid?.(); + if (uid === undefined || gid === undefined) throw new Error("service identity probe requires POSIX uid/gid"); + + const result = await runCli([ + ...fixture.args, + "--service-uid", + String(uid), + "--service-gid", + String(gid), + ]); + + expect(result).toMatchObject({ exitCode: 0, stderr: "" }); + const probeLog = await readFile(marker, "utf8"); + expect(probeLog).toContain(workspaceRoot); + expect(probeLog).toContain("service-setpriv\n"); + expect(probeLog).toContain("service-bwrap\n"); + expect(probeLog).toContain("service-prisma\n"); + expect(probeLog).toContain("service-database\n"); + }); + + async function createFixture(options: { + workspaceRoot: string; + marker: string; + pgIsReadyScript?: string; + databaseUrl?: string; + }): Promise<{ args: string[] }> { + const binDir = join(root, "bin"); + const cphBin = join(binDir, "cph"); + const bwrapBin = join(binDir, "bwrap"); + const pgIsReadyBin = join(binDir, "pg_isready"); + const runuserBin = join(binDir, "runuser"); + const setprivBin = join(binDir, "setpriv"); + const envFile = join(root, "platform.env"); + await Promise.all([ + mkdir(binDir, { recursive: true }), + mkdir(join(hubDir, "dist"), { recursive: true }), + mkdir(join(hubDir, "dist", "deployment"), { recursive: true }), + mkdir(join(hubDir, "node_modules", "prisma", "build"), { recursive: true }), + mkdir(join(hubDir, "prisma"), { recursive: true }), + ]); + await Promise.all([ + executable(cphBin, `#!/bin/sh\nprintf 'cph\\n' >> '${options.marker}'\nprintf 'cph 0.0.2\\n'\n`), + executable(bwrapBin, `#!/bin/sh\nprintf 'service-bwrap\\n' >> '${options.marker}'\nexit 0\n`), + executable( + runuserBin, + `#!/bin/sh\nprintf '%s\\n' "$*" >> '${options.marker}'\n[ "$1" = "-u" ] || exit 91\nshift 2\n[ "$1" = "--" ] || exit 92\nshift\nexec "$@"\n`, + ), + executable( + setprivBin, + `#!/bin/sh\n[ "$1" = "--no-new-privs" ] || exit 93\nshift\nprintf 'service-setpriv\\n' >> '${options.marker}'\nexec "$@"\n`, + ), + executable( + pgIsReadyBin, + options.pgIsReadyScript ?? `#!/bin/sh\nprintf 'pg_isready\\n' >> '${options.marker}'\nexit 0\n`, + ), + writeFile(join(hubDir, "dist", "server.js"), "export {};\n"), + writeFile( + join(hubDir, "dist", "deployment", "service-runtime-probe.js"), + `const { appendFileSync } = require("node:fs");\nappendFileSync(${JSON.stringify(options.marker)}, "service-database\\n");\n`, + ), + writeFile( + join(hubDir, "node_modules", "prisma", "build", "index.js"), + `const { appendFileSync } = require("node:fs");\nappendFileSync(${JSON.stringify(options.marker)}, "service-prisma\\n");\n`, + ), + writeFile(join(hubDir, "prisma", "schema.prisma"), "// fixture\n"), + ]); + await writeFile( + envFile, + [ + "NODE_ENV=production", + `DATABASE_URL=${options.databaseUrl ?? TEST_DATABASE_URL}`, + "ANTHROPIC_BASE_URL=https://openrouter.ai/api", + "ANTHROPIC_AUTH_TOKEN=provider-token", + "ANTHROPIC_API_KEY=", + "FEISHU_APP_ID=cli_app_id", + "FEISHU_APP_SECRET=feishu-app-secret", + "FEISHU_BOT_OPEN_ID=ou_bot", + `CPH_BIN=${cphBin}`, + "HOST=127.0.0.1", + "PORT=8788", + `HUB_PROJECT_WORKSPACE_ROOT=${options.workspaceRoot}`, + "HUB_PUBLIC_BASE_URL=https://hub.example.com", + "HUB_SESSION_SECRET=a-production-session-secret-with-32-bytes", + "", + ].join("\n"), + { mode: 0o600 }, + ); + + return { + args: [ + "--env-file", + envFile, + "--node-bin", + process.execPath, + "--runuser-bin", + runuserBin, + "--setpriv-bin", + setprivBin, + "--service-user", + process.env["USER"] ?? "test-user", + "--base-dir", + baseDir, + "--hub-dir", + hubDir, + "--service-home", + join(persistentDir, "home"), + "--state-dir", + join(persistentDir, "state"), + "--cache-dir", + join(persistentDir, "cache"), + "--workspace-root", + options.workspaceRoot, + "--bwrap-bin", + bwrapBin, + "--pg-isready-bin", + pgIsReadyBin, + ], + }; + } +}); + +async function executable(path: string, contents: string): Promise { + await writeFile(path, contents); + await chmod(path, 0o755); +} + +async function runCli(args: string[]): Promise<{ exitCode: number; stdout: string; stderr: string }> { + try { + const result = await execFileAsync(resolve("node_modules/.bin/tsx"), [resolve("src/deployment/preflight-cli.ts"), ...args]); + return { exitCode: 0, stdout: result.stdout, stderr: result.stderr }; + } catch (error) { + const failure = error as { code?: number; stdout?: string; stderr?: string }; + return { exitCode: failure.code ?? 1, stdout: failure.stdout ?? "", stderr: failure.stderr ?? "" }; + } +} diff --git a/hub/test/integration/server-start.test.ts b/hub/test/integration/server-start.test.ts new file mode 100644 index 0000000..0c77cad --- /dev/null +++ b/hub/test/integration/server-start.test.ts @@ -0,0 +1,61 @@ +import { createServer } from "node:net"; +import { once } from "node:events"; +import { afterEach, describe, expect, it } from "vitest"; +import { startHub } from "../../src/hub.js"; +import { TEST_DATABASE_URL } from "./helpers.js"; + +const ENV_KEYS = [ + "DATABASE_URL", + "ANTHROPIC_AUTH_TOKEN", + "ANTHROPIC_API_KEY", + "FEISHU_APP_ID", + "FEISHU_APP_SECRET", + "FEISHU_BOT_OPEN_ID", + "HUB_PROJECT_WORKSPACE_ROOT", + "HUB_SESSION_SECRET", + "HUB_PUBLIC_BASE_URL", + "HUB_FEISHU_LISTENER_ENABLED", + "HOST", + "PORT", +] as const; + +describe("Hub startup", () => { + const previousEnv = new Map(ENV_KEYS.map((key) => [key, process.env[key]])); + + afterEach(() => { + for (const [key, value] of previousEnv) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + }); + + it("rejects startup before the Feishu listener when the HTTP bind fails", async () => { + const blocker = createServer(); + blocker.listen(0, "127.0.0.1"); + await once(blocker, "listening"); + const address = blocker.address(); + if (address === null || typeof address === "string") throw new Error("expected an INET test listener"); + + Object.assign(process.env, { + DATABASE_URL: TEST_DATABASE_URL, + ANTHROPIC_AUTH_TOKEN: "test-provider-token", + ANTHROPIC_API_KEY: "", + FEISHU_APP_ID: "test-app", + FEISHU_APP_SECRET: "test-secret", + FEISHU_BOT_OPEN_ID: "ou_test_bot", + HUB_PROJECT_WORKSPACE_ROOT: "/tmp/cph-hub-server-start-test", + HUB_SESSION_SECRET: "integration-session-secret-with-32-bytes", + HUB_PUBLIC_BASE_URL: "https://hub.example.test", + HUB_FEISHU_LISTENER_ENABLED: "false", + HOST: "127.0.0.1", + PORT: String(address.port), + }); + + try { + await expect(startHub()).rejects.toMatchObject({ code: "EADDRINUSE" }); + } finally { + blocker.close(); + await once(blocker, "close"); + } + }); +}); diff --git a/hub/test/unit/deployment-preflight.test.ts b/hub/test/unit/deployment-preflight.test.ts new file mode 100644 index 0000000..4a0aef6 --- /dev/null +++ b/hub/test/unit/deployment-preflight.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "vitest"; +import { + DeploymentPreflightError, + validateDeploymentPreflight, + type DeploymentPreflightInput, +} from "../../src/deployment/preflight.js"; + +const VALID_ENV = { + NODE_ENV: "production", + DATABASE_URL: "postgresql://hub:secret@127.0.0.1:5432/hub", + ANTHROPIC_BASE_URL: "https://openrouter.ai/api", + ANTHROPIC_AUTH_TOKEN: "provider-token", + ANTHROPIC_API_KEY: "", + FEISHU_APP_ID: "cli_app_id", + FEISHU_APP_SECRET: "feishu-app-secret", + FEISHU_BOT_OPEN_ID: "ou_bot", + CPH_BIN: "/srv/curriculum-project-hub/current/bin/cph", + HOST: "127.0.0.1", + PORT: "8788", + HUB_PROJECT_WORKSPACE_ROOT: "/var/lib/cph-hub/workspaces", + HUB_PUBLIC_BASE_URL: "https://hub.example.com", + HUB_SESSION_SECRET: "a-production-session-secret-with-32-bytes", +} as const; + +function input(overrides: Partial = {}): DeploymentPreflightInput { + return { + baseDir: "/srv/curriculum-project-hub", + hubDir: "/srv/curriculum-project-hub/current/hub", + serviceHome: "/var/lib/cph-hub/home", + stateDir: "/var/lib/cph-hub/state", + cacheDir: "/var/cache/cph-hub", + workspaceRoot: "/var/lib/cph-hub/workspaces", + env: VALID_ENV, + ...overrides, + }; +} + +describe("validateDeploymentPreflight", () => { + it("accepts a complete production configuration with persistent storage outside the deployment tree", () => { + expect(validateDeploymentPreflight(input())).toEqual({ + bind: { host: "127.0.0.1", port: 8788 }, + databaseUrl: "postgresql://hub:secret@127.0.0.1:5432/hub", + cphBin: "/srv/curriculum-project-hub/current/bin/cph", + }); + }); + + it.each([ + ["inside the live Hub", "/srv/curriculum-project-hub/current/hub/data/workspaces"], + ["inside future releases", "/srv/curriculum-project-hub/releases/workspaces"], + ["an ancestor of the deployment root", "/srv"], + ])("rejects a workspace %s", (_description, workspaceRoot) => { + expect(() => + validateDeploymentPreflight( + input({ + workspaceRoot, + env: { ...VALID_ENV, HUB_PROJECT_WORKSPACE_ROOT: workspaceRoot }, + }), + ), + ).toThrow("must not overlap deployment root"); + }); + + it("rejects a runtime workspace that differs from the provisioned path", () => { + expect(() => + validateDeploymentPreflight( + input({ env: { ...VALID_ENV, HUB_PROJECT_WORKSPACE_ROOT: "/var/lib/cph-hub/other" } }), + ), + ).toThrow("HUB_PROJECT_WORKSPACE_ROOT must equal provisioned workspace root"); + }); + + it("rejects a relative runtime workspace even if it resolves to the provisioned path", () => { + expect(() => + validateDeploymentPreflight( + input({ env: { ...VALID_ENV, HUB_PROJECT_WORKSPACE_ROOT: "./workspaces" } }), + ), + ).toThrow("HUB_PROJECT_WORKSPACE_ROOT must be an absolute normalized path"); + }); + + it("reports all invalid required production values in one failed preflight", () => { + expect.assertions(2); + try { + validateDeploymentPreflight( + input({ + env: { + ...VALID_ENV, + ANTHROPIC_AUTH_TOKEN: "", + FEISHU_APP_SECRET: "", + HUB_PUBLIC_BASE_URL: "http://hub.example.com", + HUB_SESSION_SECRET: "short", + }, + }), + ); + } catch (error) { + expect(error).toBeInstanceOf(DeploymentPreflightError); + expect((error as Error).message).toMatchInlineSnapshot(` + "deployment preflight failed: + - ANTHROPIC_AUTH_TOKEN is required + - FEISHU_APP_SECRET is required + - HUB_PUBLIC_BASE_URL must use https + - HUB_SESSION_SECRET must contain at least 32 characters" + `); + } + }); + + it("rejects relative operational paths", () => { + expect(() => validateDeploymentPreflight(input({ cacheDir: "./cache" }))).toThrow( + "cache directory must be an absolute normalized path", + ); + }); + + it("rejects a non-empty ANTHROPIC_API_KEY that would conflict with auth-token mode", () => { + expect(() => + validateDeploymentPreflight( + input({ env: { ...VALID_ENV, ANTHROPIC_API_KEY: "conflicting-api-key" } }), + ), + ).toThrow("ANTHROPIC_API_KEY must be present and empty"); + }); +}); diff --git a/hub/test/unit/server-config.test.ts b/hub/test/unit/server-config.test.ts new file mode 100644 index 0000000..c2baed7 --- /dev/null +++ b/hub/test/unit/server-config.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { readServerBinding } from "../../src/settings/server.js"; + +describe("readServerBinding", () => { + it("honors the configured bind host and port", () => { + expect(readServerBinding({ HOST: "127.0.0.2", PORT: "9100" })).toEqual({ + host: "127.0.0.2", + port: 9100, + }); + }); + + it.each(["0", "65536", "1.5", "not-a-port"])('rejects invalid PORT="%s"', (port) => { + expect(() => readServerBinding({ HOST: "127.0.0.1", PORT: port })).toThrow( + "PORT must be an integer between 1 and 65535", + ); + }); + + it("rejects a bind URL in place of a host", () => { + expect(() => readServerBinding({ HOST: "https://127.0.0.1", PORT: "8788" })).toThrow( + "HOST must be a hostname or IP address", + ); + }); +});