openvibers/openvibe.sites Commits

deploy/scripts/deploy.sh hands over to ovhost deploy sites --restart (OpenVibe.Host strategy static-build; roadmap WS-N task 11). The old script is kept, unchanged, as deploy/scripts/deploy-legacy.sh. ovhost pulls first as the checkout owner (a stale checkout never installs old vhosts), restores the tracked dist/ the last build rewrote, runs npm ci and node build.js, installs every deploy/nginx/*.conf behind nginx -t (a failed test restores the previous vhosts, the checkout and dist/, and never reloads nginx) and sends one release notification per dist/<domain>/release.json; --restart keeps the old behaviour of rebuilding every run (people pulled before running the script). A vhost that left deploy/nginx/ is named, never removed. The wrapper asks ovhost capabilities sites first; when ovhost is missing, too old or the host inventory does not deploy sites with static-build (OVHOST_LEGACY=1 forces it) it restores dist/, pulls as the checkout owner and runs the pulled deploy-legacy.sh. test/deploy-wrapper.test.js (in npm test). README.

OP OpenVibers committed 27 Sept 2026 · 52ca284ccf · 5 files, +167 −41


Changes

ddiff --git a/README.md b/README.md
iindex 01bc544..a0e4e93 100644
---- a/README.md
++++ b/README.md
@@@ -36,13 +36,19 @@ Every domain also gets a `404.html`: its vhost answers any path that is not a fi
`dist/<domain>/` with status 404 and that page (`try_files … =404` + `error_page 404 /404.html`),
never the front page.
--Deploy (host): the repo lives at `/opt/openvibe.sites`; `deploy/scripts/deploy.sh` runs `npm ci`, rebuilds, installs
--the vhosts into `/etc/nginx/sites-available`, enables them and reloads nginx. Each domain has a
--Let's Encrypt wildcard certificate (`certbot --dns-cloudflare`, see the host's renewal configs).
--Then, for each placeholder, it runs `ovhost announce <service> --release <id> --origin https://<domain>`
--with the service and release id from that placeholder's `release.json`. OpenVibe.Host publishes each release once, as
--`host.release.published`, and open tabs check `/release.json` within seconds (WS-P task 9). This is best
--effort: it is skipped without an `ovhost` that has `announce`, stops at the first failure, and never fails the deploy.
++Deploy (host): the repo lives at `/opt/openvibe.sites`. `sudo deploy/scripts/deploy.sh` runs
++`ovhost deploy sites --restart` (OpenVibe.Host, strategy `static-build`; `DRY_RUN=1` for the plan). ovhost
++pulls first, as the checkout owner, so a stale checkout never installs old vhosts: it restores the tracked
++`dist/` the last build rewrote, fast-forwards, runs `npm ci` and `node build.js`, installs and enables every
++`deploy/nginx/*.conf` behind `nginx -t` (a failing test puts the previous vhost files back, never reloads
++nginx, and restores the checkout and `dist/`), then reloads nginx. Each domain has a Let's Encrypt wildcard
++certificate (`certbot --dns-cloudflare`, see the host's renewal configs). Then, for each placeholder, it
++publishes `host.release.published` with the service and release id from that placeholder's `release.json`
++and `https://<domain>` as the origin, once per release, so open tabs check `/release.json` within seconds
++(WS-P task 9); this is best effort, stops at the first failure and never fails the deploy. A vhost that left
++`deploy/nginx/` stays installed and is named in the output: remove it by hand once its domain is served
++elsewhere. When ovhost is missing, too old or does not deploy Sites with that strategy, the wrapper pulls
++and runs `deploy/scripts/deploy-legacy.sh`, the previous script, unchanged (`OVHOST_LEGACY=1` forces it).
When a domain becomes a real app, delete it from `sites.json`, remove its vhost, and point the
domain's nginx block at the new service — nothing else references it.
ddiff --git a/deploy/scripts/deploy-legacy.sh b/deploy/scripts/deploy-legacy.sh
nnew file mode 100755
iindex 0000000..522c550
---- /dev/null
++++ b/deploy/scripts/deploy-legacy.sh
@@@ -0,0 +1,36 @@
++#!/bin/bash
++# OpenVibe.Sites — pull, rebuild, install vhosts, reload nginx, announce each placeholder's release. Run on the host:
++# sudo -u ubuntu git -C /opt/openvibe.sites pull --ff-only && /opt/openvibe.sites/deploy/scripts/deploy.sh
++set -euo pipefail
++REPO="${REPO:-/opt/openvibe.sites}"
++cd "$REPO"
++# openvibe-shared is the pinned OpenVibe.Shared release (package.json); needs outbound HTTPS to codeload.github.com.
++npm ci --omit=dev --no-audit --no-fund
++node build.js
++changed=0
++for conf in deploy/nginx/*.conf; do
++ name=$(basename "$conf")
++ if ! cmp -s "$conf" "/etc/nginx/sites-available/$name"; then sudo install -m 644 "$conf" "/etc/nginx/sites-available/$name"; changed=1; fi
++ [ -L "/etc/nginx/sites-enabled/$name" ] || { sudo ln -s "/etc/nginx/sites-available/$name" "/etc/nginx/sites-enabled/$name"; changed=1; }
++done
++if [ "$changed" = 1 ]; then sudo nginx -t && sudo systemctl reload nginx && echo "[sites] nginx reloaded"; else echo "[sites] vhosts unchanged"; fi
++echo "[sites] $(ls dist | wc -l) sites in place"
++# Release notifications (roadmap WS-P task 9): one `ovhost announce <service> --release <id> --origin
++# https://<domain>` per placeholder, from its dist/<domain>/release.json. OpenVibe.Host publishes
++# host.release.published and open tabs check /release.json now instead of at their next poll; ovhost sends a
++# release once, so an unchanged placeholder sends nothing. Best effort: skipped without an ovhost whose
++# --help has `announce <service>`, 20 s at most each, stops at the first failure (the rest would fail the
++# same way), and never fails the deploy (OpenVibe.Host docs/release-notifications.md).
++OVHOST_BIN=$(command -v "${OVHOST:-ovhost}" 2>/dev/null || true)
++if [ -n "$OVHOST_BIN" ]; then
++ case "$("$OVHOST_BIN" --help 2>/dev/null || true)" in
++ *"announce <service>"*)
++ SUDO=""; [ "$(id -u)" -eq 0 ] || SUDO="sudo -n"
++ node -e 'const fs = require("fs"); for (const d of fs.readdirSync("dist").sort()) { try { const m = JSON.parse(fs.readFileSync(`dist/${d}/release.json`, "utf8")); if (/^[a-z][a-z0-9-]{1,39}$/.test(m.service) && /^[0-9a-f]{7,40}$/.test(m.release) && /^[a-z0-9.-]+$/.test(d)) console.log(`${m.service} ${m.release} ${d}`); } catch { /* no release.json */ } }' |
++ while read -r svc rel domain; do
++ timeout 20 $SUDO "$OVHOST_BIN" announce "$svc" --release "$rel" --origin "https://$domain" </dev/null 2>&1 | sed 's/^/[sites] /' \
++ || { echo "[sites] release notification not sent for $domain; the rest are skipped (the deploy stands)"; break; }
++ done ;;
++ *) echo "[sites] release notification skipped: this ovhost has no announce" ;;
++ esac
++fi
ddiff --git a/deploy/scripts/deploy.sh b/deploy/scripts/deploy.sh
iindex 522c550..160f9ae 100755
---- a/deploy/scripts/deploy.sh
++++ b/deploy/scripts/deploy.sh
@@@ -1,36 +1,57 @@
#!/bin/bash
--# OpenVibe.Sites — pull, rebuild, install vhosts, reload nginx, announce each placeholder's release. Run on the host:
--# sudo -u ubuntu git -C /opt/openvibe.sites pull --ff-only && /opt/openvibe.sites/deploy/scripts/deploy.sh
++# OpenVibe.Sites — deploy: a thin wrapper around `ovhost deploy sites` (OpenVibe.Host, strategy
++# static-build; roadmap WS-N task 11; OpenVibe.Host docs/deploy-strategies.md). Run on the host:
++#
++# sudo /opt/openvibe.sites/deploy/scripts/deploy.sh ovhost deploy sites --restart
++# DRY_RUN=1 /opt/openvibe.sites/deploy/scripts/deploy.sh ovhost plan sites
++#
++# ovhost pulls FIRST (as the checkout owner), so a stale checkout never installs old vhosts: the tracked
++# dist/ a previous build rewrote is restored from git, the checkout fast-forwards, then npm ci, node build.js,
++# every deploy/nginx/*.conf installed and enabled behind `nginx -t` (a failing test puts the previous vhost
++# files back and nginx is NOT reloaded; the checkout and dist/ go back too), then one release notification
++# per dist/<domain>/release.json. --restart makes it rebuild and reinstall even when the checkout is already
++# at origin/main (the old script did that every run, and people pulled before running it).
++#
++# Fallback: deploy-legacy.sh (the previous script, unchanged) when ovhost is missing or too old (no
++# `capabilities`, deploy-api < 1) or the host inventory does not deploy sites with strategy static-build;
++# OVHOST_LEGACY=1 forces it. The fallback keeps the old rule "pull before deploying": it restores dist/, pulls
++# as the checkout owner, then runs the freshly pulled deploy-legacy.sh.
set -euo pipefail
++
++SERVICE=sites
++STRATEGY=static-build
REPO="${REPO:-/opt/openvibe.sites}"
--cd "$REPO"
--# openvibe-shared is the pinned OpenVibe.Shared release (package.json); needs outbound HTTPS to codeload.github.com.
--npm ci --omit=dev --no-audit --no-fund
--node build.js
--changed=0
--for conf in deploy/nginx/*.conf; do
-- name=$(basename "$conf")
-- if ! cmp -s "$conf" "/etc/nginx/sites-available/$name"; then sudo install -m 644 "$conf" "/etc/nginx/sites-available/$name"; changed=1; fi
-- [ -L "/etc/nginx/sites-enabled/$name" ] || { sudo ln -s "/etc/nginx/sites-available/$name" "/etc/nginx/sites-enabled/$name"; changed=1; }
--done
--if [ "$changed" = 1 ]; then sudo nginx -t && sudo systemctl reload nginx && echo "[sites] nginx reloaded"; else echo "[sites] vhosts unchanged"; fi
--echo "[sites] $(ls dist | wc -l) sites in place"
--# Release notifications (roadmap WS-P task 9): one `ovhost announce <service> --release <id> --origin
--# https://<domain>` per placeholder, from its dist/<domain>/release.json. OpenVibe.Host publishes
--# host.release.published and open tabs check /release.json now instead of at their next poll; ovhost sends a
--# release once, so an unchanged placeholder sends nothing. Best effort: skipped without an ovhost whose
--# --help has `announce <service>`, 20 s at most each, stops at the first failure (the rest would fail the
--# same way), and never fails the deploy (OpenVibe.Host docs/release-notifications.md).
--OVHOST_BIN=$(command -v "${OVHOST:-ovhost}" 2>/dev/null || true)
--if [ -n "$OVHOST_BIN" ]; then
-- case "$("$OVHOST_BIN" --help 2>/dev/null || true)" in
-- *"announce <service>"*)
-- SUDO=""; [ "$(id -u)" -eq 0 ] || SUDO="sudo -n"
-- node -e 'const fs = require("fs"); for (const d of fs.readdirSync("dist").sort()) { try { const m = JSON.parse(fs.readFileSync(`dist/${d}/release.json`, "utf8")); if (/^[a-z][a-z0-9-]{1,39}$/.test(m.service) && /^[0-9a-f]{7,40}$/.test(m.release) && /^[a-z0-9.-]+$/.test(d)) console.log(`${m.service} ${m.release} ${d}`); } catch { /* no release.json */ } }' |
-- while read -r svc rel domain; do
-- timeout 20 $SUDO "$OVHOST_BIN" announce "$svc" --release "$rel" --origin "https://$domain" </dev/null 2>&1 | sed 's/^/[sites] /' \
-- || { echo "[sites] release notification not sent for $domain; the rest are skipped (the deploy stands)"; break; }
-- done ;;
-- *) echo "[sites] release notification skipped: this ovhost has no announce" ;;
-- esac
--fi
++OVHOST="${OVHOST:-/usr/local/bin/ovhost}"
++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
++[ "$#" -eq 0 ] || { echo "Usage: $0 (DRY_RUN=1 for the plan)"; exit 1; }
++
++legacy() {
++ echo "[sites] $1 — running deploy-legacy.sh (the previous deploy script) instead"
++ [ "${DRY_RUN:-0}" = 1 ] && { echo "[sites] ✗ deploy-legacy.sh has no DRY_RUN; nothing was done" >&2; exit 1; }
++ local owner
++ owner=$(stat -c %U "$REPO")
++ as_owner() { if [ "$(id -un)" = "$owner" ]; then "$@"; else sudo -u "$owner" "$@"; fi; }
++ as_owner git -C "$REPO" checkout -- dist/
++ as_owner git -C "$REPO" pull --ff-only
++ exec bash "${DEPLOY_LEGACY:-$REPO/deploy/scripts/deploy-legacy.sh}"
++}
++
++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
++}
++
++probe || legacy "$REASON"
++
++if [ "${DRY_RUN:-0}" = 1 ]; then exec "${SUDO[@]}" "$OVHOST" plan "$SERVICE" --restart; fi
++echo "[sites] ovhost deploy $SERVICE --restart"
++exec "${SUDO[@]}" "$OVHOST" deploy "$SERVICE" --restart
ddiff --git a/package.json b/package.json
iindex d3bf8f9..fce8c97 100644
---- a/package.json
++++ b/package.json
@@@ -4,7 +4,7 @@
"description": "Placeholder and hub pages for the planned OpenVibe domains (static build)",
"scripts": {
"build": "node build.js",
-- "test": "node test/build.test.js"
++ "test": "node test/build.test.js && node test/deploy-wrapper.test.js"
},
"dependencies": {
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0"
ddiff --git a/test/deploy-wrapper.test.js b/test/deploy-wrapper.test.js
nnew file mode 100644
iindex 0000000..e8925dc
---- /dev/null
++++ b/test/deploy-wrapper.test.js
@@@ -0,0 +1,63 @@
++'use strict';
++// deploy/scripts/deploy.sh is a thin wrapper around `ovhost deploy sites --restart` (OpenVibe.Host,
++// strategy static-build; roadmap WS-N task 11) with deploy-legacy.sh (the previous script, unchanged) as
++// its fallback, which still pulls first. A fake ovhost records what the wrapper asks for; the fallback
++// runs against a temp checkout with a real git origin.
++// node test/deploy-wrapper.test.js
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const { spawnSync, execFileSync } = require('child_process');
++
++const WRAPPER = path.join(__dirname, '..', 'deploy', 'scripts', 'deploy.sh');
++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'sites-deploy-wrapper-'));
++const log = path.join(tmp, 'calls.log');
++const sh = (cmd) => execFileSync('bash', ['-c', cmd], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
++
++const ovhost = path.join(tmp, 'ovhost');
++fs.writeFileSync(ovhost, `#!/usr/bin/env bash
++if [ "$1" = capabilities ]; then
++ [ -n "$FAKE_OLD" ] && exit 1
++ printf '%b\\n' "\${FAKE_CAPS:-ovhost=0.3.0\\ndeploy-api=1\\nservice=sites\\nstrategy=static-build\\nmanaged=yes\\nlayout=git}"
++ 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 at $(git -C "$REPO" log -1 --format=%s) dist=$(cat "$REPO/dist/x/index.html")" >> "${log}"\n`, { mode: 0o755 });
++
++// A checkout with an origin that is one commit ahead, and a dist/ the last build left dirty.
++const origin = path.join(tmp, 'origin.git');
++const work = path.join(tmp, 'work');
++const repo = path.join(tmp, 'repo');
++sh(`git init -q --bare -b main "${origin}" && git init -q -b main "${work}" && cd "${work}" && git config user.email t@t && git config user.name t && mkdir -p dist/x && echo v1 > dist/x/index.html && git add -A && git commit -qm v1 && git remote add origin "${origin}" && git push -q origin main`);
++sh(`git clone -q "${origin}" "${repo}" && cd "${work}" && echo v2 > dist/x/index.html && git commit -qam v2 && git push -q origin main`);
++fs.writeFileSync(path.join(repo, 'dist', 'x', 'index.html'), 'v1 rebuilt on the host\n');
++
++function run(args = [], env = {}) {
++ fs.rmSync(log, { force: true });
++ const r = spawnSync('bash', [WRAPPER, ...args], { env: { PATH: process.env.PATH, HOME: tmp, OVHOST: ovhost, OVHOST_SUDO: '', DEPLOY_LEGACY: legacy, REPO: repo, ...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 };
++}
++
++assert.deepStrictEqual(run().calls, ['ovhost deploy sites --restart'], 'rebuild and reinstall every run, as before');
++assert.deepStrictEqual(run([], { DRY_RUN: '1' }).calls, ['ovhost plan sites --restart']);
++assert.strictEqual(run([], { FAKE_EXIT: '2' }).code, 2, "ovhost's exit code is the wrapper's (2: nginx -t failed, nothing reloaded)");
++assert.strictEqual(run(['--nope']).code, 1);
++
++let r = run([], { FAKE_CAPS: 'deploy-api=1\\nstrategy=git-checkout\\nmanaged=yes' });
++assert.match(r.out, /does not deploy sites with strategy static-build \(git-checkout\)/);
++assert.deepStrictEqual(r.calls, ['legacy at v2 dist=v2'], 'the fallback restores dist/ and pulls before the legacy script runs');
++r = run([], { OVHOST_LEGACY: '1', DRY_RUN: '1' });
++assert.strictEqual(r.code, 1, 'the legacy script has no DRY_RUN: nothing runs');
++r = run([], { FAKE_OLD: '1' });
++assert.deepStrictEqual(r.calls, ['legacy at v2 dist=v2']);
++assert.match(fs.readFileSync(WRAPPER, 'utf8'), /^set -euo pipefail$/m);
++assert.strictEqual(fs.readFileSync(path.join(__dirname, '..', 'deploy', 'scripts', 'deploy-legacy.sh'), 'utf8').includes('sudo nginx -t && sudo systemctl reload nginx'), true, 'deploy-legacy.sh is the previous script: nginx -t gates the reload');
++
++fs.rmSync(tmp, { recursive: true, force: true });
++console.log('deploy wrapper: all checks passed');