openvibers/openvibe.sites Commits

Sites: the legacy deploy fallback is deleted; deploy.sh only runs ovhost (#38)

OP OpenVibers committed 10 Oct 2026 · f2c2fc4e3b · 4 files, +42 −128

Plan T1 (the legacy deploy path). Sites deploys with `sudo ovhost deploy sites --restart` (strategy static-build). This deletes `deploy/scripts/deploy-legacy.sh` and the wrapper's fallback to it (`DEPLOY_LEGACY`, `OVHOST_LEGACY`, the capability probe). `deploy.sh` runs `ovhost deploy sites --restart` (or `ovhost plan sites` with `DRY_RUN=1`) and exits 1 without ovhost. The old wrapper had no other subcommands, so nothing else changes. `test/deploy-wrapper.test.js` checks the delegation, the missing-ovhost failure and that no legacy path remains; it passes. Drafted by a Codex job and reviewed here.

Changes

ddiff --git a/README.md b/README.md
iindex 1f23ace..a37e19a 100644
---- a/README.md
++++ b/README.md
@@@ -73,13 +73,13 @@ The pinned `openvibe-contracts` release supplies the catalog `sites.json` mirror
- `openvibe-contracts` v0.126.0 — `scripts/sync-catalog.js` reads `openvibe-contracts/lib/products` (`catalog()`) to generate `sites.json`.
- `openvibe-shared` v2.20.3 — `build.js` uses `legal` for the terms, privacy and dmca pages, `files` for the `BROWSER` list copied to `dist/_shared/`, and `app-icon` for the manifest and head tags; `test/run.js` uses `test-runner`.
--- OpenVibe.Host — the deploy wrapper probes and calls `ovhost` (the `capabilities` probe, `deploy`, `plan`, `announce`).
++- OpenVibe.Host — the deploy wrapper calls `ovhost deploy sites --restart`, or `ovhost plan sites --restart` with `DRY_RUN=1`.
- OpenVibe.Network at `https://openvibe.network` — the frozen pages and shared browser files load its assets and API in the browser; `NET.networkUrl` in `build.js` is that base.
## Capabilities
- Sites checks and requires no OpenVibe capability, and holds no grant on another service: it serves static files and calls no service API.
--- The deploy wrapper probes the host inventory with `ovhost capabilities sites` and refuses unless it reports `strategy=static-build`, `managed=yes` and a `deploy-api` of at least 1; otherwise it falls back to `deploy/scripts/deploy-legacy.sh`.
++- The deploy wrapper requires `ovhost` to be installed and exits with an error if it is missing.
## Acceptance
@@@ -87,12 +87,12 @@ The pinned `openvibe-contracts` release supplies the catalog `sites.json` mirror
- `node build.js --check` verifies the generated files are current; `test/build.test.js` runs it.
- `test/build.test.js` proves the generated files are current, that `frozen.json` is exactly openvibe.tips and openvibe.vip, that every retired domain has left `dist/` and `deploy/nginx/`, that each frozen page says it is a placeholder with no pricing or unbacked-hosting copy, and (for any notice) the noindex, destination links, files and true-404 vhost.
- `test/sync-catalog.test.js` proves `scripts/sync-catalog.js --check` passes (sites.json matches the Contracts catalog), that every vhost in `deploy/nginx/` has a `sites.json` entry with a home in the catalog, and that `dropNoRepo` stops a row carrying both `plannedRepo` and `noRepo`.
--- `test/deploy-wrapper.test.js` drives `deploy/scripts/deploy.sh` against a fake ovhost and a temp checkout: it runs `ovhost deploy sites --restart`, `DRY_RUN=1` runs `ovhost plan sites --restart` instead, ovhost's exit code is the wrapper's, and it falls back to `deploy-legacy.sh` when the host does not deploy sites with strategy static-build.
++- `test/deploy-wrapper.test.js` drives `deploy/scripts/deploy.sh` against a fake ovhost: it runs `ovhost deploy sites --restart`, `DRY_RUN=1` runs `ovhost plan sites --restart` instead, ovhost's exit code is the wrapper's, and a missing ovhost produces an error.
## Security
- Sites has no server process, token or environment file; it writes static files at build time, so there is no request input, auth surface or rate limiting to configure.
--- The build is offline except for fetching the two pinned packages over HTTPS from codeload.github.com (`npm ci` in `deploy/scripts/deploy-legacy.sh`).
++- The build is offline; deployment runs `npm ci` to fetch the two pinned packages over HTTPS from codeload.github.com.
- Generated HTML is built from committed page snapshots and openvibe-shared templates, not user input; the generated 404 page escapes the values it interpolates (`esc` in `build.js`).
- Every vhost sets `X-Content-Type-Options: nosniff`, `X-Frame-Options: SAMEORIGIN`, `Referrer-Policy: strict-origin-when-cross-origin` and HSTS (`max-age=31536000`), denies dotfiles except `/.well-known` (where the host serves `security.txt`), and returns a real 404 (`try_files $uri $uri.html $uri/ =404`) instead of the front page.
- `robots.txt` disallows `/auth/`; CI runs the shared secret scan (gitleaks) and dependency audit workflows.
@@@ -101,7 +101,7 @@ The pinned `openvibe-contracts` release supplies the catalog `sites.json` mirror
- Production is the checkout `/opt/openvibe.sites`. It is deployed by running `deploy/scripts/deploy.sh` on the host, a thin wrapper around `sudo ovhost deploy sites --restart` (OpenVibe.Host, strategy static-build, since the 2026-09-27 cutover).
- ovhost pulls first (restoring the tracked `dist/` a previous build rewrote and fast-forwarding the checkout), then runs `npm ci` and `node build.js`, installs every `deploy/nginx/*.conf` into `/etc/nginx/sites-available/` and enables it behind `nginx -t` (a failure puts the previous vhosts back and does not reload nginx), and sends one `ovhost announce` release per `dist/<domain>/release.json`.
--- Fallback: `deploy/scripts/deploy-legacy.sh` (the previous script) when ovhost is missing or too old, when the host inventory does not deploy sites with strategy static-build, or with `OVHOST_LEGACY=1`. `DRY_RUN=1` runs `ovhost plan sites --restart` instead.
++- `DRY_RUN=1` runs `ovhost plan sites --restart` instead. If ovhost is missing, the wrapper exits with an error.
- There is no systemd unit, no port and no environment file. nginx serves each domain from `root /opt/openvibe.sites/dist/<domain>` through `deploy/nginx/<domain>.conf`. A vhost removed from `deploy/nginx/` stays installed on the host until it is removed by hand.
Report security issues through [SECURITY.md](SECURITY.md).
ddiff --git a/deploy/scripts/deploy-legacy.sh b/deploy/scripts/deploy-legacy.sh
ddeleted file mode 100755
iindex 522c550..0000000
---- a/deploy/scripts/deploy-legacy.sh
++++ /dev/null
@@@ -1,36 +0,0 @@
--#!/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 160f9ae..b64b0eb 100755
---- a/deploy/scripts/deploy.sh
++++ b/deploy/scripts/deploy.sh
@@@ -1,56 +1,21 @@
#!/bin/bash
--# 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:
++# OpenVibe.Sites — deploy through OpenVibe.Host. 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
++# DRY_RUN=1 /opt/openvibe.sites/deploy/scripts/deploy.sh ovhost plan sites --restart
#
--# 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.
++# --restart rebuilds and reinstalls even when the checkout is already at origin/main.
set -euo pipefail
SERVICE=sites
--STRATEGY=static-build
--REPO="${REPO:-/opt/openvibe.sites}"
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 ! command -v "$OVHOST" >/dev/null 2>&1; then
++ echo "[sites] ovhost not found ($OVHOST); install ovhost to deploy sites" >&2
++ exit 1
++fi
if [ "${DRY_RUN:-0}" = 1 ]; then exec "${SUDO[@]}" "$OVHOST" plan "$SERVICE" --restart; fi
echo "[sites] ovhost deploy $SERVICE --restart"
ddiff --git a/test/deploy-wrapper.test.js b/test/deploy-wrapper.test.js
iindex e8925dc..42aaa4b 100644
---- a/test/deploy-wrapper.test.js
++++ b/test/deploy-wrapper.test.js
@@@ -1,63 +1,48 @@
'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.
++// Verify that deploy/scripts/deploy.sh delegates to ovhost with the Sites service and --restart.
// 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 { spawnSync } = 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
++
++try {
++ fs.writeFileSync(ovhost, `#!/usr/bin/env bash
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, OVHOST: ovhost, OVHOST_SUDO: '', ...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 };
++ }
--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']);
++ 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");
++ const invalidArgs = run(['--nope']);
++ assert.strictEqual(invalidArgs.code, 1);
++ assert.deepStrictEqual(invalidArgs.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);
++ const missing = run([], { OVHOST: path.join(tmp, 'missing-ovhost') });
++ assert.strictEqual(missing.code, 1);
++ assert.match(missing.out, /ovhost not found/);
++ assert.deepStrictEqual(missing.calls, []);
++ assert.doesNotMatch(fs.readFileSync(WRAPPER, 'utf8'), /legacy/i);
--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');
++ console.log('deploy wrapper: all checks passed');
++} finally {
++ fs.rmSync(tmp, { recursive: true, force: true });
++}