openvibers/openvibe.live Commits
deploy/scripts/deploy.sh hands over to ovhost deploy live (OpenVibe.Host strategy release-layout; roadmap WS-N task 11). The old script is kept, unchanged, as deploy/scripts/deploy-legacy.sh. The wrapper maps --wait-idle, --restart, --rollback (ovhost rollback live), DRY_RUN=1 (ovhost plan live) and READY_TIMEOUT, and refuses --force, which meant 'discard local tracked changes' here but drops live streams in ovhost. It asks ovhost capabilities live first and runs deploy-legacy.sh with the same arguments, saying why, when ovhost is missing, too old (no capabilities, deploy-api < 1) or the host inventory does not deploy live with release-layout (OVHOST_LEGACY=1 forces it), so a deploy never breaks while the host is cut over. test/deploy-wrapper.test.js covers the mapping and the fallback; test/deploy-sim.test.js now runs deploy-legacy.sh; test/systemd-units.test.js keeps the listener check and the changed-socket restart assertions on deploy-legacy.sh and checks the wrapper only hands over to release-layout, whose socket rule OpenVibe.Host tests. docs/deploy.md, deploy/README.md, README.md.
Changes
@@@ -118,7 +118,7 @@ Minimum `.env`: `BASE_URL`, `JWT_SECRET`, `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL## Deployment- Production path `/opt/openvibe.live`, env file `/etc/openvibe/live.env` (0600), unit `openvibe-live.service` (see `deploy/systemd/`).--- `deploy/scripts/deploy.sh` classifies changes: static-only deploys never restart; server changes restart behind the systemd socket with readiness checks and rollback. Details: [docs/deploy.md](docs/deploy.md).++- `deploy/scripts/deploy.sh` runs `ovhost deploy live` (OpenVibe.Host, strategy `release-layout`), with `deploy-legacy.sh` (the previous script) as its fallback: static-only deploys never restart; server changes restart behind the systemd socket with readiness checks and rollback. Details: [docs/deploy.md](docs/deploy.md).- nginx config at `deploy/nginx/openvibe.live.conf` (`openvibe.live`, `www.openvibe.live`, `ingest.openvibe.live`; certs `/etc/letsencrypt/live/openvibe.live/`).---@@@ -2,7 +2,8 @@How deploys work: [../docs/deploy.md](../docs/deploy.md).--- `scripts/deploy.sh` — classifies a change (static / server / dependencies / schema / units) and does the least disruptive thing; `--wait-idle`, `--rollback`, `DRY_RUN=1`++- `scripts/deploy.sh` — hands over to `ovhost deploy live` (OpenVibe.Host, strategy `release-layout`): `--wait-idle`, `--restart`, `--rollback` (`ovhost rollback live`), `DRY_RUN=1` (`ovhost plan live`); falls back to `deploy-legacy.sh` when ovhost is missing, too old or does not manage Live++- `scripts/deploy-legacy.sh` — the previous deploy script, unchanged: classifies a change (static / server / dependencies / schema / units) and does the least disruptive thing- `scripts/migrate-to-releases.sh` — one-time move to the release layout (`releases/`, `current`, `shared/data`)- `scripts/post-deploy-check.sh` — smoke checks on the host- `systemd/openvibe-live.service` + `.socket` — current (git checkout) layout, socket activationnnew file mode 100755@@@ -0,0 +1,478 @@++#!/usr/bin/env bash++# ═══════════════════════════════════════════════════════════════++# OpenVibe.Live — deploy++#++# Fetch, classify what changed, prepare, and do the least disruptive thing that makes it live.++#++# sudo deploy/scripts/deploy.sh deploy origin/main if it moved++# sudo deploy/scripts/deploy.sh --wait-idle hold a restart until nobody is live (max 8h)++# sudo deploy/scripts/deploy.sh --restart restart even if nothing changed++# sudo deploy/scripts/deploy.sh --rollback release layout: switch back to the previous release++# sudo deploy/scripts/deploy.sh --force legacy layout: discard local tracked changes++# DRY_RUN=1 deploy/scripts/deploy.sh print the plan, change nothing++#++# What a change costs (see docs/deploy.md):++# docs/, README etc., tests no process restart; the new files are just in place++# public/ no restart — the server re-reads HTML and assets (content-hashed)++# server/, vendor/, package* restart; HTTP connections queue on the systemd socket, but live++# WebSocket, WHIP, WebRTC and RTMP sessions are dropped and reconnect++# package-lock.json dependencies installed BEFORE anything is interrupted++# deploy/systemd/ units installed, daemon-reload, restart++# deploy/nginx/ never auto-installed (the live config is generated elsewhere);++# the script says so++#++# After a deploy or rollback that went live: `ovhost announce live` (release notification to open tabs;++# best effort, skipped without an ovhost that has `announce`, never changes the exit code).++#++# Two layouts:++# release /opt/openvibe.live/releases/<id> (code + node_modules), current -> releases/<id>,++# shared/data. A deploy builds a new release while the old one serves; switching is an++# atomic symlink rename; rollback switches back to a release that still has its own++# node_modules. Set up once with deploy/scripts/migrate-to-releases.sh.++# legacy /opt/openvibe.live is a git checkout (what production runs until migrated).++#++# Exit codes: 0 success · 1 usage/precondition · 2 validation failed · 3 not ready after restart++# (rolled back) · 4 rollback itself failed — MANUAL INTERVENTION · 5 gave up waiting for idle++# ═══════════════════════════════════════════════════════════════++set -euo pipefail++++FORCE_DEPLOY=false++ALWAYS_RESTART=false++WAIT_IDLE=false++ROLLBACK=false++while [ "$#" -gt 0 ]; do++ case "$1" in++ --force|-f|--ignore-local-changes) FORCE_DEPLOY=true; shift ;;++ --restart) ALWAYS_RESTART=true; shift ;;++ --wait-idle) WAIT_IDLE=true; shift ;;++ --rollback) ROLLBACK=true; shift ;;++ --) shift; break ;;++ *) echo "Usage: $0 [--wait-idle] [--restart] [--rollback] [--force]"; exit 1 ;;++ esac++done++++BASE_DIR="${BASE_DIR:-/opt/openvibe.live}"++SERVICE="${SERVICE:-openvibe-live}"++SYSTEMD_DIR="${SYSTEMD_DIR:-/etc/systemd/system}"++SYSTEMCTL="${SYSTEMCTL:-systemctl}"++SS="${SS:-ss}"++SOCKET_CHANGED=false++# Unit files land in /etc: when systemctl is being run through sudo, so is the install.++SUDO_INSTALL=""; case "$SYSTEMCTL" in sudo*) SUDO_INSTALL="sudo";; esac; [ "$(id -u)" -eq 0 ] && SUDO_INSTALL=""++SITE_URL="${SITE_URL:-https://openvibe.live}"++API_URL="${API_URL:-http://127.0.0.1:3000}"++GIT_REMOTE="${GIT_REMOTE:-origin}"++GIT_BRANCH="${GIT_BRANCH:-main}"++READY_PATH="${READY_PATH:-/api/ready}"++UPDATES_PATH="${UPDATES_PATH:-/updates}"++BROADCAST_ENDPOINT="${BROADCAST_ENDPOINT:-/api/admin/broadcast}"++READY_TIMEOUT="${READY_TIMEOUT:-90}"++IDLE_MAX_SECONDS="${IDLE_MAX_SECONDS:-28800}"++KEEP_RELEASES="${KEEP_RELEASES:-5}"++NPM="${NPM:-npm}"++DRY_RUN="${DRY_RUN:-0}"++# Not /tmp: fs.protected_regular stops root from reopening a lock a plain user created there.++LOCK_FILE="${LOCK_FILE:-$BASE_DIR/.deploy.lock}"++++say() { echo "[Deploy] $*"; }++die() { echo "[Deploy] ✗ $1" >&2; exit "${2:-1}"; }++run() { if [ "$DRY_RUN" = "1" ]; then echo " would run: $*"; else "$@"; fi; }++++exec 9>"$LOCK_FILE"++flock -n 9 || die "another deploy is already running (lock: $LOCK_FILE)"++++if [ -d "$BASE_DIR/releases" ] && [ -L "$BASE_DIR/current" ]; then LAYOUT=release; else LAYOUT=legacy; fi++++echo "╔══════════════════════════════════════╗"++echo "║ OpenVibe.Live — deploy ║"++echo "╚══════════════════════════════════════╝"++say "base=$BASE_DIR layout=$LAYOUT service=$SERVICE branch=$GIT_BRANCH dry_run=$DRY_RUN"++++# ── Classification ───────────────────────────────────────────────────────────++# Prints one word per category that the changed file list touches.++classify() {++ local files="$1" cats=""++ add() { case " $cats " in *" $1 "*) ;; *) cats="$cats $1" ;; esac; }++ while IFS= read -r f; do++ [ -n "$f" ] || continue++ case "$f" in++ package.json|package-lock.json) add deps; add server ;;++ server/db/migrations.js|server/db/schema.sql) add schema; add server ;;++ server/*|vendor/*|.env.example) add server ;;++ public/*) add static ;;++ deploy/systemd/*) add systemd ;;++ deploy/nginx/*) add nginx ;;++ docs/*) add static ;;++ *) add none ;;++ esac++ done <<< "$files"++ echo "$cats"++}++has() { case " $1 " in *" $2 "*) return 0 ;; *) return 1 ;; esac; }++++# Whether package.json changed in a way that needs an install (scripts/description edits do not).++deps_changed() {++ local from="$1" to="$2" dir="$3"++ git -C "$dir" diff --quiet "$from" "$to" -- package-lock.json || return 0++ local a b++ a=$(git -C "$dir" show "$from:package.json" 2>/dev/null | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{try{const j=JSON.parse(d);console.log(JSON.stringify([j.dependencies,j.optionalDependencies,j.engines]))}catch{console.log("x")}})')++ b=$(git -C "$dir" show "$to:package.json" 2>/dev/null | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{try{const j=JSON.parse(d);console.log(JSON.stringify([j.dependencies,j.optionalDependencies,j.engines]))}catch{console.log("y")}})')++ [ "$a" != "$b" ]++}++++# ── Live-stream guard ────────────────────────────────────────────────────────++# A restart drops every RTMP/WHIP/WebRTC publisher. Socket activation keeps HTTP accepting, nothing more.++live_count() {++ curl -sf --max-time 5 "${API_URL}/api/streams" 2>/dev/null \++ | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{try{const j=JSON.parse(d);console.log((j.streams||[]).filter(s=>s.is_live).length)}catch{console.log(0)}})' \++ || echo 0++}++wait_for_idle() {++ [ "$WAIT_IDLE" = true ] || return 0++ local waited=0 quiet=0++ while [ "$waited" -lt "$IDLE_MAX_SECONDS" ]; do++ local n; n=$(live_count)++ if [ "$n" = "0" ]; then quiet=$((quiet+1)); else quiet=0; fi++ [ "$quiet" -ge 2 ] && { say "no one live for two checks — proceeding"; return 0; }++ say "$n live stream(s); holding the restart (${waited}s waited)"++ [ "$DRY_RUN" = "1" ] && return 0++ sleep 60; waited=$((waited+60))++ done++ die "still live after ${IDLE_MAX_SECONDS}s; nothing was restarted" 5++}++++wait_ready() {++ local deadline=$(( $(date +%s) + READY_TIMEOUT ))++ while [ "$(date +%s)" -lt "$deadline" ]; do++ if curl -sf --max-time 5 "${API_URL}${READY_PATH}" >/dev/null 2>&1; then return 0; fi++ $SYSTEMCTL is-active --quiet "$SERVICE" || { say "service is not running"; return 1; }++ sleep 1; printf '.'++ done++ printf '\n'; return 1++}++++notify_chat() {++ local summary="$1"++ curl -sf -X POST "${API_URL}${BROADCAST_ENDPOINT}" \++ -H "Content-Type: application/json" -H "Authorization: Bearer ${ADMIN_TOKEN:-}" \++ -d "{\"type\":\"update\",\"summary\":$(printf '%s' "$summary" | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>console.log(JSON.stringify(d.trim())))'),\"url\":\"${SITE_URL}${UPDATES_PATH}\"}" \++ >/dev/null 2>&1 && say "chat notified" || say "chat notification skipped"++}++++install_units() {++ local unit_dir="$1" changed=false++ install_unit() {++ local src="$1" dest="$2"++ [ -f "$src" ] || return 0++ if [ ! -f "$dest" ] || ! cmp -s "$src" "$dest"; then++ say "installing $(basename "$dest")"++ run $SUDO_INSTALL install -m 0644 -D "$src" "$dest"++ changed=true++ fi++ }++ install_unit "$unit_dir/${SERVICE}.service" "$SYSTEMD_DIR/${SERVICE}.service"++ local socket_before=false++ [ -f "$SYSTEMD_DIR/${SERVICE}.socket" ] && [ -f "$unit_dir/${SERVICE}.socket" ] && ! cmp -s "$unit_dir/${SERVICE}.socket" "$SYSTEMD_DIR/${SERVICE}.socket" && socket_before=true++ install_unit "$unit_dir/${SERVICE}.socket" "$SYSTEMD_DIR/${SERVICE}.socket"++ # A socket unit changed under a running socket: after daemon-reload systemd may drop its descriptor++ # ("no socket file descriptors are open ... not functional until restarted"); restart_and_verify then++ # restarts the socket itself. Left alone, the service binds the port on its own and every later++ # restart refuses connections (what happened from 2026-09-25 to 2026-09-26).++ [ "$socket_before" = true ] && SOCKET_CHANGED=true++ install_unit "$unit_dir/${SERVICE}.service.d/socket.conf" "$SYSTEMD_DIR/${SERVICE}.service.d/socket.conf"++ if [ "$changed" = true ]; then++ run $SYSTEMCTL daemon-reload++ run $SYSTEMCTL enable "${SERVICE}.socket" >/dev/null 2>&1 || true++ run $SYSTEMCTL enable "$SERVICE" >/dev/null 2>&1 || true++ fi++}++++syntax_check() {++ local dir="$1" files="$2" checked=0++ while IFS= read -r f; do++ case "$f" in++ *.js) [ -f "$dir/$f" ] || continue++ node --check "$dir/$f" >/dev/null 2>&1 || die "syntax error in $f" 2++ checked=$((checked+1)) ;;++ *.json) [ -f "$dir/$f" ] || continue++ node -e 'JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"))' "$dir/$f" 2>/dev/null || die "invalid JSON in $f" 2 ;;++ esac++ done <<< "$files"++ say "syntax-checked $checked changed JS file(s)"++}++++backup_db() {++ # A schema migration is about to run on restart: take a consistent online copy first.++ local db="$1"++ [ -f "$db" ] || { say "no database at $db to back up"; return 0; }++ local out; out="$(dirname "$db")/backups/live-$(date +%Y%m%d-%H%M%S).db"++ run mkdir -p "$(dirname "$out")"++ say "schema changed — backing up $(basename "$db") to $out"++ if [ "$DRY_RUN" != "1" ]; then++ (cd "$2" && node -e 'const D=require("better-sqlite3");new D(process.argv[1],{readonly:true}).backup(process.argv[2]).then(()=>process.exit(0),e=>{console.error(e.message);process.exit(1)})' "$db" "$out") \++ || die "database backup failed; nothing was restarted" 2++ fi++}++++# Does systemd (pid 1) hold the socket unit's listener? `is-active` is not enough: a socket unit whose++# descriptor was dropped still reads active while the service binds the port itself.++socket_held() {++ local listen port++ listen=$($SYSTEMCTL show "${SERVICE}.socket" -p Listen --value 2>/dev/null | head -1 | awk '{print $1}')++ port=${listen##*:}++ [ -n "$port" ] || return 1++ $SS -Hltnp "sport = :$port" 2>/dev/null | grep -q '"systemd",pid=1,'++}++++restart_and_verify() {++ local on_fail="$1" socketed=false++ if $SYSTEMCTL list-unit-files "${SERVICE}.socket" >/dev/null 2>&1 && $SYSTEMCTL is-enabled --quiet "${SERVICE}.socket" 2>/dev/null; then++ socketed=true++ $SYSTEMCTL is-active --quiet "${SERVICE}.socket" || $SYSTEMCTL start "${SERVICE}.socket" || true++ fi++ local start; start=$(date +%s)++ if [ "$socketed" = true ] && { [ "$SOCKET_CHANGED" = true ] || ! socket_held; }; then++ # The socket must be rebound: stop the service (it may hold the port itself), restart the++ # socket, start the service on it. HTTP is refused for that moment, once.++ say "socket unit $([ "$SOCKET_CHANGED" = true ] && echo changed || echo 'is not holding its listener') — stopping ${SERVICE}, restarting the socket"++ $SYSTEMCTL stop "$SERVICE" 9>&- || true++ $SYSTEMCTL restart "${SERVICE}.socket" || say "✗ could not restart ${SERVICE}.socket"++ $SYSTEMCTL start "$SERVICE" 9>&-++ SOCKET_CHANGED=false++ else++ [ "$socketed" = true ] && say "socket unit holds the listener — new HTTP connections queue through the restart"++ say "restarting ${SERVICE}…"++ $SYSTEMCTL restart "$SERVICE" 9>&-++ fi++ printf '[Deploy] waiting for readiness'++ if wait_ready; then++ printf '\n'; say "ready after $(( $(date +%s) - start ))s"++ if [ "$socketed" = true ] && ! socket_held; then++ say "✗ socket activation is not in effect: systemd does not hold the listener, so restarts refuse connections (check journalctl -u ${SERVICE}.socket)"++ fi++ return 0++ fi++ say "✗ not ready within ${READY_TIMEOUT}s"++ journalctl -u "$SERVICE" -n 40 --no-pager 2>/dev/null | sed 's/^/ /' || true++ $on_fail++}++++# ── Release notification (WS-P task 9) ───────────────────────────────────────++# OpenVibe.Host publishes host.release.published to OpenVibe.Events, so open tabs check /release.json now++# instead of at their next poll. ovhost announces the release Live's own /release.json reports, once per++# release. Best effort: skipped when ovhost is missing or has no `announce`, 20 s at most, and it never++# fails a deploy (see OpenVibe.Host docs/release-notifications.md).++OVHOST="${OVHOST:-ovhost}"++announce_release() {++ [ "$DRY_RUN" = "1" ] && return 0++ command -v "$OVHOST" >/dev/null 2>&1 || return 0++ local help++ help=$("$OVHOST" --help 2>/dev/null || true)++ case "$help" in *"announce <service>"*) ;; *) say "release notification skipped: this ovhost has no announce"; return 0 ;; esac++ timeout 20 "$OVHOST" announce live 9>&- 2>&1 | sed 's/^/ /' || say "release notification not sent (the deploy stands)"++}++++# ═══════════════════════════════════════════════════════════════++# Release layout++# ═══════════════════════════════════════════════════════════════++if [ "$LAYOUT" = release ]; then++ REPO="$BASE_DIR/repo"++ CURRENT=$(readlink -f "$BASE_DIR/current")++ CUR_ID=$(basename "$CURRENT")++ CUR_SHA=$(git -C "$CURRENT" rev-parse HEAD 2>/dev/null || echo none)++ say "current release: $CUR_ID (${CUR_SHA:0:8})"++++ switch_to() {++ # Atomic: create the new link beside the old one and rename over it.++ local target="$1"++ run ln -sfn "$target" "$BASE_DIR/.current.tmp"++ run mv -Tf "$BASE_DIR/.current.tmp" "$BASE_DIR/current"++ }++++ if [ "$ROLLBACK" = true ]; then++ PREV=$(ls -1dt "$BASE_DIR"/releases/*/ 2>/dev/null | sed 's:/$::' | grep -vx "$CURRENT" | head -1 || true)++ [ -n "$PREV" ] || die "no previous release to roll back to"++ PREV_SHA=$(git -C "$PREV" rev-parse HEAD 2>/dev/null || echo none)++ CATS=$(classify "$(git -C "$REPO" diff --name-only "$PREV_SHA" "$CUR_SHA" 2>/dev/null || echo server/)")++ say "rolling back to $(basename "$PREV") (${PREV_SHA:0:8}); change classes:${CATS}"++ [ "$DRY_RUN" = "1" ] && { say "dry run complete — nothing was changed."; exit 0; }++ switch_to "$PREV"++ if has "$CATS" server || has "$CATS" systemd || [ "$ALWAYS_RESTART" = true ]; then++ install_units "$PREV/deploy/systemd/release"++ wait_for_idle++ restart_and_verify 'die "ROLLBACK RELEASE IS NOT READY — MANUAL INTERVENTION REQUIRED" 4'++ else++ say "static-only difference — switched without a restart"++ fi++ # Keep the release we left so it can be re-selected; just move it out of "newest".++ touch -h "$PREV"++ say "rolled back to $(basename "$PREV") ✅"++ announce_release++ exit 0++ fi++++ say "fetching ${GIT_REMOTE}/${GIT_BRANCH}…"++ run git -C "$REPO" fetch --quiet "$GIT_REMOTE" "$GIT_BRANCH"++ TARGET_SHA=$(git -C "$REPO" rev-parse "${GIT_REMOTE}/${GIT_BRANCH}")++ if [ "$TARGET_SHA" = "$CUR_SHA" ] && [ "$ALWAYS_RESTART" != true ]; then++ say "already at ${TARGET_SHA:0:8}; nothing to deploy."++ exit 0++ fi++++ CHANGED=$(git -C "$REPO" diff --name-only "$CUR_SHA" "$TARGET_SHA" 2>/dev/null || true)++ CATS=$(classify "$CHANGED")++ [ "$TARGET_SHA" = "$CUR_SHA" ] && CATS=" server"++ COMMIT_LOG=$(git -C "$REPO" --no-pager log --oneline "${CUR_SHA}..${TARGET_SHA}" 2>/dev/null || echo "Restart requested")++ COMMIT_COUNT=$(printf '%s\n' "$COMMIT_LOG" | grep -c . || true)++ say "$COMMIT_COUNT new commit(s); change classes:${CATS:- none}"++ printf '%s\n' "$COMMIT_LOG" | sed 's/^/ /'++++ NEW_ID="$(date +%Y%m%d-%H%M%S)-${TARGET_SHA:0:8}"++ NEW="$BASE_DIR/releases/$NEW_ID"++ say "preparing release $NEW_ID while $CUR_ID keeps serving…"++ run git -C "$REPO" worktree add --detach "$NEW" "$TARGET_SHA" >/dev/null++ [ "$DRY_RUN" = "1" ] || syntax_check "$NEW" "$CHANGED"++ run ln -sfn ../../shared/data "$NEW/data"++++ if [ "$DRY_RUN" != "1" ] && deps_changed "$CUR_SHA" "$TARGET_SHA" "$REPO"; then++ say "dependencies changed — installing into the new release (nothing interrupted yet)…"++ (cd "$NEW" && $NPM ci --omit=dev --no-audit --no-fund) || { git -C "$REPO" worktree remove --force "$NEW" || true; die "npm ci failed; the current release is untouched" 2; }++ else++ # Same lockfile: hard-link the current release's node_modules — instant, no extra disk, and the++ # new release still owns its own tree (npm never writes into an installed package in place).++ say "dependencies unchanged — linking node_modules from $CUR_ID"++ run cp -al "$CURRENT/node_modules" "$NEW/node_modules"++ fi++++ if has "$CATS" nginx; then++ say "note: deploy/nginx changed and is NOT installed by this script — copy it to /etc/nginx/sites-available,"++ say " run nginx -t and reload (OpenVibe.Network's generator mirrors it)."++ fi++++ [ "$DRY_RUN" = "1" ] && { say "dry run complete — nothing was changed."; exit 0; }++++ NEEDS_RESTART=false++ if has "$CATS" server || has "$CATS" systemd || [ "$ALWAYS_RESTART" = true ]; then NEEDS_RESTART=true; fi++++ if [ "$NEEDS_RESTART" = true ]; then++ wait_for_idle++ has "$CATS" schema && backup_db "$BASE_DIR/shared/data/live.db" "$NEW"++ FIRST_LINE=$(printf '%s\n' "$COMMIT_LOG" | head -1 | sed 's/^[a-f0-9]* //')++ notify_chat "$([ "$COMMIT_COUNT" -le 1 ] && echo "Update: ${FIRST_LINE}" || echo "${COMMIT_COUNT} updates deployed — ${FIRST_LINE}")"++ switch_to "$NEW"++ install_units "$NEW/deploy/systemd/release"++ rollback_release() {++ say "rolling back to $CUR_ID"++ switch_to "$CURRENT"++ install_units "$CURRENT/deploy/systemd/release"++ $SYSTEMCTL restart "$SERVICE" 9>&- || die "ROLLBACK FAILED: service would not restart" 4++ printf '[Deploy] waiting for rollback'++ if wait_ready; then printf '\n'; say "rolled back to $CUR_ID and serving"; exit 3; fi++ die "ROLLBACK FAILED: $CUR_ID is not ready either — MANUAL INTERVENTION REQUIRED" 4++ }++ restart_and_verify rollback_release++ say "note: established WebSocket, WHIP, RTMP and WebRTC sessions were reconnected, not preserved."++ else++ switch_to "$NEW"++ # The running process reads public/ and docs/ through the `current` link (OV_APP_ROOT), and++ # re-hashes assets within ~2s. Confirm the site answers; no process was restarted.++ sleep 3++ curl -sf --max-time 10 "${API_URL}${READY_PATH}" >/dev/null || { switch_to "$CURRENT"; die "site not ready after switching — switched back to $CUR_ID" 3; }++ say "switched to $NEW_ID without a restart (change classes:${CATS})"++ fi++++ # Prune old releases (never the current one, never the one we just left).++ ls -1dt "$BASE_DIR"/releases/*/ 2>/dev/null | sed 's:/$::' | tail -n +$((KEEP_RELEASES+1)) | while read -r old; do++ [ "$old" = "$(readlink -f "$BASE_DIR/current")" ] && continue++ [ "$old" = "$CURRENT" ] && continue++ say "pruning $(basename "$old")"++ git -C "$REPO" worktree remove --force "$old" 2>/dev/null || rm -rf "$old"++ done++ git -C "$REPO" worktree prune 2>/dev/null || true++ say "deployed $CUR_ID → $NEW_ID ✅"++ announce_release++ exit 0++fi++++# ═══════════════════════════════════════════════════════════════++# Legacy layout (git checkout in place)++# ═══════════════════════════════════════════════════════════════++[ "$ROLLBACK" = true ] && die "--rollback needs the release layout (deploy/scripts/migrate-to-releases.sh)"++REPO_DIR="$BASE_DIR"++cd "$REPO_DIR" || die "repo not found at $REPO_DIR"++OLD_HASH=$(git rev-parse HEAD 2>/dev/null || echo none)++say "current commit: ${OLD_HASH:0:8}"++++if [ "$FORCE_DEPLOY" = true ]; then++ say "force: discarding local tracked changes (untracked files are kept)"++ run git reset --hard HEAD++elif ! git diff --quiet || ! git diff --cached --quiet; then++ die "working tree has uncommitted changes — commit them, or re-run with --force"++fi++++say "fetching ${GIT_REMOTE}/${GIT_BRANCH}…"++run git fetch --quiet "$GIT_REMOTE" "$GIT_BRANCH"++TARGET_HASH=$(git rev-parse "${GIT_REMOTE}/${GIT_BRANCH}" 2>/dev/null || echo "$OLD_HASH")++if [ "$OLD_HASH" = "$TARGET_HASH" ] && [ "$ALWAYS_RESTART" != true ]; then++ say "already at ${TARGET_HASH:0:8}; nothing to deploy."++ exit 0++fi++++CHANGED=""; CATS=" server"++if [ "$OLD_HASH" != "$TARGET_HASH" ]; then++ CHANGED=$(git diff --name-only "$OLD_HASH" "$TARGET_HASH" || true)++ CATS=$(classify "$CHANGED")++ COMMIT_LOG=$(git --no-pager log --oneline "${OLD_HASH}..${TARGET_HASH}" 2>/dev/null || echo "Update deployed")++ COMMIT_COUNT=$(printf '%s\n' "$COMMIT_LOG" | grep -c . || true)++ say "$COMMIT_COUNT new commit(s); change classes:${CATS:- none}"++ printf '%s\n' "$COMMIT_LOG" | sed 's/^/ /'++else++ COMMIT_LOG="Restart requested"; COMMIT_COUNT=0++fi++++NEEDS_RESTART=false++if has "$CATS" server || has "$CATS" systemd || [ "$ALWAYS_RESTART" = true ]; then NEEDS_RESTART=true; fi++++# In place, the old process keeps running against files that are about to change underneath it, so++# anything that needs a restart waits for idle BEFORE the checkout moves.++[ "$NEEDS_RESTART" = true ] && wait_for_idle++++say "validating the target revision…"++run git merge --ff-only "$TARGET_HASH" >/dev/null 2>&1 || { [ "$DRY_RUN" = "1" ] || die "fast-forward to ${TARGET_HASH:0:8} failed — diverged history" 2; }++NEW_HASH=$(git rev-parse HEAD)++[ -n "$CHANGED" ] && [ "$DRY_RUN" != "1" ] && syntax_check "$REPO_DIR" "$CHANGED"++++if [ "$DRY_RUN" != "1" ] && [ "$OLD_HASH" != "$TARGET_HASH" ] && deps_changed "$OLD_HASH" "$TARGET_HASH" "$REPO_DIR"; then++ say "dependencies changed — installing (service still running)…"++ say "warning: legacy layout — a rollback would NOT restore the previous node_modules. Migrate to releases."++ run $NPM ci --omit=dev --no-audit --no-fund || die "npm ci failed" 2++fi++has "$CATS" nginx && say "note: deploy/nginx changed — not installed here; copy it, nginx -t, reload (see docs/deploy.md)."++++if [ "$DRY_RUN" = "1" ]; then say "dry run complete — nothing was changed. restart needed: $NEEDS_RESTART"; exit 0; fi++++if [ "$NEEDS_RESTART" != true ]; then++ say "change classes:${CATS} — no restart needed; the server re-reads public/ and docs/ from disk."++ say "deployed ${OLD_HASH:0:8} → ${NEW_HASH:0:8} without interrupting anyone ✅"++ announce_release++ exit 0++fi++++install_units "$REPO_DIR/deploy/systemd"++has "$CATS" schema && backup_db "$REPO_DIR/data/live.db" "$REPO_DIR"++FIRST_LINE=$(printf '%s\n' "$COMMIT_LOG" | head -1 | sed 's/^[a-f0-9]* //')++notify_chat "$([ "$COMMIT_COUNT" -le 1 ] && echo "Update: ${FIRST_LINE}" || echo "${COMMIT_COUNT} updates deployed — ${FIRST_LINE}")"++rollback_legacy() {++ say "rolling back to ${OLD_HASH:0:8}"++ git reset --hard "$OLD_HASH" >/dev/null 2>&1 || die "ROLLBACK FAILED: could not reset to $OLD_HASH" 4++ $SYSTEMCTL restart "$SERVICE" 9>&- || die "ROLLBACK FAILED: service would not restart" 4++ printf '[Deploy] waiting for rollback'++ if wait_ready; then printf '\n'; say "rolled back to ${OLD_HASH:0:8} and serving"; exit 3; fi++ die "ROLLBACK FAILED: ${OLD_HASH:0:8} is not ready either — MANUAL INTERVENTION REQUIRED" 4++}++restart_and_verify rollback_legacy++curl -sf --max-time 5 "${API_URL}/api/health" >/dev/null || die "health endpoint not responding after readiness" 3++say "deployed ${OLD_HASH:0:8} → ${NEW_HASH:0:8} (${COMMIT_COUNT} commit(s))"++say "note: established WebSocket, WHIP, RTMP and WebRTC sessions were reconnected, not preserved."++say "done ✅"++announce_release@@@ -1,478 +1,92 @@#!/usr/bin/env bash# ═══════════════════════════════════════════════════════════════--# OpenVibe.Live — deploy++# OpenVibe.Live — deploy (a thin wrapper around `ovhost deploy live`)#--# Fetch, classify what changed, prepare, and do the least disruptive thing that makes it live.++# OpenVibe.Host's ovhost (/usr/local/bin/ovhost, strategy "release-layout", roadmap WS-N task 11) now++# does what this script did: a new release is prepared in releases/<time>-<sha8> while `current` serves++# (npm ci only when dependencies changed, else node_modules hard-linked; node --check on changed files),++# `current` switches atomically, public/ and docs/ switch without a restart, the socket unit stays held by++# systemd (rebound only when its unit file changed or pid 1 does not hold :3000), readiness gates the++# restart, a release that is not ready is switched back (exit 3), old releases are pruned, and the release++# is announced. See OpenVibe.Host docs/deploy-strategies.md.#--# sudo deploy/scripts/deploy.sh deploy origin/main if it moved--# sudo deploy/scripts/deploy.sh --wait-idle hold a restart until nobody is live (max 8h)--# sudo deploy/scripts/deploy.sh --restart restart even if nothing changed--# sudo deploy/scripts/deploy.sh --rollback release layout: switch back to the previous release--# sudo deploy/scripts/deploy.sh --force legacy layout: discard local tracked changes--# DRY_RUN=1 deploy/scripts/deploy.sh print the plan, change nothing++# sudo deploy/scripts/deploy.sh ovhost deploy live++# sudo deploy/scripts/deploy.sh --wait-idle ovhost deploy live --wait-idle (hold a restart until nobody is live)++# sudo deploy/scripts/deploy.sh --restart ovhost deploy live --restart++# sudo deploy/scripts/deploy.sh --rollback ovhost rollback live (the release the current one replaced)++# DRY_RUN=1 deploy/scripts/deploy.sh ovhost plan live (changes nothing)++# READY_TIMEOUT=<s> --ready-timeout <s>#--# What a change costs (see docs/deploy.md):--# docs/, README etc., tests no process restart; the new files are just in place--# public/ no restart — the server re-reads HTML and assets (content-hashed)--# server/, vendor/, package* restart; HTTP connections queue on the systemd socket, but live--# WebSocket, WHIP, WebRTC and RTMP sessions are dropped and reconnect--# package-lock.json dependencies installed BEFORE anything is interrupted--# deploy/systemd/ units installed, daemon-reload, restart--# deploy/nginx/ never auto-installed (the live config is generated elsewhere);--# the script says so++# --force meant "discard local tracked changes" in the legacy in-place layout. ovhost never discards changes,++# and its --force drops live streams (and passes a freeze), so the wrapper refuses it: run++# `sudo ovhost deploy live --force` yourself when that is what you mean.#--# After a deploy or rollback that went live: `ovhost announce live` (release notification to open tabs;--# best effort, skipped without an ovhost that has `announce`, never changes the exit code).++# Fallback: deploy-legacy.sh (the previous script, unchanged) runs instead, with the same arguments, when++# ovhost is missing or too old (no `capabilities`, deploy-api < 1), or the host inventory does not deploy++# live with strategy release-layout. OVHOST_LEGACY=1 forces it; OVHOST=<path> picks another ovhost.#--# Two layouts:--# release /opt/openvibe.live/releases/<id> (code + node_modules), current -> releases/<id>,--# shared/data. A deploy builds a new release while the old one serves; switching is an--# atomic symlink rename; rollback switches back to a release that still has its own--# node_modules. Set up once with deploy/scripts/migrate-to-releases.sh.--# legacy /opt/openvibe.live is a git checkout (what production runs until migrated).--#--# Exit codes: 0 success · 1 usage/precondition · 2 validation failed · 3 not ready after restart--# (rolled back) · 4 rollback itself failed — MANUAL INTERVENTION · 5 gave up waiting for idle++# Exit codes are ovhost's, the same as before: 0 ok · 1 usage/precondition · 2 validation failed, nothing++# restarted · 3 not ready, rolled back · 4 rollback failed (manual intervention) · 5 protected sessions ·++# 6 frozen (ovhost freeze).# ═══════════════════════════════════════════════════════════════set -euo pipefail--FORCE_DEPLOY=false--ALWAYS_RESTART=false--WAIT_IDLE=false--ROLLBACK=false++SERVICE=live++STRATEGY=release-layout++HERE=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)++LEGACY="${DEPLOY_LEGACY:-$HERE/deploy-legacy.sh}"++OVHOST="${OVHOST:-/usr/local/bin/ovhost}"++# ovhost runs as root; tests set OVHOST_SUDO= to call it directly.++if [ "${OVHOST_SUDO-auto}" = auto ]; then if [ "$(id -u)" -eq 0 ]; then SUDO=(); else SUDO=(sudo); fi; elif [ -n "${OVHOST_SUDO}" ]; then SUDO=("$OVHOST_SUDO"); else SUDO=(); fi++++say() { echo "[Deploy] $*"; }++++ORIG=("$@")++CMD=deploy++FLAGS=()++FORCE=falsewhile [ "$#" -gt 0 ]; docase "$1" in-- --force|-f|--ignore-local-changes) FORCE_DEPLOY=true; shift ;;-- --restart) ALWAYS_RESTART=true; shift ;;-- --wait-idle) WAIT_IDLE=true; shift ;;-- --rollback) ROLLBACK=true; shift ;;++ --wait-idle) FLAGS+=(--wait-idle); shift ;;++ --restart) FLAGS+=(--restart); shift ;;++ --rollback) CMD=rollback; shift ;;++ --force|-f|--ignore-local-changes) FORCE=true; shift ;;--) shift; break ;;-- *) echo "Usage: $0 [--wait-idle] [--restart] [--rollback] [--force]"; exit 1 ;;++ *) echo "Usage: $0 [--wait-idle] [--restart] [--rollback] (DRY_RUN=1 for the plan)"; exit 1 ;;esacdone++[ -n "${READY_TIMEOUT:-}" ] && FLAGS+=(--ready-timeout "$READY_TIMEOUT")--BASE_DIR="${BASE_DIR:-/opt/openvibe.live}"--SERVICE="${SERVICE:-openvibe-live}"--SYSTEMD_DIR="${SYSTEMD_DIR:-/etc/systemd/system}"--SYSTEMCTL="${SYSTEMCTL:-systemctl}"--SS="${SS:-ss}"--SOCKET_CHANGED=false--# Unit files land in /etc: when systemctl is being run through sudo, so is the install.--SUDO_INSTALL=""; case "$SYSTEMCTL" in sudo*) SUDO_INSTALL="sudo";; esac; [ "$(id -u)" -eq 0 ] && SUDO_INSTALL=""--SITE_URL="${SITE_URL:-https://openvibe.live}"--API_URL="${API_URL:-http://127.0.0.1:3000}"--GIT_REMOTE="${GIT_REMOTE:-origin}"--GIT_BRANCH="${GIT_BRANCH:-main}"--READY_PATH="${READY_PATH:-/api/ready}"--UPDATES_PATH="${UPDATES_PATH:-/updates}"--BROADCAST_ENDPOINT="${BROADCAST_ENDPOINT:-/api/admin/broadcast}"--READY_TIMEOUT="${READY_TIMEOUT:-90}"--IDLE_MAX_SECONDS="${IDLE_MAX_SECONDS:-28800}"--KEEP_RELEASES="${KEEP_RELEASES:-5}"--NPM="${NPM:-npm}"--DRY_RUN="${DRY_RUN:-0}"--# Not /tmp: fs.protected_regular stops root from reopening a lock a plain user created there.--LOCK_FILE="${LOCK_FILE:-$BASE_DIR/.deploy.lock}"----say() { echo "[Deploy] $*"; }--die() { echo "[Deploy] ✗ $1" >&2; exit "${2:-1}"; }--run() { if [ "$DRY_RUN" = "1" ]; then echo " would run: $*"; else "$@"; fi; }----exec 9>"$LOCK_FILE"--flock -n 9 || die "another deploy is already running (lock: $LOCK_FILE)"----if [ -d "$BASE_DIR/releases" ] && [ -L "$BASE_DIR/current" ]; then LAYOUT=release; else LAYOUT=legacy; fi----echo "╔══════════════════════════════════════╗"--echo "║ OpenVibe.Live — deploy ║"--echo "╚══════════════════════════════════════╝"--say "base=$BASE_DIR layout=$LAYOUT service=$SERVICE branch=$GIT_BRANCH dry_run=$DRY_RUN"----# ── Classification ───────────────────────────────────────────────────────────--# Prints one word per category that the changed file list touches.--classify() {-- local files="$1" cats=""-- add() { case " $cats " in *" $1 "*) ;; *) cats="$cats $1" ;; esac; }-- while IFS= read -r f; do-- [ -n "$f" ] || continue-- case "$f" in-- package.json|package-lock.json) add deps; add server ;;-- server/db/migrations.js|server/db/schema.sql) add schema; add server ;;-- server/*|vendor/*|.env.example) add server ;;-- public/*) add static ;;-- deploy/systemd/*) add systemd ;;-- deploy/nginx/*) add nginx ;;-- docs/*) add static ;;-- *) add none ;;-- esac-- done <<< "$files"-- echo "$cats"--}--has() { case " $1 " in *" $2 "*) return 0 ;; *) return 1 ;; esac; }----# Whether package.json changed in a way that needs an install (scripts/description edits do not).--deps_changed() {-- local from="$1" to="$2" dir="$3"-- git -C "$dir" diff --quiet "$from" "$to" -- package-lock.json || return 0-- local a b-- a=$(git -C "$dir" show "$from:package.json" 2>/dev/null | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{try{const j=JSON.parse(d);console.log(JSON.stringify([j.dependencies,j.optionalDependencies,j.engines]))}catch{console.log("x")}})')-- b=$(git -C "$dir" show "$to:package.json" 2>/dev/null | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{try{const j=JSON.parse(d);console.log(JSON.stringify([j.dependencies,j.optionalDependencies,j.engines]))}catch{console.log("y")}})')-- [ "$a" != "$b" ]--}----# ── Live-stream guard ────────────────────────────────────────────────────────--# A restart drops every RTMP/WHIP/WebRTC publisher. Socket activation keeps HTTP accepting, nothing more.--live_count() {-- curl -sf --max-time 5 "${API_URL}/api/streams" 2>/dev/null \-- | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{try{const j=JSON.parse(d);console.log((j.streams||[]).filter(s=>s.is_live).length)}catch{console.log(0)}})' \-- || echo 0--}--wait_for_idle() {-- [ "$WAIT_IDLE" = true ] || return 0-- local waited=0 quiet=0-- while [ "$waited" -lt "$IDLE_MAX_SECONDS" ]; do-- local n; n=$(live_count)-- if [ "$n" = "0" ]; then quiet=$((quiet+1)); else quiet=0; fi-- [ "$quiet" -ge 2 ] && { say "no one live for two checks — proceeding"; return 0; }-- say "$n live stream(s); holding the restart (${waited}s waited)"-- [ "$DRY_RUN" = "1" ] && return 0-- sleep 60; waited=$((waited+60))-- done-- die "still live after ${IDLE_MAX_SECONDS}s; nothing was restarted" 5--}----wait_ready() {-- local deadline=$(( $(date +%s) + READY_TIMEOUT ))-- while [ "$(date +%s)" -lt "$deadline" ]; do-- if curl -sf --max-time 5 "${API_URL}${READY_PATH}" >/dev/null 2>&1; then return 0; fi-- $SYSTEMCTL is-active --quiet "$SERVICE" || { say "service is not running"; return 1; }-- sleep 1; printf '.'-- done-- printf '\n'; return 1--}----notify_chat() {-- local summary="$1"-- curl -sf -X POST "${API_URL}${BROADCAST_ENDPOINT}" \-- -H "Content-Type: application/json" -H "Authorization: Bearer ${ADMIN_TOKEN:-}" \-- -d "{\"type\":\"update\",\"summary\":$(printf '%s' "$summary" | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>console.log(JSON.stringify(d.trim())))'),\"url\":\"${SITE_URL}${UPDATES_PATH}\"}" \-- >/dev/null 2>&1 && say "chat notified" || say "chat notification skipped"++legacy() {++ say "$1 — running deploy-legacy.sh (the previous deploy script) instead"++ exec bash "$LEGACY" "${ORIG[@]}"}--install_units() {-- local unit_dir="$1" changed=false-- install_unit() {-- local src="$1" dest="$2"-- [ -f "$src" ] || return 0-- if [ ! -f "$dest" ] || ! cmp -s "$src" "$dest"; then-- say "installing $(basename "$dest")"-- run $SUDO_INSTALL install -m 0644 -D "$src" "$dest"-- changed=true-- fi-- }-- install_unit "$unit_dir/${SERVICE}.service" "$SYSTEMD_DIR/${SERVICE}.service"-- local socket_before=false-- [ -f "$SYSTEMD_DIR/${SERVICE}.socket" ] && [ -f "$unit_dir/${SERVICE}.socket" ] && ! cmp -s "$unit_dir/${SERVICE}.socket" "$SYSTEMD_DIR/${SERVICE}.socket" && socket_before=true-- install_unit "$unit_dir/${SERVICE}.socket" "$SYSTEMD_DIR/${SERVICE}.socket"-- # A socket unit changed under a running socket: after daemon-reload systemd may drop its descriptor-- # ("no socket file descriptors are open ... not functional until restarted"); restart_and_verify then-- # restarts the socket itself. Left alone, the service binds the port on its own and every later-- # restart refuses connections (what happened from 2026-09-25 to 2026-09-26).-- [ "$socket_before" = true ] && SOCKET_CHANGED=true-- install_unit "$unit_dir/${SERVICE}.service.d/socket.conf" "$SYSTEMD_DIR/${SERVICE}.service.d/socket.conf"-- if [ "$changed" = true ]; then-- run $SYSTEMCTL daemon-reload-- run $SYSTEMCTL enable "${SERVICE}.socket" >/dev/null 2>&1 || true-- run $SYSTEMCTL enable "$SERVICE" >/dev/null 2>&1 || true-- fi++# Does this ovhost deploy live with the strategy this wrapper hands over to? Sets REASON when not.++REASON=""++probe() {++ if [ "${OVHOST_LEGACY:-0}" = 1 ]; then REASON="OVHOST_LEGACY=1"; return 1; fi++ if ! command -v "$OVHOST" >/dev/null 2>&1; then REASON="ovhost not found ($OVHOST)"; return 1; fi++ local caps api++ if ! caps=$("${SUDO[@]}" "$OVHOST" capabilities "$SERVICE" 2>/dev/null); then REASON="this ovhost has no 'capabilities' (too old) or no inventory entry for $SERVICE"; return 1; fi++ api=$(printf '%s\n' "$caps" | sed -n 's/^deploy-api=//p')++ case "$api" in ''|*[!0-9]*) REASON="this ovhost reports no deploy-api (too old)"; return 1 ;; esac++ if [ "$api" -lt 1 ]; then REASON="this ovhost's deploy-api is $api, 1 is needed"; return 1; fi++ if ! printf '%s\n' "$caps" | grep -qx "strategy=$STRATEGY"; then REASON="the host inventory does not deploy $SERVICE with strategy $STRATEGY ($(printf '%s\n' "$caps" | sed -n 's/^strategy=//p'))"; return 1; fi++ if ! printf '%s\n' "$caps" | grep -qx "managed=yes"; then REASON="ovhost does not manage $SERVICE"; return 1; fi++ return 0}--syntax_check() {-- local dir="$1" files="$2" checked=0-- while IFS= read -r f; do-- case "$f" in-- *.js) [ -f "$dir/$f" ] || continue-- node --check "$dir/$f" >/dev/null 2>&1 || die "syntax error in $f" 2-- checked=$((checked+1)) ;;-- *.json) [ -f "$dir/$f" ] || continue-- node -e 'JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"))' "$dir/$f" 2>/dev/null || die "invalid JSON in $f" 2 ;;-- esac-- done <<< "$files"-- say "syntax-checked $checked changed JS file(s)"--}----backup_db() {-- # A schema migration is about to run on restart: take a consistent online copy first.-- local db="$1"-- [ -f "$db" ] || { say "no database at $db to back up"; return 0; }-- local out; out="$(dirname "$db")/backups/live-$(date +%Y%m%d-%H%M%S).db"-- run mkdir -p "$(dirname "$out")"-- say "schema changed — backing up $(basename "$db") to $out"-- if [ "$DRY_RUN" != "1" ]; then-- (cd "$2" && node -e 'const D=require("better-sqlite3");new D(process.argv[1],{readonly:true}).backup(process.argv[2]).then(()=>process.exit(0),e=>{console.error(e.message);process.exit(1)})' "$db" "$out") \-- || die "database backup failed; nothing was restarted" 2-- fi--}++probe || legacy "$REASON"--# Does systemd (pid 1) hold the socket unit's listener? `is-active` is not enough: a socket unit whose--# descriptor was dropped still reads active while the service binds the port itself.--socket_held() {-- local listen port-- listen=$($SYSTEMCTL show "${SERVICE}.socket" -p Listen --value 2>/dev/null | head -1 | awk '{print $1}')-- port=${listen##*:}-- [ -n "$port" ] || return 1-- $SS -Hltnp "sport = :$port" 2>/dev/null | grep -q '"systemd",pid=1,'--}----restart_and_verify() {-- local on_fail="$1" socketed=false-- if $SYSTEMCTL list-unit-files "${SERVICE}.socket" >/dev/null 2>&1 && $SYSTEMCTL is-enabled --quiet "${SERVICE}.socket" 2>/dev/null; then-- socketed=true-- $SYSTEMCTL is-active --quiet "${SERVICE}.socket" || $SYSTEMCTL start "${SERVICE}.socket" || true-- fi-- local start; start=$(date +%s)-- if [ "$socketed" = true ] && { [ "$SOCKET_CHANGED" = true ] || ! socket_held; }; then-- # The socket must be rebound: stop the service (it may hold the port itself), restart the-- # socket, start the service on it. HTTP is refused for that moment, once.-- say "socket unit $([ "$SOCKET_CHANGED" = true ] && echo changed || echo 'is not holding its listener') — stopping ${SERVICE}, restarting the socket"-- $SYSTEMCTL stop "$SERVICE" 9>&- || true-- $SYSTEMCTL restart "${SERVICE}.socket" || say "✗ could not restart ${SERVICE}.socket"-- $SYSTEMCTL start "$SERVICE" 9>&--- SOCKET_CHANGED=false-- else-- [ "$socketed" = true ] && say "socket unit holds the listener — new HTTP connections queue through the restart"-- say "restarting ${SERVICE}…"-- $SYSTEMCTL restart "$SERVICE" 9>&--- fi-- printf '[Deploy] waiting for readiness'-- if wait_ready; then-- printf '\n'; say "ready after $(( $(date +%s) - start ))s"-- if [ "$socketed" = true ] && ! socket_held; then-- say "✗ socket activation is not in effect: systemd does not hold the listener, so restarts refuse connections (check journalctl -u ${SERVICE}.socket)"-- fi-- return 0-- fi-- say "✗ not ready within ${READY_TIMEOUT}s"-- journalctl -u "$SERVICE" -n 40 --no-pager 2>/dev/null | sed 's/^/ /' || true-- $on_fail--}----# ── Release notification (WS-P task 9) ───────────────────────────────────────--# OpenVibe.Host publishes host.release.published to OpenVibe.Events, so open tabs check /release.json now--# instead of at their next poll. ovhost announces the release Live's own /release.json reports, once per--# release. Best effort: skipped when ovhost is missing or has no `announce`, 20 s at most, and it never--# fails a deploy (see OpenVibe.Host docs/release-notifications.md).--OVHOST="${OVHOST:-ovhost}"--announce_release() {-- [ "$DRY_RUN" = "1" ] && return 0-- command -v "$OVHOST" >/dev/null 2>&1 || return 0-- local help-- help=$("$OVHOST" --help 2>/dev/null || true)-- case "$help" in *"announce <service>"*) ;; *) say "release notification skipped: this ovhost has no announce"; return 0 ;; esac-- timeout 20 "$OVHOST" announce live 9>&- 2>&1 | sed 's/^/ /' || say "release notification not sent (the deploy stands)"--}----# ═══════════════════════════════════════════════════════════════--# Release layout--# ═══════════════════════════════════════════════════════════════--if [ "$LAYOUT" = release ]; then-- REPO="$BASE_DIR/repo"-- CURRENT=$(readlink -f "$BASE_DIR/current")-- CUR_ID=$(basename "$CURRENT")-- CUR_SHA=$(git -C "$CURRENT" rev-parse HEAD 2>/dev/null || echo none)-- say "current release: $CUR_ID (${CUR_SHA:0:8})"---- switch_to() {-- # Atomic: create the new link beside the old one and rename over it.-- local target="$1"-- run ln -sfn "$target" "$BASE_DIR/.current.tmp"-- run mv -Tf "$BASE_DIR/.current.tmp" "$BASE_DIR/current"-- }---- if [ "$ROLLBACK" = true ]; then-- PREV=$(ls -1dt "$BASE_DIR"/releases/*/ 2>/dev/null | sed 's:/$::' | grep -vx "$CURRENT" | head -1 || true)-- [ -n "$PREV" ] || die "no previous release to roll back to"-- PREV_SHA=$(git -C "$PREV" rev-parse HEAD 2>/dev/null || echo none)-- CATS=$(classify "$(git -C "$REPO" diff --name-only "$PREV_SHA" "$CUR_SHA" 2>/dev/null || echo server/)")-- say "rolling back to $(basename "$PREV") (${PREV_SHA:0:8}); change classes:${CATS}"-- [ "$DRY_RUN" = "1" ] && { say "dry run complete — nothing was changed."; exit 0; }-- switch_to "$PREV"-- if has "$CATS" server || has "$CATS" systemd || [ "$ALWAYS_RESTART" = true ]; then-- install_units "$PREV/deploy/systemd/release"-- wait_for_idle-- restart_and_verify 'die "ROLLBACK RELEASE IS NOT READY — MANUAL INTERVENTION REQUIRED" 4'-- else-- say "static-only difference — switched without a restart"-- fi-- # Keep the release we left so it can be re-selected; just move it out of "newest".-- touch -h "$PREV"-- say "rolled back to $(basename "$PREV") ✅"-- announce_release-- exit 0-- fi---- say "fetching ${GIT_REMOTE}/${GIT_BRANCH}…"-- run git -C "$REPO" fetch --quiet "$GIT_REMOTE" "$GIT_BRANCH"-- TARGET_SHA=$(git -C "$REPO" rev-parse "${GIT_REMOTE}/${GIT_BRANCH}")-- if [ "$TARGET_SHA" = "$CUR_SHA" ] && [ "$ALWAYS_RESTART" != true ]; then-- say "already at ${TARGET_SHA:0:8}; nothing to deploy."-- exit 0-- fi---- CHANGED=$(git -C "$REPO" diff --name-only "$CUR_SHA" "$TARGET_SHA" 2>/dev/null || true)-- CATS=$(classify "$CHANGED")-- [ "$TARGET_SHA" = "$CUR_SHA" ] && CATS=" server"-- COMMIT_LOG=$(git -C "$REPO" --no-pager log --oneline "${CUR_SHA}..${TARGET_SHA}" 2>/dev/null || echo "Restart requested")-- COMMIT_COUNT=$(printf '%s\n' "$COMMIT_LOG" | grep -c . || true)-- say "$COMMIT_COUNT new commit(s); change classes:${CATS:- none}"-- printf '%s\n' "$COMMIT_LOG" | sed 's/^/ /'---- NEW_ID="$(date +%Y%m%d-%H%M%S)-${TARGET_SHA:0:8}"-- NEW="$BASE_DIR/releases/$NEW_ID"-- say "preparing release $NEW_ID while $CUR_ID keeps serving…"-- run git -C "$REPO" worktree add --detach "$NEW" "$TARGET_SHA" >/dev/null-- [ "$DRY_RUN" = "1" ] || syntax_check "$NEW" "$CHANGED"-- run ln -sfn ../../shared/data "$NEW/data"---- if [ "$DRY_RUN" != "1" ] && deps_changed "$CUR_SHA" "$TARGET_SHA" "$REPO"; then-- say "dependencies changed — installing into the new release (nothing interrupted yet)…"-- (cd "$NEW" && $NPM ci --omit=dev --no-audit --no-fund) || { git -C "$REPO" worktree remove --force "$NEW" || true; die "npm ci failed; the current release is untouched" 2; }-- else-- # Same lockfile: hard-link the current release's node_modules — instant, no extra disk, and the-- # new release still owns its own tree (npm never writes into an installed package in place).-- say "dependencies unchanged — linking node_modules from $CUR_ID"-- run cp -al "$CURRENT/node_modules" "$NEW/node_modules"-- fi---- if has "$CATS" nginx; then-- say "note: deploy/nginx changed and is NOT installed by this script — copy it to /etc/nginx/sites-available,"-- say " run nginx -t and reload (OpenVibe.Network's generator mirrors it)."-- fi---- [ "$DRY_RUN" = "1" ] && { say "dry run complete — nothing was changed."; exit 0; }---- NEEDS_RESTART=false-- if has "$CATS" server || has "$CATS" systemd || [ "$ALWAYS_RESTART" = true ]; then NEEDS_RESTART=true; fi---- if [ "$NEEDS_RESTART" = true ]; then-- wait_for_idle-- has "$CATS" schema && backup_db "$BASE_DIR/shared/data/live.db" "$NEW"-- FIRST_LINE=$(printf '%s\n' "$COMMIT_LOG" | head -1 | sed 's/^[a-f0-9]* //')-- notify_chat "$([ "$COMMIT_COUNT" -le 1 ] && echo "Update: ${FIRST_LINE}" || echo "${COMMIT_COUNT} updates deployed — ${FIRST_LINE}")"-- switch_to "$NEW"-- install_units "$NEW/deploy/systemd/release"-- rollback_release() {-- say "rolling back to $CUR_ID"-- switch_to "$CURRENT"-- install_units "$CURRENT/deploy/systemd/release"-- $SYSTEMCTL restart "$SERVICE" 9>&- || die "ROLLBACK FAILED: service would not restart" 4-- printf '[Deploy] waiting for rollback'-- if wait_ready; then printf '\n'; say "rolled back to $CUR_ID and serving"; exit 3; fi-- die "ROLLBACK FAILED: $CUR_ID is not ready either — MANUAL INTERVENTION REQUIRED" 4-- }-- restart_and_verify rollback_release-- say "note: established WebSocket, WHIP, RTMP and WebRTC sessions were reconnected, not preserved."-- else-- switch_to "$NEW"-- # The running process reads public/ and docs/ through the `current` link (OV_APP_ROOT), and-- # re-hashes assets within ~2s. Confirm the site answers; no process was restarted.-- sleep 3-- curl -sf --max-time 10 "${API_URL}${READY_PATH}" >/dev/null || { switch_to "$CURRENT"; die "site not ready after switching — switched back to $CUR_ID" 3; }-- say "switched to $NEW_ID without a restart (change classes:${CATS})"-- fi---- # Prune old releases (never the current one, never the one we just left).-- ls -1dt "$BASE_DIR"/releases/*/ 2>/dev/null | sed 's:/$::' | tail -n +$((KEEP_RELEASES+1)) | while read -r old; do-- [ "$old" = "$(readlink -f "$BASE_DIR/current")" ] && continue-- [ "$old" = "$CURRENT" ] && continue-- say "pruning $(basename "$old")"-- git -C "$REPO" worktree remove --force "$old" 2>/dev/null || rm -rf "$old"-- done-- git -C "$REPO" worktree prune 2>/dev/null || true-- say "deployed $CUR_ID → $NEW_ID ✅"-- announce_release-- exit 0++if [ "$FORCE" = true ]; then++ echo "[Deploy] ✗ --force meant 'discard local tracked changes' (legacy layout). ovhost never discards changes, and its" >&2++ echo "[Deploy] --force drops live streams and passes a freeze. Run: sudo $OVHOST ${CMD} ${SERVICE} --force if that is what you mean." >&2++ exit 1fi----# ═══════════════════════════════════════════════════════════════--# Legacy layout (git checkout in place)--# ═══════════════════════════════════════════════════════════════--[ "$ROLLBACK" = true ] && die "--rollback needs the release layout (deploy/scripts/migrate-to-releases.sh)"--REPO_DIR="$BASE_DIR"--cd "$REPO_DIR" || die "repo not found at $REPO_DIR"--OLD_HASH=$(git rev-parse HEAD 2>/dev/null || echo none)--say "current commit: ${OLD_HASH:0:8}"----if [ "$FORCE_DEPLOY" = true ]; then-- say "force: discarding local tracked changes (untracked files are kept)"-- run git reset --hard HEAD--elif ! git diff --quiet || ! git diff --cached --quiet; then-- die "working tree has uncommitted changes — commit them, or re-run with --force"++if [ "${DRY_RUN:-0}" = 1 ]; then++ say "ovhost plan $SERVICE (DRY_RUN=1: nothing changes)"++ exec "${SUDO[@]}" "$OVHOST" plan "$SERVICE"fi----say "fetching ${GIT_REMOTE}/${GIT_BRANCH}…"--run git fetch --quiet "$GIT_REMOTE" "$GIT_BRANCH"--TARGET_HASH=$(git rev-parse "${GIT_REMOTE}/${GIT_BRANCH}" 2>/dev/null || echo "$OLD_HASH")--if [ "$OLD_HASH" = "$TARGET_HASH" ] && [ "$ALWAYS_RESTART" != true ]; then-- say "already at ${TARGET_HASH:0:8}; nothing to deploy."-- exit 0--fi----CHANGED=""; CATS=" server"--if [ "$OLD_HASH" != "$TARGET_HASH" ]; then-- CHANGED=$(git diff --name-only "$OLD_HASH" "$TARGET_HASH" || true)-- CATS=$(classify "$CHANGED")-- COMMIT_LOG=$(git --no-pager log --oneline "${OLD_HASH}..${TARGET_HASH}" 2>/dev/null || echo "Update deployed")-- COMMIT_COUNT=$(printf '%s\n' "$COMMIT_LOG" | grep -c . || true)-- say "$COMMIT_COUNT new commit(s); change classes:${CATS:- none}"-- printf '%s\n' "$COMMIT_LOG" | sed 's/^/ /'--else-- COMMIT_LOG="Restart requested"; COMMIT_COUNT=0--fi----NEEDS_RESTART=false--if has "$CATS" server || has "$CATS" systemd || [ "$ALWAYS_RESTART" = true ]; then NEEDS_RESTART=true; fi----# In place, the old process keeps running against files that are about to change underneath it, so--# anything that needs a restart waits for idle BEFORE the checkout moves.--[ "$NEEDS_RESTART" = true ] && wait_for_idle----say "validating the target revision…"--run git merge --ff-only "$TARGET_HASH" >/dev/null 2>&1 || { [ "$DRY_RUN" = "1" ] || die "fast-forward to ${TARGET_HASH:0:8} failed — diverged history" 2; }--NEW_HASH=$(git rev-parse HEAD)--[ -n "$CHANGED" ] && [ "$DRY_RUN" != "1" ] && syntax_check "$REPO_DIR" "$CHANGED"----if [ "$DRY_RUN" != "1" ] && [ "$OLD_HASH" != "$TARGET_HASH" ] && deps_changed "$OLD_HASH" "$TARGET_HASH" "$REPO_DIR"; then-- say "dependencies changed — installing (service still running)…"-- say "warning: legacy layout — a rollback would NOT restore the previous node_modules. Migrate to releases."-- run $NPM ci --omit=dev --no-audit --no-fund || die "npm ci failed" 2--fi--has "$CATS" nginx && say "note: deploy/nginx changed — not installed here; copy it, nginx -t, reload (see docs/deploy.md)."----if [ "$DRY_RUN" = "1" ]; then say "dry run complete — nothing was changed. restart needed: $NEEDS_RESTART"; exit 0; fi----if [ "$NEEDS_RESTART" != true ]; then-- say "change classes:${CATS} — no restart needed; the server re-reads public/ and docs/ from disk."-- say "deployed ${OLD_HASH:0:8} → ${NEW_HASH:0:8} without interrupting anyone ✅"-- announce_release-- exit 0--fi----install_units "$REPO_DIR/deploy/systemd"--has "$CATS" schema && backup_db "$REPO_DIR/data/live.db" "$REPO_DIR"--FIRST_LINE=$(printf '%s\n' "$COMMIT_LOG" | head -1 | sed 's/^[a-f0-9]* //')--notify_chat "$([ "$COMMIT_COUNT" -le 1 ] && echo "Update: ${FIRST_LINE}" || echo "${COMMIT_COUNT} updates deployed — ${FIRST_LINE}")"--rollback_legacy() {-- say "rolling back to ${OLD_HASH:0:8}"-- git reset --hard "$OLD_HASH" >/dev/null 2>&1 || die "ROLLBACK FAILED: could not reset to $OLD_HASH" 4-- $SYSTEMCTL restart "$SERVICE" 9>&- || die "ROLLBACK FAILED: service would not restart" 4-- printf '[Deploy] waiting for rollback'-- if wait_ready; then printf '\n'; say "rolled back to ${OLD_HASH:0:8} and serving"; exit 3; fi-- die "ROLLBACK FAILED: ${OLD_HASH:0:8} is not ready either — MANUAL INTERVENTION REQUIRED" 4--}--restart_and_verify rollback_legacy--curl -sf --max-time 5 "${API_URL}/api/health" >/dev/null || die "health endpoint not responding after readiness" 3--say "deployed ${OLD_HASH:0:8} → ${NEW_HASH:0:8} (${COMMIT_COUNT} commit(s))"--say "note: established WebSocket, WHIP, RTMP and WebRTC sessions were reconnected, not preserved."--say "done ✅"--announce_release++say "ovhost $CMD $SERVICE ${FLAGS[*]:-}"++exec "${SUDO[@]}" "$OVHOST" "$CMD" "$SERVICE" "${FLAGS[@]}"@@@ -1,14 +1,28 @@# Deploying OpenVibe.Live--`deploy/scripts/deploy.sh` decides what a change needs and does the least disruptive thing.++Live deploys with `ovhost deploy live` (OpenVibe.Host, `/usr/local/bin/ovhost`, strategy++`release-layout`; roadmap WS-N task 11). `deploy/scripts/deploy.sh` is a thin wrapper that maps its old++flags onto ovhost, so the commands stay the same:```bash--sudo /opt/openvibe.live/deploy/scripts/deploy.sh # deploy origin/main if it moved--sudo /opt/openvibe.live/deploy/scripts/deploy.sh --wait-idle # hold a restart until nobody is live--sudo /opt/openvibe.live/deploy/scripts/deploy.sh --rollback # release layout: previous release--DRY_RUN=1 /opt/openvibe.live/deploy/scripts/deploy.sh # print the plan only++cd /opt/openvibe.live/current++sudo deploy/scripts/deploy.sh # ovhost deploy live: origin/main if it moved++sudo deploy/scripts/deploy.sh --wait-idle # ovhost deploy live --wait-idle: hold a restart until nobody is live++sudo deploy/scripts/deploy.sh --restart # ovhost deploy live --restart++sudo deploy/scripts/deploy.sh --rollback # ovhost rollback live: the release the current one replaced++DRY_RUN=1 deploy/scripts/deploy.sh # ovhost plan live: print the plan only```++The wrapper checks `ovhost capabilities live` first (deploy API 1, strategy `release-layout`, managed).++When ovhost is missing or too old, or the host inventory does not deploy Live with that strategy, it says++why and runs `deploy/scripts/deploy-legacy.sh`, the previous script, unchanged and with the same++arguments. `OVHOST_LEGACY=1` forces the fallback; `OVHOST=<path>` picks another ovhost. `--force`++meant "discard local tracked changes" in the legacy layout; ovhost's `--force` drops live streams, so the++wrapper refuses it (run `sudo ovhost deploy live --force` yourself if that is what you mean). Everything++below is what both do; ovhost also records every attempt in its release log (`ovhost releases live`) and++refuses a frozen service (`ovhost freeze`, exit 6). What ovhost checks is in OpenVibe.Host++`docs/deploy-strategies.md` and `test/strategy-release-layout.test.js`.++## What each kind of change costs| Files changed | Action | Interruption |@@@ -65,16 +79,19 @@ but could not restore previous `node_modules`.- The last 5 releases are kept.- Content-hashed assets from the previous release are still served under their old hashes, so a pagerendered before the switch never loads JavaScript from after it.--- **Release notification.** After a deploy or `--rollback` that went live, the script runs-- `ovhost announce live` (OpenVibe.Host, WS-P task 9). ovhost publishes `host.release.published` for the++- **Release notification.** After a deploy or `--rollback` that went live, ovhost announces it (the++ legacy script runs `ovhost announce live`; OpenVibe.Host, WS-P task 9). ovhost publishes `host.release.published` for therelease `/release.json` now reports, once per release, to OpenVibe.Events. Open tabs (openvibe-shared1.17.0 release-watch) then check `/release.json` within about 20 s instead of at their next poll. It isbest effort: skipped when `ovhost` is missing or has no `announce` (set `OVHOST` for another path), 20 s atmost, and it never changes the exit code. A static-only switch keeps the running release, so there isnothing new to announce. The credentials and the setup are in OpenVibe.Host `docs/release-notifications.md`.--`test/deploy-sim.test.js` runs the script against a simulated host (real git, fake systemctl) and--checks each of these behaviours.++`test/deploy-sim.test.js` runs `deploy-legacy.sh` against a simulated host (real git, fake systemctl)++and checks each of these behaviours; `test/deploy-wrapper.test.js` checks the wrapper's flag mapping and++its fallback. ovhost's own tests (OpenVibe.Host `test/strategy-release-layout.test.js`) cover the same++behaviours for `ovhost deploy live`, plus the socket rule: pid 1 must hold :3000, and the socket unit is++restarted only when its unit file changed or systemd does not hold the listener.## Assets and caching@@@ -1,5 +1,6 @@/**-- * deploy/scripts/deploy.sh against a simulated host.++ * deploy/scripts/deploy-legacy.sh (the release-layout deploy script from before `ovhost deploy live`;++ * deploy/scripts/deploy.sh now hands over to ovhost and falls back to it) against a simulated host.** A temp directory stands in for /opt/openvibe.live with a real git "origin", the release layout* (repo, releases/<id>, current, shared/data), a fake `systemctl` that runs a tiny app, and a fake@@@ -23,7 +24,7 @@ const path = require('path');const http = require('http');const { execFileSync, spawnSync } = require('child_process');--const DEPLOY = path.join(__dirname, '..', 'deploy', 'scripts', 'deploy.sh');++const DEPLOY = path.join(__dirname, '..', 'deploy', 'scripts', 'deploy-legacy.sh');const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-deploy-sim-'));const PORT = 20000 + Math.floor(Math.random() * 20000);const sh = (cmd, opts = {}) => execFileSync('bash', ['-c', cmd], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], ...opts });nnew file mode 100644@@@ -0,0 +1,116 @@++/**++ * deploy/scripts/deploy.sh is a thin wrapper around `ovhost deploy live` (OpenVibe.Host, strategy++ * release-layout; roadmap WS-N task 11), with deploy-legacy.sh (the previous script, unchanged) as its++ * fallback. A fake ovhost records what the wrapper asked for; a fake legacy script records the fallback.++ * - the flags map: (none) → deploy live, --wait-idle, --restart, --rollback → rollback live,++ * DRY_RUN=1 → plan live, READY_TIMEOUT → --ready-timeout;++ * - --force (legacy: discard local changes) is refused on the ovhost path, never passed on;++ * - the fallback runs, with the original arguments and a message, when ovhost is missing, has no++ * `capabilities`, reports deploy-api 0, does not deploy live with release-layout, or OVHOST_LEGACY=1;++ * - deploy-legacy.sh is the old script: it still holds the listener check and the socket restart.++ *++ * node test/deploy-wrapper.test.js++ */++'use strict';++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const { spawnSync } = require('child_process');++++const WRAPPER = path.join(__dirname, '..', 'deploy', 'scripts', 'deploy.sh');++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-deploy-wrapper-'));++const log = path.join(tmp, 'calls.log');++++// Fake ovhost: `capabilities live` prints what FAKE_CAPS says (or fails with FAKE_OLD); anything else is recorded.++const ovhost = path.join(tmp, 'ovhost');++fs.writeFileSync(ovhost, `#!/usr/bin/env bash++if [ "$1" = capabilities ]; then++ [ -n "$FAKE_OLD" ] && { echo "unknown command \\"capabilities\\""; exit 1; }++ printf '%b\\n' "\${FAKE_CAPS:-ovhost=0.3.0\\ndeploy-api=1\\nstrategies=git-checkout,multi-app,static-build,pnpm-build,release-layout\\nservice=live\\nstrategy=release-layout\\nmanaged=yes\\nlayout=release}"++ exit 0++fi++echo "ovhost $*" >> "${log}"++exit "\${FAKE_EXIT:-0}"++`, { mode: 0o755 });++const legacy = path.join(tmp, 'legacy.sh');++fs.writeFileSync(legacy, `#!/usr/bin/env bash\necho "legacy $* DRY_RUN=\${DRY_RUN:-}" >> "${log}"\nexit 0\n`, { mode: 0o755 });++++const run = (args = [], env = {}) => {++ fs.rmSync(log, { force: true });++ const r = spawnSync('bash', [WRAPPER, ...args], { env: { PATH: process.env.PATH, OVHOST: ovhost, OVHOST_SUDO: '', DEPLOY_LEGACY: legacy, ...env }, encoding: 'utf8' });++ let calls = [];++ try { calls = fs.readFileSync(log, 'utf8').trim().split('\n').filter(Boolean); } catch { /* none */ }++ return { code: r.status, out: r.stdout + r.stderr, calls };++};++++let failures = 0;++function check(name, fn) {++ try { fn(); console.log(' ✓', name); } catch (e) { failures++; console.log(' ✗', name, '\n ', String(e.message).split('\n').slice(0, 12).join('\n ')); }++}++++check('the flags map onto ovhost deploy|rollback|plan live', () => {++ assert.deepStrictEqual(run().calls, ['ovhost deploy live']);++ assert.deepStrictEqual(run(['--wait-idle']).calls, ['ovhost deploy live --wait-idle']);++ assert.deepStrictEqual(run(['--restart', '--wait-idle']).calls, ['ovhost deploy live --restart --wait-idle']);++ assert.deepStrictEqual(run(['--rollback']).calls, ['ovhost rollback live']);++ assert.deepStrictEqual(run([], { DRY_RUN: '1' }).calls, ['ovhost plan live']);++ assert.deepStrictEqual(run([], { READY_TIMEOUT: '120' }).calls, ['ovhost deploy live --ready-timeout 120']);++ const r = run(['--wait-idle']);++ assert.match(r.out, /\[Deploy\] ovhost deploy live --wait-idle/);++});++++check("ovhost's exit code is the wrapper's (3: not ready, rolled back)", () => {++ assert.strictEqual(run([], { FAKE_EXIT: '3' }).code, 3);++ assert.strictEqual(run([], { FAKE_EXIT: '5' }).code, 5);++});++++check('--force is refused on the ovhost path (it would drop live streams), never passed on', () => {++ const r = run(['--force']);++ assert.strictEqual(r.code, 1, r.out);++ assert.deepStrictEqual(r.calls, []);++ assert.match(r.out, /--force meant 'discard local tracked changes'/);++});++++check('an unknown flag is a usage error', () => {++ const r = run(['--nope']);++ assert.strictEqual(r.code, 1);++ assert.deepStrictEqual(r.calls, []);++});++++check('the fallback runs deploy-legacy.sh with the original arguments, and says why', () => {++ let r = run(['--wait-idle'], { OVHOST: path.join(tmp, 'missing-ovhost') });++ assert.strictEqual(r.code, 0, r.out);++ assert.deepStrictEqual(r.calls, ['legacy --wait-idle DRY_RUN=']);++ assert.match(r.out, /ovhost not found .* — running deploy-legacy\.sh/);++++ r = run(['--force'], { FAKE_OLD: '1' });++ assert.deepStrictEqual(r.calls, ['legacy --force DRY_RUN='], 'the legacy script keeps its own --force');++ assert.match(r.out, /no 'capabilities' \(too old\)/);++++ r = run([], { FAKE_CAPS: 'ovhost=0.2.9\\ndeploy-api=0\\nstrategy=release-layout\\nmanaged=yes' });++ assert.deepStrictEqual(r.calls, ['legacy DRY_RUN=']);++ assert.match(r.out, /deploy-api is 0, 1 is needed/);++++ r = run([], { FAKE_CAPS: 'ovhost=0.3.0\\ndeploy-api=1\\nstrategy=none\\nmanaged=no' });++ assert.deepStrictEqual(r.calls, ['legacy DRY_RUN=']);++ assert.match(r.out, /does not deploy live with strategy release-layout \(none\)/);++++ r = run(['--rollback'], { OVHOST_LEGACY: '1', DRY_RUN: '1' });++ assert.deepStrictEqual(r.calls, ['legacy --rollback DRY_RUN=1']);++});++++check('deploy-legacy.sh is the previous script: listener check, changed-socket restart, release layout', () => {++ const text = fs.readFileSync(path.join(__dirname, '..', 'deploy', 'scripts', 'deploy-legacy.sh'), 'utf8');++ assert.match(text, /socket_held\(\)/);++ assert.match(text, /SOCKET_CHANGED=true/);++ assert.match(text, /\$SYSTEMCTL restart "\$\{SERVICE\}\.socket"/);++ assert.match(text, /KEEP_RELEASES/);++ const wrapper = fs.readFileSync(WRAPPER, 'utf8');++ assert.ok(wrapper.split('\n').length < 120, 'the wrapper stays thin');++ assert.match(wrapper, /^set -euo pipefail$/m);++});++++fs.rmSync(tmp, { recursive: true, force: true });++if (failures) { console.log(`\n${failures} failure(s)`); process.exit(1); }++console.log('\ndeploy wrapper: all checks passed');@@@ -43,10 +43,17 @@ assert.strictEqual(load({ NODE_ENV: 'production', LISTEN_HOST: '10.0.0.5', BASE_assert.strictEqual(load({ NODE_ENV: 'development' }).listenHost, '0.0.0.0', 'development keeps HOST (default 0.0.0.0)');assert.strictEqual(load({ NODE_ENV: 'production', HOST: '0.0.0.0', BASE_URL: 'https://openvibe.live' }).host, '0.0.0.0', 'HOST still builds URLs');--// deploy.sh checks that systemd really holds the listener, and restarts a socket unit that changed.--const deploy = fs.readFileSync(path.join(__dirname, '..', 'deploy', 'scripts', 'deploy.sh'), 'utf8');--assert.match(deploy, /socket_held\(\)/, 'deploy.sh has the listener check');--assert.match(deploy, /SOCKET_CHANGED=true/, 'a changed socket unit is noted');--assert.match(deploy, /\$SYSTEMCTL restart "\$\{SERVICE\}\.socket"/, 'and restarted');++// Deploys check that systemd really holds the listener, and restart a socket unit that changed. Since++// WS-N task 11 deploy.sh hands over to `ovhost deploy live` (OpenVibe.Host strategy release-layout:++// systemd.socketHeld + rebindSocket, test/strategy-release-layout.test.js there) and falls back to++// deploy-legacy.sh, the previous script, which still does it itself.++const legacy = fs.readFileSync(path.join(__dirname, '..', 'deploy', 'scripts', 'deploy-legacy.sh'), 'utf8');++assert.match(legacy, /socket_held\(\)/, 'deploy-legacy.sh has the listener check');++assert.match(legacy, /SOCKET_CHANGED=true/, 'a changed socket unit is noted');++assert.match(legacy, /\$SYSTEMCTL restart "\$\{SERVICE\}\.socket"/, 'and restarted');++const wrapper = fs.readFileSync(path.join(__dirname, '..', 'deploy', 'scripts', 'deploy.sh'), 'utf8');++assert.match(wrapper, /^STRATEGY=release-layout$/m, 'deploy.sh only hands over to an ovhost that deploys live with release-layout (the socket rule)');++assert.match(wrapper, /deploy-api/, 'and checks the deploy API version before it does');++assert.match(wrapper, /exec bash "\$LEGACY"/, 'anything else falls back to deploy-legacy.sh');console.log('systemd units: all checks passed');
@@ -118,7 +118,7 @@ Minimum `.env`: `BASE_URL`, `JWT_SECRET`, `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL
@@ -118,7 +118,7 @@ Minimum `.env`: `BASE_URL`, `JWT_SECRET`, `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL
## Deployment
## Deployment
- Production path `/opt/openvibe.live`, env file `/etc/openvibe/live.env` (0600), unit `openvibe-live.service` (see `deploy/systemd/`).
- Production path `/opt/openvibe.live`, env file `/etc/openvibe/live.env` (0600), unit `openvibe-live.service` (see `deploy/systemd/`).
-- `deploy/scripts/deploy.sh` classifies changes: static-only deploys never restart; server changes restart behind the systemd socket with readiness checks and rollback. Details: [docs/deploy.md](docs/deploy.md).
+- `deploy/scripts/deploy.sh` runs `ovhost deploy live` (OpenVibe.Host, strategy `release-layout`), with `deploy-legacy.sh` (the previous script) as its fallback: static-only deploys never restart; server changes restart behind the systemd socket with readiness checks and rollback. Details: [docs/deploy.md](docs/deploy.md).
- nginx config at `deploy/nginx/openvibe.live.conf` (`openvibe.live`, `www.openvibe.live`, `ingest.openvibe.live`; certs `/etc/letsencrypt/live/openvibe.live/`).
- nginx config at `deploy/nginx/openvibe.live.conf` (`openvibe.live`, `www.openvibe.live`, `ingest.openvibe.live`; certs `/etc/letsencrypt/live/openvibe.live/`).
---
---
@@ -2,7 +2,8 @@
@@ -2,7 +2,8 @@
How deploys work: [../docs/deploy.md](../docs/deploy.md).
How deploys work: [../docs/deploy.md](../docs/deploy.md).
-- `scripts/deploy.sh` — classifies a change (static / server / dependencies / schema / units) and does the least disruptive thing; `--wait-idle`, `--rollback`, `DRY_RUN=1`
+- `scripts/deploy.sh` — hands over to `ovhost deploy live` (OpenVibe.Host, strategy `release-layout`): `--wait-idle`, `--restart`, `--rollback` (`ovhost rollback live`), `DRY_RUN=1` (`ovhost plan live`); falls back to `deploy-legacy.sh` when ovhost is missing, too old or does not manage Live
+- `scripts/deploy-legacy.sh` — the previous deploy script, unchanged: classifies a change (static / server / dependencies / schema / units) and does the least disruptive thing
- `scripts/migrate-to-releases.sh` — one-time move to the release layout (`releases/`, `current`, `shared/data`)
- `scripts/migrate-to-releases.sh` — one-time move to the release layout (`releases/`, `current`, `shared/data`)
- `scripts/post-deploy-check.sh` — smoke checks on the host
- `scripts/post-deploy-check.sh` — smoke checks on the host
- `systemd/openvibe-live.service` + `.socket` — current (git checkout) layout, socket activation
- `systemd/openvibe-live.service` + `.socket` — current (git checkout) layout, socket activation
new file mode 100755
new file mode 100755
@@ -0,0 +1,478 @@
@@ -0,0 +1,478 @@
+#!/usr/bin/env bash
+# ═══════════════════════════════════════════════════════════════
+# OpenVibe.Live — deploy
+#
+# Fetch, classify what changed, prepare, and do the least disruptive thing that makes it live.
+#
+# sudo deploy/scripts/deploy.sh deploy origin/main if it moved
+# sudo deploy/scripts/deploy.sh --wait-idle hold a restart until nobody is live (max 8h)
+# sudo deploy/scripts/deploy.sh --restart restart even if nothing changed
+# sudo deploy/scripts/deploy.sh --rollback release layout: switch back to the previous release
+# sudo deploy/scripts/deploy.sh --force legacy layout: discard local tracked changes
+# DRY_RUN=1 deploy/scripts/deploy.sh print the plan, change nothing
+#
+# What a change costs (see docs/deploy.md):
+# docs/, README etc., tests no process restart; the new files are just in place
+# public/ no restart — the server re-reads HTML and assets (content-hashed)
+# server/, vendor/, package* restart; HTTP connections queue on the systemd socket, but live
+# WebSocket, WHIP, WebRTC and RTMP sessions are dropped and reconnect
+# package-lock.json dependencies installed BEFORE anything is interrupted
+# deploy/systemd/ units installed, daemon-reload, restart
+# deploy/nginx/ never auto-installed (the live config is generated elsewhere);
+# the script says so
+#
+# After a deploy or rollback that went live: `ovhost announce live` (release notification to open tabs;
+# best effort, skipped without an ovhost that has `announce`, never changes the exit code).
+#
+# Two layouts:
+# release /opt/openvibe.live/releases/<id> (code + node_modules), current -> releases/<id>,
+# shared/data. A deploy builds a new release while the old one serves; switching is an
+# atomic symlink rename; rollback switches back to a release that still has its own
+# node_modules. Set up once with deploy/scripts/migrate-to-releases.sh.
+# legacy /opt/openvibe.live is a git checkout (what production runs until migrated).
+#
+# Exit codes: 0 success · 1 usage/precondition · 2 validation failed · 3 not ready after restart
+# (rolled back) · 4 rollback itself failed — MANUAL INTERVENTION · 5 gave up waiting for idle
+# ═══════════════════════════════════════════════════════════════
+set -euo pipefail
+
+FORCE_DEPLOY=false
+ALWAYS_RESTART=false
+WAIT_IDLE=false
+ROLLBACK=false
+while [ "$#" -gt 0 ]; do
+ case "$1" in
+ --force|-f|--ignore-local-changes) FORCE_DEPLOY=true; shift ;;
+ --restart) ALWAYS_RESTART=true; shift ;;
+ --wait-idle) WAIT_IDLE=true; shift ;;
+ --rollback) ROLLBACK=true; shift ;;
+ --) shift; break ;;
+ *) echo "Usage: $0 [--wait-idle] [--restart] [--rollback] [--force]"; exit 1 ;;
+ esac
+done
+
+BASE_DIR="${BASE_DIR:-/opt/openvibe.live}"
+SERVICE="${SERVICE:-openvibe-live}"
+SYSTEMD_DIR="${SYSTEMD_DIR:-/etc/systemd/system}"
+SYSTEMCTL="${SYSTEMCTL:-systemctl}"
+SS="${SS:-ss}"
+SOCKET_CHANGED=false
+# Unit files land in /etc: when systemctl is being run through sudo, so is the install.
+SUDO_INSTALL=""; case "$SYSTEMCTL" in sudo*) SUDO_INSTALL="sudo";; esac; [ "$(id -u)" -eq 0 ] && SUDO_INSTALL=""
+SITE_URL="${SITE_URL:-https://openvibe.live}"
+API_URL="${API_URL:-http://127.0.0.1:3000}"
+GIT_REMOTE="${GIT_REMOTE:-origin}"
+GIT_BRANCH="${GIT_BRANCH:-main}"
+READY_PATH="${READY_PATH:-/api/ready}"
+UPDATES_PATH="${UPDATES_PATH:-/updates}"
+BROADCAST_ENDPOINT="${BROADCAST_ENDPOINT:-/api/admin/broadcast}"
+READY_TIMEOUT="${READY_TIMEOUT:-90}"
+IDLE_MAX_SECONDS="${IDLE_MAX_SECONDS:-28800}"
+KEEP_RELEASES="${KEEP_RELEASES:-5}"
+NPM="${NPM:-npm}"
+DRY_RUN="${DRY_RUN:-0}"
+# Not /tmp: fs.protected_regular stops root from reopening a lock a plain user created there.
+LOCK_FILE="${LOCK_FILE:-$BASE_DIR/.deploy.lock}"
+
+say() { echo "[Deploy] $*"; }
+die() { echo "[Deploy] ✗ $1" >&2; exit "${2:-1}"; }
+run() { if [ "$DRY_RUN" = "1" ]; then echo " would run: $*"; else "$@"; fi; }
+
+exec 9>"$LOCK_FILE"
+flock -n 9 || die "another deploy is already running (lock: $LOCK_FILE)"
+
+if [ -d "$BASE_DIR/releases" ] && [ -L "$BASE_DIR/current" ]; then LAYOUT=release; else LAYOUT=legacy; fi
+
+echo "╔══════════════════════════════════════╗"
+echo "║ OpenVibe.Live — deploy ║"
+echo "╚══════════════════════════════════════╝"
+say "base=$BASE_DIR layout=$LAYOUT service=$SERVICE branch=$GIT_BRANCH dry_run=$DRY_RUN"
+
+# ── Classification ───────────────────────────────────────────────────────────
+# Prints one word per category that the changed file list touches.
+classify() {
+ local files="$1" cats=""
+ add() { case " $cats " in *" $1 "*) ;; *) cats="$cats $1" ;; esac; }
+ while IFS= read -r f; do
+ [ -n "$f" ] || continue
+ case "$f" in
+ package.json|package-lock.json) add deps; add server ;;
+ server/db/migrations.js|server/db/schema.sql) add schema; add server ;;
+ server/*|vendor/*|.env.example) add server ;;
+ public/*) add static ;;
+ deploy/systemd/*) add systemd ;;
+ deploy/nginx/*) add nginx ;;
+ docs/*) add static ;;
+ *) add none ;;
+ esac
+ done <<< "$files"
+ echo "$cats"
+}
+has() { case " $1 " in *" $2 "*) return 0 ;; *) return 1 ;; esac; }
+
+# Whether package.json changed in a way that needs an install (scripts/description edits do not).
+deps_changed() {
+ local from="$1" to="$2" dir="$3"
+ git -C "$dir" diff --quiet "$from" "$to" -- package-lock.json || return 0
+ local a b
+ a=$(git -C "$dir" show "$from:package.json" 2>/dev/null | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{try{const j=JSON.parse(d);console.log(JSON.stringify([j.dependencies,j.optionalDependencies,j.engines]))}catch{console.log("x")}})')
+ b=$(git -C "$dir" show "$to:package.json" 2>/dev/null | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{try{const j=JSON.parse(d);console.log(JSON.stringify([j.dependencies,j.optionalDependencies,j.engines]))}catch{console.log("y")}})')
+ [ "$a" != "$b" ]
+}
+
+# ── Live-stream guard ────────────────────────────────────────────────────────
+# A restart drops every RTMP/WHIP/WebRTC publisher. Socket activation keeps HTTP accepting, nothing more.
+live_count() {
+ curl -sf --max-time 5 "${API_URL}/api/streams" 2>/dev/null \
+ | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{try{const j=JSON.parse(d);console.log((j.streams||[]).filter(s=>s.is_live).length)}catch{console.log(0)}})' \
+ || echo 0
+}
+wait_for_idle() {
+ [ "$WAIT_IDLE" = true ] || return 0
+ local waited=0 quiet=0
+ while [ "$waited" -lt "$IDLE_MAX_SECONDS" ]; do
+ local n; n=$(live_count)
+ if [ "$n" = "0" ]; then quiet=$((quiet+1)); else quiet=0; fi
+ [ "$quiet" -ge 2 ] && { say "no one live for two checks — proceeding"; return 0; }
+ say "$n live stream(s); holding the restart (${waited}s waited)"
+ [ "$DRY_RUN" = "1" ] && return 0
+ sleep 60; waited=$((waited+60))
+ done
+ die "still live after ${IDLE_MAX_SECONDS}s; nothing was restarted" 5
+}
+
+wait_ready() {
+ local deadline=$(( $(date +%s) + READY_TIMEOUT ))
+ while [ "$(date +%s)" -lt "$deadline" ]; do
+ if curl -sf --max-time 5 "${API_URL}${READY_PATH}" >/dev/null 2>&1; then return 0; fi
+ $SYSTEMCTL is-active --quiet "$SERVICE" || { say "service is not running"; return 1; }
+ sleep 1; printf '.'
+ done
+ printf '\n'; return 1
+}
+
+notify_chat() {
+ local summary="$1"
+ curl -sf -X POST "${API_URL}${BROADCAST_ENDPOINT}" \
+ -H "Content-Type: application/json" -H "Authorization: Bearer ${ADMIN_TOKEN:-}" \
+ -d "{\"type\":\"update\",\"summary\":$(printf '%s' "$summary" | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>console.log(JSON.stringify(d.trim())))'),\"url\":\"${SITE_URL}${UPDATES_PATH}\"}" \
+ >/dev/null 2>&1 && say "chat notified" || say "chat notification skipped"
+}
+
+install_units() {
+ local unit_dir="$1" changed=false
+ install_unit() {
+ local src="$1" dest="$2"
+ [ -f "$src" ] || return 0
+ if [ ! -f "$dest" ] || ! cmp -s "$src" "$dest"; then
+ say "installing $(basename "$dest")"
+ run $SUDO_INSTALL install -m 0644 -D "$src" "$dest"
+ changed=true
+ fi
+ }
+ install_unit "$unit_dir/${SERVICE}.service" "$SYSTEMD_DIR/${SERVICE}.service"
+ local socket_before=false
+ [ -f "$SYSTEMD_DIR/${SERVICE}.socket" ] && [ -f "$unit_dir/${SERVICE}.socket" ] && ! cmp -s "$unit_dir/${SERVICE}.socket" "$SYSTEMD_DIR/${SERVICE}.socket" && socket_before=true
+ install_unit "$unit_dir/${SERVICE}.socket" "$SYSTEMD_DIR/${SERVICE}.socket"
+ # A socket unit changed under a running socket: after daemon-reload systemd may drop its descriptor
+ # ("no socket file descriptors are open ... not functional until restarted"); restart_and_verify then
+ # restarts the socket itself. Left alone, the service binds the port on its own and every later
+ # restart refuses connections (what happened from 2026-09-25 to 2026-09-26).
+ [ "$socket_before" = true ] && SOCKET_CHANGED=true
+ install_unit "$unit_dir/${SERVICE}.service.d/socket.conf" "$SYSTEMD_DIR/${SERVICE}.service.d/socket.conf"
+ if [ "$changed" = true ]; then
+ run $SYSTEMCTL daemon-reload
+ run $SYSTEMCTL enable "${SERVICE}.socket" >/dev/null 2>&1 || true
+ run $SYSTEMCTL enable "$SERVICE" >/dev/null 2>&1 || true
+ fi
+}
+
+syntax_check() {
+ local dir="$1" files="$2" checked=0
+ while IFS= read -r f; do
+ case "$f" in
+ *.js) [ -f "$dir/$f" ] || continue
+ node --check "$dir/$f" >/dev/null 2>&1 || die "syntax error in $f" 2
+ checked=$((checked+1)) ;;
+ *.json) [ -f "$dir/$f" ] || continue
+ node -e 'JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"))' "$dir/$f" 2>/dev/null || die "invalid JSON in $f" 2 ;;
+ esac
+ done <<< "$files"
+ say "syntax-checked $checked changed JS file(s)"
+}
+
+backup_db() {
+ # A schema migration is about to run on restart: take a consistent online copy first.
+ local db="$1"
+ [ -f "$db" ] || { say "no database at $db to back up"; return 0; }
+ local out; out="$(dirname "$db")/backups/live-$(date +%Y%m%d-%H%M%S).db"
+ run mkdir -p "$(dirname "$out")"
+ say "schema changed — backing up $(basename "$db") to $out"
+ if [ "$DRY_RUN" != "1" ]; then
+ (cd "$2" && node -e 'const D=require("better-sqlite3");new D(process.argv[1],{readonly:true}).backup(process.argv[2]).then(()=>process.exit(0),e=>{console.error(e.message);process.exit(1)})' "$db" "$out") \
+ || die "database backup failed; nothing was restarted" 2
+ fi
+}
+
+# Does systemd (pid 1) hold the socket unit's listener? `is-active` is not enough: a socket unit whose
+# descriptor was dropped still reads active while the service binds the port itself.
+socket_held() {
+ local listen port
+ listen=$($SYSTEMCTL show "${SERVICE}.socket" -p Listen --value 2>/dev/null | head -1 | awk '{print $1}')
+ port=${listen##*:}
+ [ -n "$port" ] || return 1
+ $SS -Hltnp "sport = :$port" 2>/dev/null | grep -q '"systemd",pid=1,'
+}
+
+restart_and_verify() {
+ local on_fail="$1" socketed=false
+ if $SYSTEMCTL list-unit-files "${SERVICE}.socket" >/dev/null 2>&1 && $SYSTEMCTL is-enabled --quiet "${SERVICE}.socket" 2>/dev/null; then
+ socketed=true
+ $SYSTEMCTL is-active --quiet "${SERVICE}.socket" || $SYSTEMCTL start "${SERVICE}.socket" || true
+ fi
+ local start; start=$(date +%s)
+ if [ "$socketed" = true ] && { [ "$SOCKET_CHANGED" = true ] || ! socket_held; }; then
+ # The socket must be rebound: stop the service (it may hold the port itself), restart the
+ # socket, start the service on it. HTTP is refused for that moment, once.
+ say "socket unit $([ "$SOCKET_CHANGED" = true ] && echo changed || echo 'is not holding its listener') — stopping ${SERVICE}, restarting the socket"
+ $SYSTEMCTL stop "$SERVICE" 9>&- || true
+ $SYSTEMCTL restart "${SERVICE}.socket" || say "✗ could not restart ${SERVICE}.socket"
+ $SYSTEMCTL start "$SERVICE" 9>&-
+ SOCKET_CHANGED=false
+ else
+ [ "$socketed" = true ] && say "socket unit holds the listener — new HTTP connections queue through the restart"
+ say "restarting ${SERVICE}…"
+ $SYSTEMCTL restart "$SERVICE" 9>&-
+ fi
+ printf '[Deploy] waiting for readiness'
+ if wait_ready; then
+ printf '\n'; say "ready after $(( $(date +%s) - start ))s"
+ if [ "$socketed" = true ] && ! socket_held; then
+ say "✗ socket activation is not in effect: systemd does not hold the listener, so restarts refuse connections (check journalctl -u ${SERVICE}.socket)"
+ fi
+ return 0
+ fi
+ say "✗ not ready within ${READY_TIMEOUT}s"
+ journalctl -u "$SERVICE" -n 40 --no-pager 2>/dev/null | sed 's/^/ /' || true
+ $on_fail
+}
+
+# ── Release notification (WS-P task 9) ───────────────────────────────────────
+# OpenVibe.Host publishes host.release.published to OpenVibe.Events, so open tabs check /release.json now
+# instead of at their next poll. ovhost announces the release Live's own /release.json reports, once per
+# release. Best effort: skipped when ovhost is missing or has no `announce`, 20 s at most, and it never
+# fails a deploy (see OpenVibe.Host docs/release-notifications.md).
+OVHOST="${OVHOST:-ovhost}"
+announce_release() {
+ [ "$DRY_RUN" = "1" ] && return 0
+ command -v "$OVHOST" >/dev/null 2>&1 || return 0
+ local help
+ help=$("$OVHOST" --help 2>/dev/null || true)
+ case "$help" in *"announce <service>"*) ;; *) say "release notification skipped: this ovhost has no announce"; return 0 ;; esac
+ timeout 20 "$OVHOST" announce live 9>&- 2>&1 | sed 's/^/ /' || say "release notification not sent (the deploy stands)"
+}
+
+# ═══════════════════════════════════════════════════════════════
+# Release layout
+# ═══════════════════════════════════════════════════════════════
+if [ "$LAYOUT" = release ]; then
+ REPO="$BASE_DIR/repo"
+ CURRENT=$(readlink -f "$BASE_DIR/current")
+ CUR_ID=$(basename "$CURRENT")
+ CUR_SHA=$(git -C "$CURRENT" rev-parse HEAD 2>/dev/null || echo none)
+ say "current release: $CUR_ID (${CUR_SHA:0:8})"
+
+ switch_to() {
+ # Atomic: create the new link beside the old one and rename over it.
+ local target="$1"
+ run ln -sfn "$target" "$BASE_DIR/.current.tmp"
+ run mv -Tf "$BASE_DIR/.current.tmp" "$BASE_DIR/current"
+ }
+
+ if [ "$ROLLBACK" = true ]; then
+ PREV=$(ls -1dt "$BASE_DIR"/releases/*/ 2>/dev/null | sed 's:/$::' | grep -vx "$CURRENT" | head -1 || true)
+ [ -n "$PREV" ] || die "no previous release to roll back to"
+ PREV_SHA=$(git -C "$PREV" rev-parse HEAD 2>/dev/null || echo none)
+ CATS=$(classify "$(git -C "$REPO" diff --name-only "$PREV_SHA" "$CUR_SHA" 2>/dev/null || echo server/)")
+ say "rolling back to $(basename "$PREV") (${PREV_SHA:0:8}); change classes:${CATS}"
+ [ "$DRY_RUN" = "1" ] && { say "dry run complete — nothing was changed."; exit 0; }
+ switch_to "$PREV"
+ if has "$CATS" server || has "$CATS" systemd || [ "$ALWAYS_RESTART" = true ]; then
+ install_units "$PREV/deploy/systemd/release"
+ wait_for_idle
+ restart_and_verify 'die "ROLLBACK RELEASE IS NOT READY — MANUAL INTERVENTION REQUIRED" 4'
+ else
+ say "static-only difference — switched without a restart"
+ fi
+ # Keep the release we left so it can be re-selected; just move it out of "newest".
+ touch -h "$PREV"
+ say "rolled back to $(basename "$PREV") ✅"
+ announce_release
+ exit 0
+ fi
+
+ say "fetching ${GIT_REMOTE}/${GIT_BRANCH}…"
+ run git -C "$REPO" fetch --quiet "$GIT_REMOTE" "$GIT_BRANCH"
+ TARGET_SHA=$(git -C "$REPO" rev-parse "${GIT_REMOTE}/${GIT_BRANCH}")
+ if [ "$TARGET_SHA" = "$CUR_SHA" ] && [ "$ALWAYS_RESTART" != true ]; then
+ say "already at ${TARGET_SHA:0:8}; nothing to deploy."
+ exit 0
+ fi
+
+ CHANGED=$(git -C "$REPO" diff --name-only "$CUR_SHA" "$TARGET_SHA" 2>/dev/null || true)
+ CATS=$(classify "$CHANGED")
+ [ "$TARGET_SHA" = "$CUR_SHA" ] && CATS=" server"
+ COMMIT_LOG=$(git -C "$REPO" --no-pager log --oneline "${CUR_SHA}..${TARGET_SHA}" 2>/dev/null || echo "Restart requested")
+ COMMIT_COUNT=$(printf '%s\n' "$COMMIT_LOG" | grep -c . || true)
+ say "$COMMIT_COUNT new commit(s); change classes:${CATS:- none}"
+ printf '%s\n' "$COMMIT_LOG" | sed 's/^/ /'
+
+ NEW_ID="$(date +%Y%m%d-%H%M%S)-${TARGET_SHA:0:8}"
+ NEW="$BASE_DIR/releases/$NEW_ID"
+ say "preparing release $NEW_ID while $CUR_ID keeps serving…"
+ run git -C "$REPO" worktree add --detach "$NEW" "$TARGET_SHA" >/dev/null
+ [ "$DRY_RUN" = "1" ] || syntax_check "$NEW" "$CHANGED"
+ run ln -sfn ../../shared/data "$NEW/data"
+
+ if [ "$DRY_RUN" != "1" ] && deps_changed "$CUR_SHA" "$TARGET_SHA" "$REPO"; then
+ say "dependencies changed — installing into the new release (nothing interrupted yet)…"
+ (cd "$NEW" && $NPM ci --omit=dev --no-audit --no-fund) || { git -C "$REPO" worktree remove --force "$NEW" || true; die "npm ci failed; the current release is untouched" 2; }
+ else
+ # Same lockfile: hard-link the current release's node_modules — instant, no extra disk, and the
+ # new release still owns its own tree (npm never writes into an installed package in place).
+ say "dependencies unchanged — linking node_modules from $CUR_ID"
+ run cp -al "$CURRENT/node_modules" "$NEW/node_modules"
+ fi
+
+ if has "$CATS" nginx; then
+ say "note: deploy/nginx changed and is NOT installed by this script — copy it to /etc/nginx/sites-available,"
+ say " run nginx -t and reload (OpenVibe.Network's generator mirrors it)."
+ fi
+
+ [ "$DRY_RUN" = "1" ] && { say "dry run complete — nothing was changed."; exit 0; }
+
+ NEEDS_RESTART=false
+ if has "$CATS" server || has "$CATS" systemd || [ "$ALWAYS_RESTART" = true ]; then NEEDS_RESTART=true; fi
+
+ if [ "$NEEDS_RESTART" = true ]; then
+ wait_for_idle
+ has "$CATS" schema && backup_db "$BASE_DIR/shared/data/live.db" "$NEW"
+ FIRST_LINE=$(printf '%s\n' "$COMMIT_LOG" | head -1 | sed 's/^[a-f0-9]* //')
+ notify_chat "$([ "$COMMIT_COUNT" -le 1 ] && echo "Update: ${FIRST_LINE}" || echo "${COMMIT_COUNT} updates deployed — ${FIRST_LINE}")"
+ switch_to "$NEW"
+ install_units "$NEW/deploy/systemd/release"
+ rollback_release() {
+ say "rolling back to $CUR_ID"
+ switch_to "$CURRENT"
+ install_units "$CURRENT/deploy/systemd/release"
+ $SYSTEMCTL restart "$SERVICE" 9>&- || die "ROLLBACK FAILED: service would not restart" 4
+ printf '[Deploy] waiting for rollback'
+ if wait_ready; then printf '\n'; say "rolled back to $CUR_ID and serving"; exit 3; fi
+ die "ROLLBACK FAILED: $CUR_ID is not ready either — MANUAL INTERVENTION REQUIRED" 4
+ }
+ restart_and_verify rollback_release
+ say "note: established WebSocket, WHIP, RTMP and WebRTC sessions were reconnected, not preserved."
+ else
+ switch_to "$NEW"
+ # The running process reads public/ and docs/ through the `current` link (OV_APP_ROOT), and
+ # re-hashes assets within ~2s. Confirm the site answers; no process was restarted.
+ sleep 3
+ curl -sf --max-time 10 "${API_URL}${READY_PATH}" >/dev/null || { switch_to "$CURRENT"; die "site not ready after switching — switched back to $CUR_ID" 3; }
+ say "switched to $NEW_ID without a restart (change classes:${CATS})"
+ fi
+
+ # Prune old releases (never the current one, never the one we just left).
+ ls -1dt "$BASE_DIR"/releases/*/ 2>/dev/null | sed 's:/$::' | tail -n +$((KEEP_RELEASES+1)) | while read -r old; do
+ [ "$old" = "$(readlink -f "$BASE_DIR/current")" ] && continue
+ [ "$old" = "$CURRENT" ] && continue
+ say "pruning $(basename "$old")"
+ git -C "$REPO" worktree remove --force "$old" 2>/dev/null || rm -rf "$old"
+ done
+ git -C "$REPO" worktree prune 2>/dev/null || true
+ say "deployed $CUR_ID → $NEW_ID ✅"
+ announce_release
+ exit 0
+fi
+
+# ═══════════════════════════════════════════════════════════════
+# Legacy layout (git checkout in place)
+# ═══════════════════════════════════════════════════════════════
+[ "$ROLLBACK" = true ] && die "--rollback needs the release layout (deploy/scripts/migrate-to-releases.sh)"
+REPO_DIR="$BASE_DIR"
+cd "$REPO_DIR" || die "repo not found at $REPO_DIR"
+OLD_HASH=$(git rev-parse HEAD 2>/dev/null || echo none)
+say "current commit: ${OLD_HASH:0:8}"
+
+if [ "$FORCE_DEPLOY" = true ]; then
+ say "force: discarding local tracked changes (untracked files are kept)"
+ run git reset --hard HEAD
+elif ! git diff --quiet || ! git diff --cached --quiet; then
+ die "working tree has uncommitted changes — commit them, or re-run with --force"
+fi
+
+say "fetching ${GIT_REMOTE}/${GIT_BRANCH}…"
+run git fetch --quiet "$GIT_REMOTE" "$GIT_BRANCH"
+TARGET_HASH=$(git rev-parse "${GIT_REMOTE}/${GIT_BRANCH}" 2>/dev/null || echo "$OLD_HASH")
+if [ "$OLD_HASH" = "$TARGET_HASH" ] && [ "$ALWAYS_RESTART" != true ]; then
+ say "already at ${TARGET_HASH:0:8}; nothing to deploy."
+ exit 0
+fi
+
+CHANGED=""; CATS=" server"
+if [ "$OLD_HASH" != "$TARGET_HASH" ]; then
+ CHANGED=$(git diff --name-only "$OLD_HASH" "$TARGET_HASH" || true)
+ CATS=$(classify "$CHANGED")
+ COMMIT_LOG=$(git --no-pager log --oneline "${OLD_HASH}..${TARGET_HASH}" 2>/dev/null || echo "Update deployed")
+ COMMIT_COUNT=$(printf '%s\n' "$COMMIT_LOG" | grep -c . || true)
+ say "$COMMIT_COUNT new commit(s); change classes:${CATS:- none}"
+ printf '%s\n' "$COMMIT_LOG" | sed 's/^/ /'
+else
+ COMMIT_LOG="Restart requested"; COMMIT_COUNT=0
+fi
+
+NEEDS_RESTART=false
+if has "$CATS" server || has "$CATS" systemd || [ "$ALWAYS_RESTART" = true ]; then NEEDS_RESTART=true; fi
+
+# In place, the old process keeps running against files that are about to change underneath it, so
+# anything that needs a restart waits for idle BEFORE the checkout moves.
+[ "$NEEDS_RESTART" = true ] && wait_for_idle
+
+say "validating the target revision…"
+run git merge --ff-only "$TARGET_HASH" >/dev/null 2>&1 || { [ "$DRY_RUN" = "1" ] || die "fast-forward to ${TARGET_HASH:0:8} failed — diverged history" 2; }
+NEW_HASH=$(git rev-parse HEAD)
+[ -n "$CHANGED" ] && [ "$DRY_RUN" != "1" ] && syntax_check "$REPO_DIR" "$CHANGED"
+
+if [ "$DRY_RUN" != "1" ] && [ "$OLD_HASH" != "$TARGET_HASH" ] && deps_changed "$OLD_HASH" "$TARGET_HASH" "$REPO_DIR"; then
+ say "dependencies changed — installing (service still running)…"
+ say "warning: legacy layout — a rollback would NOT restore the previous node_modules. Migrate to releases."
+ run $NPM ci --omit=dev --no-audit --no-fund || die "npm ci failed" 2
+fi
+has "$CATS" nginx && say "note: deploy/nginx changed — not installed here; copy it, nginx -t, reload (see docs/deploy.md)."
+
+if [ "$DRY_RUN" = "1" ]; then say "dry run complete — nothing was changed. restart needed: $NEEDS_RESTART"; exit 0; fi
+
+if [ "$NEEDS_RESTART" != true ]; then
+ say "change classes:${CATS} — no restart needed; the server re-reads public/ and docs/ from disk."
+ say "deployed ${OLD_HASH:0:8} → ${NEW_HASH:0:8} without interrupting anyone ✅"
+ announce_release
+ exit 0
+fi
+
+install_units "$REPO_DIR/deploy/systemd"
+has "$CATS" schema && backup_db "$REPO_DIR/data/live.db" "$REPO_DIR"
+FIRST_LINE=$(printf '%s\n' "$COMMIT_LOG" | head -1 | sed 's/^[a-f0-9]* //')
+notify_chat "$([ "$COMMIT_COUNT" -le 1 ] && echo "Update: ${FIRST_LINE}" || echo "${COMMIT_COUNT} updates deployed — ${FIRST_LINE}")"
+rollback_legacy() {
+ say "rolling back to ${OLD_HASH:0:8}"
+ git reset --hard "$OLD_HASH" >/dev/null 2>&1 || die "ROLLBACK FAILED: could not reset to $OLD_HASH" 4
+ $SYSTEMCTL restart "$SERVICE" 9>&- || die "ROLLBACK FAILED: service would not restart" 4
+ printf '[Deploy] waiting for rollback'
+ if wait_ready; then printf '\n'; say "rolled back to ${OLD_HASH:0:8} and serving"; exit 3; fi
+ die "ROLLBACK FAILED: ${OLD_HASH:0:8} is not ready either — MANUAL INTERVENTION REQUIRED" 4
+}
+restart_and_verify rollback_legacy
+curl -sf --max-time 5 "${API_URL}/api/health" >/dev/null || die "health endpoint not responding after readiness" 3
+say "deployed ${OLD_HASH:0:8} → ${NEW_HASH:0:8} (${COMMIT_COUNT} commit(s))"
+say "note: established WebSocket, WHIP, RTMP and WebRTC sessions were reconnected, not preserved."
+say "done ✅"
+announce_release
@@ -1,478 +1,92 @@
@@ -1,478 +1,92 @@
#!/usr/bin/env bash
#!/usr/bin/env bash
# ═══════════════════════════════════════════════════════════════
# ═══════════════════════════════════════════════════════════════
-# OpenVibe.Live — deploy
+# OpenVibe.Live — deploy (a thin wrapper around `ovhost deploy live`)
#
#
-# Fetch, classify what changed, prepare, and do the least disruptive thing that makes it live.
+# OpenVibe.Host's ovhost (/usr/local/bin/ovhost, strategy "release-layout", roadmap WS-N task 11) now
+# does what this script did: a new release is prepared in releases/<time>-<sha8> while `current` serves
+# (npm ci only when dependencies changed, else node_modules hard-linked; node --check on changed files),
+# `current` switches atomically, public/ and docs/ switch without a restart, the socket unit stays held by
+# systemd (rebound only when its unit file changed or pid 1 does not hold :3000), readiness gates the
+# restart, a release that is not ready is switched back (exit 3), old releases are pruned, and the release
+# is announced. See OpenVibe.Host docs/deploy-strategies.md.
#
#
-# sudo deploy/scripts/deploy.sh deploy origin/main if it moved
-# sudo deploy/scripts/deploy.sh --wait-idle hold a restart until nobody is live (max 8h)
-# sudo deploy/scripts/deploy.sh --restart restart even if nothing changed
-# sudo deploy/scripts/deploy.sh --rollback release layout: switch back to the previous release
-# sudo deploy/scripts/deploy.sh --force legacy layout: discard local tracked changes
-# DRY_RUN=1 deploy/scripts/deploy.sh print the plan, change nothing
+# sudo deploy/scripts/deploy.sh ovhost deploy live
+# sudo deploy/scripts/deploy.sh --wait-idle ovhost deploy live --wait-idle (hold a restart until nobody is live)
+# sudo deploy/scripts/deploy.sh --restart ovhost deploy live --restart
+# sudo deploy/scripts/deploy.sh --rollback ovhost rollback live (the release the current one replaced)
+# DRY_RUN=1 deploy/scripts/deploy.sh ovhost plan live (changes nothing)
+# READY_TIMEOUT=<s> --ready-timeout <s>
#
#
-# What a change costs (see docs/deploy.md):
-# docs/, README etc., tests no process restart; the new files are just in place
-# public/ no restart — the server re-reads HTML and assets (content-hashed)
-# server/, vendor/, package* restart; HTTP connections queue on the systemd socket, but live
-# WebSocket, WHIP, WebRTC and RTMP sessions are dropped and reconnect
-# package-lock.json dependencies installed BEFORE anything is interrupted
-# deploy/systemd/ units installed, daemon-reload, restart
-# deploy/nginx/ never auto-installed (the live config is generated elsewhere);
-# the script says so
+# --force meant "discard local tracked changes" in the legacy in-place layout. ovhost never discards changes,
+# and its --force drops live streams (and passes a freeze), so the wrapper refuses it: run
+# `sudo ovhost deploy live --force` yourself when that is what you mean.
#
#
-# After a deploy or rollback that went live: `ovhost announce live` (release notification to open tabs;
-# best effort, skipped without an ovhost that has `announce`, never changes the exit code).
+# Fallback: deploy-legacy.sh (the previous script, unchanged) runs instead, with the same arguments, when
+# ovhost is missing or too old (no `capabilities`, deploy-api < 1), or the host inventory does not deploy
+# live with strategy release-layout. OVHOST_LEGACY=1 forces it; OVHOST=<path> picks another ovhost.
#
#
-# Two layouts:
-# release /opt/openvibe.live/releases/<id> (code + node_modules), current -> releases/<id>,
-# shared/data. A deploy builds a new release while the old one serves; switching is an
-# atomic symlink rename; rollback switches back to a release that still has its own
-# node_modules. Set up once with deploy/scripts/migrate-to-releases.sh.
-# legacy /opt/openvibe.live is a git checkout (what production runs until migrated).
-#
-# Exit codes: 0 success · 1 usage/precondition · 2 validation failed · 3 not ready after restart
-# (rolled back) · 4 rollback itself failed — MANUAL INTERVENTION · 5 gave up waiting for idle
+# Exit codes are ovhost's, the same as before: 0 ok · 1 usage/precondition · 2 validation failed, nothing
+# restarted · 3 not ready, rolled back · 4 rollback failed (manual intervention) · 5 protected sessions ·
+# 6 frozen (ovhost freeze).
# ═══════════════════════════════════════════════════════════════
# ═══════════════════════════════════════════════════════════════
set -euo pipefail
set -euo pipefail
-FORCE_DEPLOY=false
-ALWAYS_RESTART=false
-WAIT_IDLE=false
-ROLLBACK=false
+SERVICE=live
+STRATEGY=release-layout
+HERE=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
+LEGACY="${DEPLOY_LEGACY:-$HERE/deploy-legacy.sh}"
+OVHOST="${OVHOST:-/usr/local/bin/ovhost}"
+# ovhost runs as root; tests set OVHOST_SUDO= to call it directly.
+if [ "${OVHOST_SUDO-auto}" = auto ]; then if [ "$(id -u)" -eq 0 ]; then SUDO=(); else SUDO=(sudo); fi; elif [ -n "${OVHOST_SUDO}" ]; then SUDO=("$OVHOST_SUDO"); else SUDO=(); fi
+
+say() { echo "[Deploy] $*"; }
+
+ORIG=("$@")
+CMD=deploy
+FLAGS=()
+FORCE=false
while [ "$#" -gt 0 ]; do
while [ "$#" -gt 0 ]; do
case "$1" in
case "$1" in
- --force|-f|--ignore-local-changes) FORCE_DEPLOY=true; shift ;;
- --restart) ALWAYS_RESTART=true; shift ;;
- --wait-idle) WAIT_IDLE=true; shift ;;
- --rollback) ROLLBACK=true; shift ;;
+ --wait-idle) FLAGS+=(--wait-idle); shift ;;
+ --restart) FLAGS+=(--restart); shift ;;
+ --rollback) CMD=rollback; shift ;;
+ --force|-f|--ignore-local-changes) FORCE=true; shift ;;
--) shift; break ;;
--) shift; break ;;
- *) echo "Usage: $0 [--wait-idle] [--restart] [--rollback] [--force]"; exit 1 ;;
+ *) echo "Usage: $0 [--wait-idle] [--restart] [--rollback] (DRY_RUN=1 for the plan)"; exit 1 ;;
esac
esac
done
done
+[ -n "${READY_TIMEOUT:-}" ] && FLAGS+=(--ready-timeout "$READY_TIMEOUT")
-BASE_DIR="${BASE_DIR:-/opt/openvibe.live}"
-SERVICE="${SERVICE:-openvibe-live}"
-SYSTEMD_DIR="${SYSTEMD_DIR:-/etc/systemd/system}"
-SYSTEMCTL="${SYSTEMCTL:-systemctl}"
-SS="${SS:-ss}"
-SOCKET_CHANGED=false
-# Unit files land in /etc: when systemctl is being run through sudo, so is the install.
-SUDO_INSTALL=""; case "$SYSTEMCTL" in sudo*) SUDO_INSTALL="sudo";; esac; [ "$(id -u)" -eq 0 ] && SUDO_INSTALL=""
-SITE_URL="${SITE_URL:-https://openvibe.live}"
-API_URL="${API_URL:-http://127.0.0.1:3000}"
-GIT_REMOTE="${GIT_REMOTE:-origin}"
-GIT_BRANCH="${GIT_BRANCH:-main}"
-READY_PATH="${READY_PATH:-/api/ready}"
-UPDATES_PATH="${UPDATES_PATH:-/updates}"
-BROADCAST_ENDPOINT="${BROADCAST_ENDPOINT:-/api/admin/broadcast}"
-READY_TIMEOUT="${READY_TIMEOUT:-90}"
-IDLE_MAX_SECONDS="${IDLE_MAX_SECONDS:-28800}"
-KEEP_RELEASES="${KEEP_RELEASES:-5}"
-NPM="${NPM:-npm}"
-DRY_RUN="${DRY_RUN:-0}"
-# Not /tmp: fs.protected_regular stops root from reopening a lock a plain user created there.
-LOCK_FILE="${LOCK_FILE:-$BASE_DIR/.deploy.lock}"
-
-say() { echo "[Deploy] $*"; }
-die() { echo "[Deploy] ✗ $1" >&2; exit "${2:-1}"; }
-run() { if [ "$DRY_RUN" = "1" ]; then echo " would run: $*"; else "$@"; fi; }
-
-exec 9>"$LOCK_FILE"
-flock -n 9 || die "another deploy is already running (lock: $LOCK_FILE)"
-
-if [ -d "$BASE_DIR/releases" ] && [ -L "$BASE_DIR/current" ]; then LAYOUT=release; else LAYOUT=legacy; fi
-
-echo "╔══════════════════════════════════════╗"
-echo "║ OpenVibe.Live — deploy ║"
-echo "╚══════════════════════════════════════╝"
-say "base=$BASE_DIR layout=$LAYOUT service=$SERVICE branch=$GIT_BRANCH dry_run=$DRY_RUN"
-
-# ── Classification ───────────────────────────────────────────────────────────
-# Prints one word per category that the changed file list touches.
-classify() {
- local files="$1" cats=""
- add() { case " $cats " in *" $1 "*) ;; *) cats="$cats $1" ;; esac; }
- while IFS= read -r f; do
- [ -n "$f" ] || continue
- case "$f" in
- package.json|package-lock.json) add deps; add server ;;
- server/db/migrations.js|server/db/schema.sql) add schema; add server ;;
- server/*|vendor/*|.env.example) add server ;;
- public/*) add static ;;
- deploy/systemd/*) add systemd ;;
- deploy/nginx/*) add nginx ;;
- docs/*) add static ;;
- *) add none ;;
- esac
- done <<< "$files"
- echo "$cats"
-}
-has() { case " $1 " in *" $2 "*) return 0 ;; *) return 1 ;; esac; }
-
-# Whether package.json changed in a way that needs an install (scripts/description edits do not).
-deps_changed() {
- local from="$1" to="$2" dir="$3"
- git -C "$dir" diff --quiet "$from" "$to" -- package-lock.json || return 0
- local a b
- a=$(git -C "$dir" show "$from:package.json" 2>/dev/null | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{try{const j=JSON.parse(d);console.log(JSON.stringify([j.dependencies,j.optionalDependencies,j.engines]))}catch{console.log("x")}})')
- b=$(git -C "$dir" show "$to:package.json" 2>/dev/null | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{try{const j=JSON.parse(d);console.log(JSON.stringify([j.dependencies,j.optionalDependencies,j.engines]))}catch{console.log("y")}})')
- [ "$a" != "$b" ]
-}
-
-# ── Live-stream guard ────────────────────────────────────────────────────────
-# A restart drops every RTMP/WHIP/WebRTC publisher. Socket activation keeps HTTP accepting, nothing more.
-live_count() {
- curl -sf --max-time 5 "${API_URL}/api/streams" 2>/dev/null \
- | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{try{const j=JSON.parse(d);console.log((j.streams||[]).filter(s=>s.is_live).length)}catch{console.log(0)}})' \
- || echo 0
-}
-wait_for_idle() {
- [ "$WAIT_IDLE" = true ] || return 0
- local waited=0 quiet=0
- while [ "$waited" -lt "$IDLE_MAX_SECONDS" ]; do
- local n; n=$(live_count)
- if [ "$n" = "0" ]; then quiet=$((quiet+1)); else quiet=0; fi
- [ "$quiet" -ge 2 ] && { say "no one live for two checks — proceeding"; return 0; }
- say "$n live stream(s); holding the restart (${waited}s waited)"
- [ "$DRY_RUN" = "1" ] && return 0
- sleep 60; waited=$((waited+60))
- done
- die "still live after ${IDLE_MAX_SECONDS}s; nothing was restarted" 5
-}
-
-wait_ready() {
- local deadline=$(( $(date +%s) + READY_TIMEOUT ))
- while [ "$(date +%s)" -lt "$deadline" ]; do
- if curl -sf --max-time 5 "${API_URL}${READY_PATH}" >/dev/null 2>&1; then return 0; fi
- $SYSTEMCTL is-active --quiet "$SERVICE" || { say "service is not running"; return 1; }
- sleep 1; printf '.'
- done
- printf '\n'; return 1
-}
-
-notify_chat() {
- local summary="$1"
- curl -sf -X POST "${API_URL}${BROADCAST_ENDPOINT}" \
- -H "Content-Type: application/json" -H "Authorization: Bearer ${ADMIN_TOKEN:-}" \
- -d "{\"type\":\"update\",\"summary\":$(printf '%s' "$summary" | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>console.log(JSON.stringify(d.trim())))'),\"url\":\"${SITE_URL}${UPDATES_PATH}\"}" \
- >/dev/null 2>&1 && say "chat notified" || say "chat notification skipped"
+legacy() {
+ say "$1 — running deploy-legacy.sh (the previous deploy script) instead"
+ exec bash "$LEGACY" "${ORIG[@]}"
}
}
-install_units() {
- local unit_dir="$1" changed=false
- install_unit() {
- local src="$1" dest="$2"
- [ -f "$src" ] || return 0
- if [ ! -f "$dest" ] || ! cmp -s "$src" "$dest"; then
- say "installing $(basename "$dest")"
- run $SUDO_INSTALL install -m 0644 -D "$src" "$dest"
- changed=true
- fi
- }
- install_unit "$unit_dir/${SERVICE}.service" "$SYSTEMD_DIR/${SERVICE}.service"
- local socket_before=false
- [ -f "$SYSTEMD_DIR/${SERVICE}.socket" ] && [ -f "$unit_dir/${SERVICE}.socket" ] && ! cmp -s "$unit_dir/${SERVICE}.socket" "$SYSTEMD_DIR/${SERVICE}.socket" && socket_before=true
- install_unit "$unit_dir/${SERVICE}.socket" "$SYSTEMD_DIR/${SERVICE}.socket"
- # A socket unit changed under a running socket: after daemon-reload systemd may drop its descriptor
- # ("no socket file descriptors are open ... not functional until restarted"); restart_and_verify then
- # restarts the socket itself. Left alone, the service binds the port on its own and every later
- # restart refuses connections (what happened from 2026-09-25 to 2026-09-26).
- [ "$socket_before" = true ] && SOCKET_CHANGED=true
- install_unit "$unit_dir/${SERVICE}.service.d/socket.conf" "$SYSTEMD_DIR/${SERVICE}.service.d/socket.conf"
- if [ "$changed" = true ]; then
- run $SYSTEMCTL daemon-reload
- run $SYSTEMCTL enable "${SERVICE}.socket" >/dev/null 2>&1 || true
- run $SYSTEMCTL enable "$SERVICE" >/dev/null 2>&1 || true
- fi
+# Does this ovhost deploy live with the strategy this wrapper hands over to? Sets REASON when not.
+REASON=""
+probe() {
+ if [ "${OVHOST_LEGACY:-0}" = 1 ]; then REASON="OVHOST_LEGACY=1"; return 1; fi
+ if ! command -v "$OVHOST" >/dev/null 2>&1; then REASON="ovhost not found ($OVHOST)"; return 1; fi
+ local caps api
+ if ! caps=$("${SUDO[@]}" "$OVHOST" capabilities "$SERVICE" 2>/dev/null); then REASON="this ovhost has no 'capabilities' (too old) or no inventory entry for $SERVICE"; return 1; fi
+ api=$(printf '%s\n' "$caps" | sed -n 's/^deploy-api=//p')
+ case "$api" in ''|*[!0-9]*) REASON="this ovhost reports no deploy-api (too old)"; return 1 ;; esac
+ if [ "$api" -lt 1 ]; then REASON="this ovhost's deploy-api is $api, 1 is needed"; return 1; fi
+ if ! printf '%s\n' "$caps" | grep -qx "strategy=$STRATEGY"; then REASON="the host inventory does not deploy $SERVICE with strategy $STRATEGY ($(printf '%s\n' "$caps" | sed -n 's/^strategy=//p'))"; return 1; fi
+ if ! printf '%s\n' "$caps" | grep -qx "managed=yes"; then REASON="ovhost does not manage $SERVICE"; return 1; fi
+ return 0
}
}
-syntax_check() {
- local dir="$1" files="$2" checked=0
- while IFS= read -r f; do
- case "$f" in
- *.js) [ -f "$dir/$f" ] || continue
- node --check "$dir/$f" >/dev/null 2>&1 || die "syntax error in $f" 2
- checked=$((checked+1)) ;;
- *.json) [ -f "$dir/$f" ] || continue
- node -e 'JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"))' "$dir/$f" 2>/dev/null || die "invalid JSON in $f" 2 ;;
- esac
- done <<< "$files"
- say "syntax-checked $checked changed JS file(s)"
-}
-
-backup_db() {
- # A schema migration is about to run on restart: take a consistent online copy first.
- local db="$1"
- [ -f "$db" ] || { say "no database at $db to back up"; return 0; }
- local out; out="$(dirname "$db")/backups/live-$(date +%Y%m%d-%H%M%S).db"
- run mkdir -p "$(dirname "$out")"
- say "schema changed — backing up $(basename "$db") to $out"
- if [ "$DRY_RUN" != "1" ]; then
- (cd "$2" && node -e 'const D=require("better-sqlite3");new D(process.argv[1],{readonly:true}).backup(process.argv[2]).then(()=>process.exit(0),e=>{console.error(e.message);process.exit(1)})' "$db" "$out") \
- || die "database backup failed; nothing was restarted" 2
- fi
-}
+probe || legacy "$REASON"
-# Does systemd (pid 1) hold the socket unit's listener? `is-active` is not enough: a socket unit whose
-# descriptor was dropped still reads active while the service binds the port itself.
-socket_held() {
- local listen port
- listen=$($SYSTEMCTL show "${SERVICE}.socket" -p Listen --value 2>/dev/null | head -1 | awk '{print $1}')
- port=${listen##*:}
- [ -n "$port" ] || return 1
- $SS -Hltnp "sport = :$port" 2>/dev/null | grep -q '"systemd",pid=1,'
-}
-
-restart_and_verify() {
- local on_fail="$1" socketed=false
- if $SYSTEMCTL list-unit-files "${SERVICE}.socket" >/dev/null 2>&1 && $SYSTEMCTL is-enabled --quiet "${SERVICE}.socket" 2>/dev/null; then
- socketed=true
- $SYSTEMCTL is-active --quiet "${SERVICE}.socket" || $SYSTEMCTL start "${SERVICE}.socket" || true
- fi
- local start; start=$(date +%s)
- if [ "$socketed" = true ] && { [ "$SOCKET_CHANGED" = true ] || ! socket_held; }; then
- # The socket must be rebound: stop the service (it may hold the port itself), restart the
- # socket, start the service on it. HTTP is refused for that moment, once.
- say "socket unit $([ "$SOCKET_CHANGED" = true ] && echo changed || echo 'is not holding its listener') — stopping ${SERVICE}, restarting the socket"
- $SYSTEMCTL stop "$SERVICE" 9>&- || true
- $SYSTEMCTL restart "${SERVICE}.socket" || say "✗ could not restart ${SERVICE}.socket"
- $SYSTEMCTL start "$SERVICE" 9>&-
- SOCKET_CHANGED=false
- else
- [ "$socketed" = true ] && say "socket unit holds the listener — new HTTP connections queue through the restart"
- say "restarting ${SERVICE}…"
- $SYSTEMCTL restart "$SERVICE" 9>&-
- fi
- printf '[Deploy] waiting for readiness'
- if wait_ready; then
- printf '\n'; say "ready after $(( $(date +%s) - start ))s"
- if [ "$socketed" = true ] && ! socket_held; then
- say "✗ socket activation is not in effect: systemd does not hold the listener, so restarts refuse connections (check journalctl -u ${SERVICE}.socket)"
- fi
- return 0
- fi
- say "✗ not ready within ${READY_TIMEOUT}s"
- journalctl -u "$SERVICE" -n 40 --no-pager 2>/dev/null | sed 's/^/ /' || true
- $on_fail
-}
-
-# ── Release notification (WS-P task 9) ───────────────────────────────────────
-# OpenVibe.Host publishes host.release.published to OpenVibe.Events, so open tabs check /release.json now
-# instead of at their next poll. ovhost announces the release Live's own /release.json reports, once per
-# release. Best effort: skipped when ovhost is missing or has no `announce`, 20 s at most, and it never
-# fails a deploy (see OpenVibe.Host docs/release-notifications.md).
-OVHOST="${OVHOST:-ovhost}"
-announce_release() {
- [ "$DRY_RUN" = "1" ] && return 0
- command -v "$OVHOST" >/dev/null 2>&1 || return 0
- local help
- help=$("$OVHOST" --help 2>/dev/null || true)
- case "$help" in *"announce <service>"*) ;; *) say "release notification skipped: this ovhost has no announce"; return 0 ;; esac
- timeout 20 "$OVHOST" announce live 9>&- 2>&1 | sed 's/^/ /' || say "release notification not sent (the deploy stands)"
-}
-
-# ═══════════════════════════════════════════════════════════════
-# Release layout
-# ═══════════════════════════════════════════════════════════════
-if [ "$LAYOUT" = release ]; then
- REPO="$BASE_DIR/repo"
- CURRENT=$(readlink -f "$BASE_DIR/current")
- CUR_ID=$(basename "$CURRENT")
- CUR_SHA=$(git -C "$CURRENT" rev-parse HEAD 2>/dev/null || echo none)
- say "current release: $CUR_ID (${CUR_SHA:0:8})"
-
- switch_to() {
- # Atomic: create the new link beside the old one and rename over it.
- local target="$1"
- run ln -sfn "$target" "$BASE_DIR/.current.tmp"
- run mv -Tf "$BASE_DIR/.current.tmp" "$BASE_DIR/current"
- }
-
- if [ "$ROLLBACK" = true ]; then
- PREV=$(ls -1dt "$BASE_DIR"/releases/*/ 2>/dev/null | sed 's:/$::' | grep -vx "$CURRENT" | head -1 || true)
- [ -n "$PREV" ] || die "no previous release to roll back to"
- PREV_SHA=$(git -C "$PREV" rev-parse HEAD 2>/dev/null || echo none)
- CATS=$(classify "$(git -C "$REPO" diff --name-only "$PREV_SHA" "$CUR_SHA" 2>/dev/null || echo server/)")
- say "rolling back to $(basename "$PREV") (${PREV_SHA:0:8}); change classes:${CATS}"
- [ "$DRY_RUN" = "1" ] && { say "dry run complete — nothing was changed."; exit 0; }
- switch_to "$PREV"
- if has "$CATS" server || has "$CATS" systemd || [ "$ALWAYS_RESTART" = true ]; then
- install_units "$PREV/deploy/systemd/release"
- wait_for_idle
- restart_and_verify 'die "ROLLBACK RELEASE IS NOT READY — MANUAL INTERVENTION REQUIRED" 4'
- else
- say "static-only difference — switched without a restart"
- fi
- # Keep the release we left so it can be re-selected; just move it out of "newest".
- touch -h "$PREV"
- say "rolled back to $(basename "$PREV") ✅"
- announce_release
- exit 0
- fi
-
- say "fetching ${GIT_REMOTE}/${GIT_BRANCH}…"
- run git -C "$REPO" fetch --quiet "$GIT_REMOTE" "$GIT_BRANCH"
- TARGET_SHA=$(git -C "$REPO" rev-parse "${GIT_REMOTE}/${GIT_BRANCH}")
- if [ "$TARGET_SHA" = "$CUR_SHA" ] && [ "$ALWAYS_RESTART" != true ]; then
- say "already at ${TARGET_SHA:0:8}; nothing to deploy."
- exit 0
- fi
-
- CHANGED=$(git -C "$REPO" diff --name-only "$CUR_SHA" "$TARGET_SHA" 2>/dev/null || true)
- CATS=$(classify "$CHANGED")
- [ "$TARGET_SHA" = "$CUR_SHA" ] && CATS=" server"
- COMMIT_LOG=$(git -C "$REPO" --no-pager log --oneline "${CUR_SHA}..${TARGET_SHA}" 2>/dev/null || echo "Restart requested")
- COMMIT_COUNT=$(printf '%s\n' "$COMMIT_LOG" | grep -c . || true)
- say "$COMMIT_COUNT new commit(s); change classes:${CATS:- none}"
- printf '%s\n' "$COMMIT_LOG" | sed 's/^/ /'
-
- NEW_ID="$(date +%Y%m%d-%H%M%S)-${TARGET_SHA:0:8}"
- NEW="$BASE_DIR/releases/$NEW_ID"
- say "preparing release $NEW_ID while $CUR_ID keeps serving…"
- run git -C "$REPO" worktree add --detach "$NEW" "$TARGET_SHA" >/dev/null
- [ "$DRY_RUN" = "1" ] || syntax_check "$NEW" "$CHANGED"
- run ln -sfn ../../shared/data "$NEW/data"
-
- if [ "$DRY_RUN" != "1" ] && deps_changed "$CUR_SHA" "$TARGET_SHA" "$REPO"; then
- say "dependencies changed — installing into the new release (nothing interrupted yet)…"
- (cd "$NEW" && $NPM ci --omit=dev --no-audit --no-fund) || { git -C "$REPO" worktree remove --force "$NEW" || true; die "npm ci failed; the current release is untouched" 2; }
- else
- # Same lockfile: hard-link the current release's node_modules — instant, no extra disk, and the
- # new release still owns its own tree (npm never writes into an installed package in place).
- say "dependencies unchanged — linking node_modules from $CUR_ID"
- run cp -al "$CURRENT/node_modules" "$NEW/node_modules"
- fi
-
- if has "$CATS" nginx; then
- say "note: deploy/nginx changed and is NOT installed by this script — copy it to /etc/nginx/sites-available,"
- say " run nginx -t and reload (OpenVibe.Network's generator mirrors it)."
- fi
-
- [ "$DRY_RUN" = "1" ] && { say "dry run complete — nothing was changed."; exit 0; }
-
- NEEDS_RESTART=false
- if has "$CATS" server || has "$CATS" systemd || [ "$ALWAYS_RESTART" = true ]; then NEEDS_RESTART=true; fi
-
- if [ "$NEEDS_RESTART" = true ]; then
- wait_for_idle
- has "$CATS" schema && backup_db "$BASE_DIR/shared/data/live.db" "$NEW"
- FIRST_LINE=$(printf '%s\n' "$COMMIT_LOG" | head -1 | sed 's/^[a-f0-9]* //')
- notify_chat "$([ "$COMMIT_COUNT" -le 1 ] && echo "Update: ${FIRST_LINE}" || echo "${COMMIT_COUNT} updates deployed — ${FIRST_LINE}")"
- switch_to "$NEW"
- install_units "$NEW/deploy/systemd/release"
- rollback_release() {
- say "rolling back to $CUR_ID"
- switch_to "$CURRENT"
- install_units "$CURRENT/deploy/systemd/release"
- $SYSTEMCTL restart "$SERVICE" 9>&- || die "ROLLBACK FAILED: service would not restart" 4
- printf '[Deploy] waiting for rollback'
- if wait_ready; then printf '\n'; say "rolled back to $CUR_ID and serving"; exit 3; fi
- die "ROLLBACK FAILED: $CUR_ID is not ready either — MANUAL INTERVENTION REQUIRED" 4
- }
- restart_and_verify rollback_release
- say "note: established WebSocket, WHIP, RTMP and WebRTC sessions were reconnected, not preserved."
- else
- switch_to "$NEW"
- # The running process reads public/ and docs/ through the `current` link (OV_APP_ROOT), and
- # re-hashes assets within ~2s. Confirm the site answers; no process was restarted.
- sleep 3
- curl -sf --max-time 10 "${API_URL}${READY_PATH}" >/dev/null || { switch_to "$CURRENT"; die "site not ready after switching — switched back to $CUR_ID" 3; }
- say "switched to $NEW_ID without a restart (change classes:${CATS})"
- fi
-
- # Prune old releases (never the current one, never the one we just left).
- ls -1dt "$BASE_DIR"/releases/*/ 2>/dev/null | sed 's:/$::' | tail -n +$((KEEP_RELEASES+1)) | while read -r old; do
- [ "$old" = "$(readlink -f "$BASE_DIR/current")" ] && continue
- [ "$old" = "$CURRENT" ] && continue
- say "pruning $(basename "$old")"
- git -C "$REPO" worktree remove --force "$old" 2>/dev/null || rm -rf "$old"
- done
- git -C "$REPO" worktree prune 2>/dev/null || true
- say "deployed $CUR_ID → $NEW_ID ✅"
- announce_release
- exit 0
+if [ "$FORCE" = true ]; then
+ echo "[Deploy] ✗ --force meant 'discard local tracked changes' (legacy layout). ovhost never discards changes, and its" >&2
+ echo "[Deploy] --force drops live streams and passes a freeze. Run: sudo $OVHOST ${CMD} ${SERVICE} --force if that is what you mean." >&2
+ exit 1
fi
fi
-
-# ═══════════════════════════════════════════════════════════════
-# Legacy layout (git checkout in place)
-# ═══════════════════════════════════════════════════════════════
-[ "$ROLLBACK" = true ] && die "--rollback needs the release layout (deploy/scripts/migrate-to-releases.sh)"
-REPO_DIR="$BASE_DIR"
-cd "$REPO_DIR" || die "repo not found at $REPO_DIR"
-OLD_HASH=$(git rev-parse HEAD 2>/dev/null || echo none)
-say "current commit: ${OLD_HASH:0:8}"
-
-if [ "$FORCE_DEPLOY" = true ]; then
- say "force: discarding local tracked changes (untracked files are kept)"
- run git reset --hard HEAD
-elif ! git diff --quiet || ! git diff --cached --quiet; then
- die "working tree has uncommitted changes — commit them, or re-run with --force"
+if [ "${DRY_RUN:-0}" = 1 ]; then
+ say "ovhost plan $SERVICE (DRY_RUN=1: nothing changes)"
+ exec "${SUDO[@]}" "$OVHOST" plan "$SERVICE"
fi
fi
-
-say "fetching ${GIT_REMOTE}/${GIT_BRANCH}…"
-run git fetch --quiet "$GIT_REMOTE" "$GIT_BRANCH"
-TARGET_HASH=$(git rev-parse "${GIT_REMOTE}/${GIT_BRANCH}" 2>/dev/null || echo "$OLD_HASH")
-if [ "$OLD_HASH" = "$TARGET_HASH" ] && [ "$ALWAYS_RESTART" != true ]; then
- say "already at ${TARGET_HASH:0:8}; nothing to deploy."
- exit 0
-fi
-
-CHANGED=""; CATS=" server"
-if [ "$OLD_HASH" != "$TARGET_HASH" ]; then
- CHANGED=$(git diff --name-only "$OLD_HASH" "$TARGET_HASH" || true)
- CATS=$(classify "$CHANGED")
- COMMIT_LOG=$(git --no-pager log --oneline "${OLD_HASH}..${TARGET_HASH}" 2>/dev/null || echo "Update deployed")
- COMMIT_COUNT=$(printf '%s\n' "$COMMIT_LOG" | grep -c . || true)
- say "$COMMIT_COUNT new commit(s); change classes:${CATS:- none}"
- printf '%s\n' "$COMMIT_LOG" | sed 's/^/ /'
-else
- COMMIT_LOG="Restart requested"; COMMIT_COUNT=0
-fi
-
-NEEDS_RESTART=false
-if has "$CATS" server || has "$CATS" systemd || [ "$ALWAYS_RESTART" = true ]; then NEEDS_RESTART=true; fi
-
-# In place, the old process keeps running against files that are about to change underneath it, so
-# anything that needs a restart waits for idle BEFORE the checkout moves.
-[ "$NEEDS_RESTART" = true ] && wait_for_idle
-
-say "validating the target revision…"
-run git merge --ff-only "$TARGET_HASH" >/dev/null 2>&1 || { [ "$DRY_RUN" = "1" ] || die "fast-forward to ${TARGET_HASH:0:8} failed — diverged history" 2; }
-NEW_HASH=$(git rev-parse HEAD)
-[ -n "$CHANGED" ] && [ "$DRY_RUN" != "1" ] && syntax_check "$REPO_DIR" "$CHANGED"
-
-if [ "$DRY_RUN" != "1" ] && [ "$OLD_HASH" != "$TARGET_HASH" ] && deps_changed "$OLD_HASH" "$TARGET_HASH" "$REPO_DIR"; then
- say "dependencies changed — installing (service still running)…"
- say "warning: legacy layout — a rollback would NOT restore the previous node_modules. Migrate to releases."
- run $NPM ci --omit=dev --no-audit --no-fund || die "npm ci failed" 2
-fi
-has "$CATS" nginx && say "note: deploy/nginx changed — not installed here; copy it, nginx -t, reload (see docs/deploy.md)."
-
-if [ "$DRY_RUN" = "1" ]; then say "dry run complete — nothing was changed. restart needed: $NEEDS_RESTART"; exit 0; fi
-
-if [ "$NEEDS_RESTART" != true ]; then
- say "change classes:${CATS} — no restart needed; the server re-reads public/ and docs/ from disk."
- say "deployed ${OLD_HASH:0:8} → ${NEW_HASH:0:8} without interrupting anyone ✅"
- announce_release
- exit 0
-fi
-
-install_units "$REPO_DIR/deploy/systemd"
-has "$CATS" schema && backup_db "$REPO_DIR/data/live.db" "$REPO_DIR"
-FIRST_LINE=$(printf '%s\n' "$COMMIT_LOG" | head -1 | sed 's/^[a-f0-9]* //')
-notify_chat "$([ "$COMMIT_COUNT" -le 1 ] && echo "Update: ${FIRST_LINE}" || echo "${COMMIT_COUNT} updates deployed — ${FIRST_LINE}")"
-rollback_legacy() {
- say "rolling back to ${OLD_HASH:0:8}"
- git reset --hard "$OLD_HASH" >/dev/null 2>&1 || die "ROLLBACK FAILED: could not reset to $OLD_HASH" 4
- $SYSTEMCTL restart "$SERVICE" 9>&- || die "ROLLBACK FAILED: service would not restart" 4
- printf '[Deploy] waiting for rollback'
- if wait_ready; then printf '\n'; say "rolled back to ${OLD_HASH:0:8} and serving"; exit 3; fi
- die "ROLLBACK FAILED: ${OLD_HASH:0:8} is not ready either — MANUAL INTERVENTION REQUIRED" 4
-}
-restart_and_verify rollback_legacy
-curl -sf --max-time 5 "${API_URL}/api/health" >/dev/null || die "health endpoint not responding after readiness" 3
-say "deployed ${OLD_HASH:0:8} → ${NEW_HASH:0:8} (${COMMIT_COUNT} commit(s))"
-say "note: established WebSocket, WHIP, RTMP and WebRTC sessions were reconnected, not preserved."
-say "done ✅"
-announce_release
+say "ovhost $CMD $SERVICE ${FLAGS[*]:-}"
+exec "${SUDO[@]}" "$OVHOST" "$CMD" "$SERVICE" "${FLAGS[@]}"
@@ -1,14 +1,28 @@
@@ -1,14 +1,28 @@
# Deploying OpenVibe.Live
# Deploying OpenVibe.Live
-`deploy/scripts/deploy.sh` decides what a change needs and does the least disruptive thing.
+Live deploys with `ovhost deploy live` (OpenVibe.Host, `/usr/local/bin/ovhost`, strategy
+`release-layout`; roadmap WS-N task 11). `deploy/scripts/deploy.sh` is a thin wrapper that maps its old
+flags onto ovhost, so the commands stay the same:
```bash
```bash
-sudo /opt/openvibe.live/deploy/scripts/deploy.sh # deploy origin/main if it moved
-sudo /opt/openvibe.live/deploy/scripts/deploy.sh --wait-idle # hold a restart until nobody is live
-sudo /opt/openvibe.live/deploy/scripts/deploy.sh --rollback # release layout: previous release
-DRY_RUN=1 /opt/openvibe.live/deploy/scripts/deploy.sh # print the plan only
+cd /opt/openvibe.live/current
+sudo deploy/scripts/deploy.sh # ovhost deploy live: origin/main if it moved
+sudo deploy/scripts/deploy.sh --wait-idle # ovhost deploy live --wait-idle: hold a restart until nobody is live
+sudo deploy/scripts/deploy.sh --restart # ovhost deploy live --restart
+sudo deploy/scripts/deploy.sh --rollback # ovhost rollback live: the release the current one replaced
+DRY_RUN=1 deploy/scripts/deploy.sh # ovhost plan live: print the plan only
```
```
+The wrapper checks `ovhost capabilities live` first (deploy API 1, strategy `release-layout`, managed).
+When ovhost is missing or too old, or the host inventory does not deploy Live with that strategy, it says
+why and runs `deploy/scripts/deploy-legacy.sh`, the previous script, unchanged and with the same
+arguments. `OVHOST_LEGACY=1` forces the fallback; `OVHOST=<path>` picks another ovhost. `--force`
+meant "discard local tracked changes" in the legacy layout; ovhost's `--force` drops live streams, so the
+wrapper refuses it (run `sudo ovhost deploy live --force` yourself if that is what you mean). Everything
+below is what both do; ovhost also records every attempt in its release log (`ovhost releases live`) and
+refuses a frozen service (`ovhost freeze`, exit 6). What ovhost checks is in OpenVibe.Host
+`docs/deploy-strategies.md` and `test/strategy-release-layout.test.js`.
+
## What each kind of change costs
## What each kind of change costs
| Files changed | Action | Interruption |
| Files changed | Action | Interruption |
@@ -65,16 +79,19 @@ but could not restore previous `node_modules`.
@@ -65,16 +79,19 @@ but could not restore previous `node_modules`.
- The last 5 releases are kept.
- The last 5 releases are kept.
- Content-hashed assets from the previous release are still served under their old hashes, so a page
- Content-hashed assets from the previous release are still served under their old hashes, so a page
rendered before the switch never loads JavaScript from after it.
rendered before the switch never loads JavaScript from after it.
-- **Release notification.** After a deploy or `--rollback` that went live, the script runs
- `ovhost announce live` (OpenVibe.Host, WS-P task 9). ovhost publishes `host.release.published` for the
+- **Release notification.** After a deploy or `--rollback` that went live, ovhost announces it (the
+ legacy script runs `ovhost announce live`; OpenVibe.Host, WS-P task 9). ovhost publishes `host.release.published` for the
release `/release.json` now reports, once per release, to OpenVibe.Events. Open tabs (openvibe-shared
release `/release.json` now reports, once per release, to OpenVibe.Events. Open tabs (openvibe-shared
1.17.0 release-watch) then check `/release.json` within about 20 s instead of at their next poll. It is
1.17.0 release-watch) then check `/release.json` within about 20 s instead of at their next poll. It is
best effort: skipped when `ovhost` is missing or has no `announce` (set `OVHOST` for another path), 20 s at
best effort: skipped when `ovhost` is missing or has no `announce` (set `OVHOST` for another path), 20 s at
most, and it never changes the exit code. A static-only switch keeps the running release, so there is
most, and it never changes the exit code. A static-only switch keeps the running release, so there is
nothing new to announce. The credentials and the setup are in OpenVibe.Host `docs/release-notifications.md`.
nothing new to announce. The credentials and the setup are in OpenVibe.Host `docs/release-notifications.md`.
-`test/deploy-sim.test.js` runs the script against a simulated host (real git, fake systemctl) and
-checks each of these behaviours.
+`test/deploy-sim.test.js` runs `deploy-legacy.sh` against a simulated host (real git, fake systemctl)
+and checks each of these behaviours; `test/deploy-wrapper.test.js` checks the wrapper's flag mapping and
+its fallback. ovhost's own tests (OpenVibe.Host `test/strategy-release-layout.test.js`) cover the same
+behaviours for `ovhost deploy live`, plus the socket rule: pid 1 must hold :3000, and the socket unit is
+restarted only when its unit file changed or systemd does not hold the listener.
## Assets and caching
## Assets and caching
@@ -1,5 +1,6 @@
@@ -1,5 +1,6 @@
/**
/**
- * deploy/scripts/deploy.sh against a simulated host.
+ * deploy/scripts/deploy-legacy.sh (the release-layout deploy script from before `ovhost deploy live`;
+ * deploy/scripts/deploy.sh now hands over to ovhost and falls back to it) against a simulated host.
*
*
* A temp directory stands in for /opt/openvibe.live with a real git "origin", the release layout
* A temp directory stands in for /opt/openvibe.live with a real git "origin", the release layout
* (repo, releases/<id>, current, shared/data), a fake `systemctl` that runs a tiny app, and a fake
* (repo, releases/<id>, current, shared/data), a fake `systemctl` that runs a tiny app, and a fake
@@ -23,7 +24,7 @@ const path = require('path');
@@ -23,7 +24,7 @@ const path = require('path');
const http = require('http');
const http = require('http');
const { execFileSync, spawnSync } = require('child_process');
const { execFileSync, spawnSync } = require('child_process');
-const DEPLOY = path.join(__dirname, '..', 'deploy', 'scripts', 'deploy.sh');
+const DEPLOY = path.join(__dirname, '..', 'deploy', 'scripts', 'deploy-legacy.sh');
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-deploy-sim-'));
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-deploy-sim-'));
const PORT = 20000 + Math.floor(Math.random() * 20000);
const PORT = 20000 + Math.floor(Math.random() * 20000);
const sh = (cmd, opts = {}) => execFileSync('bash', ['-c', cmd], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], ...opts });
const sh = (cmd, opts = {}) => execFileSync('bash', ['-c', cmd], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], ...opts });
new file mode 100644
new file mode 100644
@@ -0,0 +1,116 @@
@@ -0,0 +1,116 @@
+/**
+ * deploy/scripts/deploy.sh is a thin wrapper around `ovhost deploy live` (OpenVibe.Host, strategy
+ * release-layout; roadmap WS-N task 11), with deploy-legacy.sh (the previous script, unchanged) as its
+ * fallback. A fake ovhost records what the wrapper asked for; a fake legacy script records the fallback.
+ * - the flags map: (none) → deploy live, --wait-idle, --restart, --rollback → rollback live,
+ * DRY_RUN=1 → plan live, READY_TIMEOUT → --ready-timeout;
+ * - --force (legacy: discard local changes) is refused on the ovhost path, never passed on;
+ * - the fallback runs, with the original arguments and a message, when ovhost is missing, has no
+ * `capabilities`, reports deploy-api 0, does not deploy live with release-layout, or OVHOST_LEGACY=1;
+ * - deploy-legacy.sh is the old script: it still holds the listener check and the socket restart.
+ *
+ * node test/deploy-wrapper.test.js
+ */
+'use strict';
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const { spawnSync } = require('child_process');
+
+const WRAPPER = path.join(__dirname, '..', 'deploy', 'scripts', 'deploy.sh');
+const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-deploy-wrapper-'));
+const log = path.join(tmp, 'calls.log');
+
+// Fake ovhost: `capabilities live` prints what FAKE_CAPS says (or fails with FAKE_OLD); anything else is recorded.
+const ovhost = path.join(tmp, 'ovhost');
+fs.writeFileSync(ovhost, `#!/usr/bin/env bash
+if [ "$1" = capabilities ]; then
+ [ -n "$FAKE_OLD" ] && { echo "unknown command \\"capabilities\\""; exit 1; }
+ printf '%b\\n' "\${FAKE_CAPS:-ovhost=0.3.0\\ndeploy-api=1\\nstrategies=git-checkout,multi-app,static-build,pnpm-build,release-layout\\nservice=live\\nstrategy=release-layout\\nmanaged=yes\\nlayout=release}"
+ exit 0
+fi
+echo "ovhost $*" >> "${log}"
+exit "\${FAKE_EXIT:-0}"
+`, { mode: 0o755 });
+const legacy = path.join(tmp, 'legacy.sh');
+fs.writeFileSync(legacy, `#!/usr/bin/env bash\necho "legacy $* DRY_RUN=\${DRY_RUN:-}" >> "${log}"\nexit 0\n`, { mode: 0o755 });
+
+const run = (args = [], env = {}) => {
+ fs.rmSync(log, { force: true });
+ const r = spawnSync('bash', [WRAPPER, ...args], { env: { PATH: process.env.PATH, OVHOST: ovhost, OVHOST_SUDO: '', DEPLOY_LEGACY: legacy, ...env }, encoding: 'utf8' });
+ let calls = [];
+ try { calls = fs.readFileSync(log, 'utf8').trim().split('\n').filter(Boolean); } catch { /* none */ }
+ return { code: r.status, out: r.stdout + r.stderr, calls };
+};
+
+let failures = 0;
+function check(name, fn) {
+ try { fn(); console.log(' ✓', name); } catch (e) { failures++; console.log(' ✗', name, '\n ', String(e.message).split('\n').slice(0, 12).join('\n ')); }
+}
+
+check('the flags map onto ovhost deploy|rollback|plan live', () => {
+ assert.deepStrictEqual(run().calls, ['ovhost deploy live']);
+ assert.deepStrictEqual(run(['--wait-idle']).calls, ['ovhost deploy live --wait-idle']);
+ assert.deepStrictEqual(run(['--restart', '--wait-idle']).calls, ['ovhost deploy live --restart --wait-idle']);
+ assert.deepStrictEqual(run(['--rollback']).calls, ['ovhost rollback live']);
+ assert.deepStrictEqual(run([], { DRY_RUN: '1' }).calls, ['ovhost plan live']);
+ assert.deepStrictEqual(run([], { READY_TIMEOUT: '120' }).calls, ['ovhost deploy live --ready-timeout 120']);
+ const r = run(['--wait-idle']);
+ assert.match(r.out, /\[Deploy\] ovhost deploy live --wait-idle/);
+});
+
+check("ovhost's exit code is the wrapper's (3: not ready, rolled back)", () => {
+ assert.strictEqual(run([], { FAKE_EXIT: '3' }).code, 3);
+ assert.strictEqual(run([], { FAKE_EXIT: '5' }).code, 5);
+});
+
+check('--force is refused on the ovhost path (it would drop live streams), never passed on', () => {
+ const r = run(['--force']);
+ assert.strictEqual(r.code, 1, r.out);
+ assert.deepStrictEqual(r.calls, []);
+ assert.match(r.out, /--force meant 'discard local tracked changes'/);
+});
+
+check('an unknown flag is a usage error', () => {
+ const r = run(['--nope']);
+ assert.strictEqual(r.code, 1);
+ assert.deepStrictEqual(r.calls, []);
+});
+
+check('the fallback runs deploy-legacy.sh with the original arguments, and says why', () => {
+ let r = run(['--wait-idle'], { OVHOST: path.join(tmp, 'missing-ovhost') });
+ assert.strictEqual(r.code, 0, r.out);
+ assert.deepStrictEqual(r.calls, ['legacy --wait-idle DRY_RUN=']);
+ assert.match(r.out, /ovhost not found .* — running deploy-legacy\.sh/);
+
+ r = run(['--force'], { FAKE_OLD: '1' });
+ assert.deepStrictEqual(r.calls, ['legacy --force DRY_RUN='], 'the legacy script keeps its own --force');
+ assert.match(r.out, /no 'capabilities' \(too old\)/);
+
+ r = run([], { FAKE_CAPS: 'ovhost=0.2.9\\ndeploy-api=0\\nstrategy=release-layout\\nmanaged=yes' });
+ assert.deepStrictEqual(r.calls, ['legacy DRY_RUN=']);
+ assert.match(r.out, /deploy-api is 0, 1 is needed/);
+
+ r = run([], { FAKE_CAPS: 'ovhost=0.3.0\\ndeploy-api=1\\nstrategy=none\\nmanaged=no' });
+ assert.deepStrictEqual(r.calls, ['legacy DRY_RUN=']);
+ assert.match(r.out, /does not deploy live with strategy release-layout \(none\)/);
+
+ r = run(['--rollback'], { OVHOST_LEGACY: '1', DRY_RUN: '1' });
+ assert.deepStrictEqual(r.calls, ['legacy --rollback DRY_RUN=1']);
+});
+
+check('deploy-legacy.sh is the previous script: listener check, changed-socket restart, release layout', () => {
+ const text = fs.readFileSync(path.join(__dirname, '..', 'deploy', 'scripts', 'deploy-legacy.sh'), 'utf8');
+ assert.match(text, /socket_held\(\)/);
+ assert.match(text, /SOCKET_CHANGED=true/);
+ assert.match(text, /\$SYSTEMCTL restart "\$\{SERVICE\}\.socket"/);
+ assert.match(text, /KEEP_RELEASES/);
+ const wrapper = fs.readFileSync(WRAPPER, 'utf8');
+ assert.ok(wrapper.split('\n').length < 120, 'the wrapper stays thin');
+ assert.match(wrapper, /^set -euo pipefail$/m);
+});
+
+fs.rmSync(tmp, { recursive: true, force: true });
+if (failures) { console.log(`\n${failures} failure(s)`); process.exit(1); }
+console.log('\ndeploy wrapper: all checks passed');
@@ -43,10 +43,17 @@ assert.strictEqual(load({ NODE_ENV: 'production', LISTEN_HOST: '10.0.0.5', BASE_
@@ -43,10 +43,17 @@ assert.strictEqual(load({ NODE_ENV: 'production', LISTEN_HOST: '10.0.0.5', BASE_
assert.strictEqual(load({ NODE_ENV: 'development' }).listenHost, '0.0.0.0', 'development keeps HOST (default 0.0.0.0)');
assert.strictEqual(load({ NODE_ENV: 'development' }).listenHost, '0.0.0.0', 'development keeps HOST (default 0.0.0.0)');
assert.strictEqual(load({ NODE_ENV: 'production', HOST: '0.0.0.0', BASE_URL: 'https://openvibe.live' }).host, '0.0.0.0', 'HOST still builds URLs');
assert.strictEqual(load({ NODE_ENV: 'production', HOST: '0.0.0.0', BASE_URL: 'https://openvibe.live' }).host, '0.0.0.0', 'HOST still builds URLs');
-// deploy.sh checks that systemd really holds the listener, and restarts a socket unit that changed.
-const deploy = fs.readFileSync(path.join(__dirname, '..', 'deploy', 'scripts', 'deploy.sh'), 'utf8');
-assert.match(deploy, /socket_held\(\)/, 'deploy.sh has the listener check');
-assert.match(deploy, /SOCKET_CHANGED=true/, 'a changed socket unit is noted');
-assert.match(deploy, /\$SYSTEMCTL restart "\$\{SERVICE\}\.socket"/, 'and restarted');
+// Deploys check that systemd really holds the listener, and restart a socket unit that changed. Since
+// WS-N task 11 deploy.sh hands over to `ovhost deploy live` (OpenVibe.Host strategy release-layout:
+// systemd.socketHeld + rebindSocket, test/strategy-release-layout.test.js there) and falls back to
+// deploy-legacy.sh, the previous script, which still does it itself.
+const legacy = fs.readFileSync(path.join(__dirname, '..', 'deploy', 'scripts', 'deploy-legacy.sh'), 'utf8');
+assert.match(legacy, /socket_held\(\)/, 'deploy-legacy.sh has the listener check');
+assert.match(legacy, /SOCKET_CHANGED=true/, 'a changed socket unit is noted');
+assert.match(legacy, /\$SYSTEMCTL restart "\$\{SERVICE\}\.socket"/, 'and restarted');
+const wrapper = fs.readFileSync(path.join(__dirname, '..', 'deploy', 'scripts', 'deploy.sh'), 'utf8');
+assert.match(wrapper, /^STRATEGY=release-layout$/m, 'deploy.sh only hands over to an ovhost that deploys live with release-layout (the socket rule)');
+assert.match(wrapper, /deploy-api/, 'and checks the deploy API version before it does');
+assert.match(wrapper, /exec bash "\$LEGACY"/, 'anything else falls back to deploy-legacy.sh');
console.log('systemd units: all checks passed');
console.log('systemd units: all checks passed');