forked from bai/curriculum-project-hub
feat: provision non-root Hub service
This commit is contained in:
@@ -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=$!
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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/` 与实现部件**物理分离、平级共存**:谁是上游、谁向谁对齐,一眼可见。
|
||||
|
||||
+14
-6
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+215
-33
@@ -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" <<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=postgresql://paradigm:change-me@127.0.0.1:5432/paradigm
|
||||
# Claude Code SDK auth via OpenRouter's Anthropic Skin (ADR-0017).
|
||||
# ANTHROPIC_AUTH_TOKEN = your OpenRouter API key; ANTHROPIC_API_KEY must be empty.
|
||||
DATABASE_URL=
|
||||
ANTHROPIC_BASE_URL=https://openrouter.ai/api
|
||||
ANTHROPIC_AUTH_TOKEN=
|
||||
ANTHROPIC_API_KEY=
|
||||
FEISHU_APP_ID=
|
||||
FEISHU_APP_SECRET=
|
||||
FEISHU_BOT_OPEN_ID=
|
||||
PORT=$PORT
|
||||
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_FEISHU_LISTENER_ENABLED=true
|
||||
CPH_SANDBOX_EXTRA_DENY_READ=$ENV_FILE
|
||||
EOF
|
||||
chmod 0600 "$ENV_FILE"
|
||||
echo "Seeded $ENV_FILE — edit it to fill in ANTHROPIC_AUTH_TOKEN and Feishu creds, then re-run."
|
||||
exit 0
|
||||
echo "[install] seeded complete production configuration: $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"
|
||||
|
||||
@@ -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<void> {
|
||||
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<DeploymentPreflightInput> {
|
||||
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<string> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string, string>();
|
||||
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<string, string>, 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<string, string>, 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;
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env node
|
||||
import "dotenv/config";
|
||||
import "../hub.js";
|
||||
import { prisma } from "../db.js";
|
||||
|
||||
async function main(): Promise<void> {
|
||||
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;
|
||||
});
|
||||
@@ -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<void> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
+4
-88
@@ -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<void> {
|
||||
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;
|
||||
});
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { isIP } from "node:net";
|
||||
|
||||
export type ServerEnv = Readonly<Record<string, string | undefined>>;
|
||||
|
||||
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 };
|
||||
}
|
||||
@@ -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<void> {
|
||||
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 ?? "" };
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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> = {}): 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");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user